10.1 进程概念及应用

10.1.1 并发服务端的实现方法

通过改进服务端,使其同时向多个发起请求的客户端提供服务。由于数据通信时间比 CPU 运算时间占比更大,向多个客户端提供服务是一种有效的 CPU 使用方式。

接下来讨论同时向多个客户端提供服务的并发服务器端。下面列出的是具有代表性的并发服务端的实现模型和方法:

  • 多进程服务器:通过创建多个进程提供服务
  • 多路复用服务器:通过捆绑并统一管理 I/O 对象提供服务
  • 多线程服务器:通过生成与客户端等量的线程提供服务

先尝试第一种方法:多进程服务器。

10.1.2 理解进程

进程的定义如下:

占用内存空间的正在运行的程序

假如你下载了一个游戏到电脑上,此时的游戏不是进程,而是程序。只有当游戏被加载到主内存并进入运行状态,这是才可称为进程。

10.1.3 进程 ID

在说进程创建方法之前,先要简要说明进程 ID。无论进程是如何创建的,所有的进程都会被操作系统分配一个 ID。此 ID 被称为「进程ID」,其值大于 2 。1 要分配给操作系统启动后的(用于协助操作系统)首个进程,因此用户无法得到 ID 值为 1 。接下来观察在 UNIX/LINUX 中运行的进程。

  1. ps au

通过上面的命令可查看当前运行的所有进程。需要注意的是,该命令同时列出了 PID(进程ID)。参数 a 和 u列出了所有进程的详细信息。
image.png

10.1.4 通过调用 fork 函数创建进程

用于创建多进程服务端的 fork 函数:

  1. #include <unistd.h>
  2. pid_t fork(void); // 成功时返回进程ID,失败时返回 -1

fork 函数将创建调用的进程副本。也就是说,并非根据完全不同的程序创建进程,而是复制正在运行的、调用 fork 函数的进程。另外,两个进程都执行 fork 函数调用后的语句(准确的说是在 fork 函数返回后)。但因为是通过同一个进程、复制相同的内存空间,之后的程序流要根据 fork 函数的返回值加以区分。即利用 fork 函数的如下特点区分程序执行流程。

  • 父进程:fork 函数返回子进程 ID
  • 子进程:fork 函数返回 0

此处,「父进程」(Parent Process)指原进程,即调用 fork 函数的主体,而「子进程」(Child Process)是通过父进程调用 fork 函数复制出的进程。接下来是调用 fork 函数后的程序运行流程。如图所示:

C10 多进程服务器端 - 图2
从图中可以看出,父进程调用 fork 函数的同时复制出子进程,并分别得到 fork 函数的返回值。但复制前,父进程将全局变量 gval 增加到 11,将局部变量 lval 的值增加到 25,因此在这种状态下完成进程复制。复制完成后根据 fork 函数的返回类型区分父子进程。父进程的 lval 的值增加 1 ,但这不会影响子进程的 lval 值。同样子进程将 gval 的值增加 1 也不会影响到父进程的 gval 。因为 fork 函数调用后分成了完全不同的进程,只是二者共享同一段代码而已。接下来给出一个例子:

  1. #include <stdio.h>
  2. #include <unistd.h>
  3. int gval = 10;
  4. int main(int argc, char *argv[]){
  5. pid_t pid;
  6. int lval = 20;
  7. gval++, lval += 5;
  8. pid = fork();
  9. if (pid == 0)
  10. gval += 2, lval += 2;
  11. else
  12. gval -= 2, lval -= 2;
  13. if (pid == 0)
  14. printf("Child Proc: [%d,%d] \n", gval, lval);
  15. else
  16. printf("Parent Proc: [%d,%d] \n", gval, lval);
  17. return 0;
  18. }

运行结果:
image.png

10.2 进程和僵尸进程

文件操作中,关闭文件和打开文件同等重要。同样,进程销毁和进程创建也同等重要。如果未认真对待进程销毁,它们将变成僵尸进程。

