【Linux 网络】高级 IO -- 详解

一、IO 的基本概念

I/O(input/output)也就是输入和输出,在冯诺依曼体系结构当中,将数据从输入设备拷贝到内存就叫作输入,将数据从内存拷贝到输出设备就叫作输出。

  • 对文件进行的读写操作本质就是一种 IO,文件 IO 对应的外设就是磁盘。
  • 对网络进行的读写操作本质也是一种 IO,网络 IO 对应的外设就是网卡。

OS 如何得知外设中有数据可读取?

输入就是操作系统将数据从外设拷贝到内存的过程,操作系统一定要通过某种方法得知特定外设上是否有数据就绪。

并不是操作系统想要从外设读取数据时,外设上就一定有数据可以被读取。比如:用户正在访问某台服务器,当用户的请求报文发出后就需要等待从网卡中读取服务器发来的响应数据,但此时服务器可能还没有收到请求报文,或者是正在对请求报文进行数据分析,也可能是服务器发来的响应数据还在网络中路由。但操作系统不会主动去检测外设上是否有数据就绪,这种做法会降低操作系统的工作效率,因为大部分情况下外设中都是没有数据的,所以操作系统所做的大部分检测工作其实都是徒劳的。

操作系统实际上采用的是中断的方式来得知外设上是否有数据准备就绪了,当某个外设上面有数据就绪时,该外设就会向 CPU 中的中断控制器发送中断信号,中断控制器再根据产生的中断信号的优先级按顺序发送给 CPU。

每一个中断信号都有一个对应的中断处理程序,存储中断信号和中断处理程序映射关系的表被称为中断向量表,当 CPU 收到某个中断信号时就会自动停止正在运行的程序,然后根据该中断向量表来执行该中断信号对应的中断处理程序,处理完毕后再返回原先被暂停的程序继续运行。

注意:CPU 不直接和外设交互指的是在数据层面上,而外设其实是可以直接将某些控制信号发送给 CPU 中的某些控制器的。


IO 最主要的问题就是效率问题,IO 的效率极为低下,以读取数据为例:

  • 当 read/recv 时,如果底层缓冲区中没有数据,read/recv 就会阻塞等待。
  • 当 read/recv 时,如果底层缓冲区中有数据,read/recv 就会进行拷贝(学习 TCP 时,就知道了 read/recv 等一系列接口本质就是拷贝函数)

所以,IO 的本质就是:等待(等待 IO 条件就绪)+ 数据拷贝(当 IO 条件就绪后将数据拷贝到内存或外设)。只要缓冲区中没有数据,read/recv 就会一直阻塞等待,直到缓冲区中出现数据,然后进行拷贝,所以 read/recv 就会花费大量时间在等这一操作上面,这就是一种低效的 IO 模式。

任何 IO 的过程,都包含 "等" 和 "拷贝" 这两个步骤,但在实际的应用场景中 "等" 消耗的时间往往比 "拷贝" 消耗的时间多。


OS 如何处理从网卡中读取到的数据包?

操作系统任何时刻都可能会收到大量的数据包,因此操作系统须将这些数据包管理起来。所谓的管理即 "先描述,再组织",在内核中有一个结构 sk_buff,该结构就是用来管理和控制接收或发送数据包的信息。

简化版的 sk_buff 结构:

当操作系统从网卡中读取到一个数据包后,会将该数据依次交给链路层、网络层、传输层、应用层进行解包和分用,最终将数据包中的数据交给了上层用户。

那对应到这个 sk_buff 结构来说具体是如何进行数据包的解包和分用的呢?
  • 当操作系统从网卡中读取到一个数据包后,就会定义一个 sk_buff 结构,然后用 sk_buff 结构中的 data 指针指向这个读取到的数据包,并将这个 sk_buff 结构与其他 sk_buff 结构以双链表的形式组织起来,此时操作系统对各个数据包的管理就变成了对双链表的增删查改等操作。
  • 接下来需要将读取上来的数据包交给最底层的链路层处理,进行链路层的解包和分用,让 sk_buff 结构中的 mac_header 指针指向最初的数据包,然后向后读取链路层的报头,剩下的就是要交给网络层处理的有效载荷了,此时便完成了链路层的解包。
  • 这时链路层就需要将有效载荷向上交付给网络层进行解包和分用了,所谓的向上交付只是形象的说法,实际向上交付并不是要将数据从链路层的缓冲区拷贝到网络层的缓冲区,只需让sk_buff结构中的 network_header 指针,指向数据包中链路层报头后的数据即可,然后继续向后读取网络层的报头,便完成了网络层的解包。
  • 紧接着就是传输层对数据进行处理,同样的道理,让 sk_buff 结构中的 transport_header 指针,指向数据包中网络层报头后的数据,然后继续向后读取传输层的报头,便完成了传输层的解包。
  • 传输层解包后就可以根据具体使用的传输层协议,对应将剩下的数据拷贝到 TCP 或 UDP 的接收缓冲区供用户读取即可。

发送数据时对数据进行封装也是同样的道理,即依次在数据前面拷贝上对应的报头。应用层以下,数据包在进行封装和解包的过程中,本质数据的存储位置是没有发生变化的,实际只是在用不同的指针对数据进行操作。

但内核中的 sk_buff 并不像上面那样简单:

  • 一方面,为了保证高效的网络报文处理效率,要求 sk_buff 的结构必须是高效的。
  • 另一方面,sk_buff 结构需要被内核协议中的各个协议共同使用,因此 sk_buff 必须能够兼容所有网络协议。

如何提高 IO 效率?

想办法在单位时间内,让等待的比重降低,这样 IO 的效率就提高了。


二、五种 IO 模型

IO 的过程跟钓鱼的过程是非常相似的。

  • 钓鱼的过程同样分为 "等" 和 "拷贝" 两个步骤,只不过这里的 "等" 指的是等鱼上钩,"拷贝" 指的是当鱼上钩后将鱼从河里 "拷贝" 到鱼桶中。
  • IO 时 "等" 消耗的时间往往比 "拷贝" 消耗的时间多,钓鱼也符合这个特点。在钓鱼时大部分的时间都在等待鱼上钩,而当鱼上钩后只需要一瞬间就能将鱼 "拷贝" 上来。

五个人的不同钓鱼方式

  1. 张三:用 1 根鱼竿,将鱼钩抛入水中后,就一直盯着浮标一动不动,不理会外界的任何动静,直到有鱼上钩,就挥动鱼竿将鱼钓上来。(阻塞)
  2. 李四:用 1 根鱼竿,将鱼钩抛入水中后,就可以去做其它事情了,然后定期观察浮标的动静,如果有鱼上钩就将鱼钓上来,否则就继续做其它事情。(非阻塞轮询式)
  3. 王五:用 1 根鱼竿,将鱼钩抛入水中后,在鱼竿顶部绑一个铃铛,就可以去做其它事情了,如果铃铛一响就知道有鱼上钩了,于是挥动鱼竿将鱼钓上来,否则就不管鱼竿。(信号驱动)
  4. 赵六:用 100 根鱼竿,将 100 个鱼钩抛入水中后,就定期观察这 100 个浮漂的动静,如果某个鱼竿有鱼上钩就挥动对应的鱼竿将鱼钓上来。(多路复用、多路转接)
  5. 田七:田七是一个公司的领导,带了一个司机,此时田七也想钓鱼,但他因为要立刻回公司开会,所以他拿来一根鱼竿,让自己的司机去钓鱼,当司机将鱼桶装满时再打电话告诉他。(异步 IO)

张三,李四和王五钓鱼的效率一样吗?

张三,李四和王五钓鱼的效率本质上是一样的。

  • 因为他们的钓鱼方式都是一样的,都是先等鱼上钩,然后再将鱼钓上来。
  • 其次,因为他们每个人都是拿的一根鱼竿,在等待鱼上钩,当河里有鱼来咬鱼钩时,这条鱼咬哪一个鱼钩的概率都是相等的。

因此,张三、李四、王五三个人的钓鱼的效率是一样的,只是等鱼上钩的方式不同而已。张三是静静的等待,李四是定期观察浮漂动静,而王五是通过铃铛来判断是否有鱼上钩。


谁的效率更高?

因为赵六减少了等待的概率发生,增加了拷贝的时间,所以他的效率是最高的。

赵六同时等多个鱼竿上有鱼上钩,因此在单位时间内,赵六的鱼竿有鱼上钩的概率是最大的。假设赵六拿了 97 个鱼竿,加上张三、李四、王五各一个鱼竿,一共就有 100 个鱼竿。

当河里有鱼来咬鱼钩时,这条鱼咬张三、李四、王五的鱼钩的概率都是 1%,而咬赵六的鱼钩的概率就是 97%。

因此在单位时间内,赵六的鱼竿上有鱼的概率是张三、李四、王五的 97 倍。而高效的钓鱼就是要减少单位时间内 "等" 的时间,增加 "拷贝" 的时间,所以说赵六的钓鱼效率是这四个人中最高的。赵六的钓鱼效率之所以高,是因为赵六一次等待多个鱼竿上的鱼上钩,可以将 "等" 的时间进行重叠。


如何看待田七钓鱼方式?

田七是将钓鱼这件事交给自己的司机去做了,自己回公司去了。他并不关心司机是如何钓鱼的,司机可以采用张三,李四,王五和赵六中的任意一种方式,田七只在乎司机最后是否将桶装满。

田七本人并没有参与整个钓鱼的过程,他只是给司机派发了钓鱼的任务,所以真正钓鱼的人的是司机,所以田七在司机钓鱼的期间,可以做任何其他事情。如果将钓鱼看作是一种 IO 的话,那么田七的这种钓鱼方式就叫作异步 IO

而对于张三、李四、王五、赵六而言,他们都要自己等待鱼上钩,当鱼上钩后又需要自己把鱼从河里钓上来,对应到 IO 中就是需要自行进行数据的拷贝,因此他们四个人的钓鱼方式就叫作同步 IO


通过这里的钓鱼例子可以看到发现,阻塞 IO、非阻塞 IO 和信号驱动 IO 本质上是不能提高 IO 的效率的,但非阻塞 IO 和信号驱动 IO 能提高整体做事的效率。

