多进程编程:早期服务器实现逻辑

一、核心逻辑的伪代码解释

1.1 主程序逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
主程序开始:
打开文件"1.txt"用于读写
如果文件打开失败:
输出错误信息并退出程序
向文件中写入"nonono"字符串
如果写入失败:
关闭文件
输出错误信息并退出程序
强制将缓冲区数据写入磁盘
分配能存储3个workerData_t结构体的内存空间
如果内存分配失败:
关闭文件
输出错误信息并退出程序
调用MakeWorker函数创建3个工作进程
如果创建失败:
释放已分配的内存
关闭文件
输出错误信息并退出程序
对于每个工作进程:
等待该进程执行结束
释放内存空间
关闭文件
正常退出程序

主程序首先尝试打开文件并写入内容,确保数据持久化。接着分配内存存储工作进程信息,调用MakeWorker函数创建子进程,最后等待所有子进程结束,释放资源并退出。

1.2 工作进程创建逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
MakeWorker函数(工作进程数量, 存储工作进程信息的数组):
如果传入的参数不合法(数组为空或进程数量小于等于0):
返回-1表示失败
循环(从0到工作进程数量-1):
调用fork( )系统调用创建新进程
获取返回的pid值
如果pid小于0:
输出fork失败的错误信息
返回-1表示失败
否则如果pid等于0(当前为子进程):
进入无限循环:
休眠1秒
退出子进程(实际不会执行到这里,仅作保险)
否则(当前为父进程):
将子进程的pid存储到信息数组中
将该子进程的状态设置为FREE
打印该子进程的索引和pid信息
返回0表示成功

MakeWorker函数负责创建指定数量的工作进程。通过fork系统调用生成子进程,在父进程中记录子进程的 PID 和状态;子进程则进入休眠循环,模拟等待任务的状态。

二、程序功能概述

本程序主要实现以下功能:

  • 创建文本文件并写入指定内容

  • 批量创建多个子进程作为工作进程

  • 记录每个工作进程的 PID 和状态信息

  • 确保主进程等待所有子进程结束后再退出

  • 早期服务器实现

三、完整代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>
#include <errno.h>
#include <sys/wait.h>

// 定义进程状态枚举
enum {
FREE, // 空闲状态
BUSY // 忙碌状态
};

// 工作进程数据结构
typedef struct workerData_s {
pid_t pid; // 进程ID
int status; // 进程状态
} workerData_t;

// 函数声明
int MakeWorker(const int workernum, workerData_t *workaddr);

int main(int argc, char *argv[]) {
// 打开文件并进行写操作
FILE *fp = fopen("1.txt", "w+");
if (fp == NULL) {
perror("fopen failed");
return -1;
}
// 使用fwrite写入数据
size_t written = fwrite("nonono", 1, 6, fp);
if (written != 6) {
perror("fwrite failed");
fclose(fp);
return -1;
}
// 确保数据写入磁盘
fflush(fp);
// 分配工作进程数据结构内存
workerData_t *workaddr = (workerData_t*)calloc(3, sizeof(workerData_t));
if (workaddr == NULL) {
perror("calloc failed");
fclose(fp);
return -1;
}
// 创建3个工作进程
int ret = MakeWorker(3, workaddr);
if (ret == -1) {
fprintf(stderr, "MakeWorker failed\n");
free(workaddr);
fclose(fp);
return -1;
}
// 等待所有子进程结束,避免僵尸进程
for (int i = 0; i < 3; i++) {
if (workaddr[i].pid > 0) {
waitpid(workaddr[i].pid, NULL, 0);
}
}
// 释放资源
free(workaddr);
fclose(fp);
return 0;
}

// 创建指定数量的工作进程
int MakeWorker(const int workernum, workerData_t *workaddr) {
// 参数合法性检查
if (workaddr == NULL || workernum <= 0) {
return -1;
}
for (int i = 0; i < workernum; ++i) {
pid_t pid = fork( );
if (pid < 0) {
// 处理fork失败的情况
perror("fork failed");
return -1;
} else if (pid == 0) {
// 子进程:进入循环等待
while (1) {
sleep(1);
}
exit(EXIT_SUCCESS); // 确保子进程退出
} else {
// 父进程:记录子进程信息
workaddr[i].pid = pid;
workaddr[i].status = FREE;
printf("i == %d pid == %d\n", i, workaddr[i].pid);
}
}
return 0;
}

四、关键技术点解析

4.1 进程创建机制

程序中使用fork( )系统调用来创建子进程,这是多进程编程的核心:

1
pid_t pid = fork( );

fork( )调用后会产生两个几乎完全相同的进程:

  • 父进程中,fork( )返回子进程的 PID(一个大于 0 的整数)

  • 子进程中,fork( )返回 0

  • 若返回 -1,则表示进程创建失败

