TCPIP 网络编程-09-多进程服务器

到目前为止都是循环客户端连接,处理完一个客户端再处理另外一个。现实是一个服务器可以同时处理多个客户端请求。这里通过多进程实现网络服务器

第一步,使用 fork 分配多个进程处理客户端请求

所有进程都会从操作系统分配到 ID,1 要分配给操作系统启动后的首个进程,因此用户进程无法得到进程 ID 1

1
2
3
4
#include <unistd.h>

//成功时返回子进程 ID,失败返回 -1
pid_t fork(void);

这样记返回值:因为父进程无法获取子进程 id,所以需要在 fork 直接将 pid_t 返回给父进程,通过 getppid 可以获取父进程 id

示例地址:https://github.com/XBoom/network-ip.git 中的 apps/socket/12/

1
2
3
4
5
6
7
[root]#./client 
[12/client.c:17 main info] parent before 0 0 0 0
[12/client.c:22 main info] parent after 2 2 2 2
[12/client.c:36 main info] main after 2 2 2
[12/client.c:28 main info] son before 0 0 0 0
[12/client.c:33 main info] son after 3 3 3 3
[12/client.c:36 main info] main after 3 3 3

从上面的运行结果来看

  • 虽然子进程复制了父进程的内存结构,但是两者拥有的是独立的内存结构,不会相互影响
  • fork 后面的代码指令也会复制一份,独立运行

第二步,有了子进程,那么如何管理子进程的死亡

父子进程的退出情况一共存在以下三种情况

  • 父进程比子进程先退出
  • 父进程比子进程后退出
  • 子进程一直不退出

孤儿进程(Orphan Process)和僵尸进程(Zombie Process)是与进程状态相关的两个概念,通常在操作系统中用来描述进程的状态变化。

  1. 孤儿进程(Orphan Process):
    • 当一个父进程创建了子进程,但父进程在子进程之前终止,而子进程继续运行时,子进程就成为孤儿进程。
    • 孤儿进程会被操作系统的init进程(在Unix/Linux系统中通常是进程号1的init)接管。init进程会成为孤儿进程的新父进程,并负责收养和管理这个孤儿进程。
    • 孤儿进程不会影响系统的正常运行,因为它已经有了新的父进程(init进程)来照顾它。
  2. 僵尸进程(Zombie Process):
    • 当一个子进程终止时,它并不会立即从系统中移除,而是留下一个僵尸进程。
    • 僵尸进程仍然占用系统资源,但它不再执行任何代码。僵尸进程的存在是为了让父进程获取子进程的退出状态。
    • 父进程可以通过调用wait()waitpid()等系统调用来获取子进程的退出状态,一旦获取完成,僵尸进程就会被完全移除。
    • 如果父进程没有及时处理子进程的退出状态,可能会导致系统中积累大量的僵尸进程,从而占用系统资源。

防止僵尸进程的方法通常是在父进程中使用合适的系统调用来等待子进程的终止,并获取其退出状态。

孤儿进程会被 init 进程(PID为1的进程)领养,init 进程会负责回收孤儿进程的资源,因此孤儿进程不会变成僵尸进程

也就是说,如果父进程比子进程后退出,且父进程并没有管子进程,那么就会僵尸进程,为了防止僵尸进程,可用如下函数销毁僵尸进程

1
2
3
#include <sys/wait.h>

pid_t wait(int * statloc);

如果调用此函数的时候已经有子进程终止,那么子进程 终止时传递的返回值(exit函数返回值、main函数的返回值) 将保存到该函数的参数所指内存空间

函数参数 statloc 所指向的单元中还含有其他信息,需要宏进行分离

  • WIFEXITED 子进程正常终止时返回 true(错误系统idaoyong,信号,异常退出,资源耗尽等表示异常退出)
  • WEXITSTATUS 返回子进程的返回值
1
2
3
4
if (WIFEXITED(status)) {
// 子进程正常终止
printf("子进程退出状态: %d\n", WEXITSTATUS(status));
}

调用 wait 函数时,如果没有已终止的子进程,那么程序将阻塞直到有子进程终止,就有了第二种

