既然我们要实现多进程服务器,就要考虑这样带来的好处。

§ fork 和 sigaction 函数实现多进程服务器 - 图13

如果对于每个客户端,处理器的服务时间为 1s,那么第 50 个客户端需要等待 50s,第 100 个客户端就需要等待 100s。所以,越靠后的客户端就会越不满意于服务器的效率,至此就发展出了并发服务器。

一、并发服务器的实现方法

为了对服务器端进行改进,我们就需要充分利用到计算机。考虑到网络程序中数据通信时间比 CPU 运算时间占比更大,所以向多个客户端提供服务是一种有效利用 CPU 的方式。

§ fork 和 sigaction 函数实现多进程服务器 - 图14

下面列出的就是几种具有代表性的并发服务器端实现模型和方法:

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

下面是针对一些基础知识的补充:

§ fork 和 sigaction 函数实现多进程服务器 - 图15 CPU 的组成

§ fork 和 sigaction 函数实现多进程服务器 - 图16

中央处理单元(CPU)是进行运算和逻辑操作的部件,包含:

  • 寄存器(只能够存储有限数据)
  • 一个高频时钟(clock):对 CPU 内部操作与系统其他组件进行同步
  • 一个控制单元(control unit,CU):协调参与机器指令执行的步骤序列
  • 一个算术逻辑单元(arithmetic logic unit,ALU):执行算术运算(如加法和减法),以及逻辑运算(如与、或、非)

§ fork 和 sigaction 函数实现多进程服务器 - 图17 进程和线程的区别

§ 1.线程是什么 · 语雀 (yuque.com)

§ fork 和 sigaction 函数实现多进程服务器 - 图18 CPU 核的个数与进程数

拥有 2 个运算设备(ALU)的CPU 称作双核 CPU,拥有 4 个运算器的 CPU 称作 4 核 CPU,而核的个数与可同时运行的进程数相同。但是,若进程数超过核数,进程将分时使用 CPU 资源。但是因为 CPU 运转速度极快,我们会感到所有进程同时运行。当然,核数越多,这种感觉越明显。

§ fork 和 sigaction 函数实现多进程服务器 - 图19

二、进程概念

因为 Windows 不支持多进程,所以多进程服务器只能够在 Linux 下进行。

§ fork 和 sigaction 函数实现多进程服务器 - 图20 查看进程 ID

我们可以通过如下命令查看进程 ID

  1. ps au

Linux 系统会给创建的进程分配一个 ID,这个 ID 会被称为“进程ID”,其值为大于 2 的整数。1 要分配给操作系统启动后的首个进程(该进程用于协助操作系统),因此用户进程无法得到值为 1 的进程 ID。查看的进程如下所示:

§ fork 和 sigaction 函数实现多进程服务器 - 图21

通过指定参数 a 和 u 能够列出所有进程的详细信息。

§ fork 和 sigaction 函数实现多进程服务器 - 图22 通过调用 fork 函数创建进程

创建进程的方法很多,这里用于介绍创建多进程服务器端的 fork 函数。

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

fork 函数将复制进程,从而产生一个子进程,而 fork 函数便会返回这个子进程的进程 ID。

  1. #include <unistd.h>
  2. #include <stdio.h>
  3. int gval = 10;
  4. int main(void) {
  5. int lval = 25;
  6. pid_t pid = fork(); // pid 为子进程 ID
  7. if (pid == 0) // 子进程
  8. gval+=2, lval+=2;
  9. else // 父进程
  10. gval-=2, lval-=2;
  11. if (pid == 0)
  12. printf("Child Proc: [%d, %d] \n", gval, lval);
  13. else
  14. printf("Parent Proc: [%d, %d] \n", gval, lval);
  15. return 0;
  16. }

该函数的输出为:

§ fork 和 sigaction 函数实现多进程服务器 - 图23

从上面的运行结果可以看出来,调用了 fork 函数后,父子进程拥有完全独立的内存结构。

三、进程和僵尸进程

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

§ fork 和 sigaction 函数实现多进程服务器 - 图24 僵尸进程:

进程完成工作后,应该被销毁,但是有些进程就会变成僵尸进程,占用系统中的重要资源。这种状态下的进程称作“僵尸进程”。

§ fork 和 sigaction 函数实现多进程服务器 - 图25 产生僵尸进程的原因

为了防止僵尸进程的出现,先来解释僵尸进程产生的原因。对于上述调用的 fork 函数产生子进程,程序执行完成后,需要终止进程,而进程终止的方式有 2 种

  1. 传递参数并调用 exit 函数
  2. main 函数中执行 return 语句并返回值