其中,这个钓鱼场景中的各个事物都能与 IO 中的相关概念对应起来,鱼对应的是数据,钓鱼的河对应的是内核,浮标对应的文件描述上是否有事件就绪,每一个人对应的是执行流(进程或线程),司机对应的是操作系统,鱼竿对应的是文件描述符或套接字,装鱼的桶对应的就是用户缓冲区。


1、阻塞 IO

在内核将数据准备好之前,系统调用会一直等待。

阻塞 IO 是最常见的 IO 模型,所有的套接字,默认都是阻塞方式。

调用 recvfrom 函数来从某个套接字上读取数据时,可能底层的数据还没准备好,那么此时就需要等待数据就绪,当数据就绪后再将数据从内核拷贝到用户空间,最后 recvfrom 函数才会返回。

在 recvfrom 函数等待数据就绪期间,在用户看来该进程或线程就阻塞了,本质就是操作系统将该进程或线程的状态设置为了某种非 R 状态,然后将其放入等待队列中,当数据就绪后操作系统再将其从等待队列中唤醒,然后该进程或线程再将数据从内核拷贝到用户空间。

以阻塞方式进行 IO 操作的进程或线程,在 "等" 和 "拷贝" 期间都不会返回,在用户看来就是阻塞了,因此被称为阻塞 IO。


2、 非阻塞 IO

如果内核还未将数据准备好,系统调用仍然会直接返回,并且返回 EWOULDBLOCK 错误码。
非阻塞 IO 往往需要程序员循环的方式反复尝试读写文件描述符,这个过程称为轮询。这对 CPU 来说是较大的浪费,一般只有特定场景下才使用。


当调用 recvfrom 函数,以非阻塞方式从某个套接字上读取数据时,如果底层数据还没有准备好,那么 recvfrom 函数会立马错误返回,而不会让该进程或线程进行阻塞等待。

因为没有读取的数据,所以该进程或线程后续还需要继续调用 recvfrom 函数,检测底层数据是否就绪,若没有就绪则继续错误返回,直到某次检测到底层数据就绪后,再将数据从内核拷贝到用户空间然后进行成功返回。

每次调用 recvfrom 函数读取数据时,就算底层数据没有就绪,recvfrom 函数也会立马返回,在用户看来该进程或线程就没有被阻塞,因此被称为非阻塞 IO。

阻塞 IO 和非阻塞 IO 的区别:阻塞 IO 当数据没有就绪时,后续检测数据是否就绪的工作是由操作系统发起的,而非阻塞 IO 当数据没有就绪时,后续检测数据是否就绪的工作是由用户发起的。


3、信号驱动 IO

内核将数据准备好的时候,使用 SIGIO 信号通知应用程序进行 IO 操作。

当底层数据就绪的时候会向当前进程或线程递交 SIGIO 信号,因此可以通过 signal 或 sigaction 函数将 SIGIO 的信号处理程序自定义为需要进行的 IO 操作,当底层数据就绪时就会自动执行对应的 IO 操作。

比如需要调用 recvfrom 函数从某个套接字上读取数据,那么就可以将该操作定义为 SIGIO 的信号处理程序。

当底层数据就绪时,操作系统就会递交 SIGIO 信号,此时就会自动执行定义的信号处理程序,进程将数据从内核拷贝到用户空间。

信号的产生是异步的,但信号驱动 IO 是同步 IO 的一种。

信号的产生异步的,因为信号在任何时刻都可能产生。但信号驱动 IO 是同步 IO 的一种,因为当底层数据就绪时,当前进程或线程需要停下正在做的事情,转而进行数据的拷贝操作,当前进程或线程仍然需要参与 IO 过程。

判断一个 IO 过程是同步还是异步的,其本质就是看当前进程或线程是否需要参与 IO 过程,若参与即为同步 IO,否则为异步 IO。


4、IO 多路转接

虽然从流程图上看起来和阻塞 IO 类似,IO 多路转接也被称为 IO 多路复用,实际上最核心在于 IO 多路转接能够同时等待多个文件描述符的就绪状态。

IO 多路转接的思想

因为 IO 过程分为 "等" 和 "拷贝" 两个步骤,因此使用的 recvfrom 等接口的底层实际上都做了两件事,第一件事是数据不就绪时需要等,第二件事是数据就绪后需要进行拷贝。

虽然 recvfrom 等接口也有 "等" 的能力,但这些接口一次只能 "等" 一个文件描述符上的数据或空间就绪,IO 效率太低。

因此系统提供了三组接口,即 select、poll 和 epoll,这些接口的核心工作就是 "等",可将所有 "等" 的工作都交给这些多路转接接口。

因为这些多路转接接口是一次 "等" 多个文件描述符的,因此能将 "等" 的时间重叠,数据就绪后再调用对应的 recvfrom 等函数进行数据的拷贝,此时这些函数就能够直接进行拷贝,而不需要 "等" 了。

IO 多路转接就像是帮人排队的黄牛,因为多路转接接口实际并没有进行数据拷贝。排队黄牛可以一次帮多个人排队,此时就将多个人排队的时间进行了重叠。


5、异步 IO

由内核在数据拷贝完成时,通知应用程序(而信号驱动是告诉应用程序何时可以开始拷贝数据)。

  • 进行异步 IO需调用一些异步 IO 接口,异步 IO 接口调用后会立马返回,因为异步 IO 不需要发起者进行 "等" 和 "拷贝" 的操作,都由操作系统来完成,只需发起 IO。
  • 当 IO 完成后操作系统会通知应用程序,因此进行异步 IO 的进程或线程并不参与 IO 的所有细节。

6、小结

任何 IO 过程中,都包含两个步骤:等待拷贝

在实际的应用场景中,等待消耗的时间往往都远远高于拷贝的时间。让 IO 更高效,最核心的办法就是在单位时间内让等待的时间尽量少。


三、高级 IO 的重要概念

1、同步通信 VS 异步通信(Synchronous Communication / Asynchronous Communication)

同步和异步关注的是消息通信机制

  • 所谓同步,就是在发出一个调用 时,在没有得到结果之前,该调用 就不返回。但是一旦调用返回,就得到返回值了。换句话说,就是由调用者 主动等待这个调用的结果。
  • 异步则是相反,调用 在发出之后,这个调用就直接返回了,所以没有返回结果。换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果。而是在调用 发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用。

为什么非阻塞 IO 在没有得到结果之前就返回了?
  • IO 是分为 "等" 和 "拷贝" 两步的,当调用 recvfrom 进行非阻塞 IO 时,若数据没有就绪,那么调用会直接返回,此时这个调用返回时并没有完成一个完整的 IO 过程,即便调用返回了也是属于错误的返回。
  • 因此该进程或线程后续还需继续调用 recvfrom,轮询检测数据是否就绪,当数据就绪后再把数据从内核拷贝到用户空间,这才是一次完整的 IO 过程。
  • 因此,在进行非阻塞 IO 时,在没有得到结果之前,虽然这个调用会返回,但后续还需要继续进行轮询检测,因此可以理解成调用还没有返回,而只有当某次轮询检测到数据就绪,并且完成数据拷贝后才认为该调用返回了。

在学习多进程多线程的时候,也提到过同步和互斥,但这里的同步通信和进程之间的同步是完全不想干的概念。

  • 进程 / 线程同步:指的是在保证数据安全的前提下,让进程/线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,谈论的是进程/线程间的一种工作关系。
  • 同步 IO:指的是进程/线程与操作系统之间的关系,谈论的是进程/线程是否需要主动参与 IO 过程。

注意:尤其是在访问临界资源的时候,一定要弄清楚这个 "同步",是同步通信异步通信的同步,还是同步与互斥的同步。


2、阻塞 VS 非阻塞

阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态。

  • 阻塞调用是指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。
  • 非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程。

3、其他高级 IO

非阻塞 IO、 纪录锁、系统 V 流机制、 I/O 多路转接(也叫 I/O 多路复用), readv 和 writev 函数以及存储映射 IO( mmap ),这些统称为高级 IO。


四、阻塞 IO

系统中大部分的接口都是阻塞式接口,如使用 read 函数从标准输入中读取数据。

程序运行后,若不进行输入操作,该进程就会阻塞。根本原因就是因为此时底层数据不就绪,所以 read 函数需要进行阻塞等待。

一旦进行输入操作,此时 read 函数就会检测到底层数据已经就绪,然后将数据读取到从内核拷贝到程序员传入的 buffer 数组中,并且将读取到的数据输出到显示器上面,最后就可以看到输入的字符串了。


五、非阻塞 IO

打开文件时,都是默认以阻塞的方式打开的。如果要以非阻塞的方式打开某个文件,需要在使用 open 函数打开文件时携带 O_NONBLOCKO_NDELAY 选项,那么此时就可以以非阻塞的方式打开文件。


1、fcntl

一个文件描述符, 默认都是阻塞 IO。

  • fd:已打开的文件描述符
  • cmd:需要进行的操作
  • ...:可变参数,传入的 cmd 值不同,后面追加的参数也不同

传入 cmd 的值不同,后面追加的参数也不相同。

fcntl 函数有 5 种功能:

  1. 复制一个现有的描述符(cmd=F_DUPFD)
  2. 获得 / 设置文件描述符标记(cmd=F_GETFD 或 F_SETFD)
  3. 获得 / 设置文件状态标记(cmd=F_GETFL 或 F_SETFL)
  4. 获得 / 设置异步 I/O 所有权(cmd=F_GETOWN 或 F_SETOWN)
  5. 获得 / 设置记录锁(cmd=F_GETLK, F_SETLK 或 F_SETLKW)

若函数调用成功,则返回值取决于具体进行的操作;若函数调用失败,则返回 -1,同时错误码被设置。


2、实现函数 SetNoBlock