10.2.1 僵尸(Zombie)进程

进程的工作完成后(执行完 main 函数中的程序后)应被销毁,但有时这些进程将变成僵尸进程,占用系统中的重要资源。这种状态下的进程称作「僵尸进程」,这也是给系统带来负担的原因之一。

僵尸进程是当子进程比父进程先结束,而父进程又没有回收子进程,释放子进程占用的资源,此时子进程将成为一个僵尸进程。如果父进程先退出 ,子进程被init接管,子进程退出后init会回收其占用的相关资源。 UNIX命令ps列出的进程的状态(”STAT”)栏标示为 “Z”则为僵尸进程。[1]

10.2.2 产生僵尸进程的原因

为了防止僵尸进程产生,先解释产生僵尸进程的原因。利用如下两个示例展示调用 fork 函数产生子进程的终止方式:

  • 传递参数并调用 exit() 函数
  • main 函数中执行 return 语句并返回值

如果父进程未主动获取子进程结束状态值,操作系统将一直保存,并让子进程长时间处于僵尸进程状态。也就是说,父母要负责收回自己生的孩子。接下来的示例是创建僵尸进程:

  1. #include <stdio.h>
  2. #include <unistd.h>
  3. int main(int argc, char *argv[]){
  4. pid_t pid = fork();
  5. if (pid == 0)
  6. puts("Hi, I am a child Process");
  7. else{
  8. printf("Child Process ID: %d \n", pid);
  9. sleep(30);
  10. }
  11. if (pid == 0)
  12. puts("End child proess");
  13. else
  14. puts("End parent process");
  15. return 0;
  16. }

运行:
C10 多进程服务器端 - 图4

因为暂停了 30 秒,所以在这个时间内可以验证一下子进程是否为僵尸进程:
C10 多进程服务器端 - 图5
通过 ps au 命令可以看出,子进程仍然存在,并没有被销毁,僵尸进程在这里显示为 Z+.30秒后,红框里面的两个进程会同时被销毁。

10.2.3 销毁僵尸进程 1:利用 wait 函数

如前所述,为了销毁子进程,父进程应该主动请求获取子进程的返回值。下面是发起请求的具体方法。有两种,下面的函数是其中一种:

  1. #include <sys/wait.h>
  2. pid_t wait(int *statloc); // 成功时返回终止的子进程 ID ,失败时返回 -1

调用此函数时如果已有子进程终止,那么子进程终止时传递的返回值(exit 函数的参数返回值,main 函数的 return 返回值)将保存到该函数的参数所指的内存空间。但函数参数指向的单元中还包含其他信息,因此需要用下列宏进行分离:

  • WIFEXITED 子进程正常终止时返回「真」
  • WEXITSTATUS 返回子进程时的返回值

也就是说,向 wait 函数传递变量 status 的地址时,调用 wait 函数后应编写如下代码:

  1. if (WIFEXITED(status)){
  2. puts("Normal termination");
  3. printf("Child pass num: %d", WEXITSTATUS(status));
  4. }

根据以上内容,有如下示例:

  1. #include <stdio.h>
  2. #include <stdlib.h>
  3. #include <unistd.h>
  4. #include <sys/wait.h>
  5. int main(int argc, char *argv[]){
  6. int status;
  7. pid_t pid = fork(); // 这里的子进程将在第11行通过 return 语句终止
  8. if (pid == 0)
  9. return 3;
  10. else{
  11. printf("Child PID: %d \n", pid);
  12. pid = fork(); // 这里的子进程将在 16 行通过 exit() 函数终止
  13. if (pid == 0)
  14. exit(7);
  15. else{
  16. printf("Child PID: %d \n", pid);
  17. // 之间终止的子进程相关信息将被保存到 status 中,同时相关子进程被完全销毁
  18. wait(&status);
  19. // 通过 WIFEXITED 来验证子进程是否正常终止,
  20. if (WIFEXITED(status))
  21. // 如果正常终止,则调用 WEXITSTATUS 宏输出子进程返回值
  22. printf("Child send one: %d \n", WEXITSTATUS(status));
  23. wait(&status); //因为之前创建了两个进程,所以再次调用 wait 函数和宏
  24. if (WIFEXITED(status))
  25. printf("Child send two: %d \n", WEXITSTATUS(status));
  26. sleep(30);
  27. }
  28. }
  29. return 0;
  30. }

