I/O(input/output)也就是输入和输出,在冯诺依曼体系结构当中,将数据从输入设备拷贝到内存就叫作输入,将数据从内存拷贝到输出设备就叫作输出。
输入就是操作系统将数据从外设拷贝到内存的过程,操作系统一定要通过某种方法得知特定外设上是否有数据就绪。
并不是操作系统想要从外设读取数据时,外设上就一定有数据可以被读取。比如:用户正在访问某台服务器,当用户的请求报文发出后就需要等待从网卡中读取服务器发来的响应数据,但此时服务器可能还没有收到请求报文,或者是正在对请求报文进行数据分析,也可能是服务器发来的响应数据还在网络中路由。但操作系统不会主动去检测外设上是否有数据就绪,这种做法会降低操作系统的工作效率,因为大部分情况下外设中都是没有数据的,所以操作系统所做的大部分检测工作其实都是徒劳的。
操作系统实际上采用的是中断的方式来得知外设上是否有数据准备就绪了,当某个外设上面有数据就绪时,该外设就会向 CPU 中的中断控制器发送中断信号,中断控制器再根据产生的中断信号的优先级按顺序发送给 CPU。
每一个中断信号都有一个对应的中断处理程序,存储中断信号和中断处理程序映射关系的表被称为中断向量表,当 CPU 收到某个中断信号时就会自动停止正在运行的程序,然后根据该中断向量表来执行该中断信号对应的中断处理程序,处理完毕后再返回原先被暂停的程序继续运行。
注意:CPU 不直接和外设交互指的是在数据层面上,而外设其实是可以直接将某些控制信号发送给 CPU 中的某些控制器的。
IO 最主要的问题就是效率问题,IO 的效率极为低下,以读取数据为例:
所以,IO 的本质就是:等待(等待 IO 条件就绪) + 数据拷贝(当 IO 条件就绪后将数据拷贝到内存或外设)。只要缓冲区中没有数据,read/recv 就会一直阻塞等待,直到缓冲区中出现数据,然后进行拷贝,所以 read/recv 就会花费大量时间在等这一操作上面,这就是一种低效的 IO 模式。
任何 IO 的过程,都包含 “等” 和 “拷贝” 这两个步骤,但在实际的应用场景中 “等” 消耗的时间往往比 “拷贝” 消耗的时间多。
操作系统任何时刻都可能会收到大量的数据包,因此操作系统须将这些数据包管理起来。所谓的管理即 “先描述,再组织”,在内核中有一个结构 sk_buff,该结构就是用来管理和控制接收或发送数据包的信息。
简化版的 sk_buff 结构:
当操作系统从网卡中读取到一个数据包后,会将该数据依次交给链路层、网络层、传输层、应用层进行解包和分用,最终将数据包中的数据交给了上层用户。
发送数据时对数据进行封装也是同样的道理,即依次在数据前面拷贝上对应的报头。应用层以下,数据包在进行封装和解包的过程中,本质数据的存储位置是没有发生变化的,实际只是在用不同的指针对数据进行操作。
但内核中的 sk_buff 并不像上面那样简单:
- 一方面,为了保证高效的网络报文处理效率,要求 sk_buff 的结构必须是高效的。
- 另一方面,sk_buff 结构需要被内核协议中的各个协议共同使用,因此 sk_buff 必须能够兼容所有网络协议。
想办法在单位时间内,让等待的比重降低,这样 IO 的效率就提高了。
IO 的过程跟钓鱼的过程是非常相似的。
- 钓鱼的过程同样分为 “等” 和 “拷贝” 两个步骤,只不过这里的 “等” 指的是等鱼上钩,“拷贝” 指的是当鱼上钩后将鱼从河里 “拷贝” 到鱼桶中。
- IO 时 “等” 消耗的时间往往比 “拷贝” 消耗的时间多,钓鱼也符合这个特点。在钓鱼时大部分的时间都在等待鱼上钩,而当鱼上钩后只需要一瞬间就能将鱼 “拷贝” 上来。
五个人的不同钓鱼方式:
- 张三:用 1 根鱼竿,将鱼钩抛入水中后,就一直盯着浮标一动不动,不理会外界的任何动静,直到有鱼上钩,就挥动鱼竿将鱼钓上来。(阻塞)
- 李四:用 1 根鱼竿,将鱼钩抛入水中后,就可以去做其它事情了,然后定期观察浮标的动静,如果有鱼上钩就将鱼钓上来,否则就继续做其它事情。(非阻塞轮询式)
- 王五:用 1 根鱼竿,将鱼钩抛入水中后,在鱼竿顶部绑一个铃铛,就可以去做其它事情了,如果铃铛一响就知道有鱼上钩了,于是挥动鱼竿将鱼钓上来,否则就不管鱼竿。(信号驱动)
- 赵六:用 100 根鱼竿,将 100 个鱼钩抛入水中后,就定期观察这 100 个浮漂的动静,如果某个鱼竿有鱼上钩就挥动对应的鱼竿将鱼钓上来。(多路复用、多路转接)
- 田七:田七是一个公司的领导,带了一个司机,此时田七也想钓鱼,但他因为要立刻回公司开会,所以他拿来一根鱼竿,让自己的司机去钓鱼,当司机将鱼桶装满时再打电话告诉他。(异步 IO)
张三,李四和王五钓鱼的效率本质上是一样的。
- 因为他们的钓鱼方式都是一样的,都是先等鱼上钩,然后再将鱼钓上来。
- 其次,因为他们每个人都是拿的一根鱼竿,在等待鱼上钩,当河里有鱼来咬鱼钩时,这条鱼咬哪一个鱼钩的概率都是相等的。
因此,张三、李四、王五三个人的钓鱼的效率是一样的,只是等鱼上钩的方式不同而已。张三是静静的等待,李四是定期观察浮漂动静,而王五是通过铃铛来判断是否有鱼上钩。
因为赵六减少了等待的概率发生,增加了拷贝的时间,所以他的效率是最高的。
赵六同时等多个鱼竿上有鱼上钩,因此在单位时间内,赵六的鱼竿有鱼上钩的概率是最大的。假设赵六拿了 97 个鱼竿,加上张三、李四、王五各一个鱼竿,一共就有 100 个鱼竿。
当河里有鱼来咬鱼钩时,这条鱼咬张三、李四、王五的鱼钩的概率都是 1%,而咬赵六的鱼钩的概率就是 97%。因此在单位时间内,赵六的鱼竿上有鱼的概率是张三、李四、王五的 97 倍。而高效的钓鱼就是要减少单位时间内 “等” 的时间,增加 “拷贝” 的时间,所以说赵六的钓鱼效率是这四个人中最高的。赵六的钓鱼效率之所以高,是因为赵六一次等待多个鱼竿上的鱼上钩,可以将 “等” 的时间进行重叠。
田七是将钓鱼这件事交给自己的司机去做了,自己回公司去了。他并不关心司机是如何钓鱼的,司机可以采用张三,李四,王五和赵六中的任意一种方式,田七只在乎司机最后是否将桶装满。
田七本人并没有参与整个钓鱼的过程,他只是给司机派发了钓鱼的任务,所以真正钓鱼的人的是司机,所以田七在司机钓鱼的期间,可以做任何其他事情。如果将钓鱼看作是一种 IO 的话,那么田七的这种钓鱼方式就叫作异步 IO。
而对于张三、李四、王五、赵六而言,他们都要自己等待鱼上钩,当鱼上钩后又需要自己把鱼从河里钓上来,对应到 IO 中就是需要自行进行数据的拷贝,因此他们四个人的钓鱼方式就叫作同步 IO。
通过这里的钓鱼例子可以看到发现,阻塞 IO、非阻塞 IO 和信号驱动 IO 本质上是不能提高 IO 的效率的,但非阻塞 IO 和信号驱动 IO 能提高整体做事的效率。
其中,这个钓鱼场景中的各个事物都能与 IO 中的相关概念对应起来,鱼对应的是数据,钓鱼的河对应的是内核,浮标对应的文件描述上是否有事件就绪,每一个人对应的是执行流(进程或线程),司机对应的是操作系统,鱼竿对应的是文件描述符或套接字,装鱼的桶对应的就是用户缓冲区。
在内核将数据准备好之前,系统调用会一直等待。
阻塞 IO 是最常见的 IO 模型,所有的套接字,默认都是阻塞方式。
调用 recvfrom 函数来从某个套接字上读取数据时,可能底层的数据还没准备好,那么此时就需要等待数据就绪,当数据就绪后再将数据从内核拷贝到用户空间,最后 recvfrom 函数才会返回。
在 recvfrom 函数等待数据就绪期间,在用户看来该进程或线程就阻塞了,本质就是操作系统将该进程或线程的状态设置为了某种非 R 状态,然后将其放入等待队列中,当数据就绪后操作系统再将其从等待队列中唤醒,然后该进程或线程再将数据从内核拷贝到用户空间。
以阻塞方式进行 IO 操作的进程或线程,在 “等” 和 “拷贝” 期间都不会返回,在用户看来就是阻塞了,因此被称为阻塞 IO。
如果内核还未将数据准备好,系统调用仍然会直接返回,并且返回 EWOULDBLOCK 错误码。
非阻塞 IO 往往需要程序员循环的方式反复尝试读写文件描述符,这个过程称为轮询。这对 CPU 来说是较大的浪费,一般只有特定场景下才使用。
当调用 recvfrom 函数,以非阻塞方式从某个套接字上读取数据时,如果底层数据还没有准备好,那么 recvfrom 函数会立马错误返回,而不会让该进程或线程进行阻塞等待。
因为没有读取的数据,所以该进程或线程后续还需要继续调用 recvfrom 函数,检测底层数据是否就绪,若没有就绪则继续错误返回,直到某次检测到底层数据就绪后,再将数据从内核拷贝到用户空间然后进行成功返回。
每次调用 recvfrom 函数读取数据时,就算底层数据没有就绪,recvfrom 函数也会立马返回,在用户看来该进程或线程就没有被阻塞,因此被称为非阻塞 IO。
阻塞 IO 和非阻塞 IO 的区别:阻塞 IO 当数据没有就绪时,后续检测数据是否就绪的工作是由操作系统发起的,而非阻塞 IO 当数据没有就绪时,后续检测数据是否就绪的工作是由用户发起的。
内核将数据准备好的时候,使用 SIGIO 信号通知应用程序进行 IO 操作。
当底层数据就绪的时候会向当前进程或线程递交 SIGIO 信号,因此可以通过 signal 或 sigaction 函数将 SIGIO 的信号处理程序自定义为需要进行的 IO 操作,当底层数据就绪时就会自动执行对应的 IO 操作。
比如需要调用 recvfrom 函数从某个套接字上读取数据,那么就可以将该操作定义为 SIGIO 的信号处理程序。
当底层数据就绪时,操作系统就会递交 SIGIO 信号,此时就会自动执行定义的信号处理程序,进程将数据从内核拷贝到用户空间。
信号的产生是异步的,但信号驱动 IO 是同步 IO 的一种。
信号的产生异步的,因为信号在任何时刻都可能产生。但信号驱动 IO 是同步 IO 的一种,因为当底层数据就绪时,当前进程或线程需要停下正在做的事情,转而进行数据的拷贝操作,当前进程或线程仍然需要参与 IO 过程。
判断一个 IO 过程是同步还是异步的,其本质就是看当前进程或线程是否需要参与 IO 过程,若参与即为同步 IO,否则为异步 IO。
虽然从流程图上看起来和阻塞 IO 类似,IO 多路转接也被称为 IO 多路复用,实际上最核心在于 IO 多路转接能够同时等待多个文件描述符的就绪状态。
IO 多路转接的思想:
因为 IO 过程分为 “等” 和 “拷贝” 两个步骤,因此使用的 recvfrom 等接口的底层实际上都做了两件事,第一件事是数据不就绪时需要等,第二件事是数据就绪后需要进行拷贝。
虽然 recvfrom 等接口也有 “等” 的能力,但这些接口一次只能 “等” 一个文件描述符上的数据或空间就绪,IO 效率太低。
因此系统提供了三组接口,即 select、poll 和 epoll,这些接口的核心工作就是 “等”,可将所有 “等” 的工作都交给这些多路转接接口。
因为这些多路转接接口是一次 “等” 多个文件描述符的,因此能将 “等” 的时间重叠,数据就绪后再调用对应的 recvfrom 等函数进行数据的拷贝,此时这些函数就能够直接进行拷贝,而不需要 “等” 了。
IO 多路转接就像是帮人排队的黄牛,因为多路转接接口实际并没有进行数据拷贝。排队黄牛可以一次帮多个人排队,此时就将多个人排队的时间进行了重叠。
由内核在数据拷贝完成时,通知应用程序(而信号驱动是告诉应用程序何时可以开始拷贝数据)。
任何 IO 过程中,都包含两个步骤:等待和拷贝。
在实际的应用场景中,等待消耗的时间往往都远远高于拷贝的时间。让 IO 更高效,最核心的办法就是在单位时间内让等待的时间尽量少。
- IO 是分为 “等” 和 “拷贝” 两步的,当调用 recvfrom 进行非阻塞 IO 时,若数据没有就绪,那么调用会直接返回,此时这个调用返回时并没有完成一个完整的 IO 过程,即便调用返回了也是属于错误的返回。
- 因此该进程或线程后续还需继续调用 recvfrom,轮询检测数据是否就绪,当数据就绪后再把数据从内核拷贝到用户空间,这才是一次完整的 IO 过程。
- 因此,在进行非阻塞 IO 时,在没有得到结果之前,虽然这个调用会返回,但后续还需要继续进行轮询检测,因此可以理解成调用还没有返回,而只有当某次轮询检测到数据就绪,并且完成数据拷贝后才认为该调用返回了。
在学习多进程多线程的时候,也提到过同步和互斥,但这里的同步通信和进程之间的同步是完全不想干的概念。
注意:尤其是在访问临界资源的时候,一定要弄清楚这个 “同步”,是同步通信异步通信的同步,还是同步与互斥的同步。
阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态。
- 阻塞调用是指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。
- 非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程。
非阻塞 IO、 纪录锁、系统 V 流机制、 I/O 多路转接(也叫 I/O 多路复用), readv 和 writev 函数以及存储映射 IO( mmap ),这些统称为高级 IO。
系统中大部分的接口都是阻塞式接口,如使用 read 函数从标准输入中读取数据。
程序运行后,若不进行输入操作,该进程就会阻塞。根本原因就是因为此时底层数据不就绪,所以 read 函数需要进行阻塞等待。
一旦进行输入操作,此时 read 函数就会检测到底层数据已经就绪,然后将数据读取到从内核拷贝到程序员传入的 buffer 数组中,并且将读取到的数据输出到显示器上面,最后就可以看到输入的字符串了。
打开文件时,都是默认以阻塞的方式打开的。如果要以非阻塞的方式打开某个文件,需要在使用 open 函数打开文件时携带 O_NONBLOCK 或 O_NDELAY 选项,那么此时就可以以非阻塞的方式打开文件。
一个文件描述符, 默认都是阻塞 IO。
- fd:已打开的文件描述符
- cmd:需要进行的操作
- …:可变参数,传入的 cmd 值不同,后面追加的参数也不同
传入 cmd 的值不同,后面追加的参数也不相同。
fcntl 函数有 5 种功能:
- 复制一个现有的描述符(cmd=F_DUPFD)
- 获得 / 设置文件描述符标记(cmd=F_GETFD 或 F_SETFD)
- 获得 / 设置文件状态标记(cmd=F_GETFL 或 F_SETFL)
- 获得 / 设置异步 I/O 所有权(cmd=F_GETOWN 或 F_SETOWN)
- 获得 / 设置记录锁(cmd=F_GETLK, F_SETLK 或 F_SETLKW)
若函数调用成功,则返回值取决于具体进行的操作;若函数调用失败,则返回 -1,同时错误码被设置。
基于 fcntl,下面实现一个 SetNoBlock 函数,该函数用于将指定的文件描述符设置为非阻塞状态。
因此在以非阻塞的方式读取数据时,若调用 read 函数读取到的返回值为 -1,此时并不应该直接认为 read 函数在底层读取数据时出错,而应该继续判断错误码,若错误码的值为 EAGAIN、EWOULDBLOCK 或 EINTR 则应该继续调用 read 函数再次进行读取。
select 是系统提供的一个多路转接的接口,可以用来实现多路复用输入 / 输出模型。
- nfds:需要监视的文件描述符中,最大的文件描述符值 +1。
- readfds:输入输出型参数,调用时用户告知内核需要监视哪些文件描述符的读事件是否就绪,返回时内核告知用户哪些文件描述符的读事件已就绪。(这个参数使用一次过后,需要进行重新设定)
- writefds:输入输出型参数,调用时用户告知内核需要监视哪些文件描述符的写事件是否就绪,返回时内核告知用户哪些文件描述符的写事件已就绪。(这个参数使用一次过后,需要进行重新设定)
- exceptfds:输入输出型参数,调用时用户告知内核需要监视哪些文件描述符的异常事件是否就绪,返回时内核告知用户哪些文件描述符的异常事件已就绪。(这个参数使用一次过后,需要进行重新设定)
- timeout:输入输出型参数,调用时由用户设置 select 的等待时间,返回时表示 timeout 的剩余时间。
- NULL / nullptr:select 调用后进行阻塞等待,直到被监视的某个文件描述符上的某个事件就绪。
- 0:select 调用后进行非阻塞等待,无论被监视的文件描述符上的事件是否就绪,select 检测后都会立即返回。
- 特定的时间值:select 调用后在指定的时间内进行阻塞等待,若被监视的文件描述符上一直没有事件就绪,则在该时间后 select 进行超时返回。
只要有一个 fd 数据就绪或空间就绪,就可以进行返回了。
select 调用失败时,错误码可能被设置为:
- EBADF:文件描述符为无效的或该文件已关闭。
- EINTR:此调用被信号所中断。
- EINVAL:参数 nfds 为负值。
- ENOMEM:核心内存不足。
fd_set 结构与 sigset_t 结构类似,其实这个结构就是一个整数数组,更严格的说 fd_set 本质也是一个位图,用位图中对应的位来表示要监视的文件描述符。
调用 select 函数之前就需用 fd_set 结构定义出对应的文件描述符集,然后将需要监视的文件描述符添加到文件描述符集中,这个添加的过程本质就是在进行位操作,但是这个位操作不需要用户自己进行,系统提供了一组专门的接口,用于对 fd_set 类型的位图进行各种操作。
- 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 的个数是有上限的。
传入 select 函数的最后一个参数 timeout,是一个指向 timeval 结构的指针。timeval 结构用于描述一段时间长度,如果在这个时间内,需要监视的描述符没有事件发生则函数返回,返回值为 0。该结构中包含两个成员,其中 tv_sec 表示的是秒,tv_usec 表示的是微秒
select 等待多个 fd,等待策略可以选择:
此时,timeout 表示距离下一次 timeout 还剩多长时间(输出的含义)。
- 执行成功则返回文件描述词状态已改变的个数。
- 如果返回 0 代表在描述词状态改变前已超过 timeout 时间,没有返回。
- 当有错误发生时则返回 -1,错误原因存于 errno,此时参数 readfds,writefds,exceptfds 和 timeout 的值变成不可预测。
错误值可能为:
- EBADF:文件描述词为无效的或该文件已关闭。
- EINTR:此调用被信号所中断。
- EINVAL:参数 n 为负值。
- ENOMEM:核心内存不足。
理解 select 模型的关键在于理解 fd_set,为说明方便,取 fd_set 长度为 1 字节,fd_set 中的每一 bit 可以对应一个文件描述符 fd,则 1 字节长的 fd_set 最大可以对应 8 个 fd。
注意 :没有事件发生的 fd=5 被清空。
- socket 内核中,接收缓冲区中的字节数,大于等于低水位标记 SO_RCVLOWAT,此时可以无阻塞的读取该文件描述符,并且返回值大于 0。
- socket TCP 通信中,对端关闭连接,此时对该 socket 读,则返回 0。
- 监听 socket 上有新的连接请求。
- socket 上有未处理的错误。
获取新连接,依旧把它看作成 IO 事件,input 事件。
发生阻塞。
- socket 内核中,发送缓冲区中的可用字节数(发送缓冲区的空闲位置大小),大于等于低水位标记 SO_SNDLOWAT,此时可以无阻塞的写,并且返回值大于 0。
- socket 的写操作被关闭(close 或者 shutdown),如果此时进行写操作的话,会触发 SIGPIPE 信号。
- socket 使用非阻塞 connect 连接成功或失败之后,socket 上有未读取的错误。
socket 上收到带外数据。
注意:带外数据和 TCP 的紧急模式相关,TCP 报头中的 URG 标志位和 16 位紧急指针搭配使用,就能够发送/接收带外数据。
若要实现一个简单的 select 服务器,该服务器要做的就是读取客户端发来的数据并进行打印,那么该 select 服务器的工作流程如下:
注意:
- 传入 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 进行重新设置时,还需要记录最大文件描述符值。
编写一个 Socket 类,对套接字相关的接口进行一定封装,为了让外部能直接调用 Socket 类中封装的函数,于是将部分函数定义成静态成员函数。
使用 telnet 工具连接服务器,此时通过 telnet 向服务器发送的数据就能够被服务器读到并且打印输出了。
虽然 selectServer 仅是一个单进程、单线程服务器,但却可以同时为多个客户端提供服务。因为 select 函数调用后,会告知 select 服务器是哪个客户端对应的连接事件就绪,此时 select 服务器就可以读取对应客户端发来的数据,读取完后又会调用 select 函数等待某个客户端连接的读事件就绪。
当服务器检测到客户端退出后,也会关闭对应的连接,并将对应的套接字从 _fd_array 数组中清除。
- 可以同时等待多个文件描述符,且只负责等待(有大量的连接,但只有少量是活跃的,节省资源),实际的 IO 操作由 accept、read、write 等接口完成,保证接口在进行 IO 操作时不会被阻塞。
- select 同时等待多个文件描述符,因此可以将 “等” 的时间重叠,提高 IO 效率。
注意:上述优点也是所有多路转接接口的优点。
调用 select 函数时传入的 readfds、writefds 以及 exceptfds 都是 fd_set结构,fd_set 结构本质是一个位图,用一个 bit 位来标记一个文件描述符,因此 select 可监控的文件描述符个数取决于 fd_set 类型的 bit 位个数。
运行代码后可以发现,select可监控的文件描述符个数为 1024。
进程控制块 task_struct 中有一个 files 指针,该指针指向一个 struct files_struct 结构,进程的文件描述符表 fd_array 就存储在该结构中,其中文件描述符表 fd_array 的大小定义为 NR_OPEN_DEFAULT,NR_OPEN_DEFAULT 的值实际就是 32。
但不意味着一个进程最多只能打开 32 个文件描述符,进程能打开的文件描述符个数是可以扩展的,通过 ulimit -a 命令可以看到进程能打开的文件描述符上限。
select 可监控的文件描述符个数是 1024,除去监听套接字,那么最多只能连接 1023 个客户端。
多路转接接口 select、poll 和 epoll,需在一定的场景下使用,如果场景不适宜,可能会适得其反。
多路转接接口一般适用于多连接,且多连接中只有少部分连接比较活跃。因为少量连接比较活跃,也意味着几乎所有的连接在进行 IO 操作时,都需要花费大量时间来等待事件就绪,此时使用多路转接接口就可以将这些等的事件进行重叠,提高 IO 效率。
对于多连接中大部分连接都很活跃的场景,其实并不适合使用多路转接。因为每个连接都很活跃,也意味着任何时刻每个连接上的事件基本都是就绪的,此时根本不需要动用多路转接接口来进行等待,毕竟使用多路转接接口也是需要花费系统的时间和空间资源的。
多连接中只有少量连接是比较活跃的,如聊天工具,登录 QQ 后大部分时间其实是没有聊天的,此时服务器端不可能调用一个 read 函数阻塞等待读事件就绪。
多连接中大部分连接都很活跃,如企业当中进行数据备份时,两台服务器之间不断在交互数据,这时连接是特别活跃的,几乎不需要等的过程,也就没必要使用多路转接接口了。
- fds:一个 poll 函数监视的结构列表,每一个元素都包含三部分内容:文件描述符、监视的事件集合、就绪的事件集合。
- nfds:表示 fds 数组的长度。
- timeout:表示 poll 函数的超时时间,单位是毫秒(ms)。
- -1:poll 调用后进行阻塞等待,直到被监视的某个文件描述符上的某个事件就绪。
- 0:poll 调用后进行非阻塞等待,无论被监视的文件描述符上的事件是否就绪,poll 检测后都会立即返回。
- 特定的时间值:poll 调用后在指定的时间内进行阻塞等待,若被监视的文件描述符上没有事件就绪,则在该时间后 poll 进行超时返回。
- 若函数调用成功,则返回有事件就绪的文件描述符个数。
- 若 timeout 时间耗尽,表示超时,则返回 0,表示 poll 以非阻塞方式等待。
- 若函数调用失败,则返回 -1,poll 要以阻塞方式等待,同时错误码被设置。
poll 调用失败时,错误码可能被设置为:
- EFAULT:fds 数组不包含在调用程序的地址空间中
- EINTR:此调用被信号所中断
- EINVAL:nfds 值超过 RLIMIT_NOFILE值
- ENOMEM:核心内存不足
- fd:特定的文件描述符,若设置为负值则忽略 events 字段并且 revents 字段返回 0。
- events:需要监视该文件描述符上的哪些事件。
- revents:poll 函数返回时告知用户该文件描述符上的哪些事件已经就绪。
这些值都以宏的方式定义,二进制序列中有且只有一个 bit 位是 1,且为 1 的 bit 位各不相同。
- 返回值小于 0,表示出错。
- 返回值等于 0,表示 poll 函数等待超时。
- 返回值大于 0,表示 poll 由于监听的文件描述符就绪而返回。
poll 的工作流程和 select 基本类似,下面也实现一个简单 poll 服务器,只读取客户端发来的数据并进行打印。
_fds 数组的大小是固定设置的,因此在将新获取连接对应的文件描述符添加到 fds 数组时,可能会因为 fds 数组已满而添加失败,这时 poll 服务器只能将刚刚获取上来的连接对应的套接字进行关闭。
在调用 poll 函数时,将 timeout 的值设置成 1000,因此运行服务器后每隔 1000 毫秒没有客户端发来连接请求,那么服务器就会超时返回。
用 telnet 工具连接 poll 服务器后,poll 函数在检测到监听套接字的读事件就绪后就会调用 accept 获取建立好的连接,并打印输出客户端的 IP 和端口号等信息,此时客户端发来的数据也能成功被 poll 服务器收到并进行打印输出。
poll 服务器也是单进程、单线程服务器,同样可以为多个客户端服务。
当服务器端检测到客户端退出后,也会关闭对应的连接,并将对应的套接字从 _fds 数组中清除。
说明:
poll 中监听的文件描述符数目增多时:
按照 man 手册的说法:是为处理大批量句柄而作了改进的 poll。
epoll 是系统提供的一个多路转接接口。
epoll 有 3 个相关的系统调用。
注意: 当不再使用时,须调用 close 函数关闭 epoll 模型对应的文件描述符,当所有引用 epoll 实例的文件描述符都已关闭时,内核将销毁该实例并释放相关资源。
它不同于 select() 是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。
参数说明:
第二个参数 op 的取值有以下三种:
返回值:函数调用成功返回 0,调用失败返回 -1,同时错误码会被设置
第四个参数对应的 struct epoll_event 结构如下:
struct epoll_event 结构中有两个成员,第一个成员 events 表示的是需监视的事件,第二个成员 data 为联合体结构,一般选择使用该结构中的 fd,表示需要监听的文件描述符。
events 可以是以下几个宏的集合(常用取值)如下:
这些取值是以宏的方式定义,二进制序列中有且只有一个 bit 位是 1,且为 1 的 bit 位是各不相同的。
参数说明:
参数 timeout 的取值:
返回值:
epoll_wait 调用失败时,错误码可能被设置为:
当某一进程调用 epoll_create 函数,Linux 内核会创建一个 eventpoll 结构体,即 epoll 模型,eventpoll 结构体中的成员 rbr、rdlist 与 epoll 的使用方式密切相关。
- struct eventpoll{
- ...
- //红黑树的根节点,这棵树中存储着所有添加到epoll中的需要监视的事件
- struct rb_root rbr;
- //就绪队列中则存放着将要通过epoll_wait返回给用户的满足条件的事件
- struct list_head rdlist;
- ...
- }
在 epoll 中,对于每一个事件都有一个对应的 epitem 结构体,红黑树和就绪队列中的节点分别是基于 epitem 结构中的 rbn 成员和 rdllink 成员的,epitem 结构中的成员 ffd 记录的是指定的文件描述符值,event 成员记录的就是该文件描述符对应的事件。
- struct epitem{
- struct rb_node rbn; //红黑树节点
- struct list_head rdllink; //双向链表节点
- struct epoll_filefd ffd; //事件句柄信息
- struct eventpoll *ep; //指向其所属的eventpoll对象
- struct epoll_event event; //期待发生的事件类型
- }
注意:红黑树是一种二叉搜索树,必须有键值 key,文件描述符就可以天然的作为红黑树 key 值调用 epoll_ctl 向红黑树中新增节点时,若设置了 EPOLLONESHOT 选项,监听完这次事件后,若还需继续监听该文件描述符则需重新将其添加到 epoll 模型中,本质就是当设置了 EPOLLONESHOT 选项的事件就绪时,操作系统会自动将其从红黑树中删除。
若调用 epoll_ctl 向红黑树中新增节点时没设置 EPOLLONESHOT,那么该节点插入红黑树后就会一直存在,除非用户调用 epoll_ctl 将该节点从红黑树中删除。
所有添加到红黑树中的事件,都与设备(网卡)驱动程序建立回调方法,该回调方法在内核中被称为 ep_poll_callback。
注意:只有添加到红黑树中的事件才会与底层建立回调方法,因此只有红黑树中对应的事件就绪时,才会执行对应的回调方法将其添加到就绪队列。
当不断有监视的事件就绪时,会不断调用回调方法向就绪队列中插入节点,而上层也会不断调用 epoll_wait 函数从就绪队列中获取节点,即典型的生产者消费者模型。
由于就绪队列可能被多个执行流同时访问,因此必须要使用互斥锁进行保护,eventpoll 结构中的 lock和 mtx 就是用于保护临界资源的,因此epoll本身是线程安全的 eventpoll 结构中的 wq(wait queue)即等待队列,当多个执行流想同时访问同一个 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 号文件描述符分别对应访问服务器的两个客户端。
注意:网上有的博客中说 epoll 中使用了内存映射机制,内核可以直接将底层就绪队列通过 mmap 的方式映射到用户态,此时用户就可以直接读取到内核中就绪队列中的数据,避免了内存拷贝的额外性能开销。❌这种说法是错误的,实际操作系统并没有做任何映射机制,因为操作系统是不相信任何人的,操作系统不会让用户进程直接访问到内核的数据的,用户只能通过系统调用来获取内核的数据。我们定义的 struct epoll_event 是我们在用户空间中分配好的内存,因此用户要获取内核中的数据,势必还是要将内核的数据拷贝到用户空间。
- 在使用 select 和 poll 时,都需借助第三方数组来维护历史上的文件描述符以及需要监视的事件,第三方数组由用户自行维护,对该数组的增删改操作都需要用户进行。
- 使用 epoll 时,不需要用户维护第三方数组,epoll 底层的红黑树就充当了这个第三方数组的功能,并且该红黑树的增删改操作都是由内核维护的,用户只需要调用 epoll_ctl 让内核对该红黑树进行对应的操作即可。
- 在使用多路转接接口时,数据流都有两个方向,一个是用户告知内核,一个是内核告知用户。select 和 poll 将这两件事情都交给了同一个函数来完成,而 epoll 在接口层面上就将这两件事进行了分离,epoll 通过调用 epoll_ctl 完成用户告知内核,通过调用 epoll_wait 完成内核告知用户。
epoll 有 2 种工作方式:水平触发(LT)和边缘触发(ET)。
epoll 默认状态下就是 LT 工作模式。
如果在第 1 步将 socket 添加到 epoll 描述符的时候使用了 EPOLLET 标志,epoll 进入 ET 工作模式。
若要将 epoll 改为 ET 工作模式,则需在添加事件时设置 EPOLLET 选项。
select 和 poll 其实也是工作在 LT 模式下, epoll 既可以支持 LT, 也可以支持 ET。
因为在 ET 工作模式下,只有底层就绪事件无到有或由有到多发生变化的时候才会通知用户,这就倒逼用户当读事件就绪时必须一次性将数据全部读取完毕,当写事件就绪时必须一次性将发送缓冲区写满,否则可能再也没有机会进行读写了
因此读数据时必须循环调用 recv 函数进行读取,写数据时必须循环调用 send 函数进行写入。
注意:ET 工作模式下,recv 和 send 操作的文件描述符必须设置为非阻塞状态,这是必须的,不是可选的。
使用 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 返回文件描述符读就绪。
epoll 的高性能是有一定的特定场景的。如果场景选择的不适宜,epoll 的性能可能适得其反。
对于多连接,且多连接中只有一部分连接比较活跃时,比较适合使用 epoll。例如,典型的一个需要处理上万个客户端的服务器,例如各种互联网 APP 的入口服务器,这样的服务器就很适合 epoll。
如果只是系统内部,服务器和服务器之间进行通信,只有少数的几个连接,这种情况下用 epoll 就并不合适,具体要根据需求和场景特点来决定使用哪种 IO 模型。
参考学习资料:
Reactor 反应器模式,也被称为分发者模式或通知者模式,是一种将就绪事件派发给对应服务处理程序的事件设计模式。
当 epoll ET 服务器监测到某一事件就绪后,就会将该事件交给对应的服务处理程序进行处理。
在这个 epoll ET 服务器中,Reactor 模式中的五个角色对应如下:
在 Reactor 的工作流程中说到,在注册事件处理器时需要将其与Handle关联,本质上就是需要将读回调、写回调和异常回调与某个文件描述符关联起来。这样做的目的就是为了当某个文件描述符上的事件就绪时可以找到其对应的各种回调函数,进而执行对应的回调方法来处理该事件。可以设计一个 Connection 类,该类中的成员包括了一个文件描述符,以及该文件描述符对应的各种回调函数,以及其他成员。
在 Reactor 的工作流程中说到,当所有事件处理器注册完毕后,会使用同步事件分离器等待这些事件发生,当某个事件处理器的Handle变为 Ready 状态时,同步事件分离器会通知初始分发器,然后初始分发器会将 Ready 状态的 Handle 作为 key 来寻找其对应的事件处理器,并调用该事件处理器中对应的回调方法来响应该事件。
本质就是当事件注册完毕后,会调用 epoll_wait 函数来等待这些事件发生,当某个事件就绪时epoll_wait 函数会告知调用方,然后调用方就根据就绪的文件描述符来找到其对应的各种回调函数,并调用对应的回调函数进行事件处理。
对此可以设计一个 Reactor 类:
Connection 结构中除了包含文件描述符和其对应的读回调、写回调和异常回调外,还包含一个输入缓冲区 _inBuffer、一个输出缓冲区 _outBuffer 以及一个回指指针 _svrPtr。
Connection 结构中需提供一个管理回调的成员函数,便于外部对回调进行设置:
在 TcpServer 类中有一个 unordered_map 成员,用于建立文件描述符和与其对应的 Connection 结构之间的映射,还有一个 _epoll 成员,该成员是封装的 Epoll 对象。在初始化 TcpServer 对象时就可以调用封装的 EpollCreate 函数创建 Epoll 对象,并将该 epoll 模型对应的文件描述符记录在该对象的成员变量 _epollFd 中,便于后续使用。TcpServer 对象析构时,Epoll 对象的析构会自动调用close函数将epoll模型关闭。
TcpServer 类中的 AddConnection 函数用于进行事件注册。
在注册事件时需要传入一个文件描述符和三个回调函数,表示当该文件描述符上的事件(默认只关心读事件)就绪后应该执行的回调方法。
在 AddConnection 函数内部要做的就是,设置套接字为非阻塞(ET 模型要求),将套接字和回调函数等属性封装为一个 Connection,在将套接字添加到 epoll 模型中,对象建立文件描述符和 Connection 的映射关系并管理。
TcpServer 中的 Dispatcher 函数即初始分发器,其要做的就是调用 epoll_wait 函数等待事件发生。当某个文件描述符上的事件发生后,先通过 unordered_map 找到该文件描述符对应的 Connection 结构,然后调用 Connection 结构中对应的回调函数对该事件进行处理即可。
TcpServer 类中的 EnableReadWrite 函数,用于使能某个文件描述符的读写事件。
为某个文件描述符创建Connection结构时,可以调用Connection类提供的SetCallBack函数,将这些回调函数添加到Connection结构中
Accepter 回调用于处理连接事件,其工作流程如下:
下一次 Dispatcher 在进行事件派发时就会关注该套接字对应的事件,当事件就绪时就会执行该套接字对应的 Connection 结构中对应的回调方法。
这里实现的 ET 模式下的 epoll 服务器,因此在获取底层连接时需要循环调用 accept 函数进行读取,并且监听套接字必须设置为非阻塞。
设置文件描述符为非阻塞时,需先调用 fcntl 函数获取该文件描述符对应的文件状态标记,然后在该文件状态标记的基础上添加非阻塞标记 O_NONBLOCK,最后调用 fcntl 函数对该文件描述符的状态标记进行设置即可。
监听套接字设置为非阻塞后,当底层连接不就绪时,accept 函数会以出错的形式返回,因此当调用 accept 函数的返回值小于 0 时,需继续判断错误码。
IO 系统调用函数出错返回并且将错误码设置为 EINTR,表明本次在进行数据读取或数据写入之前被信号中断了,即IO系统调用在陷入内核,但并没有返回用户态的时候内核去处理其他信号
Accepter 获取上来的套接字在添加到 Dispatcher 中时,只添加了 EOPLLIN 和 EPOLLET 事件,即只让 epoll 关心该套接字的读事件。
之所以没有添加写事件,是因为并没有要发送的数据,因此没有必要让 epoll 关心写事件。一般读事件是会被设置的,而写事件则是按需打开的,只当有数据要发送时才会将写事件打开,并且在数据全部写入完毕后又会立即将写事件关闭。
recver 回调用于处理读事件,其工作流程如下:
报文切割本质就是为了防止粘包问题,而粘包问题还涉及到协议定制。
下一次 Dispatcher 在进行事件派发时就会关注该套接字的写事件,当写事件就绪时就会执行该套接字对应的 Connection 结构中写回调方法,进而将 _outBuffer 中的响应数据发送给客户端。
当客户端连接服务器后,在服务器端会显示客户端使用的是 5 号文件描述符,因为 4 号文件描述符已被 epoll 模型使用了。
此时客户端可以向服务器发送一些简单计算任务,计算任务间用 "X" 隔开,服务器收到计算请求处理后会将计算结果发送给客户端,计算结果之间也是用 "X" 隔开的。若发送的不是完整报文,则会保存在 socket 对应的 Connection 结构中的 _inBuffer 中。
由于使用了多路转接技术,虽然 epoll 服务器是一个单进程的服务器,但却可同时为多个客户端提供服务。
基于多路转接方案,当事件就绪的时候,采用回调的方式,进行业务处理的模式就被称为反应堆模式(Reactor)。上述代码中的 TcpServer 就是一个反应堆,其中一个个 Connection 对象就称为事件。每一个事件中都有:
- 文件描述符
- 独立的缓冲区
- 回调方法
- 回指向反应堆的指针
反应堆中有一个事件派发函数,当epoll中的某个事件就绪,事件派发函数回调用此事件的回调函数。
- 单进程:既负责事件派发又负责 IO。
- 半异步半同步:异步,事件到来是随机的。
- 同步:当前线程参与 IO。