向 exit 函数传递的参数值和 main 函数的 return 语句返回的值都会传递给操作系统,而操作系统不会销毁子进程,直到把这些值传递给产生该子进程的父进程。处在这种状态下的进程就是僵尸进程。也就是说,将子进程变成僵尸进程的正是操作系统。既然如此,僵尸进程什么时候被销毁呢?

刚才其实已经给出了提示,即 “应该向创建子进程的父进程传递子进程的 exit 参数值或 return 语句的返回值。”

如何向父进程传递这些值呢?操作系统不会主动把这些值传递给父进程,只有父进程主动调用函数时,操作系统才会传递该值。换言之,如果父进程未主动要求获得子进程的结束状态值,操作系统将一直保存,并让子进程长时间处于僵尸进程状态。也就是说,父母负责收回自己生的孩子。

§ fork 和 sigaction 函数实现多进程服务器 - 图26

下面创建一个僵尸进程

  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'm a child process");
  7. }
  8. else {
  9. printf("Child Process ID: %d \n", pid); // 输出子进程 ID
  10. sleep(20);
  11. // 父进程暂停 20s,如果父进程终止,处于僵尸状态的子进程将同时销毁
  12. // 因此,延缓父进程的执行以验证僵尸进程
  13. }
  14. if (pid == 0) {
  15. puts("End Child Process");
  16. }
  17. else {
  18. puts("End parent process");
  19. }
  20. return 0;
  21. }

上述代码输出为

§ fork 和 sigaction 函数实现多进程服务器 - 图27

sleep(20) 暂停期间,我们可以在其他窗口查看创建的子进程 ID。

§ fork 和 sigaction 函数实现多进程服务器 - 图28

可以看到,PID 为 429712 的进程状态为僵尸进程,经过 20 秒的等待时间后,PID 为 429711 的父进程和僵尸子进程 429712 同时销毁。

§ fork 和 sigaction 函数实现多进程服务器 - 图29

§ fork 和 sigaction 函数实现多进程服务器 - 图30 销毁僵尸进程方法 1:利用 wait 函数

为了销毁子进程,父进程应该主动请求获取子进程的返回值,让父进程发起请求的方法有 2 种,第一种方法就是 wait 函数。

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

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

  • WIFEEXITED 子进程正常终止时返回 “真”
  • 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. // 创建子进程1
  8. pid_t pid = fork();
  9. if (pid == 0) {
  10. return 3; // 通过调用 return 终止子进程1
  11. }
  12. else {
  13. printf("Child PID: %d \n", pid);
  14. pid = fork(); // 创建子进程2
  15. if (pid == 0) {
  16. exit(7); // 通过 exit 终止子进程2
  17. }
  18. else {
  19. printf("Child PID: %d \n", pid);
  20. // 调用 wait 函数,之前终止的子进程1相关信息将被保存到 status 变量
  21. // 同时相关子进程被完全销毁
  22. wait(&status);
  23. // 通过 WIFEXITED 宏验证子进程是否正常终止
  24. // 如果正常退出,则调用 WIEXITSTATUS 宏输出子进程1的返回值
  25. if (WIFEXITED(status)) {
  26. printf("Child send one: %d \n", WEXITSTATUS(status));
  27. }
  28. // 因为之前创建了2个进程,所以再次调用 wait 函数和宏
  29. wait(&status);
  30. if (WIFEXITED(status)) {
  31. printf("Child send two: %d \n", WEXITSTATUS(status));
  32. }
  33. // 为暂停父进程终止而插入的代码,此时可查看子进程的状态
  34. sleep(30);
  35. }
  36. }
  37. return 0;
  38. }

运行以上程序,输出结果如下

§ fork 和 sigaction 函数实现多进程服务器 - 图31

此时,我们在程序 sleep(30) 期间,使用 ps au 查看进程是否被销毁

§ fork 和 sigaction 函数实现多进程服务器 - 图32

可以看到系统中并没有上述结果中的 PID 对应的进程,这是因为调用了 wait 函数销毁了子进程 1 和子进程 2,并且取得了子进程的返回值 3 和 7.

上述就是通过 wait 函数销毁僵尸进程的方法。调用 wait 函数时,如果没有已终止的子进程,那么程序将阻塞到直有子进程的时候终止,因此必须谨慎调用该函数。