结果:
C10 多进程服务器端 - 图6

此时,系统中并没有上述 PID 对应的进程,这是因为调用了 wait 函数,完全销毁了该子进程。另外两个子进程返回时返回的 3 和 7 传递到了父进程。

这就是通过 wait 函数消灭僵尸进程的方法,调用 wait 函数时,如果没有已经终止的子进程,那么程序将阻塞(Blocking)直到有子进程终止,因此要谨慎调用该函数。

10.2.4 销毁僵尸进程 2:使用 waitpid 函数

wait 函数会引起程序阻塞,还可以考虑调用 waitpid 函数。这是防止僵尸进程的第二种方法,也是防止阻塞的方法。

  1. #include <sys/wait.h>
  2. pid_t waitpid(pid_t pid, int *statloc, int options);
  3. /*
  4. 成功时返回终止的子进程ID 或 0 ,失败时返回 -1
  5. pid: 等待终止的目标子进程的ID,若传 -1,则与 wait 函数相同,可以等待任意子进程终止
  6. statloc: 与 wait 函数的 statloc 参数具有相同含义
  7. options: 传递头文件 sys/wait.h 声明的常量 WNOHANG ,
  8. 即使没有终止的子进程也不会进入阻塞状态,而是返回 0 退出函数。
  9. */

以下是 waitpid 的使用示例:

  1. #include <stdio.h>
  2. #include <unistd.h>
  3. #include <sys/wait.h>
  4. int main(int argc, char *argv[]){
  5. int status;
  6. pid_t pid = fork();
  7. if (pid == 0){
  8. sleep(15); //用 sleep 推迟子进程的执行
  9. return 24;
  10. }else{
  11. // waitpid 传递参数 WNOHANG ,这样之前有没有终止的子进程则返回0
  12. while (!waitpid(-1, &status, WNOHANG)){
  13. sleep(3);
  14. puts("sleep 3 sec.");
  15. }
  16. if (WIFEXITED(status))
  17. printf("Child send %d \n", WEXITSTATUS(status));
  18. }
  19. return 0;
  20. }

结果:
C10 多进程服务器端 - 图7

可以看出来,在 while 循环中正好执行了 5 次。这也证明了 waitpid 函数并没有阻塞。

该函数配合while使用提供了一个在等待子进程结束时继续父进程内容的方式。

10.3 信号处理

我们已经知道了进程的创建及销毁的办法,但是还有一个问题没有解决。

子进程究竟何时终止?调用 waitpid 函数后要无休止的等待吗?

10.3.1 向操作系统求助

一个解决方法是:子进程结束后通知OS,由OS通知父进程子进程结束。

这就是:信号处理机制(Signal Handing)。此处「信号」是在特定事件发生时由操作系统向进程发送的消息。另外,为了响应该消息,执行与消息相关的自定义操作的过程被称为「信号处理」。

10.3.2 信号与 signal 函数

下面进程和操作系统的对话可以帮助理解信号处理:

进程:操作系统,如果我之前创建的子进程终止,就帮我调用 zombie_handler 函数。

操作系统:好的,如果你的子进程终止,我就帮你调用 zombie_handler 函数,你先把要函数要执行的语句写好。

上述的对话,相当于「注册信号」的过程。即进程发现自己的子进程结束时,请求操作系统调用的特定函数。该请求可以通过如下函数调用完成:

  1. #include <signal.h>
  2. void (*signal(int signo, void (*func)(int)))(int);
  3. /*
  4. 为了在产生信号时调用,返回之前注册的函数指针
  5. 函数名: signal
  6. 参数:
  7. int signo // 信号类型,系统预定义,以数字表示
  8. void(*func)(int) // 信号处理函数,用户自定义
  9. 返回类型:参数类型为int型,返回类型为 void 型的函数指针
  10. */

