基于TCP的服务端/客户端

类比 打电话

TCP / IP协议栈

image-20220228174635103

可以理解为数据收发分成 4 个层次化过程。

TCP和UDP是以IP层提供的路径信息为基础 完成实际的数据传输,故该层称为传输层。

TCP可以保证数据的可靠传输,但是它发送数据是以 IP层为基础(这是协议栈层次化的原因)。

image-20220228174924673

实现基于TCP的服务端 / 客户端

  • TCP服务端的默认函数的调用顺序

    image-20220228175057148

    说明:服务器端首先创建的套接字并非 真正的服务端套接字, 调用listen函数时的套接字才是服务端套接字。(我的理解是只有调用了bind()和listen()才能确定是服务端,所以此套接字才能确定是服务端套接字。如果直接调用connect()就是客户端套接字。)

  • TCP客户端的默认函数的调用顺序

    image-20220228175534790

    与服务端相比,区别就在于**「请求连接」**,他是创建客户端套接字后向服务端发起的连接请求。服务端调用 listen 函数后创建连接请求等待队列,之后客户端即可请求连接。

    客户端调用 connect 函数候,发生以下函数之一才会返回(完成函数调用):

    • 服务端接受连接请求

    • 发生断网等一场状况而中断连接请求

      注意:接受连接不代表服务端调用 accept 函数,其实只是服务器端把连接请求信息记录到等待队列。 因此 connect 函数返回后并不应该立即进行数据交换

    基于 TCP 的服务端/客户端函数调用关系

    TCP客户端套接字在调用connect函数时自动分配IP地址和端口号。

    image-20220228191544816

实现迭代服务端 / 客户端

image-20220228191630336

调用accept函数后,紧接着调用 I/O 相关的 read write 函数,然后调用 close 函数。这并非针对服务器套接字,而是针对 accept 函数调用时创建的套接字

TCP原理

  • TCP套接字的I/O套件:image-20220228194310120

  • write函数调用瞬间,数据将移至输出缓冲;read函数调用瞬间,从输入缓冲读取数据。

  • I/O缓冲特性如下:

    • I/O缓冲在每个TCP套接字中单独存在;
    • I/O缓冲在 创建套接字时 自动生成
    • 即使关闭套接字也会继续传递输出缓冲中遗留的数据;
    • 关闭套接字将丢失输入缓冲中的数据。
  • TCP内部工作原理:与对方套接字的连接

    • 三次握手 image-20220228195230426
    • 套接字是 全双工方式工作的,即 可以 双向传递数据
  • TCP内部工作原理:与对方主机的数据交换

    • image-20220228195604776

      ACK号 = SEQ号 + 传递的字节数 + 1

      超时重传:image-20220228195717473

  • TCP内部工作原理:断开套接字的连接

    • image-20220228195828319
    • 图中数据包内的 FIN 表示断开连接。 也就是 双方各发送一次FIN消息后断开连接。 此过程经历4个阶段,因此称为 四次握手。 图中主机B传递了 两次ACK 5001,第二次FIN数据包中的ACK 5001只是因为接受了 ACK消息后未接收到的数据重传的。
  1. TCP套接字连接设置的三次握手过程。尤其是3次数据交换过程每次收发的数据内容。

三次握手主要分为:

  1. 与对方套接字 建立连接
  2. 与对方套接字进行 数据交换
  3. 断开与对方套接字的连接

每次收发的数据内容主要有:

  1. 由主机1给主机2发送初始的SEQ,首次连接请求的关键字是SYN,表示 收发数据前同步传输的消息
  2. 主机2收到报文以后,给主机 1 传递信息,用一个新的SEQ表示自己的序号,然后ACK代表已经接受到主机1的消息,希望接受下一个消息
  3. 主机1收到主机2的确认以后,还需要给主机2给出确认,此时再发送一次SEQ和ACK

基于UDP的服务端/客户端

类比 寄信

UDP是一种不可靠的数据传输方式。TCP 的生命在于流控制。

  • UDP的工作原理

    image-20220228201106602

    IP 的作用就是让离开主机 B 的 UDP 数据包准确传递到主机 A 。但是把 UDP 数据包最终交给主机 A 的某一 UDP 套接字的过程是由 UDP 完成的。UDP 的最重要的作用就是根据端口号将传到主机的数据包交付给最终的 UDP 套接字。

    • UDP的高效使用

      TCP比UDP慢的原因有:

      • 收发数据前后进行的连接设置及清楚过程;
      • 收发过程中为保证可靠性而添加的流控制。

      如果收发的数据量小但是需要频繁连接时,UDP比TCP更高效。

  • 实现基于 UDP 的服务端/客户端

    • UDP中的服务端和客户端没有连接

    • UDP服务器和客户端均只需一个套接字

      image-20220228203444351

    • 基于UDP的数据I/O函数

      • TCP套接字保持与对方套接字的连接,所以传输数据时无需加上地址信息。但 **UDP套接字不会保持连接状态(UDP套接字只有简单的油筒功能)。**因此每次传输数据都需添加目标地址信息(相当于寄信在信件中填写地址)。

        发送UDP数据的函数:

         1
         2
         3
         4
         5
         6
         7
         8
         9
        10
        11
        12
        
        #include <sys/socket.h>
        ssize_t sendto(int sock, void *buff, size_t nbytes, int flags,
        struct sockaddr *to, socklen_t addrlen);
        /*
        成功时返回传输的字节数,失败是返回 -1
        sock: 用于传输数据的 UDP 套接字
        buff: 保存待传输数据的缓冲地址值
        nbytes: 待传输的数据长度,以字节为单位
        flags: 可选项参数,若没有则传递 0
        to: 存有目标地址的 sockaddr 结构体变量的地址值	【与TCP输出函数最大的区别】
        addrlen: 传递给参数 to 的地址值结构体变量长度
        */
        

        接受UDP数据的函数:

         1
         2
         3
         4
         5
         6
         7
         8
         9
        10
        11
        12
        
        #include <sys/socket.h>
        ssize_t recvfrom(int sock, void *buff, size_t nbytes, int flags,
        struct sockaddr *from, socklen_t *addrlen);
        /*
        成功时返回传输的字节数,失败是返回 -1
        sock: 用于传输数据的 UDP 套接字
        buff: 保存待传输数据的缓冲地址值
        nbytes: 待传输的数据长度,以字节为单位
        flags: 可选项参数,若没有则传递 0
        from: 存有发送端地址信息的 sockaddr 结构体变量的地址值
        addrlen: 保存参数 from 的结构体变量长度的变量地址值。
        */
        
      • UDP客户端套接字的地址分配

      UDP 程序中,调用 sendto 函数传输数据前应该完成对套接字的地址分配工作,因此调用 bind 函数。当然,bind 函数在 TCP 程序中出现过,但 bind 函数不区分 TCP 和 UDP,也就是说,在 UDP 程序中同样可以调用。另外,如果调用 sendto 函数尚未分配地址信息,则在首次调用 sendto 函数时给相应套接字自动分配 IP 和端口。而且此时分配的地址一直保留到程序结束为止,因此也可以用来和其他UDP 套接字进行数据交换。当然,IP 用主机IP,端口号用未选用的任意端口号

      调用 sendto 函数时自动分配IP和端口号,因此,UDP 客户端中通常无需额外的地址分配过程。所以之前的示例中省略了该过程。这也是普遍的实现方式。

  • UDP 的数据传输特性和调用 connect 函数

    • 存在数据边界的 UDP 套接字

      TCP数据传输中不存在数据边界,这表示「数据传输过程中调用 I/O 函数的次数不具有任何意义

      相反,UDP 是具有数据边界的下一,传输中调用 I/O 函数的次数非常重要。因此,输入函数的调用次数和输出函数的调用次数完全一致,这样才能保证接收全部已经发送的数据

      UDP 通信过程中 I/O 的调用次数必须保持一致

    • 已连接(connect)UDP 套接字与未连接(unconnected)UDP 套接字

      TCP 套接字中需注册待传传输数据的目标IP和端口号,而在 UDP 中无需注册。因此通过 sendto 函数传输数据的过程大概可以分为以下 3 个阶段:

      • 第 1 阶段:向 UDP 套接字注册目标 IP 和端口号;
      • 第 2 阶段:传输数据;
      • 第 3 阶段:删除 UDP 套接字中注册的目标地址信息。

      每次调用 sendto 函数时重复上述过程。每次都变更目标地址,因此可以重复利用同一 UDP 套接字向不同目标传递数据。这种未注册目标地址信息的套接字称为未连接套接字,反之,注册了目标地址的套接字称为连接 connected 套接字。显然,UDP 套接字默认属于未连接套接字

      当一台主机向另一台主机传输很多信息时,上述的三个阶段中,第一个阶段和第三个阶段占整个通信过程中近三分之一的时间,缩短这部分的时间将会大大提高整体性能。

    • 创建已连接 UDP 套接字

      创建已连接 UDP 套接字过程格外简单,只需针对 UDP 套接字调用 connect 函数。

      1
      2
      3
      4
      5
      6
      
      sock = socket(PF_INET, SOCK_DGRAM, 0);
      memset(&adr, 0, sizeof(adr));
      adr.sin_family = AF_INET;
      adr.sin_addr.s_addr = inet_addr(argv[1]);
      adr.sin_port = htons(atoi(argv[2]));
      connect(sock, (struct sockaddr *)&adr, sizeof(adr));
      

      针对 UDP 调用 connect 函数并不是意味着要与对方 UDP 套接字连接,这只是向 UDP 套接字注册目标IP和端口信息。

      之后就与 TCP 套接字一致,每次调用 sendto 函数时只需传递信息数据。因为已经指定了收发对象,所以不仅可以使用 sendto、recvfrom 函数,还可以使用 write、read 函数进行通信。