§ fork 和 sigaction 函数实现多进程服务器 - 图33 销毁僵尸进程 2:使用 waitpid 函数

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

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

上述函数的参数含义如下

  • pid:等待终止的目标子进程的 ID,若传递 -1,则与 wait 函数相同,可以等待任意子进程终止。
  • statloc:与 wait 函数的 statloc 参数有相同含义,即保存子进程的返回值
  • options:传递头文件 sys/wait.h 中声明的常量 WNOHANG,即使没有终止的子进程也不会进入阻塞状态,而是返回 0 并退出函数

下面介绍调用上述函数的示例,调用 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 函数推迟子进程的执行,导致程序延迟 15 秒
  9. return 24; // 使用 return 终止子进程
  10. }
  11. else {
  12. // 调用 waitpid 函数,向第三个参数传递 WNOHANG
  13. // 所以如果之前没有终止的子进程则将返回 0
  14. while (!waitpid(-1, &status, WNOHANG)) {
  15. sleep(3);
  16. puts("Sleep 3 seconds.");
  17. }
  18. if (WIFEXITED(status)) {
  19. printf("Child send %d \n", WEXITSTATUS(status));
  20. }
  21. }
  22. return 0;
  23. }

查看上述的运行结果:

§ fork 和 sigaction 函数实现多进程服务器 - 图34

可以看到上述父进程一共执行了 5 次 puts("Sleep 3 seconds."),从而可以看到 waitpid 函数并未阻塞。

四、利用信号机制,销毁运行完的子进程

§ fork 和 sigaction 函数实现多进程服务器 - 图35 给进程定一个闹钟, 结束时发出信号

由于等待进程时间不确定,所以要引入信号机制,给进程一个闹钟,等进程结束的时候闹钟响起(即发起信号),从而销毁进程,防止僵尸化。

我们之前看到了进程的创建以及销毁方法,但还有一个问题没有解决

  1. 子进程究竟什么时候终止,调用 waitpid 函数后要无休止等待吗?

父进程往往与子进程一样繁忙,因此不能只调用 waitpid 函数以等待子进程终止。

针对这个问题,我们使用信号处理的方式来解决。

Java 题外话

因为进程或线程是由操作系统提供支持的,所以 Windows 和 Linux 使用不同的方式来创建进程或线程。而我们学习的 C 或 C++ 是不支持跨平台的,所以在这两个平台上的代码没法直接移植,而 Java 由于其虚拟机保持了平台移植性,而它能够以独立于操作系统的方式提供进程和线程的创建方法。

既然如此,Java 网络编程是否相对简单?正如我们学过的,网络编程需要一定的操作系统只是,因此有些人会把网络编程当作系统编程的一部分。基于 Java 进行网络编程,的确会拜托特定的操作系统,所以有人误以为 Java 网络编程相对简单。

如果在语言层面支持网络编程所需的所有机制,将延长学习时间。要通过面向对象的方法编写高性能网络程序,需要更多努力和知识。大家尝试使用 Java 语言网路编程,编写不局限于 Linux 或 Windows 平台的程序。Java 在分布式环境(手机,Linux 电脑,Windows 电脑等各种设备)提供理想的网络编程模型

§ fork 和 sigaction 函数实现多进程服务器 - 图36 利用 sigaction 函数进行信号处理

之前学习的 signal 函数 已经足以编写防止僵尸进程生成的代码,现在用到的 sigaction 函数类似于 signal 函数,并且完全可以代替后者,使之更加稳定。

为什么 sigaction 函数相比于 signal 函数要更加稳定?

signal 函数在 UNIX 系列的不同操作系统中可能存在区别,但 sigaction 函数却是跨平台的,所以其是完全相同的。

signal 函数 § fork 和 sigaction 函数实现多进程服务器 - 图37 sigaction 函数

现在很少用 signal 函数编写程序,现在用到的 signal 函数也多是为了保持对旧程序的兼容。而跨平台的 sigaction 函数并不是能够完全代替 signal 函数,所以下面就是介绍的可替换的部分。

sigaction 函数介绍

函数原型

  1. #include <signal.h>
  2. // sigaction 函数执行成功时返回 0,失败时返回 -1
  3. int sigaction (int signo,
  4. const struct sigaction *act,
  5. struct sigaction *oldact)

函数参数:

  • signo:与 signal 函数相同,传递信号信息。
  • act:对应于第一个参数的信号处理函数(信号处理器)信息
  • oldact:通过此参数获取之前注册的信号处理函数指针,若不需要则传递 0

