9.2 I/O 多路复用:select/poll/epoll
注意:这些是Linux中的技术,对于其他系统并不适用
最基本的 Socket 模型
Socket 编程是进程间通信里比较特别的方式,特别之处在于它是可以跨主机间通信。
创建 Socket 的时候,可以指定网络层使用的是 IPv4 还是 IPv6,传输层使用的是 TCP 还是 UDP。(这里介绍TCP)
服务端的 Socket 编程过程:
- 调用socket函数,创建特定网络和传输协议的Socket对象
- 调用bind函数,绑定端口号和IP地址(每个机器有多个端口,还可能有多个网卡即IP地址,内核通过两者绑定确定是哪个程序)
- 调用listen监听
- 调用accpet从内核获取客户端连接,这个会阻塞直到收到连接
- 收到连接后会构成一个新的socket连接(和监听的socket是两个不同的对象)
客户端的Socket编程过程
- 创建好 Socket
- 调用
connect()函数发起连接,该函数的参数要指明服务端的 IP 地址和端口号
连接开始就是使用TCP连接(或者UDP)
在 TCP 连接的过程中,服务器的内核实际上为每个 Socket 维护了两个队列:
- 一个是还没完全建立连接的队列,称为 TCP 半连接队列,这个队列都是没有完成三次握手的连接,此时服务端处于
syn_rcvd的状态; - 一个是一件建立连接的队列,称为 TCP 全连接队列,这个队列都是完成了三次握手的连接,此时服务端处于
established状态;
当 TCP 全连接队列不为空后,服务端的 accept() 函数,就会从内核中的 TCP 全连接队列里拿出一个已经完成连接的 Socket 返回应用程序,后续数据传输都用这个 Socket。
连接建立后,客户端和服务端就开始相互传输数据了,双方都可以通过 read() 和 write() 函数来读写数据。
Socket对象在Linux中也是文件,文件里有两个队列,分别是发送队列和接收队列,这个两个队列里面保存的是一个个 struct sk_buff,用链表的组织形式串起来。
sk_buff 可以表示各个层的数据包,在应用层数据包叫 data,在 TCP 层我们称为 segment,在 IP 层我们叫 packet,在数据链路层称为 frame。
如何服务更多的用户?
TCP 连接是由四元组唯一确认的,这个四元组就是:本机 IP, 本机端口,对端 IP, 对端端口。
服务器作为服务方,通常会在本地固定监听一个端口,等待客户端的连接。因此服务器的本地 IP 和端口是固定的,于是对于服务端 TCP 连接的四元组只有对端 IP 和端口是会变化的,所以最大 TCP 连接数 = 客户端 IP 数 × 客户端端口数。
对于 IPv4,客户端的 IP 数最多为 2 的 32 次方,客户端的端口数最多为 2 的 16 次方,也就是服务端单机最大 TCP 连接数约为 2 的 48 次方。这就是服务器单机理论最大能连接多少个客户端
但是服务器肯定承载不了那么大的连接数,主要会受两个方面的限制:
- 文件描述符,Socket 实际上是一个文件,也就会对应一个文件描述符。在 Linux 下,单个进程打开的文件描述符数是有限制的,没有经过修改的值一般都是 1024,不过我们可以通过 ulimit 增大文件描述符的数目;
- 系统内存,每个 TCP 连接在内核中都有对应的数据结构,意味着每个连接都是会占用一定内存的;
那如果服务器的内存只有 2 GB,网卡是千兆的,能支持并发 1 万请求吗?
并发 1 万请求,也就是经典的 C10K 问题,C 是 Client 单词首字母缩写,C10K 就是单机同时处理 1 万个请求的问题。
从硬件资源角度看,对于 2GB 内存千兆网卡的服务器,如果每个请求处理占用不到 200KB 的内存和 100Kbit 的网络带宽就可以满足并发 1 万个请求。
不过,要想真正实现 C10K 的服务器,要考虑的地方在于服务器的网络 I/O 模型,效率低的模型,会加重系统开销,从而会离 C10K 的目标越来越远。
多进程模型
服务器的主进程负责监听客户的连接,一旦与客户端连接完成,accept() 函数就会返回一个「已连接 Socket」,这时就通过 fork() 函数创建一个子进程,实际上就把父进程所有相关的东西都复制一份,包括文件描述符、内存地址空间、程序计数器、执行的代码等。(两者只关心自己的功能)
这两个进程刚复制完的时候,几乎一模一样。不过,会根据返回值来区分是父进程还是子进程,如果返回值是 0,则是子进程;如果返回值是其他的整数,就是父进程。
正因为子进程会复制父进程的文件描述符,于是就可以直接使用「已连接 Socket」和客户端通信了,
但是进程的系统资源占用会很高,且进程上下文切换成本很大,同时紫禁城退出会有残留信息占用一些资源,所以多进程的连接量不能太大。
多线程模型
当服务器与客户端 TCP 完成连接后,通过 pthread_create() 函数创建线程,然后将「已连接 Socket」的文件描述符传递给线程函数,接着在线程里和客户端进行通信,从而达到并发处理的目的。
如果每来一个连接就创建一个线程,线程运行完后,还得操作系统还得销毁线程(可以通过线程池降低成本),虽说线程切换的上下文开销不大,但是如果频繁创建和销毁线程,系统开销也是不小的,且每个线程的资源占用也不会很低,很难达到很大的连接量。
用一个队列存放已连接的Socket,线程负责从队列中取出已连接的socket进程处理(注意加锁),即多对多关系。
I/O 多路复用
I/O 多路复用通过时分多路复用使用一个进程来维护多个 Socket 。每个请求处理1ms,那么一秒可以处理上千个请求。