1
2
3
4
5
6
7
#include <sys/wait.h>
/*
* pid 等待终止的目标子进程的ID,若传递-1,则与wait函数相同,可以等待任意子进程终止
* statloc 与wait的含义相同
* option WNOHANG,即使没有终止的子进程也不会进入阻塞状态,而是返回0并退出函数
*/
pid_t waitpid(pid_t pid, int *staloc, int options);

示例地址:https://github.com/XBoom/network-ip.git 中的 apps/socket/13/ ,运行结果

1
2
3
4
5
6
7
8
9
[root]#./client 
[13/client.c:20 main info] wait child
[13/client.c:20 main info] wait child
[13/client.c:20 main info] wait child
[13/client.c:20 main info] wait child
[13/client.c:12 main info] child end
[13/client.c:20 main info] wait child
[13/client.c:23 main info] child has exited 7
[13/client.c:25 main info] child pid 119 status 7

可以看到 waitpid 并没有阻塞,而是看一下当前有没有子进程退出,如果没有则返回 0 并退出。

但是父进程不能啥也不干,就循环判断子进程是否退出了,这个时候就用到了信号

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 返回类型:void (*)(int),表示返回一个指向参数为整型、返回类型为 void 的函数的指针
* 函数名:signal,是函数的名称。
* 参数列表:(int signo, void (*func)(int)),包括两个参数:
signo:整型,表示信号的编号。
(*func)(int):一个指向参数为整型、返回类型为 void 的函数的指针,用于指定处理该信号时要调用的函数
*/
void (*signal(int signo, void (*func)(int)))(int);


/**
* signo 信号信息
* act 对应与第一个参数的信号处理函数
* oldact 通过次参数获取之前注册的信号处理函数指针,若不需要则传递 0
*/
int sigaction(int signo, const struct sigaction *act, struct sigaction * oldact);

signal 很少使用,仅仅是为了向前兼容。所以最好使用 sigaction 处理信号, 常用的处理信号有

  • SIGALRM: 已经通过调用alarm函数注册的时间,通过 alarm 函数注册的时间表示:在接收到 SIGALRM 信号之前的秒数。当调用 alarm(seconds) 后,系统会在指定的秒数后发送 SIGALRM 信号给调用进程
  • SIGINT: 输入 CTRL + C
  • SIGCHLD: 子进程终止

第四步,基于前面的内容实现一个并发回声服务器

示例地址:https://github.com/XBoom/network-ip.git 中的 apps/socket/14/ ,运行结果

1
2
3
4
5
6
7
8
9
10
11
12
13
[root]#./server 5555
[14/server.c:83 main info] client disconnected
[14/server.c:19 read_childproc info] remove proc id: 308
[14/server.c:83 main info] client disconnected
[14/server.c:19 read_childproc info] remove proc id: 310

#client1
[root]#./client 127.0.0.1 5555
[14/client.c:30 main info] recv hello world

#client2
[root]#./client 127.0.0.1 5555
[14/client.c:30 main info] recv hello world

中间使用 fork 出来的子进程处理客户端发送的请求,通过信号处理子进程退出

1
2
3
4
5
struct sigaction act;
act.sa_handler = read_childproc;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
sigaction(SIGCHLD, &act, 0); //SIGCHLD 子进程终止

而信号处理函数仅干了一件事,就是处理任意退出的子进程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//子进程处理
void read_childproc(int sig)
{
pid_t pid;
int status;
/**
* @brief 等待特定或者指定的子进程
* @param
* -1 表示等待任何子进程
* status 存储终止进程的状态信息
* WNOHANG 使waitpid 函数变味非阻塞。如果没有终止的子进程,会立即返回 0,而
* 不会组阻塞程序的执行
*/
pid=waitpid(-1, &status, WNOHANG);
LOG_INFO("remove proc id: %d", pid);
}

另外每次循环子进程都会关闭一次 server_socketclient_socket,父进程关闭 client_socket

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if(child_pid == 0)  //子进程
{
close(server_socket); //子进程会复制父进程内存,所以这里需要关闭
//...

close(client_socket);
}
else
{
close(client_socket); //父进程关闭子进程
}

//...
close(server_socket);

参考文档

  1. 《TCPIP 网络编程》