基于 fcntl,下面实现一个 SetNoBlock 函数,该函数用于将指定的文件描述符设置为非阻塞状态。

  • 先调用 fcntl 函数获取该文件描述符对应的文件状态标记(位图结构),此时调用 fcntl 函数时传入的 cmd 值为 F_GETFL。
  • 在获取到的文件状态标记上添加非阻塞标记 O_NONBLOCK,再次调用 fcntl 函数对文件状态标记进行设置,此时调用 fcntl 函数时传入的 cmd 值为 F_SETFL。
  • 当 read 函数以非阻塞方式读取标准输入时,如果底层数据不就绪,那么 read 函数就会立即返回,并且是以出错的形式返回的,此时的错误码会被设置为 EAGAIN 或 EWOULDBLOCK。
  • 因此在以非阻塞方式读取数据时,如果调用 read 函数时得到的返回值是 -1,此时还需通过错误码进一步进行判断,如果错误码的值是 EAGAIN 或 EWOULDBLOCK,说明本次调用 read 函数出错是因为底层数据还没有就绪,因此后续还应该继续调用 read 函数进行轮询检测数据是否就绪,当数据继续时再进行数据的读取。
  • 调用 read 函数在读取到数据前可能被其他信号中断,此时 read 函数也会以出错的形式返回,此时的错误码会被设置为 EINTR,应重新执行 read 函数进行数据的读取。

因此在以非阻塞的方式读取数据时,若调用 read 函数读取到的返回值为 -1,此时并不应该直接认为 read 函数在底层读取数据时出错,而应该继续判断错误码,若错误码的值为 EAGAIN、EWOULDBLOCK 或 EINTR 则应该继续调用 read 函数再次进行读取。


六、I/O 多路转接

1、select

(1)初识 select

select是系统提供的一个多路转接的接口,可以用来实现多路复用输入 / 输出模型。

  • select 系统调用可以让程序同时监视多个文件描述符上的状态变化。
  • select 核心工作就是等,当监视的文件描述符中有一个或多个事件就绪时,也就是直到被监视的文件描述符有一个或多个发生了状态改变,select 才会成功返回并将对应文件描述符的就绪事件告知调用者。

(2)select 函数原型


A. 参数解释
  • nfds :需要监视的文件描述符中,最大的文件描述符值 +1。
  • readfds输入输出型参数 ,调用时用户告知内核需要监视哪些文件描述符的读事件是否就绪,返回时内核告知用户哪些文件描述符的读事件已就绪。(这个参数使用一次过后,需要进行重新设定)
  • writefds输入输出型参数 ,调用时用户告知内核需要监视哪些文件描述符的写事件是否就绪,返回时内核告知用户哪些文件描述符的写事件已就绪。(这个参数使用一次过后,需要进行重新设定)
  • exceptfds输入输出型参数 ,调用时用户告知内核需要监视哪些文件描述符的异常事件是否就绪,返回时内核告知用户哪些文件描述符的异常事件已就绪。(这个参数使用一次过后,需要进行重新设定)
  • timeout输入输出型参数,调用时由用户设置 select 的等待时间,返回时表示 timeout 的剩余时间。

B. 参数 timeout 的取值
  • NULL / nullptr:select 调用后进行阻塞等待,直到被监视的某个文件描述符上的某个事件就绪。
  • 0:select 调用后进行非阻塞等待,无论被监视的文件描述符上的事件是否就绪,select 检测后都会立即返回。
  • 特定的时间值:select 调用后在指定的时间内进行阻塞等待,若被监视的文件描述符上一直没有事件就绪,则在该时间后 select 进行超时返回。

C. 返回值说明
  • 若函数调用成功,则返回事件就绪的文件描述符个数。
  • 若 timeout 时间耗尽,则返回 0。
  • 若函数调用失败,则返回 -1,同时错误码被设置。

只要有一个 fd 数据就绪或空间就绪,就可以进行返回了。


D. 错误码

select 调用失败时,错误码可能被设置为:

  • EBADF:文件描述符为无效的或该文件已关闭。
  • EINTR:此调用被信号所中断。
  • EINVAL:参数 nfds 为负值。
  • ENOMEM:核心内存不足。

E. fd_set 结构

fd_set结构与 sigset_t 结构类似,其实这个结构就是一个整数数组,更严格的说 fd_set 本质也是一个位图,用位图中对应的位来表示要监视的文件描述符。

调用 select 函数之前就需用 fd_set 结构定义出对应的文件描述符集,然后将需要监视的文件描述符添加到文件描述符集中,这个添加的过程本质就是在进行位操作,但是这个位操作不需要用户自己进行,系统提供了一组专门的接口,用于对 fd_set 类型的位图进行各种操作。

cpp 复制代码
void FD_CLR(int fd, fd_set *set);  // 用来清除描述词组set中相关fd的位
int FD_ISSET(int fd, fd_set *set); // 用来测试描述词组set中相关fd的位是否为真
void FD_SET(int fd, fd_set *set);  // 用来设置描述词组set中相关fd的位
void FD_ZERO(fd_set *set);         // 用来清除描述词组set的全部位

注意:fd_set 是一个固定大小的位图,直接决定了 select 能同时关心的 fd 的个数是有上限的。


F. timeval 结构

传入 select 函数的最后一个参数 timeout,是一个指向 timeval 结构的指针。timeval 结构用于描述一段时间长度,如果在这个时间内,需要监视的描述符没有事件发生则函数返回,返回值为 0。该结构中包含两个成员,其中 tv_sec 表示的是秒,tv_usec 表示的是微秒

select 等待多个 fd,等待策略可以选择:

  1. 阻塞式(nullptr)
  2. 非阻塞式({0, 0})
  3. 可以设置 timeout 时间,时间内阻塞,时间到了就立马返回({5, 0})
如果在 timeout 时间内有 fd 就绪呢?

此时,timeout 表示距离下一次 timeout 还剩多长时间(输出的含义)。


a. 函数返回值
  • 执行成功则返回文件描述词状态已改变的个数。
  • 如果返回 0 代表在描述词状态改变前已超过 timeout 时间,没有返回。
  • 当有错误发生时则返回 -1,错误原因存于 errno,此时参数 readfds,writefds,exceptfds 和 timeout 的值变成不可预测。

b. 错误值

错误值可能为:

  • EBADF:文件描述词为无效的或该文件已关闭。
  • EINTR:此调用被信号所中断。
  • EINVAL:参数 n 为负值。
  • ENOMEM:核心内存不足。


(3)理解 select 执行过程

理解 select 模型的关键在于理解 fd_set,为说明方便,取 fd_set 长度为 1 字节,fd_set 中的每一 bit 可以对应一个文件描述符 fd,则 1 字节长的 fd_set 最大可以对应 8 个 fd。

  1. 执行 fd_set set; FD_ZERO(&set); 则 set 用位表示是 0000,0000。
  2. 若 fd=5,执行 FD_SET(fd,&set); 后 set 变为 0001,0000(第 5 位置为 1)。
  3. 若再加入 fd=2,fd=1,则 set 变为 0001,0011。
  4. 执行 select(6,&set,0,0,0) 阻塞等待。
  5. 若 fd=1,fd=2 上都发生可读事件,则 select 返回,此时 set 变为 0000,0011。

注意 :没有事件发生的 fd=5 被清空。


(4)socket 就绪条件

A. 读就绪
  • socket 内核中,接收缓冲区中的字节数,大于等于低水位标记 SO_RCVLOWAT,此时可以无阻塞的读取该文件描述符,并且返回值大于 0。
  • socket TCP 通信中,对端关闭连接,此时对该 socket 读,则返回 0。
  • 监听 socket 上有新的连接请求。
  • socket 上有未处理的错误。
如何看待 listensock?

获取新连接,依旧把它看作成 IO 事件,input 事件。

如果没有连接到来呢?

发生阻塞。


B. 写就绪
  • socket 内核中,发送缓冲区中的可用字节数(发送缓冲区的空闲位置大小),大于等于低水位标记 SO_SNDLOWAT,此时可以无阻塞的写,并且返回值大于 0。
  • socket 的写操作被关闭(close 或者 shutdown),如果此时进行写操作的话,会触发 SIGPIPE 信号。
  • socket 使用非阻塞 connect 连接成功或失败之后,socket 上有未读取的错误。

C. 异常就绪(了解)

socket 上收到带外数据。
注意 :带外数据和 TCP 的紧急模式相关,TCP 报头中的 URG 标志位和 16 位紧急指针搭配使用,就能够发送/接收带外数据。


(5)select 基本工作流程

若要实现一个简单的 select 服务器,该服务器要做的就是读取客户端发来的数据并进行打印,那么该 select 服务器的工作流程如下:

  1. 先初始化服务器,完成套接字的创建、绑定和监听。
  2. 定义一个 _fd_array 数组用于保存监听套接字和已经与客户端建立连接的套接字,初始化时就可将监听套接字添加到 _fd_array 数组中。
  3. 然后服务器开始循环调用 select 函数,检测读事件是否就绪,若就绪则执行对应操作。
  4. 每次调用 select 函数之前,都需要定义一个读文件描述符集 readfds,并将 _fd_array 中的文件描述符依次设置进 readfds 中,表示让 select 监视这些文件描述符的读事件是否就绪。
  5. 当 select 检测到数据就绪时会将读事件就绪的文件描述符设置进 readfds 中,此时就能够得知哪些文件描述符的读事件就绪,并对这些文件描述符进行对应操作。
  6. 若读事件就绪的是监听套接字,则调用 accept 函数从底层全连接队列获取已建立的连接,并将该连接对应的套接字添加到 _fd_array 数组中。
  7. 若读事件就绪的是与客户端建立连接的套接字,则调用 read 函数读取客户端发来的数据并进行打印输出。
  8. 服务器与客户端建立连接的套接字读事件就绪,也可能是客户端将连接关闭了,此时服务器应该调用 close 关闭该套接字,并将该套接字从 _fd_array 数组中清除,不需要再监视该文件描述符的读事件了。

注意

  • 传入 select 函数的 readfds、writefds 和 exceptfds 都是输入输出型参数。当 select 函数返回时这些参数中的值已经被修改了,因此每次调用 select 函数时都需对其进行重新设置,timeout 也是如此。
  • 因为每次调用 select 函数之前都需要对 readfds 进行重新设置,所以需要定义一个 _fd_array 数组保存与客户端已经建立的若干连接和监听套接字,实际 _fd_array 数组中的文件描述符就是需要让 select 监视读事件的文件描述符。
  • select 服务器只是读取客户端发来的数据,因此只需要让 select 监视特定文件描述符的读事件,若要同时让 select 监视特定文件描述符的读事件和写事件,则需要分别定义 readfds 和 writefds,并定义两个数组分别保存需要被监视读事件和写事件的文件描述符,便于每次调用 select 函数前对 readfds 和 writefds 进行重新设置。
  • 由于调用 select 时还需要传入被监视的文件描述符中最大文件描述符值 +1,因此每次在遍历 _fd_array 对 readfds 进行重新设置时,还需要记录最大文件描述符值。

