云深知处——IO多路复用(select、epoll)
序言
从高并发IO的底层原理一文中,已经看到了5大IO模型在底层是怎样运行的。紧接着,我们聚焦IO多路复用,看下其实现原理。
内核怎么知道接收的网络数据是属于哪个
socket?
socket数据包格式是一个五元组(源ip,源端口,协议,目的ip,目的端口),一般通过目的ip和目的端口就可以识别出来接收到的网络数据属于哪个socket。如果目的ip,目的端口相同呢?其实多个客户端与同一个服务端建立了连接,这个时候内核就会有多个socket,并且为它们分配多个fd文件描述符。它们收到网络数据后无法通过目的端口来直接匹配socket,还需要再通过源ip和端口来确定属于哪个socket。
内核在收到数据时,在网卡上网卡程序知道数据包的源ip+端口,目标ip+端口,进而判定数据是属于哪个socket。所以,文件描述符和数据包的源ip+端口,目标ip+端口有个映射关系。
内核怎么在一个网卡上监听多个
socket?
IO多路复用,内核负责轮询所有socket,当某个socket有数据到达了,就通知用户进程。在Linux里边有3个系统调用,select、poll、epoll;在Windows里常见的有2个,select、poll。
学习高并发,
epoll是基础。epoll作为Linux下高性能网络服务器的必备技术至关重要,Java NIO、Nginx、Redis、SkyNet和大部分游戏服务器都使用到这一多路复用技术。
select、poll底层系统调用的核心原理
select
Linux、Windows内核都提供了select操作,可以把1024个文件描述符的IO事件轮询,简化为一次轮询,轮询发生在内核空间。
连接以文件描述符的形式在内核中都有注册,用户程序可以通过select操作去轮询这些文件连接的IO事件(读、写、异常)。
下面这段代码就是select操作的使用方法,先准备一个数组fds存放着所有需要监视(轮询)的socket。然后调用select,如果fds中的所有socket都没有数据,select会阻塞(进程就会加入socket等待队列),直到有一个socket接收到数据(有IO事件发生),select就会返回,唤醒进程。返回之后还有一次轮询,用户可以遍历fds数组,挨个判断每个文件描述符是否有数据过来,通过FD_ISSET判断具体哪个socket收到数据,然后做出处理。
总结下主逻辑,伪代码如下,
1
2
3
4
5
6
7
8
9
int fds[] = "存放需要监听的socket"
while (1) {
int n = select(..., fds, ...) //fds加入到socket阻塞队列,select返回后,唤醒进程
for (int i=0; i < fds.count; i++) {
if (FD_ISSET(fds[i], ...)) {
//fds[i]的数据处理
}
}
}
代码核心步骤,大致分两步,
- 第一步,把所有需要轮询的
socket fd放到数组里, - 第二步,通过
select系统调用去查询数组中的文件描述符是否有数据可读,如果返回,就表示有一些fd连接有数据过来了,就去判断是哪个socket收到了数据。
select是poll的基础,看个例子,
假如程序同时监视Socket1、Socket2和Socket3三个Socket,那么在调用select之后,操作系统把进程A分别加入这三个Socket的等待队列中。
进程A在查询这3个socket连接的时候,就会加入到这3个连接的等待队列里。 现在假设连接2收到了数据,系统调用就会唤醒等待队列里的进程A,
被唤醒的进程A会被移到操作系统的工作队列,这时3个socket的等待队列都会移除进程A,假如进程A在查询1024个连接,这1024个连接的等待队列都会移除进程A。
对进程A来说,如果有一个连接的数据过来了,它会从多个等待队列中移除。
另外,在进程A中,开始执行后不知道是哪个socket来了数据,所以还会遍历所有socket。
最后,在处理完成任务后,会继续进行阻塞监听,还会加入到所有socket的等待队列里。
总结下,对于调用了
select的进程A来说,
A存在于多个socket的等待队列中;- 当某个
socket被写入数据时,A也被唤醒并从多个socket的等待队列中移除后加入内核的工作队列;- 但是此时
A并不知道是哪个socket被写入了数据,所以只能遍历所有socket;- 在
A处理完任务后移出内核的工作队列,但是此时却需要遍历所有socket并加入它们的等待队列中。
现在可以看到,select系统调用的性能问题,select有多次列表遍历,
- 第一次,进程加入到所有
socket的等待队列,需要遍历所有socket; - 第二次,当进程被某个
socket唤醒后,只知道至少有一个socket接收了数据,但不知道是哪一个,在用户空间里还需要遍历一次socket列表,才知道就绪的socket; - 另外,调用
select,把文件描述符传到内核,也有开销,主要还是遍历操作。
正是因为遍历操作开销大,出于效率的考量,才规定了
select的最大监视数量,默认只能监视1024个socket。
poll
因为这个原因,出现了poll。1997年,poll作为select的替代者,最大的区别就是,poll不再限制socket数量。
下面这段代码就是poll操作的使用方法,整体和select类似,
poll的内部实现基本跟select一样,区别在于它们底层组织fd[]的数据结构不太一样,从而实现了poll的最大文件句柄数量限制去除了。
poll描述fd集合的方式不同,poll使用pollfd结构(这个文件描述符数据结构稍微复杂点),而不是select的fd_set结构,其他的都差不多,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是poll没有最大文件描述符数量的限制。
1
2
3
SYNOPSIS
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
pollfd *fds,是C语言的列表(可以理解为数组),nfds,fd的数量,timeout,轮询的超时时限,
1
2
3
4
5
struct pollfd {
int fd; // 要监听的文件描述符
short events; // 用户指定的、需要监听的事件(输入参数)
short revents; // 内核返回的、实际发生的事件(输出参数)
};
关键在于,pollfd结构(在Java里边也会涉及到)有3个成员,fd(文件描述符)、events(事件,表示要监测的fd的事件)、revents(返回的事件),详细描述如下,
fd:每一个pollfd结构体指定了一个被监视的文件描述符,可以传递多个结构体,指示poll()监视多个文件描述符。events:表示要告诉操作系统需要监测fd的事件(输入、输出、错误),每一个事件有多个取值。revents:revents域是文件描述符的操作结果事件,内核在调用返回时设置这个域。events域中请求的任何事件都可能在revents域中返回。
select就是一个文件描述符的集合,没有事件相关的字段。
poll定义了一系列的事件常量,
| 事件 | 描述 | 是否可作为输入(events) | 是否可作为输出(revents) |
|---|---|---|---|
| POLLIN | 数据可读(包括普通数据&优先数据) | 是 | 是 |
| POLLOUT | 数据可写(普通数据&优先数据) | 是 | 是 |
| POLLRDNORM | 普通数据可读 | 是 | 是 |
| POLLRDBAND | 优先级带数据可读(linux不支持) | 是 | 是 |
| POLLPRI | 高优先级数据可读,比如TCP带外数据 | 是 | 是 |
| POLLWRNORM | 普通数据可写 | 是 | 是 |
| POLLWRBAND | 优先级带数据可写 | 是 | 是 |
| POLLRDHUP | TCP连接被对端关闭,或者关闭了写操作,由GNU引入 | 是 | 是 |
| POPPHUP | 挂起 | 否 | 是 |
| POLLERR | 错误 | 否 | 是 |
| POLLNVAL | 文件描述符没有打开 | 否 | 是 |
什么情况下数据可读?换句话说,
socket读就绪条件(读事件)有哪些?
以下4种情况,当对socket进行读操作,都不阻塞,而是返回,不同情况返回值不一样,通通都可以理解为发生了读事件。
- 内核缓冲区的字节数,大于等于接收缓冲区的低水位标记(可配置的常量)。缓冲区低水位的值默认为
1,只要收到1个字节,就认为socket发生了读事件; - 连接都是全双工模式,连接的读半部关闭(对方已经把连接关了,但我们还没关,对方不会写数据过来了),这时对套接字做
poll,它不会阻塞,也会返回,返回是0,认为也发生了读事件,只不过事件的值是0; - 如果是一个监听套接字,且有新的连接过来了(有没有完成建立的连接),这个连接的
accept操作通常也不会阻塞,也发生读事件; - 当套接字发生了错误,对它进行读操作也不会阻塞,返回
-1。
- 缓冲区数据达到低水位标记
- 该套接字接收缓冲区中的数据字节数大于等于套接字接收缓存区低水位标记
SO_RCVLOWAT。 - 对于
TCP和UDP套接字而言,缓冲区低水位的值默认为1。那就意味着,默认情况下,只要缓冲区中有数据,那就是可读的。我们可以通过使用SO_RCVLOWAT套接字选项(参见setsockopt函数)来设置该套接字的低水位大小。此种描述符就绪(可读)的情况下,当我们使用read/recv等对该套接字执行读操作的时候,套接字不会阻塞,而是成功返回一个大于0的值(即可读数据的大小)。
- 该套接字接收缓冲区中的数据字节数大于等于套接字接收缓存区低水位标记
- 连接读半关闭
- 该连接的读半部关闭(也就是接收了
FIN的TCP连接)。对这样的套接字的读操作,将不会阻塞,而是返回0(也就是EOF)。
- 该连接的读半部关闭(也就是接收了
- 监听套接字有已完成连接
- 该套接字是一个
listen的监听套接字,并且目前已经完成的连接数不为0。对这样的套接字进行accept操作通常不会阻塞。
- 该套接字是一个
- 套接字存在待处理错误
- 有一个错误套接字待处理。对这样的套接字的读操作将不阻塞并返回
-1(也就是返回了一个错误),同时把errno设置成确切的错误条件。这些待处理错误(pending error)也可以通过指定SO_ERROR套接字选项调用getsockopt获取并清除。
- 有一个错误套接字待处理。对这样的套接字的读操作将不阻塞并返回
什么时候连接可写?换句话说,
socket写就绪条件(写事件)有哪些?
- 发送缓冲区有空间,就认为是可写,比如查询一个套接字,检查它的发送缓冲区,有可写的空间,就会发生写事件,判断条件:空闲空间大于等于低水位标记;
- 你主动把
socket关了,它还是可以发生写事件,只不过返回不是正常值; - 套接字发生错误,去查,也会发生写事件,只不过返回
-1。
- 发送缓冲区空闲空间达到低水位
socket内核中,发送缓冲区中的可用字节数(发送缓冲区的空闲位置大小),大于等于低水位标记SO_SNDLOWAT,此时可以无阻塞的写,并且返回值大于0。- 对于
TCP和UDP而言,这个低水位SO_SNDLOWAT的值默认为2048,而套接字默认的发送缓冲区大小是8k,这就意味着一般一个套接字连接成功后,就是处于可写状态的。我们可以通过SO_SNDLOWAT套接字选项(参见setsockopt函数)来设置这个低水位。此种情况下,我们设置该套接字为非阻塞,对该套接字进行写操作(如write、send等),将不阻塞,并返回一个正值(例如由传输层接受的字节数,即发送的数据大小)。
- 连接写半关闭
- 该连接的写半部关闭(主动发送
FIN包的TCP连接)。对这样的套接字的写操作将会产生SIGPIPE信号。所以我们的网络程序基本都要自定义处理SIGPIPE信号。因为SIGPIPE信号的默认处理方式是程序退出。
- 该连接的写半部关闭(主动发送
- 非阻塞
connect完成- 使用非阻塞的
connect套接字已建立连接,或者connect已经以失败告终。即connect有结果了。
- 使用非阻塞的
- 套接字存在待处理错误
- 有一个错误的套接字待处理。对这样的套接字的写操作将不阻塞并返回
-1(也就是返回了一个错误),同时把errno设置成确切的错误条件。这些待处理的错误也可以通过指定SO_ERROR套接字选项调用getsockopt获取并清除。
- 有一个错误的套接字待处理。对这样的套接字的写操作将不阻塞并返回
socket可读、可写、发生异常,大概分为下面这些条件。
| 条件 | 可读吗? | 可写吗? | 异常吗? |
|---|---|---|---|
| 有数据可读 | ● | ||
| 关闭连接的读一半 | ● | ||
| 给监听套接口准备好新连接 | ● | ||
| 有可用于写的空间 | ● | ||
| 关闭连接的写一半 | ● | ||
| 待处理错误 | ● | ● | |
TCP带外数据 | ● |
poll系统调用,就是增加了一系列的事件,这些事件是select调用没有的,poll相较于select,没有做出多少性能改进。
poll系统调用的本质
select和poll系统调用的本质一样;poll的机制与select在本质上没有多大差别,每次调用时,都需要把fd集合从用户态拷贝到内核态;- 二者管理多个描述符也是进行轮询,根据描述符的状态进行处理。
epoll底层系统调用的核心原理
epoll是在select、poll出现很多年后才被发明的,是select和poll的增强版本。
select为什么低效?
一次select实际上做了两件事,第一个是把进程添加到socket等待队列,第二个是进行事件的阻塞等待,等到事件来了就返回,返回处理完,又来一次select,维护等待队列,然后阻塞。
一次select调用,将维护等待队列和阻塞等待两个步骤合二为一,紧密耦合
实际上,大部分情况下,一次系统调用所要监视的socket连接数相对固定,并不需要每次都修改(每次都把进程加入到那么多socket的等待队列里,反复地移除加入),
epoll将一个系统调用分为两个,epoll_ctl和epoll_wait,
- 第一个去维护等待队列,进程对
socket感兴趣,就加入socket等待队列,就不动了,每次调用都去找一次; - 另一个专门做事件的监听,阻塞进程。
epoll通过以下一些措施来改进效率,
- 功能分离:进程到等待队列,进程阻塞;
- 引入了就绪列表
rdlist
下面这段代码就是epoll操作的使用方法,先用epoll_create创建一个epoll对象epollfd,再通过epoll_ctl将需要监视的socket添加到epollfd的专用监听列表中,最后调用epoll_wait等待数据,返回rdlist列表中的就绪socket。
总结下主逻辑,伪代码如下,
1
2
3
4
5
6
7
8
9
int epfd = epoll_create(...);
epoll_ctl(epfd, ...); //第一步:将所有需要监听的 socket 添加到 epfd 中等待队列
while (1) {
int n = epoll_wait(...); //第二步:阻塞进程,等待事件
for("接收到数据的socket"){
//处理
}
}
epoll的三个方法
epoll_create创建一个专用的文件描述符,eventpoll对象(也就是程序中epfd所代表的对象),会有自己的等待队列;epoll_ctl,添加待监控的socket,eventpoll对象有一个监听队列,通过epoll_ctl系统调用把需要监视的socket连接添加到监听队列;epoll_wait,阻塞等待,用的很频繁,每次事件来了,就会返回,处理完了就再等待,不断轮询。当调用epoll_wait轮询操作的时候,就有点像select,进程就会进入eventpoll对象的等待队列。事件来了,进程就会移入CPU工作队列。
把等待队列移开,移到专用的文件描述符里的等待队列。这个优化之后,就不需要每次阻塞的时候,进行文件描述符反复的传输和遍历。
看下图,某个进程调用epoll_create方法时,内核会创建一个eventpoll对象(专有文件描述符),和socket一样,它也有等待队列。
epoll第一步,epoll_create,epoll创建eventpoll对象
可以用epoll_ctl添加(或删除)所有需要监听的socket到监听队列,等待队列用来放进程,监听队列用来放文件描述符。
另外一个优化措施,引入了一个就绪队列,用来放有事件发生的
socket连接,当轮询的时候,只要就绪列表有事件,就会返回就绪列表,不需要去监听列表做全局的轮询遍历,以空间换时间。
当socket收到数据后,中断程序会操作eventpoll的就绪队列rdlist,而不是直接操作读取数据的进程(比如进程A)。当socket2和socket3收到数据后,中断程序让这两个socket进入rdlist。
例子,假设进程A通过epoll来监控3个socket连接,内部结构大概如下,
epoll第三步,epoll_wait,epoll的等待列表
假设计算机中正在运行进程A和进程B,三个socket连接会加入到epoll的监听队列,在某时刻进程A运行到了epoll_wait语句,如果rdlist非空则返回,如果rdlist为空,内核会将进程A放入文件描述符eventpoll的等待队列中,阻塞进程。
如果某一个连接发生了事件(socket接收到数据),中断程序会做两个工作,
- 第一,
socket会进入rdlist; - 第二,会唤醒
eventpoll等待队列的进程,进程A会进入到工作队列,再次进入运行状态,
因为rdlist的存在,进程A可以知道哪些socket发生了变化。
总结
epoll三大步骤如下,
create会创建一个文件描述符,以及一个内部结构,不止是一个普通的文件描述符,还对应一个专用的内存结构,这个内存结构包含了一个红黑树,来维护要监听的socket。
- 为什么
epoll和select/poll性能不一样?能监听的socket文件描述符更多,不止1024个,性能更好,效率更高,因为底层用了红黑树结构,替代了select的数组结构。- 如果哪个
socket需要监听,就通过ctl系统调用把它加入到红黑树的监听队列。- 还有个就绪队列
rdlist,如果有事件发生,那么它的所有连接都会放到列表里边,然后返回给用户进程。