优雅的断开套接字的连接

之前用的方法不够优雅是因为,我们是调用 close 函数或closesocket 函数单方面断开连接的。

  • 基于 TCP 的半关闭

    TCP断开连接过程比建立连接更重要,因为连接过程中一般不会出现大问题,但是断开过程可能发生预想不到的情况。因此应该准确掌控。所以要掌握半关闭(Half-close),才能明确断开过程。

    • 单方面断开连接带来的问题

      Linux 和 Windows 的 closesocket 函数意味着完全断开连接。完全断开不仅指无法传输数据,而且也不能接收数据。因此在某些情况下,通信一方单方面的断开套接字连接,显得不太优雅。

      image-20220228211202956

      图中描述的是 2 台主机正在进行双向通信,主机 A 发送完最后的数据后,调用 close 函数断开了最后的连接,之后主机 A 无法再接受主机 B 传输的数据。实际上,是完全无法调用与接受数据相关的函数。最终,由主机 B 传输的、主机 A 必须要接受的数据也销毁了。

      为了解决这类问题,**「只关闭一部分数据交换中使用的流」**的方法应运而生。断开一部分连接是指,可以传输数据但是无法接收,或可以接受数据但无法传输。顾名思义就是只关闭流的一半。

    • 套接字和流(Stream)

      两台主机通过套接字建立连接后进入可交换数据的状态,又称「流形成的状态」。也就是把建立套接字后可交换数据的状态看作一种流。

      image-20220228211417200

      一旦两台主机之间建立了套接字连接,每个主机就会拥有单独的输入流和输出流。当然,其中一个主机的输入流与另一个主机的输出流相连,而输出流则与另一个主机的输入流相连。另外,本章讨论的「优雅的断开连接方式」只断开其中 1 个流,而非同时断开两个流。Linux 和 Windows 的 closesocket 函数将同时断开这两个流,因此与「优雅」二字还有一段距离。

    • 针对优雅断开的 shutdown 函数

      shutdown 用来关闭其中一个流:

      1
      2
      3
      4
      5
      6
      7
      
      #include <sys/socket.h>
      int shutdown(int sock, int howto);
      /*
      成功时返回 0 ,失败时返回 -1
      sock: 需要断开套接字文件描述符
      howto: 传递断开方式信息
      */
      

      调用上述函数时,第二个参数决定断开连接的方式,其值如下所示:

      • SHUT_RD : 断开输入流
      • SHUT_WR : 断开输出流
      • SHUT_RDWR : 同时断开 I/O 流

      **若向 shutdown 的第二个参数传递SHUT_RD ,则断开输入流,套接字无法接收数据。即使输入缓冲收到数据也会抹去,而且无法调用相关函数。如果向 shutdown 的第二个参数传递SHUT_WR ,则中断输出流,也就无法传输数据。若如果输出缓冲中还有未传输的数据,则将传递给目标主机。最后,若传递关键字SHUT_RDWR ,则同时中断 I/O 流。**这相当于分 2 次调用 shutdown ,其中一次以SHUT_RD 为参数,另一次以SHUT_WR 为参数。

    • 为何要半关闭

      考虑以下情况:

      一旦客户端连接到服务器,服务器将约定的文件传输给客户端,客户端收到后发送字符串「Thank you」给服务器端。

      此处「Thank you」的传递是多余的,这只是用来模拟客户端断开连接前还有数据要传输的情况。此时程序的还嫌难度并不小,因为传输文件的服务器端只需连续传输文件数据即可,而客户端无法知道需要接收数据到何时。客户端也没办法无休止的调用输入函数,因为这有可能导致程序阻塞

      是否可以让服务器和客户端约定一个代表文件尾的字符?

      这种方式也有问题,因为这意味这文件中不能有与约定字符相同的内容。为了解决该问题,服务端应最后向客户端传递 EOF 表示文件传输结束。客户端通过函数返回值接受 EOF ,这样可以避免与文件内容冲突。那么问题来了,服务端如何传递 EOF ?

      断开输出流时向主机传输 EOF。

      当然,调用 close 函数的同时关闭 I/O 流,这样也会向对方发送 EOF 。但此时无法再接受对方传输的数据。换言之,若调用 close 函数关闭流,就无法接受客户端最后发送的字符串「Thank you」。这时需要调用 shutdown 函数,只关闭服务器的输出流。这样既可以发送 EOF ,同时又保留了输入流。下面实现收发文件的服务器端/客户端。

    • 基于半关闭的文件传输程序

      上述文件传输服务器端和客户端的数据流可以整理如图:

      image-20220228213013477