一些可以在 signal 函数中注册的部分特殊情况和对应的常数:

  • SIGALRM:14,已到通过调用 alarm 函数注册时间
  • SIGINT:2,表示ctrl+c终止信号
  • SIGCHLD:20,子进程终止

接下来编写调用 signal 函数的语句完成如下请求:

「子进程终止则调用 mychild 函数」

此时 mychild 函数的参数应为 int ,返回值类型应为 void 。只有这样才能称为 signal 函数的第二个参数。另外,常数 SIGCHLD 定义了子进程终止的情况,应成为 signal 函数的第一个参数。也就是说,signal 函数调用语句如下:

  1. signal(SIGCHLD , mychild);

接下来编写 signal 函数的调用语句,分别完成如下两个请求:

  1. 已到通过 alarm 函数注册的时间,请调用 timeout
  2. 输入 ctrl+c 时调用 keycontrol

代表这 2 种情况的常数分别为 SIGALRM 和 SIGINT ,因此按如下方式调用 signal 函数:

  1. signal(SIGALRM , timeout);
  2. signal(SIGINT , keycontrol);

以上就是信号注册过程。注册好信号之后,发生注册信号时(注册的情况发生时),操作系统将调用该信号对应的函数。

alarm 函数:

  1. #include <unistd.h>
  2. unsigned int alarm(unsigned int seconds); // 返回以秒为单位的距 SIGALRM 信号发生所剩时间

向alarm传递一个正整型参数,相应时间后(以秒为单位)将产生 SIGALRM 信号。若向该函数传递为 0 ,则之前对 SIGALRM 信号的预约将取消。如果通过该函数预约信号后未指定该信号对应的处理函数,则(通过调用 signal 函数)终止进程,不做任何处理。

实例:

  1. #include <stdio.h>
  2. #include <unistd.h>
  3. #include <signal.h>
  4. void timeout(int sig) {
  5. if (sig == SIGALRM)
  6. puts("Time out!");
  7. alarm(2); // 每隔 2 秒重复产生 SIGALRM 信号
  8. }
  9. void keycontrol(int sig) {
  10. if (sig == SIGINT)
  11. puts("CTRL+C pressed");
  12. }
  13. int main(int argc, char *argv[]){
  14. // 注册对应信号的处理函数
  15. signal(SIGALRM, timeout);
  16. signal(SIGINT, keycontrol);
  17. alarm(2); // 预约 2 秒后产生 SIGALRM 信号
  18. for (int i = 0; i < 3; i++){
  19. puts("wait...");
  20. sleep(100); // 进程睡眠100s,但是信号将唤醒睡眠的进程
  21. }
  22. return 0;
  23. }

结果:
C10 多进程服务器端 - 图8

上述结果是没有任何输入的运行结果。当输入 ctrl+c 时,可以看到 CTRL+C pressed 的字符串:

C10 多进程服务器端 - 图9

发生信号时将唤醒由于调用 sleep 函数而进入阻塞状态的进程。

调用函数的主题的确是操作系统,但是进程处于睡眠状态时无法调用函数,因此,产生信号时,为了调用信号处理器,将唤醒由于调用 sleep 函数而进入阻塞状态的进程。而且,进程一旦被唤醒,就不会再进入睡眠状态。即使还未到 sleep 中规定的时间也是如此。所以上述示例运行不到 10 秒后就会结束,连续输入 CTRL+C 可能连一秒都不到。

简言之,就是本来系统要睡眠100秒,但是到了 alarm(2) 规定的两秒之后,就会唤醒睡眠的进程,进程被唤醒了就不会再进入睡眠状态了,所以就不用等待100秒。如果把 timeout() 函数中的 alarm(2) 注释掉,就会先输出**wait...**,然后再输出**Time out!** (这时已经跳过了第一次的 sleep(100) 秒),然后就真的会睡眠100秒,因为没有再发出 alarm(2) 的信号。