需要注意的是,fork( )函数创建的子进程是父进程的一个副本,它会复制父进程的数据段、堆栈段等,但子进程有自己独立的地址空间。在实际应用中,这种复制会带来一定的开销,因此在创建大量进程时需要谨慎考虑。

4.2 数据结构设计

程序中定义了workerData_t结构体来管理工作进程信息:

1
2
3
4
typedef struct workerData_s {
pid_t pid; // 进程ID,用于唯一标识一个进程
int status; // 进程状态,取值为FREE或BUSY
} workerData_t;

通过这个结构体,父进程可以方便地记录和管理所有子进程的状态。在实际的多进程应用中,还可以根据需求扩展该结构体,比如添加进程创建时间、进程所处理的任务信息等,以便更好地对进程进行监控和管理。

4.3 资源管理

程序中对文件和内存资源进行了严格管理:

  • 文件操作:使用fopen( )打开文件,fclose( )关闭文件,确保文件描述符被正确释放。同时,使用fflush( )强制将缓冲区数据写入磁盘,避免数据丢失。

  • 内存管理:使用calloc( )分配内存,free( )释放内存,防止内存泄漏。calloc( )函数在分配内存的同时会将内存初始化为 0,这在某些情况下可以避免一些潜在的错误。

在大型程序中,资源管理尤为重要。如果资源管理不当,可能会导致程序运行缓慢、崩溃甚至系统不稳定等问题。因此,开发者需要养成良好的资源管理习惯,确保每一个分配的资源都能被正确释放。

4.4 僵尸进程预防

为了避免子进程成为僵尸进程,父进程使用waitpid( )等待所有子进程结束:

1
waitpid(workaddr[i].pid, NULL, 0);

waitpid( )会暂停父进程的执行,直到指定的子进程结束,从而确保子进程的资源被正确回收。僵尸进程是指已经终止但尚未被父进程回收的进程,它们会占用系统资源,如进程 ID 等。如果系统中存在大量的僵尸进程,可能会导致新的进程无法创建。除了使用waitpid( )函数外,还可以通过信号机制来处理子进程的终止,比如注册SIGCHLD信号的处理函数,在信号处理函数中回收子进程资源。

五、程序执行流程

  1. 初始化阶段:打开文件并写入数据,为工作进程信息分配内存空间。在这个阶段,需要对各种操作进行错误检查,确保程序能够正常启动。
  2. 进程创建阶段:通过循环调用fork( )创建多个子进程,子进程进入休眠循环,父进程记录子进程信息。在创建子进程时,要注意处理fork( )函数可能返回的错误,避免因进程创建失败而影响整个程序的运行。
  3. 等待阶段:父进程逐个等待所有子进程结束。这一阶段的主要目的是回收子进程资源,防止僵尸进程的产生。
  4. 清理阶段:释放已分配的内存,关闭文件,程序正常退出。在程序退出前,确保所有资源都被正确释放,是保证程序稳定性的重要环节。

六、程序扩展与优化方向

6.1 进程池概念引入

当前程序中,工作进程的数量是固定的,在实际应用中,我们可以将其扩展为进程池。进程池是一组预先创建的进程,它们可以重复使用来处理多个任务,而不是为每个任务创建一个新进程。这样可以减少进程创建和销毁的开销,提高程序的性能。

进程池的基本工作原理是:

  • 预先创建一定数量的工作进程。

  • 当有任务到来时,从进程池中选择一个空闲的进程来处理任务。

  • 任务处理完成后,该进程回到空闲状态,等待下一个任务。

要实现进程池,需要在父进程和子进程之间建立通信机制,比如使用管道、消息队列等,以便父进程向子进程分配任务,子进程向父进程反馈任务处理结果。

6.2 子进程任务处理扩展

当前程序中的子进程只是进入休眠循环,并没有实际的任务处理逻辑。在实际应用中,可以根据具体需求为子进程添加任务处理功能。例如,子进程可以从文件中读取数据进行处理,或者接收网络请求并进行响应等。

以下是一个简单的子进程任务处理扩展示例,子进程从文件中读取数据并打印:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
else if (pid == 0) {
// 子进程:处理任务
FILE *fp = fopen("1.txt", "r");
if (fp == NULL) {
perror("fopen failed in child process");
exit(EXIT_FAILURE);
}
char buf[1024];
size_t nread;
while ((nread = fread(buf, 1, sizeof(buf), fp)) > 0) {
printf("Child process %d read: %.*s\n", getpid( ), (int)nread, buf);
}
fclose(fp);
exit(EXIT_SUCCESS);
}