我们将使用上述 sigaction 函数初始化 sigaction 结构体变量,该结构体定义如下。

  1. struct sigaction {
  2. void (*sa_handler)(int); // sa_handler 是信号处理函数的指针
  3. sigset_t sa_mask; // sa_mask 和 sa_flags 用来指定信号相关的选项和特性,
  4. int sa_flags; // 我们的目的主要是防止产生僵尸进程,故省略。
  5. }

下面给出的示例,包含了尚未讲解的使用 sigaction 函数所需的全部内容。

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

运行结果

§ fork 和 sigaction 函数实现多进程服务器 - 图38

§ fork 和 sigaction 函数实现多进程服务器 - 图39 利用信号处理技术消灭僵尸进程

下面就编写程序来消灭僵尸进程,子进程终止时将产生 SIGCHLD 信号,知道这一点很容易就完成了。下面使用 sigaction 函数编写例子

  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);
  11. printf("Child send: %d \n", WEXITSTATUS(status));
  12. }
  13. }
  14. int main(int argc, char *argv[]) {
  15. pid_t pid;
  16. // 注册信号对应的处理器,若子进程终止,调用定义的函数 read_childproc。
  17. // 处理函数中调用了 waitpid 函数,所以子进程将正常终止,不会成为僵尸进程。
  18. struct sigaction act;
  19. act.sa_handler = read_childproc;
  20. sigemptyset(&act.sa_mask);
  21. act.sa_flags = 0;
  22. sigaction(SIGCHLD, &act, 0);
  23. pid = fork(); // 创建子进程 1
  24. if (pid == 0) { // 子进程 1 执行区域
  25. puts("HI!I'm child process");
  26. sleep(10);
  27. return 12;
  28. }
  29. else { // 父进程执行区域
  30. printf("Child proc id:%d \n", pid);
  31. pid = fork(); // 创建子进程 2
  32. if (pid == 0) { // 子进程 2 执行区域
  33. puts("Hi! I'm child process");
  34. sleep(10);
  35. exit(24);
  36. }
  37. else {
  38. int i;
  39. printf("Child proc id:%d \n", pid);
  40. // 为了等待发生 SIGCHLD 信号,使父进程共暂停 5 次,每次间隔 5 秒,
  41. // 发生信号时,父进程将被唤醒,因此实际暂停时间不到 25 秒
  42. for (i=0; i<5; i++) {
  43. puts("Wait ...");
  44. sleep(5);
  45. }
  46. }
  47. }
  48. }

运行结果

§ fork 和 sigaction 函数实现多进程服务器 - 图40

从中可以看到,子进程并没有变成僵尸进程,而不是正常终止了。下面就编写多进程服务端。

五、基于多任务的并发服务器

§ fork 和 sigaction 函数实现多进程服务器 - 图41 并发服务器模型

现在扩展服务器为并发服务器,使之能够向多个客户端提供服务。下图是基于多进程的并发回声服务器端的实现模型。

§ fork 和 sigaction 函数实现多进程服务器 - 图42

从这里可以看到,当客户端发起连接请求的时候,回声客户端就创建子进程服务。

§ fork 和 sigaction 函数实现多进程服务器 - 图43

为了完成并发任务,回声服务器端需要完成以下操作:

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

我们的父进程取得了客户端的套接字,而通过简单的 fork() ,则子进程就能得到这个资源,所以这个过程很简单。

§ fork 和 sigaction 函数实现多进程服务器 - 图44 实现并发服务器