(6)select 服务器

A. Sock.hpp

编写一个 Socket 类,对套接字相关的接口进行一定封装,为了让外部能直接调用 Socket 类中封装的函数,于是将部分函数定义成静态成员函数。


B. selectServer.hpp
  • 当调用 accept 函数从底层获取上来连接后,不能立即调用 read 函数读取该连接中的数据。因为此时新连接中的数据可能并没有就绪,如果直接调用 read 函数可能发生阻塞,应该将这个等待过程交给 select 函数来完成,因此在获取完连接后直接将该连接对应的文件描述符添加到 _fd_array 数组中即可,当该连接的读事件就绪时再进行数据读取。
  • 添加文件描述符到 fd_array 数组中,本质就是遍历 fd_array 数组,找到一个没有被使用的位置将该文件描述符添加进去。但有可能 _fd_array 数组中全部的位置都已被占用,那么文件描述符就会添加失败,此时就只能将刚获取上来的连接对应的套接字进行关闭,因为此时服务器已经没有能力处理这个连接了。

C. select 服务器测试

使用 telnet 工具连接服务器,此时通过 telnet 向服务器发送的数据就能够被服务器读到并且打印输出了。

虽然 selectServer 仅是一个单进程、单线程服务器,但却可以同时为多个客户端提供服务。因为 select 函数调用后,会告知 select 服务器是哪个客户端对应的连接事件就绪,此时 select 服务器就可以读取对应客户端发来的数据,读取完后又会调用 select 函数等待某个客户端连接的读事件就绪。

当服务器检测到客户端退出后,也会关闭对应的连接,并将对应的套接字从 _fd_array 数组中清除。


D. 存在的问题
  1. select 服务器如果要向客户端发送数据的话,不能直接调用 write 函数。因为调用 write 函数时,实际上也分为 "等" 和 "拷贝" 两步,也应该将 "等" 的这个过程交给 select 函数,因此在每次调用 select 函数之前,除了需要重新设置 readfds,也要重新设置 writefds,并且还需要一个数组来保存需被监视写事件是否就绪的文件描述符,当某一文件描述符的写事件就绪时,才能够调用 write 函数向客户端发送数据。
  2. 没有定制协议。代码中读取数据时并没有按照某种规则进行读取,可能造成粘包问题,根本原因就是没有定制协议。比如,HTTP 协议规定在读取底层数据时读取到空行就表明读完了一个 HTTP 报头,此时再根据 HTTP 报头中的 Content-Length 属性得知正文的长度,最终就能够读取到一个完整的 HTTP 报文,HTTP 协议通过这种方式避免了粘包问题。
  3. 没有对应的输入输出缓冲区。代码中直接将读取的数据存储到了字符数组 buffer 中,这是不严谨的,因为本次数据读取可能并没有读取到一个完整的报文,此时服务器就不能进行数据的分析处理,应该将读取到的数据存储到一个输入缓冲区中,当读取到一个完整的报文后再让服务器进行处理。此外,如果服务器要能够对客户端进行响应,那么服务器的响应数据也不应该直接调用 write 函数发送给客户端,而是应该先存储到一个输出缓冲区中,因为响应数据可能很庞大,无法一次发送完毕,所以可能需要进行分批发送。

(7)select 的优点

  • 可以同时等待多个文件描述符,且只负责等待(有大量的连接,但只有少量是活跃的,节省资源),实际的 IO 操作由 accept、read、write 等接口完成,保证接口在进行 IO 操作时不会被阻塞。
  • select 同时等待多个文件描述符,因此可以将 "等" 的时间重叠,提高 IO 效率

注意:上述优点也是所有多路转接接口的优点。


(8)select 的缺点

  • 每次调用 select,都需手动设置 fd 集合,从接口使用角度来说也非常不便。
  • 每次调用 select,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大。
  • 同时每次调用 select 都需要在内核遍历传递进来的所有 fd,这个开销在 fd 很多时也很大。
  • select 可监控的文件描述符数量太少。

为了维护第三方数组,select 服务器会充满大量的遍历操作。OS 底层帮我们关心 fd 的时候,也要进行遍历。

  • 每一次都要对 select 输出参数进行重新设定。
  • select 能够同时管理的 fd 的个数是有上限的。
  • 因为几乎每一个参数都是输入输出型的,也就决定了 select 一定会频繁的进行用户到内核、内核到用户的参数数据拷贝。
  • 编码比较复杂。

A. select 可监控的文件描述符个数

调用 select 函数时传入的 readfds、writefds 以及 exceptfds 都是 fd_set结构,fd_set 结构本质是一个位图,用一个 bit 位来标记一个文件描述符,因此 select 可监控的文件描述符个数取决于 fd_set 类型的 bit 位个数。

运行代码后可以发现,select可监控的文件描述符个数为 1024。


B. 一个进程能打开的文件描述符个数

进程控制块 task_struct 中有一个 files 指针,该指针指向一个 struct files_struct 结构,进程的文件描述符表 fd_array 就存储在该结构中,其中文件描述符表 fd_array 的大小定义为 NR_OPEN_DEFAULT,NR_OPEN_DEFAULT 的值实际就是 32。

但不意味着一个进程最多只能打开 32 个文件描述符,进程能打开的文件描述符个数是可以扩展的,通过 ulimit -a 命令可以看到进程能打开的文件描述符上限。

select 可监控的文件描述符个数是 1024,除去监听套接字,那么最多只能连接 1023 个客户端。


(9)select 的适用场景

多路转接接口 select、poll 和 epoll,需在一定的场景下使用,如果场景不适宜,可能会适得其反。

多路转接接口一般适用于多连接,且多连接中只有少部分连接比较活跃。因为少量连接比较活跃,也意味着几乎所有的连接在进行 IO 操作时,都需要花费大量时间来等待事件就绪,此时使用多路转接接口就可以将这些等的事件进行重叠,提高 IO 效率。

对于多连接中大部分连接都很活跃的场景,其实并不适合使用多路转接。因为每个连接都很活跃,也意味着任何时刻每个连接上的事件基本都是就绪的,此时根本不需要动用多路转接接口来进行等待,毕竟使用多路转接接口也是需要花费系统的时间和空间资源的。

多连接中只有少量连接是比较活跃的,如聊天工具,登录 QQ 后大部分时间其实是没有聊天的,此时服务器端不可能调用一个 read 函数阻塞等待读事件就绪。

多连接中大部分连接都很活跃,如企业当中进行数据备份时,两台服务器之间不断在交互数据,这时连接是特别活跃的,几乎不需要等的过程,也就没必要使用多路转接接口了。


2、poll

(1)poll 函数接口


A. 参数说明
  • fds:一个 poll 函数监视的结构列表,每一个元素都包含三部分内容:文件描述符、监视的事件集合、就绪的事件集合。
  • nfds:表示 fds 数组的长度。
  • timeout:表示 poll 函数的超时时间,单位是毫秒(ms)。

B. 参数 timeout 的取值
  • -1:poll 调用后进行阻塞等待,直到被监视的某个文件描述符上的某个事件就绪。
  • 0:poll 调用后进行非阻塞等待,无论被监视的文件描述符上的事件是否就绪,poll 检测后都会立即返回。
  • 特定的时间值:poll 调用后在指定的时间内进行阻塞等待,若被监视的文件描述符上没有事件就绪,则在该时间后 poll 进行超时返回。

C. 返回值说明
  • 若函数调用成功,则返回有事件就绪的文件描述符个数。
  • 若 timeout 时间耗尽,表示超时,则返回 0,表示 poll 以非阻塞方式等待。
  • 若函数调用失败,则返回 -1,poll 要以阻塞方式等待,同时错误码被设置。

D. 错误码

poll 调用失败时,错误码可能被设置为:

  • EFAULT:fds 数组不包含在调用程序的地址空间中
  • EINTR:此调用被信号所中断
  • EINVAL:nfds 值超过 RLIMIT_NOFILE值
  • ENOMEM:核心内存不足

(2)struct pollfd 结构


A. 参数说明
  • fd :特定的文件描述符,若设置为负值则忽略 events 字段并且 revents 字段返回 0。
  • events:需要监视该文件描述符上的哪些事件。
  • revents:poll 函数返回时告知用户该文件描述符上的哪些事件已经就绪。

B. events 和 revents 的取值

这些值都以宏的方式定义,二进制序列中有且只有一个 bit 位是 1,且为 1 的 bit 位各不相同。

  • 在调用 poll 函数之前,可以通过或运算符将要监视的事件添加到events成员中
  • 在 poll 函数返回后,可以通过与运算符检测revents成员中是否包含特定事件,以得知对应文件描述符的特定事件是否就绪

C. 返回结果
  • 返回值小于 0,表示出错。
  • 返回值等于 0,表示 poll 函数等待超时。
  • 返回值大于 0,表示 poll 由于监听的文件描述符就绪而返回。

(3)poll 服务器

poll 的工作流程和 select 基本类似,下面也实现一个简单 poll 服务器,只读取客户端发来的数据并进行打印。


A. pollServer.hpp

_fds 数组的大小是固定设置的,因此在将新获取连接对应的文件描述符添加到 fds 数组时,可能会因为 fds 数组已满而添加失败,这时 poll 服务器只能将刚刚获取上来的连接对应的套接字进行关闭。


B. poll 服务器测试

在调用 poll 函数时,将 timeout 的值设置成 1000,因此运行服务器后每隔 1000 毫秒没有客户端发来连接请求,那么服务器就会超时返回。

用 telnet 工具连接 poll 服务器后,poll 函数在检测到监听套接字的读事件就绪后就会调用 accept 获取建立好的连接,并打印输出客户端的 IP 和端口号等信息,此时客户端发来的数据也能成功被 poll 服务器收到并进行打印输出。