我们熟悉的 select/poll/epoll 内核提供给用户态的多路复用系统调用,进程可以通过一个系统调用函数从内核中获取多个事件。
在获取事件时,先把所有连接(文件描述符)传给内核,再由内核返回产生了事件的连接,然后在用户态中再处理这些连接对应的请求即可。
select/poll
select :将已连接的 Socket 都放到一个文件描述符集合,然后调用 select() 函数将文件描述符集合拷贝到内核里,让内核通过遍历文件描述符集合的方式检查是否有网络事件产生,当检查到有事件产生后,将此 Socket 标记为可读或可写,接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。
因此需要进行 2 次「遍历」文件描述符集合, 2 次「拷贝」文件描述符集合。
select 使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制,默认最大值为 1024,只能监听 0~1023 的文件描述符。
poll 不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。
两者并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合,因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。
epoll
epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述符
- 可以高效的管理(主要是删除以及添加时判定是否重复)
- 把需要监控的 socket 通过
epoll_ctl()函数加入内核中的红黑树里(需要指定监视事件类型,如读写或者错误) - 通过对这棵红黑树进行操作,这样就不需要像 select/poll 每次操作时都传入整个 socket 集合,只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。
epoll 使用事件驱动的机制
- 内核里维护了一个链表来记录就绪事件
- 当某个 socket 有事件发生时,通过**回调函数(回调是核心,使得不需要扫描)**内核会将其加入到这个就绪事件列表中,
- 当用户调用
epoll_wait()函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。
从下图你可以看到 epoll 相关的接口作用:

epoll 的方式即使监听的 Socket 数量越多的时候,效率不会大幅度降低,能够同时监听的 Socket 的数目也非常的多了,上限就为系统定义的进程打开的最大文件描述符个数。因而,epoll 被称为解决 C10K 问题的利器。
epoll_wait 实现的内核代码中调用了 __put_user 函数将数据从内核拷贝到用户空间,并不是共享内存。(在源码中可以看到)

epoll 支持两种事件触发模式,分别是边缘触发(edge-triggered,ET)和水平触发(level-triggered,LT)。
边缘触发:当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次,即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完;
- 需要通过循环不断的读取,所以会导致阻塞,一般需要和非阻塞 I/O (没有数据会报错)搭配使用
- 效率更高,可以减少系统调用次数,减少上下文切换
- 边缘可以理解为二进制的01跳变才触发
水平触发:当被监控的 Socket 上有可读事件发生时,服务器端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完才结束,目的是告诉我们有数据需要读取;
- 没必要一次执行尽可能多的读写操作
- 水平可以理解为二进制的0或者1的持续时间可以一直触发
举个例子,你的快递被放到了一个快递箱里,如果快递箱只会通过短信通知你一次,即使你一直没有去取,它也不会再发送第二条短信提醒你,这个方式就是边缘触发;如果快递箱发现你的快递没有被取出,它就会不停地发短信通知你,直到你取出了快递,它才消停,这个就是水平触发的方式。
select/poll 只有水平触发模式,epoll 默认的触发模式是水平触发,但是可以根据应用场景设置为边缘触发模式。
使用 I/O 多路复用时,最好搭配非阻塞 I/O 一起使用,因为多路复用API 返回的事件并不一定可读写的,如果使用阻塞 I/O,那么在调用 read/write 时则会发生程序阻塞,因此最好搭配非阻塞 I/O,以便应对极少数的特殊情况。(比如报告未可读,但是数据校验不通过导致丢弃,会发生无法读取的问题)