10.3.3 利用 sigaction 函数进行信号处理

sigaction 函数,类似于 signal 函数,而且可以完全代替后者,也更稳定。因为:

signal 函数在 Unix 系列的不同操作系统可能存在区别,但 sigaction 函数完全相同

实际上现在很少用 signal 函数编写程序,他只是为了保持对旧程序的兼容,下面介绍 sigaction 函数,只讲解可以替换 signal 函数的功能:

  1. #include <signal.h>
  2. int sigaction(int signo, const struct sigaction *act, struct sigaction *oldact);
  3. /*
  4. 成功时返回 0 ,失败时返回 -1
  5. act: 对于第一个参数的信号处理函数(信号处理器)信息。
  6. oldact: 通过此参数获取之前注册的信号处理函数指针,若不需要则传递 0
  7. */

声明并初始化 sigaction 结构体变量以调用上述函数,该结构体定义如下:

  1. struct sigaction{
  2. void (*sa_handler)(int);
  3. sigset_t sa_mask;
  4. int sa_flags;
  5. };

此结构体的成员 sa_handler 保存信号处理的函数指针值(地址值)。sa_mask 和 sa_flags 的所有位初始化 0 即可。这 2 个成员用于指定信号相关的选项和特性,而我们的目的主要是防止产生僵尸进程,故省略。

下面的示例是关于 sigaction 函数的使用方法:

  1. #include <stdio.h>
  2. #include <unistd.h>
  3. #include <signal.h>
  4. void timeout(int sig){
  5. if (sig == SIGALRM)
  6. puts("Time out!");
  7. alarm(2);
  8. }
  9. int main(int argc, char *argv[]){
  10. int i;
  11. struct sigaction act;
  12. act.sa_handler = timeout; //保存函数指针
  13. sigemptyset(&act.sa_mask); //将 sa_mask 函数的所有位初始化成0
  14. act.sa_flags = 0; //sa_flags 同样初始化成 0
  15. sigaction(SIGALRM, &act, 0); //注册 SIGALRM 信号的处理器。
  16. alarm(2); //2 秒后发生 SIGALRM 信号
  17. for (int i = 0; i < 3; i++){
  18. puts("wait...");
  19. sleep(100);
  20. }
  21. return 0;
  22. }

10.3.4 利用信号处理技术消灭僵尸进程

下面利用子进程终止时产生 SIGCHLD 信号这一点,来用信号处理来消灭僵尸进程。看以下代码:

  1. #include <stdio.h>
  2. #include <stdlib.h>
  3. #include <unistd.h>
  4. #include <signal.h>
  5. #include <sys/wait.h>
  6. void read_childproc(int sig){
  7. int status;
  8. pid_t id = waitpid(-1, &status, WNOHANG);
  9. if (WIFEXITED(status)){
  10. printf("Removed proc id: %d \n", id); //子进程的 pid
  11. printf("Child send: %d \n", WEXITSTATUS(status)); //子进程的返回值
  12. }
  13. }
  14. int main(int argc, char *argv[]){
  15. pid_t pid;
  16. // 定义并初始化sigaction
  17. struct sigaction act;
  18. act.sa_handler = read_childproc;
  19. sigemptyset(&act.sa_mask);
  20. act.sa_flags = 0;
  21. // 注册 SIGCHLD 处理函数
  22. sigaction(SIGCHLD, &act, 0);
  23. pid = fork();
  24. if (pid == 0) {
  25. puts("Hi I'm first child process");
  26. sleep(10);
  27. return 12;
  28. } else {
  29. printf("first Child proc id: %d\n", pid);
  30. pid = fork();
  31. if (pid == 0) {
  32. puts("Hi! I'm second child process");
  33. sleep(10);
  34. exit(24);
  35. } else {
  36. int i;
  37. printf("second Child proc id: %d \n", pid);
  38. for (i = 0; i < 5; i++) {
  39. puts("wait");
  40. sleep(5);
  41. }
  42. }
  43. }
  44. return 0;
  45. }