下面给出的是并发回声服务器端的实现代码,该程序基于多进程实现并发服务。

  1. #include <stdio.h>
  2. #include <stdlib.h>
  3. #include <string.h>
  4. #include <unistd.h>
  5. #include <sys/wait.h>
  6. #include <arpa/inet.h>
  7. #include <sys/socket.h>
  8. #define BUF_SIZE 30
  9. void error_handling(char *message);
  10. void read_childproc(int sig);
  11. int main(int argc, char *argv[]) {
  12. int serv_sock, clnt_sock;
  13. struct sockaddr_in serv_adr, clnt_adr;
  14. pid_t pid;
  15. struct sigaction act;
  16. socklen_t adr_sz;
  17. int str_len, state;
  18. char buf[BUF_SIZE];
  19. // 1.检查输入格式
  20. if (argc != 2) {
  21. printf("Usage: %s <port> \n", argv[0]);
  22. exit(1);
  23. }
  24. // 2.注册信号
  25. act.sa_handler = read_childproc;
  26. sigemptyset(&act.sa_mask);
  27. act.sa_flags = 0;
  28. state = sigaction(SIGCHLD, &act, 0);
  29. // 3.设定服务器端套接字
  30. serv_sock = socket(PF_INET, SOCK_STREAM, 0);
  31. memset(&serv_adr, 0, sizeof(serv_adr));
  32. serv_adr.sin_family = AF_INET;
  33. serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
  34. serv_adr.sin_port = htons(atoi(argv[1]));
  35. // 4.bind() 函数将套接字与特定的 IP 地址和端口绑定起来
  36. if (bind( serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr) ) == -1)
  37. error_handling("bind() error");
  38. // 5.listen() 函数可以让套接字进入被动监听状态
  39. if (listen(serv_sock, 5) == -1)
  40. error_handling("listen() error");
  41. while(1) {
  42. // 6.等待客户端发起连接,从而使用 accept 建立连接
  43. adr_sz = sizeof(clnt_adr);
  44. clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
  45. if (clnt_sock == -1)
  46. continue;
  47. else
  48. puts("new client connected...");
  49. // 7.创建子进程,每个子进程对应一个客户端
  50. pid = fork();
  51. if (pid == -1) {
  52. close(clnt_sock);
  53. continue;
  54. }
  55. if (pid == 0) { // 8.子进程干活
  56. close(serv_sock);
  57. while(str_len = read(clnt_sock, buf, BUF_SIZE) != 0)
  58. write(clnt_sock, buf, str_len);
  59. close(clnt_sock);
  60. puts("client disconnected...");
  61. return 0;
  62. }
  63. else
  64. // 9.注意:因为子进程复制了套接字,所以父进程的套接字需要销毁
  65. close(clnt_sock);
  66. }
  67. close(serv_sock);
  68. return 0;
  69. }
  70. void read_childproc(int sig) {
  71. pid_t pid;
  72. int status;
  73. pid = waitpid(-1, &status, WNOHANG);
  74. printf("removed proc id: %d \n", pid);
  75. }
  76. void error_handling(char *message) {
  77. fputs(message, stderr);
  78. fputc('\n', stderr);
  79. exit(1);
  80. }

为什么多进程会改变 accept() 的阻塞状态呢?

这是因为,父进程在 accept 建立连接后,后面的工作就交给它的小弟—子进程了,而它就进入了新的 accept 状态,从而等待下一个客户端的连接

§ fork 和 sigaction 函数实现多进程服务器 - 图45

验证上述的并发服务器,对于 Windows 客户端,使用如下代码。

  1. #include <stdio.h>
  2. #include <stdlib.h>
  3. #include <WinSock2.h>
  4. #pragma comment(lib, "ws2_32.lib") // 加载 ws2_32.dll
  5. #define BUF_SIZE 100
  6. int main() {
  7. // 初始化 DLL
  8. WSADATA wsaData;
  9. WSAStartup(MAKEWORD(2, 2), &wsaData);
  10. // 创建 TCP 套接字
  11. SOCKET sock = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);
  12. // 向服务器发起请求
  13. struct sockaddr_in sockAddr;
  14. memset(&sockAddr, 0, sizeof(sockAddr)); // 每个字节都用 0 填充
  15. sockAddr.sin_family = PF_INET;
  16. sockAddr.sin_addr.s_addr = inet_addr("116.62.223.115");
  17. sockAddr.sin_port = htons(1234);
  18. connect(sock, (SOCKADDR*)&sockAddr, sizeof(SOCKADDR));
  19. // 获取用户输入的字符串并发送给服务器
  20. char bufSend[BUF_SIZE] = {0};
  21. printf("Input a string: ");
  22. scanf("%s", bufSend);
  23. send(sock, bufSend, strlen(bufSend), 0);
  24. // 接收服务器传回的数据
  25. char bufRecv[BUF_SIZE] = {0};
  26. recv(sock, bufRecv, BUF_SIZE, 0);
  27. // 输出接收到的数据
  28. printf("Message from server: %s\n", bufRecv);
  29. // 关闭套接字
  30. closesocket(sock);
  31. // 终止使用 DLL
  32. WSACleanup();
  33. system("pause");
  34. return 0;
  35. }

使用 gcc client.c -o client.exe -lwsock32 编译上述的代码,然后运行。

可以看到服务器端的运行结果如下:

§ fork 和 sigaction 函数实现多进程服务器 - 图46

§ fork 和 sigaction 函数实现多进程服务器 - 图47 通过 fork 复制文件描述符