域名及网络地址

  • 域名系统

    • DNS 是对IP地址和域名进行相互转换的系统,其核心是 DNS 服务器。

      域名就是我们常常在地址栏里面输入的地址,将比较难记忆的IP地址变成人类容易理解的信息。

      • DNS服务器

        相当于一个字典,可以查询出某一个域名对应的IP地址

        image-20220228213748644

  • IP地址和域名之间的转换

    域名的必要性:因为IP地址可能经常改变,而且也不容易记忆,通过域名可以随时更改解析,达到更换IP的目的

    • 利用域名获取IP地址

      1
      2
      3
      4
      5
      
      #include <netdb.h>
      struct hostent *gethostbyname(const char *hostname);
      /*
      成功时返回 hostent 结构体地址,失败时返回 NULL 指针
      */
      

      返回时 地址信息装入hostent结构体:

       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      
      struct hostent
      {
      char *h_name; /* Official name of host. */
      char **h_aliases; /* Alias list.可以通过多个域名访问同一主页。同一IP可以绑定多个域名 */
      int h_addrtype; /* Host address type. */
      int h_length; /* Length of address. */
      char **h_addr_list; /* List of addresses from name server. */
      
      /* h_addr_list: 这个是最重要的的成员。通过此变量以整数形式保存域名相对应的IP地址。另外,用
      户比较多的网站有可能分配多个IP地址给同一个域名,利用多个服务器做负载均衡。此时可以通
      过此变量获取IP地址信息。*/
      };
      

      调用 gethostbyname 函数后,返回的结构体变量如图所示:

      image-20220228214256209

    • 利用IP地址获取域名

      1
      2
      3
      4
      5
      6
      7
      8
      9
      
      #include <netdb.h>
      struct hostent *gethostbyaddr(const char *addr, socklen_t len, int family);
      /*
      成功时返回 hostent 结构体变量地址值,失败时返回 NULL 指针
      addr: 含有IP地址信息的 in_addr 结构体指针。为了同时传递 IPV4 地址之外的全部信息,该变量
      的类型声明为 char 指针
      len: 向第一个参数传递的地址信息的字节数,IPV4时为 4 ,IPV6 时为16.
      family: 传递地址族信息,ipv4 是 AF_INET ,IPV6是 AF_INET6
      */
      

多进程服务器端

进程概念及应用

  • 并发服务端的实现方法

    通过改进服务端,使其同时向所有发起请求的客户端提供服务,以提高平均满意度。而且,网络程序中数据通信时间比 CPU 运算时间占比更大,因此,向多个客户端提供服务是一种有效的利用 CPU 的方式。

    具有代表性的并发服务端的实现模型和方法:

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

    进程定义: 占用内存空间的正在运行的程序

  • 进程 ID

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

    1
    
    ps au	# 该命令同时列出了 PID(进程ID)。参数 a 和 u列出了所有进程的详细信息。
    

    image-20220228220448677

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

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

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

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

    image-20220228220630035

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

    例子:

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

    编译运行:

    1
    2
    
    gcc fork.c -o fork
    ./fork
    

    运行结果:

    image-20220228220941348

    可以看出,当执行了 fork 函数之后,此后就相当于有了两个程序在执行代码,对于父进程来说,fork函数返回的是子进程的ID,对于子进程来说,fork 函数返回 0。所以这两个变量,父进程进行了 +2 操作 ,而子进程进行了 -2 操作,所以结果是这样。

进程和僵尸进程

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

  • 僵尸(Zombie)进程

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

    僵尸进程是当子进程比父进程先结束,而父进程又没有回收子进程,释放子进程占用的资源,此 时子进程将成为一个僵尸进程。如果父进程先退出 ,子进程被init接管,子进程退出后init会回收 其占用的相关资源

  • 产生僵尸进程的原因

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

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

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

    应该向创建子进程的父进程传递子进程的 exit 参数值或 return 语句的返回值。

    如何向父进程传递这些值呢?操作系统不会主动把这些值传递给父进程。只有父进程主动发起请求(函数调用)的时候,操作系统才会传递该值。换言之,如果父进程未主动要求获得子进程结束状态值,操作系统将一直保存,并让子进程长时间处于僵尸进程状态。

    也就是说,父母要负责收回自己生的孩子。

    创建僵尸进程的例子:

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

    编译运行:

    1
    2
    
    gcc zombie.c -o zombie
    ./zombie
    

    结果:

    image-20220228221831918

    因为暂停了 30 秒,所以在这个时间内可以验证一下子进程是否为僵尸进程。

    image-20220228221859255

    通过 ps au 命令可以看出,子进程仍然存在,并没有被销毁,僵尸进程在这里显示为 Z+ .30秒后,红 框里面的两个进程会同时被销毁。

    利用 ./zombie & 可以使程序在后台运行,不用打开新的命令行窗口

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

    为了销毁子进程,父进程应该主动请求获取子进程的返回值。

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

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

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

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

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

    调用 wait 函数时,如果没有已经终止的子进程,那么程序将阻塞(Blocking)直到有子进程终止,因此要谨慎调用该函数

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

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

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