结果:

  1. first Child proc id: 35159
  2. second Child proc id: 35160
  3. wait
  4. Hi I'm first child process
  5. Hi! I'm second child process
  6. wait
  7. Removed proc id: 35159
  8. Child send: 12
  9. wait
  10. Removed proc id: 35160
  11. Child send: 24
  12. wait
  13. wait

10.4 基于多任务的并发服务器

10.4.1 基于进程的并发服务器模型

之前的回声服务器每次只能同事向 1 个客户端提供服务。因此,需要扩展回声服务器,使其可以同时向多个客户端提供服务。下图是基于多进程的回声服务器的模型。

C10 多进程服务器端 - 图10
从图中可以看出,每当有客户端请求时(连接请求),回声服务器都创建子进程以提供服务。如果请求的客户端有 5 个,则将创建 5 个子进程来提供服务,为了完成这些任务,需要经过如下过程:

  • 第一阶段:回声服务器端(父进程)通过调用 accept 函数受理连接请求
  • 第二阶段:此时获取的套接字文件描述符创建并传递给子进程
  • 第三阶段:进程利用传递来的文件描述符提供服务

    10.4.2 实现并发服务器

    下面是基于多进程实现的并发的回声服务器的服务端,可以结合第四章的 echo_client.c 回声客户端来运行。 ```c

    include

    include

    include

    include

    include

    include

    include

    include

define BUF_SIZE 30

void error_handling(char *message); void read_childproc(int sig);

int main(int argc, char *argv[]){ int serv_sock, clnt_sock; struct sockaddr_in serv_adr, clnt_adr;

  1. pid_t pid;
  2. struct sigaction act;
  3. socklen_t adr_sz;
  4. int str_len, state;
  5. char buf[BUF_SIZE];
  6. if (argc != 2){
  7. printf("Usgae : %s <port>\n", argv[0]);
  8. exit(1);
  9. }
  10. act.sa_handler = read_childproc; //防止僵尸进程
  11. sigemptyset(&act.sa_mask);
  12. act.sa_flags = 0;
  13. state = sigaction(SIGCHLD, &act, 0); //注册信号处理器,把成功的返回值给 state
  14. serv_sock = socket(PF_INET, SOCK_STREAM, 0); //创建服务端套接字
  15. memset(&serv_adr, 0, sizeof(serv_adr));
  16. serv_adr.sin_family = AF_INET;
  17. serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
  18. serv_adr.sin_port = htons(atoi(argv[1]));
  19. if (bind(serv_sock, (struct sockaddr *)&serv_adr, sizeof(serv_adr)) == -1)
  20. error_handling("bind() error");
  21. if (listen(serv_sock, 5) == -1)
  22. error_handling("listen() error");
  23. while (1){
  24. adr_sz = sizeof(clnt_adr);
  25. clnt_sock = accept(serv_sock, (struct sockaddr *)&clnt_adr, &adr_sz);
  26. if (clnt_sock == -1)
  27. continue;
  28. else
  29. puts("new client connected...");
  30. pid = fork(); //此时,父子进程分别带有一个套接字
  31. if (pid == -1){
  32. close(clnt_sock);
  33. continue;
  34. }
  35. if (pid == 0){
  36. close(serv_sock); //关闭服务器套接字,因为从父进程传递到了子进程
  37. while ((str_len = read(clnt_sock, buf, BUFSIZ)) != 0)
  38. write(clnt_sock, buf, str_len);
  39. close(clnt_sock);
  40. puts("client disconnected...");
  41. return 0;
  42. }
  43. //通过 accept 函数创建的套接字文件描述符已经复制给子进程,因为服务器端要销毁自己拥有的
  44. else
  45. close(clnt_sock); // 不能关闭serv_sock,因为下一次父进程需要serv_sock来产生新的clnt_sock供子进程进行通信
  46. }
  47. close(serv_sock);
  48. return 0;

}

void error_handling(char *message){ fputs(message, stderr); fputc(‘\n’, stderr); exit(1); } void read_childproc(int sig){ pid_t pid; int status; pid = waitpid(-1, &status, WNOHANG); printf(“removed proc id: %d \n”, pid); }

  1. <a name="055472ad"></a>
  2. ## 10.4.3 通过 fork 函数复制文件描述符
  3. 示例中给出了通过 fork 函数复制文件描述符的过程。父进程将 2 个套接字(一个是服务端套接字另一个是客户端套接字)文件描述符复制给了子进程。
  4. **调用 fork 函数时赋值父进程的所有资源,但是套接字不是归进程所有的,而是归操作系统所有**,只是进程拥有代表相应套接字的文件描述符。
  5. ![](https://s2.ax1x.com/2019/01/21/kP7Rjx.png#crop=0&crop=0&crop=1&crop=1&id=wbICf&originHeight=363&originWidth=513&originalType=binary&ratio=1&rotation=0&showTitle=false&status=done&style=none&title=)<br />如图所示,**1 个套接字存在 2 个文件描述符时,只有 2 个文件描述符都终止(销毁)后,才能销毁套接字。**如果维持图中的状态,**即使子进程销毁了与客户端连接的套接字文件描述符,也无法销毁套接字(服务器套接字同样如此)。因此调用 fork 函数候,要将无关紧要的套接字文件描述符关掉**,如图所示:
  6. ![](https://s2.ax1x.com/2019/01/21/kPH7ZT.png#crop=0&crop=0&crop=1&crop=1&id=rPezX&originHeight=356&originWidth=505&originalType=binary&ratio=1&rotation=0&showTitle=false&status=done&style=none&title=)
  7. <a name="8f342aff"></a>
  8. # 10.5 分割 TCP 的 I/O 程序
  9. <a name="1f5be228"></a>
  10. ## 10.5.1 分割 I/O 的优点
  11. 我们已经实现的回声客户端的数据回声方式如下:
  12. > 向服务器传输数据,并等待服务器端回复。无条件等待,直到接收完服务器端的回声数据后,才能传输下一批数据。
  13. 传输数据后要等待服务器端返回的数据,因为程序代码中连续调用了 read 和 write 函数。
  14. 现在可以创建多个进程,因此可以分割数据收发过程。默认分割过程如下图所示:<br />![](https://s2.ax1x.com/2019/01/21/kPbhkD.png#crop=0&crop=0&crop=1&crop=1&id=Ugi34&originHeight=295&originWidth=459&originalType=binary&ratio=1&rotation=0&showTitle=false&status=done&style=none&title=)
  15. 从图中可以看出,客户端的父进程负责接收数据,额外创建的子进程负责发送数据,分割后,不同进程分别负责输入输出,这样,无论客户端是否从服务器端接收完数据都可以进程传输。
  16. 分割 I/O 程序的另外一个好处是,可以**提高频繁交换数据的程序性能**,图下图所示:<br />![](https://s2.ax1x.com/2019/01/21/kPbvtg.png#crop=0&crop=0&crop=1&crop=1&id=CnWRT&originHeight=374&originWidth=612&originalType=binary&ratio=1&rotation=0&showTitle=false&status=done&style=none&title=)
  17. <a name="ffab2d71"></a>
  18. ## 10.5.2 回声客户端的 I/O 程序分割
  19. 下面是回声客户端的 I/O 分割的代码实现:
  20. ```c
  21. #include <stdio.h>
  22. #include <stdlib.h>
  23. #include <string.h>
  24. #include <unistd.h>
  25. #include <arpa/inet.h>
  26. #include <sys/socket.h>
  27. #define BUF_SIZE 30
  28. void error_handling(char *message);
  29. void read_routine(int sock, char *buf);
  30. void write_routine(int sock, char *buf);
  31. int main(int argc, char *argv[]){
  32. int sock;
  33. pid_t pid;
  34. char buf[BUF_SIZE];
  35. struct sockaddr_in serv_adr;
  36. if (argc != 3){
  37. printf("Usage : %s <IP> <port>\n", argv[0]);
  38. exit(1);
  39. }
  40. sock = socket(PF_INET, SOCK_STREAM, 0);
  41. memset(&serv_adr, 0, sizeof(serv_adr));
  42. serv_adr.sin_family = AF_INET;
  43. serv_adr.sin_addr.s_addr = inet_addr(argv[1]);
  44. serv_adr.sin_port = htons(atoi(argv[2]));
  45. if (connect(sock, (struct sockaddr *)&serv_adr, sizeof(serv_adr)) == -1)
  46. error_handling("connect() error!");
  47. pid = fork();
  48. if (pid == 0)
  49. write_routine(sock, buf);
  50. else
  51. read_routine(sock, buf);
  52. close(sock);
  53. return 0;
  54. }
  55. void read_routine(int sock, char *buf){
  56. while (1){
  57. int str_len = read(sock, buf, BUF_SIZE);
  58. if (str_len == 0)
  59. return;
  60. buf[str_len] = 0;
  61. printf("Message from server: %s", buf);
  62. }
  63. }
  64. void write_routine(int sock, char *buf){
  65. while (1){
  66. fgets(buf, BUF_SIZE, stdin);
  67. if (!strcmp(buf, "q\n") || !strcmp(buf, "Q\n")){
  68. // 向服务器端传递 EOF,因为fork函数复制了文件描述符
  69. // 所以通过1次close调用不够关闭描述符总而传递EOF,需要调用shutdown来显示关闭
  70. shutdown(sock, SHUT_WR);
  71. return;
  72. }
  73. write(sock, buf, strlen(buf));
  74. }
  75. }
  76. void error_handling(char *message){
  77. fputs(message, stderr);
  78. fputc('\n', stderr);
  79. exit(1);
  80. }