poll 服务器也是单进程、单线程服务器,同样可以为多个客户端服务。

当服务器端检测到客户端退出后,也会关闭对应的连接,并将对应的套接字从 _fds 数组中清除。


(4)poll 的优点

  • struct pollfd 结构中包含了 events 和 revents,相当于将 select 的输入输出型参数进行分离,因此在每次调用 poll 之前,不需像 select 一样重新对参数进行设置,接口使用比 select 方便。
  • poll 可监控的文件描述符数量没有限制(但是数量过大后性能也是会下降)。
  • poll 也可以同时等待多个文件描述符,提高 IO 效率
  • 有大量的连接,但只有少量是活跃的,节省资源。

说明:

  • 虽然代码中将 _fds 数组的元素个数定义为 100,但 _fds 数组的大小可以增大,poll 函数能监视多少文件描述符由 poll 函数的第二个参数决定。
  • 而 fd_set 类型只有 1024 个 bit 位,因此 select 函数最多只能监视 1024 个文件描述符。

(5)poll 的缺点

poll 中监听的文件描述符数目增多时:

  • 和 select 函数一样,当 poll 返回后,需要遍历 _fds 数组来获取就绪的文件描述符(轮询pollfd来获取就绪的描述符)。
  • 每次调用 poll 都需将大量 struct pollfd 结构从用户态拷贝到内核态,这个开销会随着 poll 监视的文件描述符数目增多而增大。
  • 同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。
  • 同时每次调用 poll 都需要在内核遍历传递进来的所有 fd,这个开销在 fd 很多时也很大。
  • poll 的代码也比较复杂,但比 select 容易一些。

3、epoll

(1)初识 epoll

按照 man 手册的说法:是为处理大批量句柄而作了改进的****poll

epoll 是系统提供的一个多路转接接口。

  • epoll 系统调用也可以让程序同时监视多个文件描述符上的事件是否就绪,与 select 和 poll 的定位是一样的,适用场景也相同。
  • epoll 在命名上比 poll 多了一个 e,可以理解成是 extend,epoll 就是为了同时处理大量文件描述符而改进的 poll。
  • epoll 在 2.5.44 内核中被引进,几乎具备了 select 和 poll 所有优点,它被公认为 Linux2.6 下性能最好的多路 I/O 就绪通知方法。

(2)epoll 的相关系统调用

epoll 有 3 个相关的系统调用。


A. epoll_create 函数
  • 参数 size:自 Linux2.6.8 后,size 参数是被忽略的,但 size 的值必须设置为大于 0 的值。
  • 返回值:epoll 模型创建成功返回其对应的文件描述符,否则返回 -1,同时错误码被设置。用完之后,必须调用 close() 关闭。

注意: 当不再使用时,须调用 close 函数关闭 epoll 模型对应的文件描述符,当所有引用 epoll 实例的文件描述符都已关闭时,内核将销毁该实例并释放相关资源。


B. epoll_ctl 函数

它不同于 select() 是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。

参数说明:

  • epfd:epoll_create 函数的返回值(epoll 的句柄)
  • op:表示具体的动作,用三个宏来表示
  • fd:需要监视的文件描述符 fd
  • event:需要监视该文件描述符上的哪些事件

第二个参数 op 的取值有以下三种:

  • EPOLL_CTL_ADD:注册新的文件描述符 fd 到指定的 epoll 模型 epfd 中。
  • EPOLL_CTL_MOD:修改已经注册的文件描述符的监听事件。
  • EPOLL_CTL_DEL:从 epoll 模型 epfd 中删除指定的文件描述符 fd。

返回值:函数调用成功返回 0,调用失败返回 -1,同时错误码会被设置

第四个参数对应的 struct epoll_event 结构如下:

struct epoll_event 结构中有两个成员,第一个成员 events 表示的是需监视的事件,第二个成员 data 为联合体结构,一般选择使用该结构中的 fd,表示需要监听的文件描述符。

events 可以是以下几个宏的集合(常用取值)如下:

  • EPOLLIN:表示对应的文件描述符可以读(包括对端 SOCKET 正常关闭)。
  • EPOLLOUT:表示对应的文件描述符可以写。
  • EPOLLPRI:表示对应的文件描述符有紧急的数据可读(表示应该有带外数据到来)。
  • EPOLLERR:表示对应的文件描述符发送错误。
  • EPOLLHUP:表示对应的文件描述符被挂断,即对端将文件描述符关闭了。
  • EPOLLET:将 epoll 的工作方式设置为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
  • EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需继续监听该文件描述符 socket 的话,需重新将该文件描述符添加到 EPOLL 队列里。

这些取值是以宏的方式定义,二进制序列中有且只有一个 bit 位是 1,且为 1 的 bit 位是各不相同的。


C. epoll_wait 函数

参数说明:

  • epfd:epoll_create 函数的返回值(epoll 句柄),用于指定 epoll 模型。
  • events:内核会将已就绪的事件拷贝到 events 数组中(不能是空指针,内核只负责将就绪事件拷贝到该数组,不会在用户态中分配内存)。
  • maxevents:events 数组中的元素个数,该值不能大于创建 epoll 模型时传入的 size 值。
  • timeout:表示 epoll_wait 函数的超时时间,单位是毫秒(ms),0 会立即返回,-1 是永久阻塞。

参数 timeout 的取值:

  • -1:epoll_wait 调用后进行阻塞等待,直到被监视的某个文件描述符上的某个事件就绪。
  • 0:epoll_wait 调用后进行非阻塞等待,无论被监视的文件描述符上的事件是否就绪,epoll_wait 检测后都会立即返回。
  • 特定的时间值:epoll_wait 调用后在特定的时间内阻塞等待,若被监视的文件描述符上没有事件就绪,则在该时间后 epoll_wait 超时返回。

返回值:

  • 若函数调用成功,则返回有事件就绪的文件描述符个数。
  • 若 timeout 时间耗尽,则返回 0。
  • 若函数调用失败,则返回 -1,同时错误码会被设置。

epoll_wait 调用失败时,错误码可能被设置为:

  • EBADF:传入的 epoll 模型对应的文件描述符无效。
  • EFAULT:events 指向的数组空间无法通过写入权限访问。
  • EINTR:此调用被信号所中断。
  • EINVAL:epfd 不是一个 epoll 模型对应的文件描述符,或传入的 maxevents 值小于等于 0。

(3)epoll 工作原理

A. 红黑树 && 就绪队列

当某一进程调用 epoll_create 函数,Linux 内核会创建一个 eventpoll 结构体,即 epoll 模型,eventpoll 结构体中的成员 rbr、rdlist 与 epoll 的使用方式密切相关。

cpp 复制代码
struct eventpoll{
	...
	//红黑树的根节点,这棵树中存储着所有添加到epoll中的需要监视的事件
	struct rb_root rbr;
	//就绪队列中则存放着将要通过epoll_wait返回给用户的满足条件的事件
	struct list_head rdlist;
	...
}
  • epoll 模型中的红黑树本质就是告诉内核,需要监视哪些文件描述符上的哪些事件,调用 epll_ctl 函数就是在对这颗红黑树进行增删改操作。
  • epoll 模型中的就绪队列本质就是告诉内核,哪些文件描述符上的哪些事件已就绪,调用 epoll_wait 函数就是从就绪队列中获取已就绪的事件。

在 epoll 中,对于每一个事件都有一个对应的 epitem 结构体,红黑树和就绪队列中的节点分别是基于 epitem 结构中的 rbn 成员和 rdllink 成员的,epitem 结构中的成员 ffd 记录的是指定的文件描述符值,event 成员记录的就是该文件描述符对应的事件。

cpp 复制代码
struct epitem{
	struct rb_node rbn;       //红黑树节点
	struct list_head rdllink; //双向链表节点
	struct epoll_filefd ffd;  //事件句柄信息
	struct eventpoll *ep;     //指向其所属的eventpoll对象
	struct epoll_event event; //期待发生的事件类型
}
  • 对于 epitem 结构中 rbn 成员而言,ffd 与 event 的含义是:需要监视 ffd 上的 event 事件是否就绪。
  • 对于 epitem 结构中的 rdlink 成员而言,ffd 与 event 的含义是:ffd 上的 event 事件已就绪。

注意红黑树 是一种二叉搜索树,必须有键值 key文件描述符 就可以天然的作为红黑树 key 值调用 epoll_ctl 向红黑树中新增节点时,若设置了 EPOLLONESHOT 选项,监听完这次事件后,若还需继续监听该文件描述符则需重新将其添加到 epoll 模型中,本质就是当设置了 EPOLLONESHOT 选项的事件就绪时,操作系统会自动将其从红黑树中删除。

若调用 epoll_ctl 向红黑树中新增节点时没设置 EPOLLONESHOT,那么该节点插入红黑树后就会一直存在,除非用户调用 epoll_ctl 将该节点从红黑树中删除。


B. 回调机制

所有添加到红黑树中的事件,都与设备(网卡)驱动程序建立回调方法,该回调方法在内核中被称为 ep_poll_callback。

  • 对于 select 和 poll 而言,操作系统在监视多个文件描述符上的事件是否就绪时,需让操作系统主动对这多个文件描述符进行轮询检测,这会增加操作系统的负担。
  • 对于 epoll 而言,操作系统不需主动进行事件的检测,当红黑树中监视的事件就绪时,会自动调用对应回调方法,将就绪的事件添加到就绪队列中。
  • 当用户调用 epoll_wait 函数获取就绪事件时,只需关注底层就绪队列是否为空,若不为空则将就绪队列中的就绪事件拷贝给用户。
  • 采用回调机制最大的好处:不再需要操作系统主动对就绪事件进行检测,当事件就绪时会自动调用对应的回调函数进行处理。

注意:只有添加到红黑树中的事件才会与底层建立回调方法,因此只有红黑树中对应的事件就绪时,才会执行对应的回调方法将其添加到就绪队列。

当不断有监视的事件就绪时,会不断调用回调方法向就绪队列中插入节点,而上层也会不断调用 epoll_wait 函数从就绪队列中获取节点,即典型的生产者消费者模型。