在上述的代码中,我们看到通过 fork() 函数,父进程将 2 个套接字文件描述符(一个是服务器端套接字,另一个是与客户端连接的套接字)复制给子进程。

只复制文件描述符吗?是否也复制了套接字呢?

文件描述符的实际复制多少有些难以理解。调用 fork 函数时复制父进程的所有资源,有些人可能认为也会同时复制套接字。但套接字并非进程所有—从严格意义上来说,套接字属于操作系统,只是进程拥有代表相应套接字的文件描述符,因此不会复制套接字。而且,一个套接字对应一个端口,所以复制套接字的话,就会造成端口对应不明确了。

所以,fork 函数只会复制文字描述符,所以就是多个文字描述符对应 1 套套接字。

§ fork 和 sigaction 函数实现多进程服务器 - 图48

我们看到可以实现 多个描述符->一个套接字 的情况,因此要想清除套接字,就必须得把对应的所有描述符给销毁,这就对应了上述代码中的 close(clnt_sock)

六、分割 TCP 的 I/O 程序

上面谈论的是服务器端的相关内容,下面讨论客户端中分割 I/O 程序的方法。

§ fork 和 sigaction 函数实现多进程服务器 - 图49 分割 I/O 程序的客户端模型

我们现在的客户端在向服务器端发送完消息后,本身就一直处于等待回信的状态,这何尝不是一种阻塞?所以我们要创建非阻塞的客户端。

而实现这个目标也很简单,和服务器端一样,我们同样通过多进程的方式将写和读的过程分割开来。多进程的客户端模型如下

§ fork 和 sigaction 函数实现多进程服务器 - 图50

在上述的模型中,回声客户端有 2 个进程,其中

  • 父进程:接收数据
  • 子进程:发送数据

相比于原来 1 个进程中同时实现数据收发逻辑,其细节上已经减少了很多,所以它的优点非常明显。分割 I/O 程序后,也能够提升程序的收发性能。

§ fork 和 sigaction 函数实现多进程服务器 - 图51

§ fork 和 sigaction 函数实现多进程服务器 - 图52 实现分割 I/O 程序的客户端

下面是读写分离客户端的代码实现

  1. #include <stdio.h>
  2. #include <stdlib.h>
  3. #include <string.h>
  4. #include <unistd.h>
  5. #include <arpa/inet.h>
  6. #include <sys/socket.h>
  7. #define BUF_SIZE 30
  8. void error_handling(char *message);
  9. void read_routine(int sock, char *buf);
  10. void write_routine(int sock, char *buf);
  11. int main(int argc, char *argv[]) {
  12. int sock;
  13. pid_t pid;
  14. char buf[BUF_SIZE];
  15. struct sockaddr_in serv_adr;
  16. if (argc != 3) {
  17. printf("Usage: %s <IP> <port> \n", argv[0]);
  18. exit(1);
  19. }
  20. sock = socket(PF_INET, SOCK_STREAM, 0);
  21. memset(&serv_adr, 0, sizeof(serv_adr));
  22. serv_adr.sin_family = AF_INET;
  23. serv_adr.sin_addr.s_adr = inet_addr(argv[1]);
  24. serv_adr.sin_port = htons(atoi(argv[2]));
  25. if (connect(sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
  26. error_handling("connect() error!");
  27. pid = fork();
  28. if (pid == 0)
  29. write_routine(sock, buf); // 子进程写
  30. else
  31. read_routine(sock, buf); // 父进程读
  32. close(sock);
  33. return 0;
  34. }
  35. void read_routine(int sock, char *buf) { // 只实现读数据功能
  36. while(1) {
  37. int str_len = read(sock, buf, BUF_SIZE);
  38. if (str_len == 0)
  39. return;
  40. buf[str_len] = 0;
  41. printf("Message from server: %s", buf);
  42. }
  43. }
  44. void write_routine(int sock, char *buf) { // 只实现写数据功能
  45. while(1) {
  46. fgets(buf, BUF_SIZE, stdin);
  47. if (!strcmp(buf, "q\n") || !strcmp(buf, "Q\n")) { // 客户端退出
  48. shutdown(sock, SHUT_WR); // 客户端只读不写,shutdown函数向服务器传递EOF
  49. return;
  50. }
  51. write(sock, buf, strlen(buf));
  52. }
  53. }
  54. void error_handling(char *message) {
  55. fputs(message, stderr);
  56. fputc('\n', stderr);
  57. exit(1);
  58. }