可以配合刚才的并发服务器进行执行:
C10 多进程服务器端 - 图11
可以看出,基本和以前的一样,但是里面的内部结构却发生了很大的变化。

10.6 习题

  1. 下列关于进程的说法错误的是?
    答:以下加粗的内容为正确的
    1. 从操作系统的角度上说,进程是程序运行的单位
    2. 进程根据创建方式建立父子关系
    3. 进程可以包含其他进程,即一个进程的内存空间可以包含其他进程
    4. 子进程可以创建其他子进程,而创建出来的子进程还可以创建其他子进程,但所有这些进程只与一个父进程建立父子关系。
  2. 调用 fork 函数将创建子进程,一下关于子进程错误的是?
    答:以下加粗的内容为正确的
    1. 父进程销毁时也会同时销毁子进程
    2. 子进程是复制父进程所有资源创建出的进程
    3. 父子进程共享全局变量
    4. 通过 fork 函数创建的子进程将执行从开始到 fork 函数调用为止的代码。
  3. 创建子进程时复制父进程所有内容,此时复制对象也包含套接字文件描述符。编写程序验证赋值的文件描述符整数值是否与原文件描述符数值相同。
    数值相同。
  4. 请说明进程变为僵尸进程的过程以及预防措施。
    答:当一个父进程以fork()系统调用建立一个新的子进程后,核心进程就会在进程表中给这个子进程分配一个进入点,然后将相关信息存储在该进入点所对应的进程表内。这些信息中有一项是其父进程的识别码。而当这个子进程结束的时候(比如调用exit命令结束),其实他并没有真正的被销毁,而是留下一个称为僵尸进程(Zombie)的数据结构(系统调用exit的作用是使进程退出,但是也仅仅限于一个正常的进程变成了一个僵尸进程,并不能完全将其销毁)。预防措施:通过 wait 和 waitpid 函数加上信号函数写代码来预防。