由于就绪队列可能被多个执行流同时访问,因此必须要使用互斥锁进行保护,eventpoll 结构中的 lock和 mtx 就是用于保护临界资源的,因此epoll本身是线程安全的 eventpoll 结构中的 wq(wait queue)即等待队列,当多个执行流想同时访问同一个 epoll 模型时,就需在该等待队列下进行等待。


(4)epoll 服务器测试

编写 epoll 服务器在调用 epoll_wait 函数时,将 timeout 的值设置成了 -1,因此运行服务器后若没有客户端发来连接请求,那么服务器就会调用 epoll_wait 函数后阻塞等待。

使用 telnet 工具连接 epoll 服务器后,epoll 服务器调用的 epoll_wait 函数在检测到监听套接字的读事件就绪后就会调用 accept 获取建立好的连接,并打印输出客户端的 IP 和端口号,此时客户端发来的数据也能成功被 epoll 服务器收到并进行打印输出。

该 epoll 服务器同样为单进程、单线程服务器,但可以为多个客户端提供服务。当服务器端检测到客户端退出后,也会关闭对应连接,此时 epoll 服务器对应的 5 号和 6 号文件描述符就关闭了。

使用 ls /proc/PID/fd 命令,查看当前epoll服务器的文件描述符的使用情况。文件描述符 0、1、2 是默认打开的,分别对应的是标准输入、标准输出和标准错误,3 号文件描述符对应的是监听套接字,4 号文件描述符对应 epoll 句柄,5 号和 6 号文件描述符分别对应访问服务器的两个客户端。


