
多进程服务器端
10.1 进程概念及应用
之前实现的迭代服务器端每次只能服务一个客户端。当前面的客户端尚未结束通信时,后续请求只能留在等待队列中。网络通信的等待时间通常大于运算时间,因此让多个客户端同时获得服务,也能更有效地利用系统资源。
常见的并发服务器端有以下几种实现方式:
- 通过创建多个进程提供服务。
- 通过复用统一管理多个对象。
- 通过创建多个线程提供服务。
本章主要介绍下的多进程方式。不支持同样的进程模型,因此没有对应的平台实现。
理解进程
进程()简单来说就是“占用内存空间且正在运行的程序”。保存在硬盘上但尚未运行的只是程序;当它被加载到内存并开始执行时,才成为进程。
操作系统会为每个进程分配唯一的进程,即。在中可以通过命令查看正在运行的进程及其。
通过fork创建进程
用于创建当前进程的副本:
#include <unistd.h>
pid_t fork(void);//成功时在父进程中返回子进程ID,//在子进程中返回0,失败时返回-1。调用后,父子进程都会从函数返回位置继续执行,所以必须根据返回值区分两条执行流:
pid_t pid = fork();
if (pid == 0){ /* 子进程执行区域 */}else{ /* 父进程执行区域,pid是子进程ID */}会复制父进程当时的内存状态,但复制完成后父子进程拥有彼此独立的内存空间。一方修改全局变量或局部变量,不会直接影响另一方。
10.2 进程和僵尸进程
子进程执行完的,或者调用后,会把终止状态交给操作系统。操作系统不会立即删除该进程,而是保留这些信息,等待父进程读取。在终止状态尚未被回收期间,子进程就处于僵尸进程()状态。
僵尸进程已经不再执行业务代码,但仍会占用进程表等系统资源。因此,父进程需要主动获取子进程的终止状态。
利用wait销毁僵尸进程
#include <sys/wait.h>
pid_t wait(int *statloc);//成功时返回终止的子进程ID,失败时返回-1。会把某个已终止子进程的状态写入指向的内存,同时完成该子进程的回收。可以通过宏拆分其中的信息:
int status;wait(&status);
if (WIFEXITED(status)) printf("Child return: %d\n", WEXITSTATUS(status));用于判断子进程是否正常终止,用于取出返回值。若尚无子进程结束,会阻塞等待,因此需谨慎调用。
利用waitpid避免阻塞
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *statloc, int options);//成功时返回终止的子进程ID,//使用WNOHANG且暂无子进程结束时返回0。为时表示等待任意子进程。第三个参数传入后,即使尚无子进程终止也不会阻塞,而是直接返回。
10.3 信号处理
父进程通常还要继续受理连接,不能不断调用轮询子进程是否终止。子进程结束时,操作系统会向父进程发送信号;父进程可以注册对应的处理函数,只在信号到来时回收子进程。
signal函数
#include <signal.h>
void (*signal(int signo, void (*func)(int)))(int);表示需要关注的信号,是信号发生时由操作系统调用的处理函数。常见信号包括:
| 信号 | 含义 |
|---|---|
| SIGCHLD | 子进程终止 |
| SIGALRM | 设置的时间到达 |
| SIGINT | 用户输入 |
例如,子进程终止时调用:
signal(SIGCHLD, read_childproc);书中还通过演示了定时信号:
#include <unistd.h>
unsigned int alarm(unsigned int seconds);调用后经过指定秒数会产生。信号到来时,还会唤醒因等函数处于阻塞状态的进程。
使用sigaction注册信号
在不同系统中可能存在行为差异,实际程序中更常使用:
#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oldact);//成功时返回0,失败时返回-1。用于回收子进程的核心结构如下:
void read_childproc(int sig){ int status; while (waitpid(-1, &status, WNOHANG) > 0);}
struct sigaction act;act.sa_handler = read_childproc;sigemptyset(&act.sa_mask);act.sa_flags = 0;sigaction(SIGCHLD, &act, 0);当产生时,处理函数通过非阻塞的取走子进程终止状态,从而避免僵尸进程残留。
10.4 基于多任务的并发服务器
多进程回声服务器端的基本逻辑是:父进程专门通过受理连接,每当得到一个客户端套接字后调用,由新建子进程为该客户端提供服务。

父进程负责反复受理连接,子进程则独立处理已连接客户端。核心代码可以简化为:
while (1){ clnt_sock = accept(serv_sock, (struct sockaddr *)&clnt_adr, &adr_sz); if (clnt_sock == -1) continue;
pid = fork(); if (pid == 0) { close(serv_sock); while ((str_len = read(clnt_sock, buf, BUF_SIZE)) != 0) write(clnt_sock, buf, str_len);
close(clnt_sock); return 0; } else { close(clnt_sock); }}fork与套接字文件描述符
复制的是套接字文件描述符,而不是在操作系统中创建一个新套接字。父子进程中的描述符会指向同一套接字对象。

如果父子进程都保留所有描述符,即使其中一方调用,套接字也不会立即被销毁。只有指向该套接字的相关描述符全部关闭后,操作系统才会真正销毁它。

所以调用后应立即整理无关描述符:
- 子进程不负责接受新连接,应关闭。
- 父进程不负责当前客户端的数据交换,应关闭自己持有的副本。
父进程继续调用,各子进程独立处理已连接客户端,便实现了同时向多个客户端提供服务。
10.5 分割TCP的I/O程序
之前的回声客户端按照“发送一次,等待回声,再发送下一次”的顺序工作。利用可以把收发逻辑拆分到两个进程中:父进程只负责接收,子进程只负责发送。

这样一方面可以简化代码,另一方面使数据发送不再必须等待前一次接收完成。如图所示,分割后客户端可以连续发送数据,对频繁交换数据的程序更有意义。

客户端中的分工可以简化为:
pid = fork();
if (pid == 0) write_routine(sock, buf); //子进程只发送else read_routine(sock, buf); //父进程只接收发送进程遇到退出输入时,需要对输出流执行半关闭:
if (!strcmp(buf, "q\n") || !strcmp(buf, "Q\n")){ shutdown(sock, SHUT_WR); return;}由于后父子进程都持有指向同一套接字的描述符,只在发送进程中调用并不能立即向服务器传递。调用可明确关闭套接字的输出流,同时让另一进程继续接收服务器剩余的数据。