信号处理

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

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

  • 向操作系统求助

    子进程终止的识别主题是操作系统,因此,若操作系统能把如下信息告诉正忙于工作的父进程,将有助于构建更高效的程序 为了实现上述的功能,引入信号处理机制(Signal Handing)。此处**「信号」是在特定事件发生时由操作系统向进程发送的消息**。另外,为了响应该消息,执行与消息相关的自定义操作的过程被称为「处理」或「信号处理」。

  • 信号与signal函数

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

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

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

    1
    2
    3
    4
    5
    6
    7
    8
    
    #include <signal.h>
    void (*signal(int signo, void (*func)(int)))(int);
    /*
    为了在产生信号时调用,返回之前注册的函数指针
    函数名: signal
    参数:int signo,void(*func)(int)
    返回类型:参数类型为int型,返回 void 型函数指针
    */
    

    调用上述函数时,第一个参数为特殊情况信息,第二个参数为特殊情况下将要调用的函数的地址值(指针)。发生第一个参数代表的情况时,调用第二个参数所指的函数。下面给出可以在 signal 函数中注册的部分特殊情况和对应的函数。

    • SIGALRM:已到通过调用 alarm 函数注册时间
    • SIGINT:输入 ctrl+c
    • SIGCHLD:子进程终止

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

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

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

    1
    
    signal(1 SIGCHLD , #include);
    

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

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

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

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

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

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

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

     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
    
    #include <stdio.h>
    #include <unistd.h>
    #include <signal.h>
    void timeout(int sig) //信号处理器
    {
        if (sig == SIGALRM)
        puts("Time out!");
        alarm(2); //为了每隔 2 秒重复产生 SIGALRM 信号,在信号处理器中调用 alarm 函数
    }
    void keycontrol(int sig) //信号处理器
    {
        if (sig == SIGINT)
        puts("CTRL+C pressed");
    }
    int main(int argc, char *argv[])
    {
        int i;
        signal(SIGALRM, timeout); //注册信号及相应处理器
        signal(SIGINT, keycontrol);
        alarm(2); //预约 2 秒候发生 SIGALRM 信号
        for (i = 0; i < 3; i++)
        {
            puts("wait...");
            sleep(100);
        }
        return 0;
    }
    

    编译运行:

    1
    2
    
    gcc signal.c -o signal
    ./signal
    

    结果:

    image-20220301094117516

    上述结果是没有任何输入的运行结果。当输入 ctrl+c 时:

    image-20220301094250009

    就可以看到 CTRL+C pressed 的字符串。

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

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

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

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

    前面所学的内容可以防止僵尸进程,还有一个函数,叫做 sigaction 函数,他类似于 signal 函数,而且可以完全代替后者,也更稳定。之所以稳定,是因为:

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

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

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

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

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

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

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

     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
    
    #include <stdio.h>
    #include <unistd.h>
    #include <signal.h>
    void timeout(int sig)
    {
        if (sig == SIGALRM)
        puts("Time out!");
        alarm(2);
    }
    int main(int argc, char *argv[])
    {
        int i;
        struct sigaction act;
        act.sa_handler = timeout; //保存函数指针
        sigemptyset(&act.sa_mask); //将 sa_mask 函数的所有位初始化成0
        act.sa_flags = 0; //sa_flags 同样初始化成 0
        sigaction(SIGALRM, &act, 0); //注册 SIGALRM 信号的处理器。
        alarm(2); //2 秒后发生 SIGALRM 信号
        for (int i = 0; i < 3; i++)
        {
            puts("wait...");
            sleep(100);
        }
        return 0;
    }
    

    编译运行:

    1
    2
    
    gcc sigaction.c -o sigaction
    ./sigaction
    

    结果:

    1
    2
    3
    4
    5
    6
    
    wait...
    Time out!
    wait...
    Time out!
    wait...
    Time out!
    

    可以发现,结果和之前用 signal 函数的结果没有什么区别。以上就是信号处理的相关理论。

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

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

    remove_zomebie.c

     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
    
    #include <stdio.h>
    #include <stdlib.h>
    #include <unistd.h>
    #include <signal.h>
    #include <sys/wait.h>
    void read_childproc(int sig)
    {
        int status;
        pid_t id = waitpid(-1, &status, WNOHANG);
        if (WIFEXITED(status))
        {
            printf("Removed proc id: %d \n", id); //子进程的 pid
            printf("Child send: %d \n", WEXITSTATUS(status)); //子进程的返回值
        }
    }
    int main(int argc, char *argv[])
    {
        pid_t pid;
        struct sigaction act;
        act.sa_handler = read_childproc;
        sigemptyset(&act.sa_mask);
        act.sa_flags = 0;
        sigaction(SIGCHLD, &act, 0);
        pid = fork();
        if (pid == 0) //子进程执行阶段
        {
          puts("Hi I'm child process 12");
            sleep(10);
          return 12;
        }
        else //父进程执行阶段
        {
            printf("Child proc id: %d\n", pid);
            pid = fork();
            if (pid == 0)
            {
                puts("Hi! I'm child process 24");
                sleep(10);
                exit(24);
            }
            else
            {
                int i;
                printf("Child proc id: %d \n", pid);
                for (i = 0; i < 5; i++)
                {
                    puts("wait");
                    sleep(5);
                }
            }
        }
        return 0;
    }
    

    编译运行,其结果为:

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

    请自习观察结果,结果中的每一个空行代表间隔了5 秒,程序是先创建了两个子进程,然后子进程 10秒之后会返回值,第一个 wait 由于子进程在执行,所以直接被唤醒,然后这两个子进程正在睡 10 秒,所以 5 秒之后第二个 wait 开始执行,又过了 5 秒,两个子进程同时被唤醒。所以剩下的 wait 也被唤醒。 所以在本程序的过程中,当子进程终止时候,会向系统发送一个信号,然后调用我们提前写好的处理函数,在处理函数中使用 waitpid 来处理僵尸进程,获取子进程返回值。

基于多任务的并发处理器

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

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

    image-20220301100308618

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

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

    示例中给出了通过 fork 函数复制文件描述符的过程。父进程将 2 个套接字(一个是服务端套接字另一个是客户端套接字)文件描述符复制给了子进程。

    调用 fork 函数时复制父进程的所有资源,但是套接字不是归进程所有的,而是归操作系统所有只是进程拥有代表相应套接字的文件描述符。

    image-20220301100850674

    如图所示,1 个套接字存在 2 个文件描述符时,只有 2 个文件描述符都终止(销毁)后,才能销毁套接字。如果维持图中的状态,即使子进程销毁了与客户端连接的套接字文件描述符,也无法销毁套接字(服务器套接字同样如此)。因此调用 fork 函数时,要将无关紧要的套接字文件描述符关掉,如图所示:

    image-20220301101022889

分割TCP的I / O 程序

  • 分割 I/O 的优点

    我们已经实现的回声客户端的数据回声方式如下:

    向服务器传输数据,并等待服务器端回复。无条件等待,直到接收完服务器端的回声数据后,才能传输下一批数据。

    传输数据后要等待服务器端返回的数据,因为程序代码中重复调用了 read 和 write 函数。只能这么写的原因之一是,程序在 1 个进程中运行,现在可以创建多个进程,因此可以分割数据收发过程。默认分割过程如下图所示:

    image-20220301101914748

    从图中可以看出,客户端的父进程负责接收数据,额外创建的子进程负责发送数据,分割后,不同进程分别负责输入输出,这样,无论客户端是否从服务器端接收完数据都可以进程传输。

    分割 I/O 程序的另外一个好处是,可以提高频繁交换数据的程序性能,图下图所示:

    image-20220301102122233

    根据上图显示可以看出,再网络不好的情况下,明显提升速度。

进程间通信

进程间通信,意味着可以在两个不同的进程中可以交换数据。

进程间通信的基本概念

  • 通过管道(PIPE)的进程间通信

    模型:image-20220301103254070

    可以看出,为了完成进程间通信,需要创建进程。管道并非属于进程的资源,而是和套接字一样,属于操作系统(也就不是 fork 函数的复制对象)。所以,两个进程通过操作系统提供的内存空间进行通信。下面是创建管道的函数。

    1
    2
    3
    4
    5
    6
    7
    
    #include <unistd.h>
    int pipe(int filedes[2]);
    /*
    成功时返回 0 ,失败时返回 -1
    filedes[0]: 通过管道接收数据时使用的文件描述符,即管道出口
    filedes[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
    
    #include <stdio.h>
    #include <unistd.h>
    #define BUF_SIZE 30
    int main(int argc, char *argv[])
    {
        int fds[2];
        char str[] = "Who are you?";
        char buf[BUF_SIZE];
        pid_t pid;
        // 调用 pipe 函数创建管道,fds 数组中保存用于 I/O 的文件描述符
        pipe(fds);
        pid = fork(); //子进程将同时拥有创建管道获取的2个文件描述符,复制的并非管道,而是文件描述符
        if (pid == 0)
        {
            write(fds[1], str, sizeof(str));
        }
        else
        {
            read(fds[0], buf, BUF_SIZE);
            puts(buf);
        }
        return 0;
    }
    

    编译运行,其结果为:

    1
    
    Who are you?
    

    可以从程序中看出,首先创建了一个管道,子进程通过 fds[1] 把数据写入管道,父进程从 fds[0] 再把 数据读出来。可以从下图看出:

    image-20220301103631074

  • 通过管道进行进程间双向通信

    模型:

    image-20220301103743071

    示例:

     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
    
    #include <stdio.h>
    #include <unistd.h>
    #define BUF_SIZE 30
    
    int main(int argc, char *argv[])
    {
        int fds[2];
        char str1[] = "Who are you?";
        char str2[] = "Thank you for your message";
        char buf[BUF_SIZE];
        pid_t pid;
    
        pipe(fds);
        pid = fork();
        if (pid == 0)
        {
            write(fds[1], str1, sizeof(str1));
            sleep(2);
            read(fds[0], buf, BUF_SIZE);
            printf("Child proc output: %s \n", buf);
        }
        else
        {
            read(fds[0], buf, BUF_SIZE);
            printf("Parent proc output: %s \n", buf);
            write(fds[1], str2, sizeof(str2));
            sleep(3);
        }
        return 0;
    }
    

    结果:

    1
    2
    
    Parent proc output: Who are you?
    Child proc output: Thank you for your message
    

    运行结果是正确的,但是如果注释掉第18行的代码,就会出现问题,导致一直等待下去。因为数据进入管道后变成了无主数据。也就是通过 read 函数先读取数据的进程将得到数据,即使该进程将数据传到了管道。因为,注释第18行会产生问题。第19行,自己成将读回自己在第 17 行向管道发送的数据。结果父进程调用 read 函数后,无限期等待数据进入管道。

    当一个管道不满足需求时,就需要创建两个管道,各自负责不同的数据流动,过程如下图所示:

    image-20220301104538524

    下面采用上述模型改进【双向通信模型1】.

     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
    
    #include <stdio.h>
    #include <unistd.h>
    #define BUF_SIZE 30
    int main(int argc, char *argv[])
    {
        int fds1[2], fds2[2];
        char str1[] = "Who are you?";
        char str2[] = "Thank you for your message";
        char buf[BUF_SIZE];
        pid_t pid;
        pipe(fds1), pipe(fds2);
        pid = fork();
        if (pid == 0)
        {
            write(fds1[1], str1, sizeof(str1));
            read(fds2[0], buf, BUF_SIZE);
            printf("Child proc output: %s \n", buf);
        }
        else
        {
            read(fds1[0], buf, BUF_SIZE);
            printf("Parent proc output: %s \n", buf);
            write(fds2[1], str2, sizeof(str2));
        }
        return 0;
    }
    

    上面通过创建两个管道实现了功能,此时,不需要额外再使用 sleep 函数。运行结果和上面一样。

运用进程间通信

  • 保存消息的回声服务器

    下面对第 10 章的 echo_mpserv.c 进行改进,添加一个功能:

    将回声客户端传输的字符串按序保存到文件中

    实现该任务将创建一个新进程,从向客户端提供服务的进程读取字符串信息,下面是代码: echo_storeserv.c

     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
    94
    95
    96
    97
    
    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    #include <unistd.h>
    #include <signal.h>
    #include <sys/wait.h>
    #include <arpa/inet.h>
    #include <sys/socket.h>
    #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;
        int fds[2];
        pid_t pid;
        struct sigaction act;
    
        socklen_t adr_sz;
        int str_len, state;
        char buf[BUF_SIZE];
        if (argc != 2)
        {
            printf("Usgae : %s <port>\n", argv[0]);
            exit(1);
        }
        act.sa_handler = read_childproc; //防止僵尸进程
        sigemptyset(&act.sa_mask);
        act.sa_flags = 0;
        state = sigaction(SIGCHLD, &act, 0); //注册信号处理器,把成功的返回
        值给 state
        serv_sock = socket(PF_INET, SOCK_STREAM, 0); //创建服务端套接字
        memset(&serv_adr, 0, sizeof(serv_adr));
        serv_adr.sin_family = AF_INET;
        serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
        serv_adr.sin_port = htons(atoi(argv[1]));
    
        if (bind(serv_sock, (struct sockaddr *)&serv_adr, sizeof(serv_adr)) == -1) //分配IP地址和端口号
        error_handling("bind() error");
        if (listen(serv_sock, 5) == -1) //进入等待连接请求状态
        error_handling("listen() error");
        pipe(fds);
        pid = fork();
        if (pid == 0)
        {
            FILE *fp = fopen("echomsg.txt", "wt");
            char msgbuf[BUF_SIZE];
            int i, len;
            for (int i = 0; i < 10; i++)
            {
                len = read(fds[0], msgbuf, BUF_SIZE);
                fwrite((void *)msgbuf, 1, len, fp);
            }
            fclose(fp);
            return 0;
        }
        while (1)
        {
            adr_sz = sizeof(clnt_adr);
            clnt_sock = accept(serv_sock, (struct sockaddr *)&clnt_adr, &adr_sz);
            if (clnt_sock == -1)
                continue;
            else
                puts("new client connected...");
            pid = fork(); //此时,父子进程分别带有一个套接字
            if (pid == 0) //子进程运行区域,此部分向客户端提供回声服务
            {
                close(serv_sock); //关闭服务器套接字,因为从父进程传递到了子进程
                while ((str_len = read(clnt_sock, buf, BUFSIZ)) != 0)
              {
                    write(clnt_sock, buf, str_len);
                    write(fds[1], buf, str_len);
                }
                close(clnt_sock);
                puts("client disconnected...");
                return 0;
            }
            else
                close(clnt_sock); //通过 accept 函数创建的套接字文件描述符已经复制给子进程,因为服务器端要销毁自己拥有的
        }
        close(serv_sock);
        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
    2
    
    gcc echo_storeserv.c -o serv
    ./serv 9190
    

    此服务端配合第 10 章的客户端 echo_mpclient.c 使用,运行结果如下图:

    image-20220301110323594

    image-20220301110426889

    从图上可以看出,服务端已经生成了文件,把客户端的消息保存可下来,只保存了10次消息。

I/O复用

基于I/O复用的服务器端

  • 多进程服务端的缺点和解决方法

    为了构建并发服务器,只要有客户端连接请求就会创建新进程。这的确是实际操作中采用的一种方案,但并非十全十美,因为创建进程要付出很大的代价。这需要大量的运算和内存空间,由于每个进程都具有独立的内存空间,所以相互间的数据交换也要采用相对复杂的方法(IPC 属于相对复杂的通信方法)。

    I/O 复用技术可以解决这个问题。

  • 理解复用

    「复用」在电子及通信工程领域很常见,向这些领域的专家询问其概念,可能会得到如下答复:

    在 1 个通信频道中传递多个数据(信号)的技术

    「复用」的含义:

    为了提高物理设备的效率,只用最少的物理要素传递最多数据时使用的技术。

    上述两种方法的内容完全一致。可以用纸电话模型做一个类比:

    image-20220301110805823

    上图是一个纸杯电话系统,为了使得三人同时通话,说话时要同时对着两个纸杯,接听时也需要耳朵同时对准两个纸杯。为了完成 3 人通话,可以进行如下图的改进:

    image-20220301110835160

    如图做出改进,就是引入了复用技术。

    复用技术的优点:

    • 减少连线长度
    • 减少纸杯个数

    即使减少了连线和纸杯的量仍然可以进行三人同时说话,但是如果碰到以下情况:

    「好像不能同时说话?」

    实际上,因为是在进行对话,所以很少发生同时说话的情况。也就是说,上述系统采用的是「时分复用」技术。因为说话人声频率不同,即使在同时说话也能进行一定程度上的区分(杂音也随之增多)。因此,也可以说是「频分复用技术」。

  • 复用技术在服务器端的应用

    纸杯电话系统引入复用技术之后可以减少纸杯数量和连线长度。服务器端引入复用技术可以减少所需进程数。下图是多进程服务端的模型:

    image-20220301111011897

    下图是引入复用技术之后的模型:

    image-20220301111051677

    从图上可以看出,引入复用技术之后,可以减少进程数。重要的是,无论连接多少客户端,提供服务的进程只有一个

理解select函数并实现服务端

select 函数是最具代表性的实现复用服务器的方法。在 Windows 平台下也有同名函数,所以具有很好的移植性。

  • select 函数的功能和调用顺序

    使用 select 函数时可以将多个文件描述符集中到一起统一监视,项目如下:

    • 是否存在套接字接收数据?
    • 无需阻塞传输数据的套接字有哪些?
    • 哪些套接字发生了异常?

    术语:「事件」。当发生监视项对应情况时,称「发生了事件」。

    select 函数的使用方法与一般函数的区别并不大,更准确的说,他很难使用。但是为了实现 I/O 复用服务器端,我们应该掌握 select 函数,并运用于套接字编程当中。认为「select 函数是 I/O 复用的全部内容」也并不为过。

    select 函数的调用过程如下图所示:

  • 设置文件描述符

    利用 select 函数可以同时监视多个文件描述符。当然,监视文件描述符可以视为监视套接字。此时首先需要将要监视的文件描述符集中在一起。集中时也要按照监视项(接收、传输、异常)进行区分,即按照上述 3 种监视项分成 3 类。

    利用 fd_set 数组变量执行此操作,如图所示,该数组是存有0和1的位数组。

    image-20220301131110634

    图中最左端的位表示文件描述符 0(所在位置)。如果该位设置为 1,则表示该文件描述符是监视对象。那么图中哪些文件描述符是监视对象呢?很明显,是描述符 1 和 3。在 fd_set 变量中注册或更改值的操作都由下列宏完成。

    • FD_ZERO(fd_set *fdset) :将 fd_set 变量所指的位全部初始化成0
    • FD_SET(int fd,fd_set *fdset) :在参数 fdset 指向的变量中注册文件描述符 fd 的信息
    • FD_SLR(int fd,fd_set *fdset) :从参数 fdset 指向的变量中清除文件描述符 fd 的信息
    • FD_ISSET(int fd,fd_set *fdset) :若参数 fdset 指向的变量中包含文件描述符 fd 的信息,则返回「真」

    上述函数中,FD_ISSET 用于验证 select 函数的调用结果,通过下图解释这些函数的功能:

    image-20220301131322850

  • 设置检查(监视)范围及超时

    下面是 select 函数的定义:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    
    #include <sys/select.h>
    #include <sys/time.h>
    int select(int maxfd, fd_set *readset, fd_set *writeset,
    fd_set *exceptset, const struct timeval *timeout);
    /*
    成功时返回大于 0 的值,失败时返回 -1
    maxfd: 监视对象文件描述符数量
    readset: 将所有关注「是否存在待读取数据」的文件描述符注册到 fd_set 型变量,并传递其地址
    值。
    writeset: 将所有关注「是否可传输无阻塞数据」的文件描述符注册到 fd_set 型变量,并传递其
    地址值。
    exceptset: 将所有关注「是否发生异常」的文件描述符注册到 fd_set 型变量,并传递其地址值。
    timeout: 调用 select 函数后,为防止陷入无限阻塞的状态,传递超时(time-out)信息
    返回值: 发生错误时返回 -1,超时时返回0,。因发生关注的时间返回时,返回大于0的值,该值是发生
    事件的文件描述符数。
    */
    

    如上所述,select 函数用来验证 3 种监视的变化情况,根据监视项声明 3 个 fd_set 型变量,分别向其注册文件描述符信息,并把变量的地址值传递到上述函数的第二到第四个参数。但在此之前(调用select 函数之前)需要决定下面两件事:

    1. 文件描述符的监视(检查)范围是?
    2. 如何设定 select 函数的超时时间?

    第一,文件描述符的监视范围和 select 的第一个参数有关。实际上,select 函数要求通过第一个参数传递监视对象文件描述符的数量。因此,需要得到注册在 fd_set 变量中的文件描述符数。但每次新建文件描述符时,其值就会增加 1 ,故只需将最大的文件描述符值加 1 再传递给 select 函数即可。加 1 是因为文件描述符的值是从 0 开始的。 第二,select 函数的超时时间与 select 函数的最后一个参数有关,其中 timeval 结构体定义如下:

    1
    2
    3
    4
    5
    
    struct timeval
    {
        long tv_sec;
        long tv_usec;
    };
    

    本来 select 函数只有在监视文件描述符发生变化时才返回。如果未发生变化,就会进入阻塞状态。指定超时时间就是为了防止这种情况的发生。通过上述结构体变量,将秒数填入 tv_sec 的成员,将微妙数填入 tv_usec 的成员,然后将结构体的地址值传递到 select 函数的最后一个参数。此时,即使文件描述符未发生变化,只要过了指定时间,也可以从函数中返回。不过这种情况下, select 函数返回 0 。因此,可以通过返回值了解原因。如果不向设置超时,则传递 NULL 参数。

  • 调用 select 函数查看结果

    select 返回正整数时,怎样获知哪些文件描述符发生了变化?向 select 函数的第二到第四个参数传递的fd_set 变量中将产生如图所示的变化:image-20220301132136815

    由图可知,select 函数调用完成候,向其传递的 fd_set 变量将发生变化。原来为 1 的所有位将变成 0,但是发生了变化的文件描述符除外。因此,可以认为值仍为 1 的位置上的文件描述符发生了变化。

  • select 函数调用示例

     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
    
    #include <stdio.h>
    #include <unistd.h>
    #include <sys/time.h>
    #include <sys/select.h>
    #define BUF_SIZE 30
    int main(int argc, char *argv[])
    {
        fd_set reads, temps;
    
        int result, str_len;
        char buf[BUF_SIZE];
        struct timeval timeout;
        FD_ZERO(&reads); //初始化变量
        FD_SET(0, &reads); //将文件描述符0对应的位设置为1
        /*
        timeout.tv_sec=5;
        timeout.tv_usec=5000;
        */
    
        while (1)
        {
            temps = reads; //为了防止调用了select 函数后,位的内容改变,先提前存一下
            timeout.tv_sec = 5;
            timeout.tv_usec = 0;
            result = select(1, &temps, 0, 0, &timeout); //如果控制台输入数据,则返回大于0的数,没有就会超时
            if (result == -1)
            {
                puts("select error!");
                break;
            }
            else if (result == 0)
            {
                puts("Time-out!");
            }
            else
            {
                if (FD_ISSET(0, &temps)) //验证发生变化的值是否是标准输入端,0为输入端, 1 为输出端 2为错误端
                {
                    str_len = read(0, buf, BUF_SIZE);
                    buf[str_len] = 0;
                    printf("message from console: %s", buf);
                }
            }
        }
        return 0;
    }
    

    编译运行,其结果为:

    image-20220301132521187

    可以看出,如果运行后在标准输入流输入数据,就会在标准输出流输出数据,但是如果 5 秒没有输入数 据,就提示超时。

  • 实现 I/O 复用服务器端

    下面通过 select 函数实现 I/O 复用服务器端。下面是基于 I/O 复用的回声服务器端。 echo_selectserv.c 编译运行:

     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
    
    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    #include <unistd.h>
    #include <arpa/inet.h>
    #include <sys/socket.h>
    #include <sys/time.h>
    #include <sys/select.h>
    
    #define BUF_SIZE 100
    void error_handling(char *message);
    
    int main(int argc, char *argv[])
    {
        int serv_sock, clnt_sock;
        struct sockaddr_in serv_adr, clnt_adr;
        struct timeval timeout;
        fd_set reads, cpy_reads;
        socklen_t adr_sz;
        int fd_max, str_len, fd_num, i;
        char buf[BUF_SIZE];
        if (argc != 2)
        {
            printf("Usage : %s <port>\n", argv[0]);
            exit(1);
        }
        serv_sock = socket(PF_INET, SOCK_STREAM, 0);
        memset(&serv_adr, 0, sizeof(serv_adr));
        serv_adr.sin_family = AF_INET;
        serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
        serv_adr.sin_port = htons(atoi(argv[1]));
        if (bind(serv_sock, (struct sockaddr *)&serv_adr, sizeof(serv_adr)) == -1)
          error_handling("bind() error");
        if (listen(serv_sock, 5) == -1)
          error_handling("listen() error");
    
        FD_ZERO(&reads);
        FD_SET(serv_sock, &reads); //注册服务端套接字
        fd_max = serv_sock;
        while (1)
        {
            cpy_reads = reads;
            timeout.tv_sec = 5;
            timeout.tv_usec = 5000;
            if ((fd_num = select(fd_max + 1, &cpy_reads, 0, 0, &timeout)) == -1) //开始监视,每次重新监听
              break;
            if (fd_num == 0)
                continue;
            for (i = 0; i < fd_max + 1; i++)
            {
                if (FD_ISSET(i, &cpy_reads)) //查找发生变化的套接字文件描述符
                {
                    if (i == serv_sock) //如果是服务端套接字时,受理连接请求
                    {
                        adr_sz = sizeof(clnt_adr);
                        clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
    
                        FD_SET(clnt_sock, &reads); //注册一个clnt_sock
                        if (fd_max < clnt_sock)
                            fd_max = clnt_sock;
                        printf("Connected client: %d \n", clnt_sock);
                  }
                    else //不是服务端套接字时
                    {
                        str_len = read(i, buf, BUF_SIZE); //i指的是当前发起请求的客户端
                        if (str_len == 0)
                        {
                            FD_CLR(i, &reads);
                            close(i);
                            printf("closed client: %d \n", i);
                        }
                        else
                        {
                            write(i, buf, str_len);
                        }
                    }
                }
            }
        }
        close(serv_sock);
        return 0;
    }
    
    void error_handling(char *message)
    {
        fputs(message, stderr);
        fputc('\n', stderr);
        exit(1);
    }
    

    编译第四章回声客户端。其结果如下:

    image-20220301134233354

    从图上可以看出,虽然只用了一个进程,但是却实现了可以和多个客户端进行通信,这都是利用了select 的特点。

多种I/O函数

send & recv函数

readv & writev函数

套接字和标准I/O

优于select的epoll

epoll理解及应用

select 复用方法由来已久,因此,利用该技术后,无论如何优化程序性能也无法同时接入上百个客户端。这种 select 方式并不适合以 web 服务器端开发为主流的现代开发环境,所以需要学习 Linux 环境下的 epoll。

  • 基于 select 的 I/O 复用技术速度慢的原因

    第 12 章实现了基于 select 的 I/O 复用技术服务端,其中有不合理的设计如下:

    1. 调用 select 函数后常见的针对所有文件描述符的循环语句;
    2. 每次调用 select 函数时都需要向该函数传递监视对象信息;

    上述两点可以从 echo_selectserv.c 得到确认,调用 select 函数后,并不是把发生变化的文件描述符单独集中在一起,而是通过作为监视对象的 fd_set 变量的变化,找出发生变化的文件描述符(54,56行),因此无法避免针对所有监视对象的循环语句。而且,作为监视对象的 fd_set 会发生变化,所以调用 select 函数前应该复制并保存原有信息,并在每次调用 select 函数时传递新的监视对象信息。

    select 性能上最大的弱点是:每次传递监视对象信息,准确的说,select 是监视套接字变化的函数。而套接字是操作系统管理的,所以 select 函数要借助操作系统才能完成功能。select 函数的这一缺点可以通过如下方式弥补:

    仅向操作系统传递一次监视对象,监视范围或内容发生变化时只通知发生变化的事项

    这样就无需每次调用 select 函数时都想操作系统传递监视对象信息,但是前提操作系统支持这种处理方式。Linux 的支持方式是 epollWindows 的支持方式是 IOCP

  • select 也有优点

    select 的兼容性比较高,这样就可以支持很多的操作系统,不受平台的限制,使用 select 函数满足以下两个条件:

    1. 服务器接入者少
    2. 程序应该具有兼容性
  • 实现 epoll 时必要的函数和结构体

    能够克服 select 函数缺点的 epoll 函数具有以下优点,这些优点正好与之前的 select 函数缺点相反。

    1. 无需编写以监视状态变化为目的的针对所有文件描述符的循环语句;
    2. 调用对应于 select 函数的 epoll_wait 函数时无需每次传递监视对象信息

    下面是 epoll 函数的功能:

    1. epoll_create:创建保存 epoll 文件描述符的空间
    2. epoll_ctl:向空间注册并注销文件描述符
    3. epoll_wait:与 select 函数类似,等待文件描述符发生变化

    select 函数中为了保存监视对象的文件描述符,直接声明了 fd_set 变量,但 epoll 方式下的操作系统负责保存监视对象文件描述符,因此需要向操作系统请求创建保存文件描述符的空间,此时用的函数就是epoll_create

    • 此外,为了添加和删除监视对象文件描述符,select 方式中需要 FD_SET、FD_CLR 函数。但在 epoll 方式中,通过 epoll_ctl 函数请求操作系统完成。
    • 最后,select 方式下调用 select 函数等待文件描述符的变化,而 epoll方式下 调用 epoll_wait 函数。
    • 还有,select 方式中通过 fd_set 变量查看监视对象的状态变化,而 epoll 方式通过如下结构体 epoll_event 将发生变化的文件描述符单独集中在一起。
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    
    struct epoll_event
    {
        __uint32_t events;
        epoll_data_t data;
    };
    typedef union epoll_data {
        void *ptr;
        int fd;
        __uint32_t u32;
        __uint64_t u64;
    } epoll_data_t;
    

    声明足够大的 epoll_event 结构体数组候,传递给 epoll_wait 函数时,发生变化的文件描述符信息将被填入数组。因此,无需像 select 函数那样针对所有文件描述符进行循环。

  • epoll_create

    epoll 是从 Linux 的 2.5.44 版内核开始引入的。通过以下命令可以查看 Linux 内核版本:

    1
    
    cat /proc/1 sys/kernel/osrelease
    

    下面是 epoll_create 函数的原型:

    1
    2
    3
    4
    5
    6
    
    #include <sys/epoll.h>
    int epoll_create(int size);
    /*
    成功时返回 epoll 的文件描述符,失败时返回 -1
    size:epoll 实例的大小
    */
    

    调用 epoll_create 函数时创建的文件描述符保存空间称为「epoll 例程」,但有些情况下名称不同,需要稍加注意。通过参数 size 传递的值决定 epoll 例程的大小,但该值只是向操作系统提出的建议。换言之,size 并不用来决定 epoll 的大小,而仅供操作系统参考

    Linux 2.6.8 之后的内核将完全传入 epoll_create 函数的 size 函数,因此内核会根据情况调整epoll 例程大小。但是本书程序并没有忽略 size

    epoll_create 函数创建的资源与套接字相同,也由操作系统管理。因此,该函数和创建套接字的情况相同,也会返回文件描述符,也就是说返回的文件描述符主要用于区分 epoll 例程。需要终止时,与其他文件描述符相同,也要调用 close 函数.

  • epoll_ctl

    生成例程后,应在其内部注册监视对象文件描述符,此时使用 epoll_ctl 函数。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    #include <sys/epoll.h>
    int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
    /*
    成功时返回 0 ,失败时返回 -1
    epfd:用于注册监视对象的 epoll 例程的文件描述符
    op:用于制定监视对象的添加、删除或更改等操作
    fd:需要注册的监视对象文件描述符
    event:监视对象的事件类型
    */
    

    与其他 epoll 函数相比,该函数看起来有些复杂,但通过调用语句就很容易理解,假设按照如下形式调用 epoll_ctl 函数:

    1
    
    epoll_ctl(A,1 EPOLL_CTL_ADD,B,C);
    

    第二个参数 EPOLL_CTL_ADD 意味着「添加」,上述语句有如下意义:

    epoll 例程 A 中注册文件描述符 B ,主要目的是为了监视参数 C 中的事件

    再介绍一个调用语句。

    1
    
    epoll_ctl(A,EPOLL_CTL_DEL,B,NULL);
    

    上述语句中第二个参数意味这「删除」,有以下含义:

    从 epoll 例程 A 中删除文件描述符 B

    从上述示例中可以看出,从监视对象中删除时,不需要监视类型,因此向第四个参数可以传递为 NULL 下面是第二个参数的含义:

    • EPOLL_CTL_ADD:将文件描述符注册到 epoll 例程
    • EPOLL_CTL_DEL:从 epoll 例程中删除文件描述符
    • EPOLL_CTL_MOD:更改注册的文件描述符的关注事件发生情况

    epoll_event 结构体用于保存事件的文件描述符结合。但也可以在 epoll 例程中注册文件描述符时,用于注册关注的事件。该函数中 epoll_event 结构体的定义并不显眼,因此通过掉英语剧说明该结构体在epoll_ctl 函数中的应用。

    1
    2
    3
    4
    5
    6
    
    struct epoll_event event;
    ...
    event.events=EPOLLIN;//发生需要读取数据的情况时
    event.data.fd=sockfd;
    epoll_ctl(epfd,EPOLL_CTL_ADD,sockfd,&event);
    ...
    

    上述代码将 epfd 注册到 epoll 例程 epfd 中,并在需要读取数据的情况下产生相应事件。接下来给出epoll_event 的成员 events 中可以保存的常量及所指的事件类型。

    • EPOLLIN:需要读取数据的情况
    • EPOLLOUT:输出缓冲为空,可以立即发送数据的情况
    • EPOLLPRI:收到 OOB 数据的情况
    • EPOLLRDHUP:断开连接或半关闭的情况,这在边缘触发方式下非常有用
    • EPOLLERR:发生错误的情况
    • EPOLLET:以边缘触发的方式得到事件通知
    • EPOLLONESHOT:发生一次事件后,相应文件描述符不再收到事件通知。因此需要向 epoll_ctl 函数的第二个参数传递 EPOLL_CTL_MOD ,再次设置事件。

    可通过位运算同时传递多个上述参数。

  • epoll_wait

    下面是函数原型:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    
    #include <sys/epoll.h>
    int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int
    timeout);
    /*
    成功时返回发生事件的文件描述符,失败时返回 -1
    epfd : 表示事件发生监视范围的 epoll 例程的文件描述符
    events : 保存发生事件的文件描述符集合的结构体地址值
    maxevents : 第二个参数中可以保存的最大事件数
    timeout : 以 1/1000 秒为单位的等待时间,传递 -1 时,一直等待直到发生事件
    */
    

    该函数调用方式如下。需要注意的是,第二个参数所指缓冲需要动态分配。

    1
    2
    3
    4
    5
    6
    7
    
    int event_cnt;
    struct epoll_event *ep_events;
    ...
    ep_events=malloc(sizeof(struct epoll_event)*EPOLL_SIZE);//EPOLL_SIZE是宏常量
    ...
    event_cnt=epoll_wait(epfd,ep_events,EPOLL_SIZE,-1);
    ...
    

    调用函数后,返回发生事件的文件描述符,同时在第二个参数指向的缓冲中保存发生事件的文件描述符集合。因此,无需像 select 一样插入针对所有文件描述符的循环。

    总结epoll的流程:

    1. epoll_create 创建一个保存 epoll 文件描述符的空间,可以没有参数
    2. 动态分配内存,给将要监视的 epoll_wait
    3. 利用 epoll_ctl 控制 添加 删除,监听事件
    4. 利用 epoll_wait 来获取改变的文件描述符,来执行程序

    select 和 epoll 的区别:

    1. 每次调用 select 函数都会向操作系统传递监视对象信息,浪费大量时间
    2. epoll 仅向操作系统传递一次监视对象,监视范围或内容发生变化时只通知发生变化的事项

条件触发和边缘触发

多线程服务器端的实现

理解线程的概念

线程创建及运行

线程存在的问题和临界区

线程同步

线程的销毁和多线程并发服务器的实现