(5)epoll 的优点(select的缺点对应

  • 接口使用方便:拆分成了三个函数,使用起来更方便高效,不需要每次循环都设置关注的文
    件描述符, 也做到了输入输出参数分离开, 不至于冗杂。
  • 数据拷贝轻量:只在新增监视事件的时候调用 epoll_ctl 将数据从用户拷贝到内核中,而 select 和 poll 每次都需要重新将需要监视的事件从用户拷贝到内核。此外,调用 epoll_wait 获取就绪事件时,只会拷贝就绪的事件,不进行不必要的拷贝操作。
  • 事件回调机制:避免操作系统主动轮询检测事件就绪,而是采用回调函数的方式,将就绪的文件描述符结构加入到就绪队列中。调用 epoll_wait 时直接访问就绪队列就知道哪些文件描述符已就绪,检测是否有文件描述符就绪的时间复杂度是 O(1),因为本质只需要判断就绪队列是否为空即可,即使文件描述符数目很多, 效率也不会受到影响。
  • 没有数量限制:监视的文件描述符数目无上限,只要内存允许,可一直向红黑树中新增节点。

注意:网上有的博客中说 epoll 中使用了内存映射机制,内核可以直接将底层就绪队列通过 mmap 的方式映射到用户态,此时用户就可以直接读取到内核中就绪队列中的数据,避免了内存拷贝的额外性能开销。❌这种说法是错误的,实际操作系统并没有做任何映射机制,因为操作系统是不相信任何人的,操作系统不会让用户进程直接访问到内核的数据的,用户只能通过系统调用来获取内核的数据。我们定义的 struct epoll_event 是我们在用户空间中分配好的内存,因此用户要获取内核中的数据,势必还是要将内核的数据拷贝到用户空间。


A. 与 select 和 poll 的不同之处
  • 在使用 select 和 poll 时,都需借助第三方数组来维护历史上的文件描述符以及需要监视的事件,第三方数组由用户自行维护,对该数组的增删改操作都需要用户进行。
  • 使用 epoll 时,不需要用户维护第三方数组,epoll 底层的红黑树就充当了这个第三方数组的功能,并且该红黑树的增删改操作都是由内核维护的,用户只需要调用 epoll_ctl 让内核对该红黑树进行对应的操作即可。
  • 在使用多路转接接口时,数据流都有两个方向,一个是用户告知内核,一个是内核告知用户。select 和 poll 将这两件事情都交给了同一个函数来完成,而 epoll 在接口层面上就将这两件事进行了分离,epoll 通过调用 epoll_ctl 完成用户告知内核,通过调用 epoll_wait 完成内核告知用户。

(6)epoll 的工作方式

epoll 有 2 种工作方式:水平触发(LT)和边缘触发(ET)。


A. 水平触发 Level Triggered 工作模式
  • 只要底层有事件就绪,epoll 就会一直通知用户。
  • 类似于数字电路中的高电平触发一样,只要一直处于高电平,则会一直触发。

epoll 默认状态下就是 LT 工作模式。

  • 由于在 LT 工作模式下,只要底层有事件就绪就会一直通知用户,因此当 epoll 检测到底层读事件就绪时,可以不立即进行处理,或者只处理一部分,因为只要底层数据没有处理完,下一次 epoll 还会通知用户事件就绪。
  • select 和 poll 其实就是工作是 LT 模式下的。
  • 支持阻塞读写和非阻塞读写。

B. 边缘触发 Edge Triggered 工作模式

如果在第 1 步将 socket 添加到 epoll 描述符的时候使用了 EPOLLET 标志,epoll 进入 ET 工作模式。

  • 只有底层就绪事件数量由无到有或由有到多发生变化的时候,epoll 才会通知用户。
  • 类似于数字电路中的上升沿触发一样,只有当电平由低变高的那一瞬间才会触发。

若要将 epoll 改为 ET 工作模式,则需在添加事件时设置 EPOLLET 选项。

  • 由于在 ET 工作模式下,只有底层就绪事件无到有或由有到多发生变化的时候才会通知用户,因此当 epoll 检测到底层读事件就绪时,必须立即进行处理,且全部处理完毕,因为有可能此后底层再也没有事件就绪,那么 epoll 就再也不会通知用户进行事件处理,此时没有处理完的数据就丢失了
  • ET 工作模式下 epoll 通知用户的次数一般比 LT 少,并且每次都将缓冲区中全部事件处理完成,从而提高网络吞吐量,因此 ET 的性能一般比 LT 性能更高,Nginx 就是默认采用 ET 模式使用 epoll 的。
  • 只支持非阻塞的读写。

select 和 poll 其实也是工作在 LT 模式下, epoll 既可以支持 LT, 也可以支持 ET。


C. ET 工作模式下如何进行读写

因为在 ET 工作模式下,只有底层就绪事件无到有或由有到多发生变化的时候才会通知用户,这就倒逼用户当读事件就绪时必须一次性将数据全部读取完毕,当写事件就绪时必须一次性将发送缓冲区写满,否则可能再也没有机会进行读写了

因此读数据时必须循环调用 recv 函数进行读取,写数据时必须循环调用 send 函数进行写入。

  • 当底层读事件就绪时,循环调用 recv 函数进行读取,直到某次调用 recv 读取时,实际读取到的字节数小于期望读取的字节数,则说明本次底层数据已读取完毕了。
  • 但有可能最后一次调用 recv 读取时,刚好实际读取的字节数和期望读取的字节数相等,但此时底层数据也恰好读取完毕了,若再调用 recv 函数进行读取,那么 recv 就会因为底层没有数据而被阻塞。
  • 在这里阻塞是非常严重的,就比如博客写的服务器都是单进程的服务器,若 recv 被阻塞住,并且此后该数据再也不就绪,那么就相当于服务器挂掉了,因此在 ET 工作模式下循环调用 recv 函数进行读取时,必须将对应的文件描述符设置为非阻塞状态。
  • 调用 send 函数写数据时也是同样的道理,需循环调用 send 函数进行数据的写入,且必须将对应的文件描述符设置为非阻塞状态。

**注意:**ET 工作模式下,recv 和 send 操作的文件描述符必须设置为非阻塞状态,这是必须的,不是可选的。


D. 对比 LT 和 ET
  • LT 是 epoll 的默认行为。使用 ET 能够减少 epoll 触发的次数,但是需要在一次响应就绪过程中就把所有的数据都处理完。应用层尽快的取走了缓冲区中的数据,那么在单位时间内,该模式下工作的服务器就可以在一定程度上给发送方发送一个更大的接收窗口,所以对方就有更大的滑动窗口,一次向我们发送更多的数据,从而提高了 IO 吞吐。
  • 在 ET 模式下,一个文件描述符就绪之后,用户不会反复收到通知,看起来比 LT 更高效,但若在 LT 模式下能够做到每次都将就绪的文件描述符立即全部处理,不让操作系统反复通知用户的话,其实 LT 和 ET 的性能也是一样的。
  • ET 的编程难度比 LT 更高。

E. 理解 ET 模式和非阻塞文件描述符

使用 ET****模式的 epoll 需要将文件描述设置为非阻塞。这个不是接口上的要求,而是 "工程实践" 上的要求。

假设这样的场景:服务器接受到一个 10k 的请求,会向客户端返回一个应答数据。如果客户端收不到应答,不会发送第二个 10k 请求。

如果服务端写的代码是阻塞式的 read,并且一次只 read 1k 数据的话(read 不能保证一次就把所有的数据都读出来,参考 man 手册的说明,可能被信号打断),剩下的 9k 数据就会待在缓冲区中。

此时由于 epoll 是 ET 模式,并不会认为文件描述符读就绪,epoll_wait 就不会再次返回,剩下的 9k 数据会一直在缓冲区中,直到下一次客户端再给服务器写数据,epoll_wait 才能返回。

但是,服务器只读到 1k 个数据,要 10k 读完才会给客户端返回响应数据。客户端要读到服务器的响应,才会发送下一个请求。客户端发送了下一个请求,epoll_wait 才会返回,才能去读缓冲区中剩余的数据。


所以,为了解决上述问题(阻塞 read 不一定能一下把完整的请求读完),于是就可以使用非阻塞轮询的方式来读缓冲区,保证一定能把完整的请求都读出来,而如果是 LT 没这个问题。只要缓冲区中的数据没读完,就能够让 epoll_wait 返回文件描述符读就绪。


(7)epoll 的使用场景

epoll 的高性能是有一定的特定场景的。如果场景选择的不适宜,epoll 的性能可能适得其反。

对于多连接,且多连接中只有一部分连接比较活跃时,比较适合使用 epoll。例如,典型的一个需要处理上万个客户端的服务器,例如各种互联网 APP 的入口服务器,这样的服务器就很适合 epoll。

如果只是系统内部,服务器和服务器之间进行通信,只有少数的几个连接,这种情况下用 epoll 就并不合适,具体要根据需求和场景特点来决定使用哪种 IO 模型。


(8)epoll 中的惊群问题(了解)

可参考:epoll 的惊群效应-CSDN博客


参考学习资料:

epoll 详解-CSDN博客

Apache 与 Nginx 网络模型-CSDN博客


七、Reactor 模式

1、Reactor 模式定义

Reactor 反应器模式,也被称为分发者模式或通知者模式,是一种将就绪事件派发给对应服务处理程序的事件设计模式。


2、Reactor 模式的角色构成


3、Reactor 模式的工作流程

  • 当向初始分发器注册具体事件处理器时,会标识出该事件处理器希望初始分发器在某个事件发生时向其通知,该事件与 Handle 关联。
  • 初始分发器会要求每个事件处理器向其传递内部的 Handle,该 Handle 向操作系统标识了事件处理器。
  • 当所有的事件处理器注册完毕后,启动初始分发器的事件循环,这时初始分发器会将每个事件处理器的 Handle 合并起来,并使用同步事件分离器等待这些事件的发生。
  • 当某个事件处理器的 Handle 变为 Ready 状态时,同步事件分离器会通知初始分发器。
  • 初始分发器会将 Ready 状态的 Handle 作为 key,来寻找其对应的事件处理器。
  • 初始分发器会调用其对应事件处理器中对应的回调方法来响应该事件。

4、epoll ET 服务器(Reactor 模式)

(1)设计思路

A. epoll ET 服务器
  • 读事件:若是监听套接字读事件就绪则调用 accept 函数获取底层连接,若是其他套接字读事件就绪则调用 recv 函数读取客户端发来的数据。
  • 写事件:写事件就绪则将待发送的数据写入到发送缓冲区中。
  • 异常事件:当某个套接字的异常事件就绪时不做过多处理,直接关闭该套接字。

当 epoll ET 服务器监测到某一事件就绪后,就会将该事件交给对应的服务处理程序进行处理。


B. Reactor 模式的五个角色

在这个 epoll ET 服务器中,Reactor 模式中的五个角色对应如下:

  • 句柄:文件描述符。
  • 同步事件分离器:I/O 多路复用 epoll。
  • 事件处理器:包括读回调、写回调和异常回调。
  • 具体事件处理器:读回调、写回调和异常回调的具体实现。
  • 初始分发器:TcpServer 中的 Dispatcher 函数。
  • Dispatcher 函数的工作即为:调用 epoll_wait 函数等待事件发生,有事件发生后将就绪的事件派发给对应的服务处理程序即可。

C. Connection 类

在 Reactor 的工作流程中说到,在注册事件处理器时需要将其与Handle关联,本质上就是需要将读回调、写回调和异常回调与某个文件描述符关联起来。这样做的目的就是为了当某个文件描述符上的事件就绪时可以找到其对应的各种回调函数,进而执行对应的回调方法来处理该事件。可以设计一个 Connection 类,该类中的成员包括了一个文件描述符,以及该文件描述符对应的各种回调函数,以及其他成员。


D. TcpServer 类

在 Reactor 的工作流程中说到,当所有事件处理器注册完毕后,会使用同步事件分离器等待这些事件发生,当某个事件处理器的Handle变为 Ready 状态时,同步事件分离器会通知初始分发器,然后初始分发器会将 Ready 状态的 Handle 作为 key 来寻找其对应的事件处理器,并调用该事件处理器中对应的回调方法来响应该事件。

本质就是当事件注册完毕后,会调用 epoll_wait 函数来等待这些事件发生,当某个事件就绪时epoll_wait 函数会告知调用方,然后调用方就根据就绪的文件描述符来找到其对应的各种回调函数,并调用对应的回调函数进行事件处理。

对此可以设计一个 Reactor 类:

  • 该类当中有一个成员函数 Dispatcher,即初始分发器,在该函数内部会调用 epoll_wait 函数等待事件的发生,当事件发生后会告知 Dispatcher 已经就绪的事件。
  • 当事件就绪后需要根据就绪的文件描述符来找到其对应的各种回调函数,由于会将每个文件描述符及其对应的各种回调都封装到一个 Connection 结构中,所以可以根据文件描述符找到其对应的 Connection 结构。
  • 使用 C++ STL 中的 unordered_map,来建立各个文件描述符与其对应的 Connection 结构之间的映射,这个 unordered_map 可以作为 TcpServer 类的一个成员变量,当需要找某个文件描述符的 Connection 结构时就可以通过该成员变量找到。
  • TcpServer 类中还需要提供成员函数 AddConnection,用于向初始分发器中注册事件。

E. epoll ET 服务器的工作流程
  • epoll ET 服务器的初始化:需进行套接字的创建、绑定、监听,创建 epoll 模型。
  • 为监听套接字创建对应的 Connection 结构,并调用 TcpServer 类中提供的 AddConnection 函数将监听套接字添加到 epoll 模型中,并建立监听套接字与其对应的 Connection 结构之间的映射关系。
  • 之后就可以不断调用 TcpServer 类中的 Dispatcher 函数进行事件派发。
  • 在事件处理过程中,会不断向 Dispatcher 中新增事件,每个事件就绪时都会自动调用其对应的回调函数处理,不断调用 Dispatcher 函数进行事件派发即可。

(2)Connection 结构

Connection 结构中除了包含文件描述符和其对应的读回调、写回调和异常回调外,还包含一个输入缓冲区 _inBuffer、一个输出缓冲区 _outBuffer 以及一个回指指针 _svrPtr。

  • 当某个文件描述符的读事件就绪时,调用 recv 函数读取客户端发来的数据,但并不能保证读到了一个完整报文,因此需要将读取到的数据暂时存放到该文件描述符对应的 _inBuffer 中,当 _inBuffer 中可以分离出一个完整的报文后再将其分离出来进行数据处理,_inBuffer 本质就是用来解决粘包问题的。
  • 当处理完一个报文请求后,需将响应数据发送给客户端,但并不能保证底层 TCP 的发送缓冲区中有足够的空间写入,因此需将要发送的数据暂时存放到该文件描述符对应的 _outBuffer 中,当底层 TCP 的发送缓冲区中有空间,即写事件就绪时,再依次发送 _outBuffer 中的数据。
  • Connection 结构中设置回指指针_svrPtr,便于快速找到 TcpServer 对象,因为后续需要根据 Connection 结构找到这个 TcpServer 对象。如上层业务处理函数 NetCal 函数向 _outBuffer 输出缓冲区递交数据后,需通过 Connection 中的回指指针,"提醒" TcpServer 进行处理。

Connection 结构中需提供一个管理回调的成员函数,便于外部对回调进行设置:


(3)TcpServer 类

在 TcpServer 类中有一个 unordered_map 成员,用于建立文件描述符和与其对应的 Connection 结构之间的映射,还有一个 _epoll 成员,该成员是封装的 Epoll 对象。在初始化 TcpServer 对象时就可以调用封装的 EpollCreate 函数创建 Epoll 对象,并将该 epoll 模型对应的文件描述符记录在该对象的成员变量 _epollFd 中,便于后续使用。TcpServer 对象析构时,Epoll 对象的析构会自动调用close函数将epoll模型关闭。


A. 封装 Epoll 类

B. TcpServer 类部分代码

a. AddConnection 函数

TcpServer 类中的 AddConnection 函数用于进行事件注册。

在注册事件时需要传入一个文件描述符和三个回调函数,表示当该文件描述符上的事件(默认只关心读事件)就绪后应该执行的回调方法。

在 AddConnection 函数内部要做的就是,设置套接字为非阻塞(ET 模型要求),将套接字和回调函数等属性封装为一个 Connection,在将套接字添加到 epoll 模型中,对象建立文件描述符和 Connection 的映射关系并管理。


b. Dispatcher函数(初始分发器)

TcpServer 中的 Dispatcher 函数即初始分发器,其要做的就是调用 epoll_wait 函数等待事件发生。当某个文件描述符上的事件发生后,先通过 unordered_map 找到该文件描述符对应的 Connection 结构,然后调用 Connection 结构中对应的回调函数对该事件进行处理即可。

  • 本代码没有用 switch 或 if 语句对 epoll_wait 函数的返回值进行判断,而是借用 for 循环对其返回值进行了判断。
  • 若 epoll_wait 的返回值为 -1 则说明 epoll_wait 函数调用失败,此时不会进入到 for 循环内部进行事件处理。
  • 若 epoll_wait 的返回值为 0 则说明 epoll_wait 函数超时返回,此时也不会进入到 for 循环内部进行事件处理。
  • 若 epoll_wait 的返回值大于 0 则说明 epoll_wait 函数调用成功,此时才会进入到 for 循环内部调用对应的回调函数对事件进行处理。
  • 事件处理时先对异常事件进行处理,将异常事件交给回调函数进行处理。

c. EnableReadWrite 函数

TcpServer 类中的 EnableReadWrite 函数,用于使能某个文件描述符的读写事件。

  • 调用 EnableReadWrite 函数时需要传入一个文件描述符,表示需要设置的是哪个文件描述符对应的事件。
  • 传入两个 bool 值,分别表示是否关心读事件以及是否关心写事件。
  • EnableReadWrite 函数内部会调用封装 EpollCtrl 函数修改该文件描述符的监听事件。

(4)回调函数

  • Accepter:当连接事件到来时调用该回调函数获取底层建立好的连接。
  • Recver:当读事件就绪时调用该回调函数读取客户端发来的数据并处理。
  • Sender:当写事件就绪时调用该回调函数向客户端发送响应数据。
  • Excepter:当异常事件就绪时调用该函数进行一系列资源的释放。

为某个文件描述符创建Connection结构时,可以调用Connection类提供的SetCallBack函数,将这些回调函数添加到Connection结构中

  • 监听套接字对应的 Connection 结构中的 _recvCb 为 Accepter,因为监听套接字的读事件就绪就意味着连接事件就绪了,而监听套接字一般只关心读事件,因此监听套接字对应的 _sendCb 和 _exceptCb 可以设置为 nullptr。
  • 当 Dispatcher 监测到监听套接字的读事件就绪时,会调用监听套接字对应的Connection结构中的 _recvCb 回调,此时就会调用 Accepter 回调获取底层建立好的连接
  • 对于与客户端建立连接的套接字,其对应的 Connection 结构中的 _recvCb、_sendCb 和 _exceptCb 分别为 Recver、Sender 和 Excepter。
  • 当 Dispatcher 监测到这些套接字的事件就绪时,就会调用其对应的 Connection 结构中对应的回调函数,即 Recver、Sender 和 Excepter。

A. Accepter

Accepter回调用于处理连接事件,其工作流程如下:

  • 调用封装的 Accept 函数获取底层建立好的连接。
  • 使用 AddConnection 函数将获取到的套接字封装为 Connection 并添加至服务器的管理中。
  • 此时套接字及其对应需要关心的事件就已注册到 Dispatcher 中。

下一次 Dispatcher 在进行事件派发时就会关注该套接字对应的事件,当事件就绪时就会执行该套接字对应的 Connection 结构中对应的回调方法。

这里实现的 ET 模式下的 epoll 服务器,因此在获取底层连接时需要循环调用 accept 函数进行读取,并且监听套接字必须设置为非阻塞。

  • 因为 ET 模式下只有当底层建立的连接从无到有或是从有到多时才会通知上层,若没有一次性将底层建立好的连接全部获取,并且此后再也没有建立好的连接,那么底层没有读取完的连接就相当于丢失了。
  • 循环调用 accept 函数也意味着,当底层连接全部被获取后再调用 accept 函数,此时就会因为底层已经没有连接了而被阻塞住,因此需要将监听套接字设置为非阻塞,这样当底层没有连接时 accept 就不会被阻塞。accept 获取到的新的套接字也需设置为非阻塞,为了避免将来循环调用 recv、send 等函数时被阻塞。
  • 设置非阻塞的操作都在 AddConnection 函数中的 SetNonBlock 函数完成。

a. 设置非阻塞

设置文件描述符为非阻塞时,需先调用 fcntl 函数获取该文件描述符对应的文件状态标记,然后在该文件状态标记的基础上添加非阻塞标记 O_NONBLOCK,最后调用 fcntl 函数对该文件描述符的状态标记进行设置即可。

监听套接字设置为非阻塞后,当底层连接不就绪时,accept 函数会以出错的形式返回,因此当调用 accept 函数的返回值小于 0 时,需继续判断错误码。

  • 若错误码为 EAGAIN 或 EWOULDBLOCK,说明本次出错返回是因为底层已经没有可获取的连接了,此时底层连接全部获取完毕,这时可以返回 0,表示本次 Accepter 调用成功。
  • 若错误码为 EINTR,说明本次调用 accept 函数获取底层连接时被信号中断了,这时还应该继续调用 accept 函数进行获取。
  • 除此之外,才说明 accept 函数是真正调用失败了,此时可以返回 -1,表示本次 accepter 调用失败。

accept、recv 和 send 等 IO 系统调用为什么会被信号中断?

IO 系统调用函数出错返回并且将错误码设置为 EINTR,表明本次在进行数据读取或数据写入之前被信号中断了,即IO系统调用在陷入内核,但并没有返回用户态的时候内核去处理其他信号

  • 在内核态返回用户态之前会检查信号的 pending 位图,即未决信号集,若 pending 位图中有未处理的信号,那么内核就会对该信号进行处理
  • IO 系统调用函数在进行IO操作前就被信号中断了,这是一个特例,因为 IO 过程分为 "等" 和 "拷贝" 两个步骤,一般 "等" 的过程比较漫长,而在这个过程中执行流其实是处于闲置状态的,因此在 "等" 的过程中若有信号产生,内核就会立即进行信号的处理。

b. 写事件按需打开

Accepter 获取上来的套接字在添加到 Dispatcher 中时,只添加了 EOPLLIN 和 EPOLLET 事件,即只让 epoll 关心该套接字的读事件。

之所以没有添加写事件,是因为并没有要发送的数据,因此没有必要让 epoll 关心写事件。一般读事件是会被设置的,而写事件则是按需打开的,只当有数据要发送时才会将写事件打开,并且在数据全部写入完毕后又会立即将写事件关闭。


B. Recver

recver回调用于处理读事件,其工作流程如下:

  • 循环调用recv函数读取数据,并将读取到的数据添加到该套接字对应 Connection 结构的 _inBuffer 中。
  • 对 _inBuffer 中的数据进行切割,将完整的报文切割出来,剩余的留在 inbuffer 中。
  • 调用业务处理函数。
  • 当 recv 函数的返回值小于 0 时需要进一步判断错误码,若错误码为 EAGAIN 或 EWOULDBLOCK 则说明底层数据读取完毕了,若错误码为 EINTR 则说明读取过程被信号中断了,此时还需继续调用 recv 函数进行读取,否则就是读取出错了。
  • 当读取出错时,直接调用该套接字对应的 _exceptCb 回调,在 _exceptCb 回调中将该套接字进行关闭。

a. 报文切割

报文切割本质就是为了防止粘包问题,而粘包问题还涉及到协议定制。

  • 需要根据协议知道如何将各个报文进行分离,如 UDP 分离报文采用的就是定长报头+自描述字段。
  • 本博客目的是演示整个数据处理的过程,为了简单起见就不进行过于复杂的协议定制了,就以 "X" 作为各个报文之间的分隔符,每个报文的最后都会以一个 "X" 作为报文结束的标志。
  • 因此现在要做的就是以 "X" 作为分隔符对 _inBuffer 中的字符串进行切割。
  • SpliteMessage 函数要做的就是对 _inBuffer 中的字符串进行切割,将切割出来的一个个报文放到 vector 中,对于最后无法切出完整报文的数据就留在 _inBuffer 中。

b. 业务处理函数
  • 对切割出来的完整报文进行反序列化。
  • 业务处理。
  • 业务处理后形成响应报文。
  • 将响应报头添加到对应 Conection 结构的 _outBuffer 中,并打开写事件。

下一次 Dispatcher 在进行事件派发时就会关注该套接字的写事件,当写事件就绪时就会执行该套接字对应的 Connection 结构中写回调方法,进而将 _outBuffer 中的响应数据发送给客户端。


c. 协议定制

C. Sender
  • 循环调用 send 函数发送数据,并将发送出去的数据从该套接字对应 Connection 结构的 _outBuffer 中删除。
  • 若循环调用 send 函数后该套接字对应的 _outBuffer 中的数据被全部发送,此时就需要将该套接字对应的写事件关闭,因为已没有要发送的数据了,若 _outBuffer 中的数据还有剩余,那么该套接字对应的写事件就应继续打开。
  • send 函数的返回值小于0时需进一步判断错误码,若错误码为 EAGAIN 或 EWOULDBLOCK 则说明底层 TCP 发送缓冲区已被写满了,这时将已经发送的数据从 _outBuffer 中移除。
  • 若错误码为 EINTR 则说明发送过程被信号中断了,此时还需要继续调用send函数进行发送,否则就是发送出错了。
  • 当发送出错时直接调用该套接字对应的 _exceptCb 回调,在 _exceptCb 回调中将该套接字进行关闭
  • 若最终 _outBuffer 中的数据全部发送成功,则 _outBuffer 被清空,可以关闭对写事件的关心。

D. Excepter
  • 对于异常事件就绪的套接字不做过多处理,调用 close 函数将该套接字关闭即可。
  • 但在关闭该套接字前,需先将该套接字从 epoll 模型中删除,并取消该套接字与其对应的 Connection 结构的映射关系。
  • 释放 Connection 对象。

(5)Socket 套接字


(6)服务器测试

当客户端连接服务器后,在服务器端会显示客户端使用的是 5 号文件描述符,因为 4 号文件描述符已被 epoll 模型使用了。

此时客户端可以向服务器发送一些简单计算任务,计算任务间用 "X" 隔开,服务器收到计算请求处理后会将计算结果发送给客户端,计算结果之间也是用 "X" 隔开的。若发送的不是完整报文,则会保存在 socket 对应的 Connection 结构中的 _inBuffer 中。

由于使用了多路转接技术,虽然 epoll 服务器是一个单进程的服务器,但却可同时为多个客户端提供服务。


(7)总结

基于多路转接方案,当事件就绪的时候,采用回调的方式,进行业务处理的模式就被称为反应堆模式(Reactor)。上述代码中的 TcpServer 就是一个反应堆,其中一个个 Connection 对象就称为事件。每一个事件中都有:

  • 文件描述符
  • 独立的缓冲区
  • 回调方法
  • 回指向反应堆的指针

反应堆中有一个事件派发函数,当epoll中的某个事件就绪,事件派发函数回调用此事件的回调函数。


A. 特性
  • 单进程:既负责事件派发又负责 IO。
  • 半异步半同步:异步,事件到来是随机的。
  • 同步:当前线程参与 IO。
相关推荐
滴水之功25 分钟前
VMware OpenWrt怎么桥接模式联网
linux·openwrt
ldinvicible44 分钟前
How to run Flutter on an Embedded Device
linux
Hacker_LaoYi1 小时前
【漏洞分析】DDOS攻防分析(四)——TCP篇
网络·tcp/ip·ddos
爱吃水果蝙蝠汤1 小时前
DATACOM-IP单播路由(BGP)-复习-实验
网络·网络协议·tcp/ip
YRr YRr2 小时前
解决Ubuntu 20.04上编译OpenCV 3.2时遇到的stdlib.h缺失错误
linux·opencv·ubuntu
认真学习的小雅兰.2 小时前
如何在Ubuntu上利用Docker和Cpolar实现Excalidraw公网访问高效绘图——“cpolar内网穿透”
linux·ubuntu·docker
zhou周大哥2 小时前
linux 安装 ffmpeg 视频转换
linux·运维·服务器
Sun_12_22 小时前
SQL注入(SQL lnjection Base)21
网络·数据库
不想起昵称9292 小时前
Linux SHELL脚本中的变量与运算
linux