select、poll和epoll
# select、poll和epoll的原理和区别
在多路复用IO
模型中,会有一个内核线程不断地去轮询多个socket
的状态,只有当真正读写事件发送时,才真正调用实际的IO
读写操作。因为在多路复用IO
模型中,只需要使用一个线程就可以管理多个socket
,系统不需要建立新的进程或者线程,也不必维护这些线程和进程,并且只有真正有读写事件进行时,才会使用IO
资源,所以它大大减少来资源占用。多路I/O
复用模型是利用select
、poll
、epoll
可以同时监察多个流的 I/O
事件的能力,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有 I/O
事件时,就从阻塞态中唤醒,于是程序就会轮询一遍所有的流(epoll
是只轮询那些真正发出了事件的流),并且只依次顺序的处理就绪的流,这种做法就避免了大量的无用操作。
可以在Linux官网或者man中查看 select、poll 和 epoll 的详细说明。
实现的源码地址:
poll
和select
: include/linux/poll.h (opens new window), fs/select.c (opens new window)epoll
: fs/poll.c (opens new window)
# select
可以在 Linux 官网或者man select
命令查看具体说明。
函数定义:
int select(int nfds, fd_set *_Nullable restrict readfds,
fd_set *_Nullable restrict writefds,
fd_set *_Nullable restrict exceptfds,
struct timeval *_Nullable restrict timeout);
2
3
4
参数说明:
- nfds:监控的文件描述符集里最大文件描述符加1
- readfds:监控有读数据达到文件描述符集合,传入传出参数
- writefds:监控有写数据达到文件描述符集合,传入传出参数
- exceptfds:监控异常发生文件描述符集合,传入传出参数
- timeout:定时阻塞监控时间,3种情况
- NULL,永远等待下去
- 设置 timeval,等待固定时间
- 设置 timeval 里时间均为0,检查描述子后立即返回,轮询
# 机制
select
函数监视的文件描述符分3类,分别是readfds
、writefds
和exceptfds
,将用户传入的数组拷贝到内核空间- 调用后
select
函数会阻塞,直到有描述符就绪(有数据 可读、可写、或者有 except)或超时(timeout 指定等待时间,如果立即返回设为NULL
即可),函数返回。 - 当
select
函数返回后,可以通过遍历fdset
,来找到就绪的描述符。
# 原理
使用 Python 代码实现用户态的select
def server():
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(('127.0.0.1', '50001'))
s.listen(10) # 设置最大监听数目,并发
s.setblocking(False) # 设置为非阻塞
clients = [] # 保存客户端 socket
while True:
try:
conn, addr = s.accept() # 非阻塞,轮询检查是否有连接
conn.setblocking(False)
clients.append((conn, addr)) # 存放客户端 socket
print('Connected by', addr)
except BlockingIOError:
pass
for cs, ca in clients:
try:
data = cs.recv(1024) # 接收数据,非阻塞
if len(data) > 0: # 收到了数据
print(f"message from {ca}: {data}")
cs.sendall(data)
else:
cs.close()
clients.remove((cs, ca))
except Exception:
pass
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
select
其实就是把 NIO 中用户态要遍历的 fd 数组(clients,即保存客户端 socket 的列表)拷贝到内核态,让内核态来遍历。因为用户判断 socket 是否有数据还是要调用内核态,所以拷贝到内核后,这样遍历的时候就不用一直在用户态和内核态之间频繁切换了。
# 执行流程
select
是一个阻塞函数,当没有数据时,会一直阻塞在select
那一行- 当有数据时会将
readfds
中对应的那一位置为1 select
函数返回,不再阻塞- 遍历文件描述符数组,判断哪个
fd
被置位(置位为1)了 - 读取数据,然后处理
int fds[5];
fd_set readfds;
sockfd = socket(AF_INET, SOCK_STREAM, 0); // 建立服务器端socket
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(50007);
server_len = sizeof(addr);
bind(sockfd, (struct sockaddr *)&addr, server_len);
listen(sockfd, 5); // 监听队列最多容纳5个
for (int i = 0; i < 5; i++) // 模拟5个客户端连接
{
memset(&client, 0, sizeof(client));
addrlen = sizeof(client);
fds[i] = accept(sockfd, (struct sockaddr *)&client, &addrlen);
if (fds[i] > max)
{
max = fds[i]; // 找到一个最大的文件描述符
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
while (1)
{
FD_ZERO(&readfds);
for (int i = 0; i < 5; i++)
{
/* &readfds 是一个 bitmap,如果5个文件描述符分别是0,1,2,4,5,7,那么这个 bitmap 为01101101 */
FD_SET(fds[i], &readfds);
}
puts("round again");
/*select 是一个系统调用,它会阻塞直到有数据发送到 socket,select 会把&readfds 相应的位置重置,但不会返回哪个 socket 有数据*/
select(max + 1, &readfds, NULL, NULL, NULL);
/*用户态只要遍历 &readfds,看哪一位被置位了,不需要每次调用系统调用来判断了,效率有很大提升,遍历到被置位的文件描述符就进行读取*/
for (int i = 0; i < 5; i++)
{
if (FD_ISSET(fds[i], &readfds))
{
memset(buffer, 0, MAXBUF);
read(fds[i], buffer, MAXBUF);
puts(buffer);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 优点
select
其实就是把 NIO 中用户态要遍历的fd
数组(客户端每一个socket
,即上面的clients
列表)拷贝到了内核态,让内核态来遍历,因为用户态判断socket
是否有数据还是要调用内核态的,所有拷贝到内核态后,这样遍历判断的时候就不用一直用户态和内核态频繁切换了
从代码中可以看出,select
系统调用后,返回了一个置位后的&readfds
,这样用户态只需进行很简单的二进制比较,就能很快知道哪些socket
需要read
数据,有效提高了效率
# 缺点
- 有限的连接数:
bitmap
最大1024为,一个进程最大只能处理1024个客户端 &readfds
不可重用:&readfds
不可重用,每次socket
有数据时,相应的位会被置位- 拷贝消耗资源:文件描述符数组拷贝到了内核态(只不过无系统调用切换上下文的开销。(内核层可优化为异步事件通知)),仍然有开销。
select
调用需要传入fd
数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的。(可优化为不复制) O(n)
复杂度:select
并没有通知用户态哪一个socket
有数据,仍然需要O(n)
的遍历。select
仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)
# poll
可以在 Linux 官网或者man poll
命令查看具体说明。
poll
与select
原理基本相同,只不过突破了连接数为1024的限制。
# 执行流程
- 将客户端
fd
数组从用户态拷贝到内核态 poll
为阻塞函数,执行poll
函数时,如果有数据会将fd
对应的revents
置为POLLIN
poll
函数返回- 循环遍历,查找哪个
fd
被置位为POLLIN
了 - 将
revents
重置为0,便于复用 - 对置位的
fd
进行读取和处理
// 模拟5个客户端
for (int i = 0; i < 5; i++)
{
memset(&client, 0, sizeof(client));
addrlen = sizeof(client);
pollfds[i].fd = accept(sockfd, (struct sockaddr *)&client, &addrlen);
pollfds[i].events = POLLIN; // 这个5个 socket 只关注只读事件
}
sleep(1);
while (1)
{
puts("round again");
/*poll 中传入 pollfds 数组,交给内核判断是否有事件发生,如果哪个发生事件则 revents 置1*/
poll(pollfds, 5, 50000);
/*遍历数组,找到哪个 pollfds 有事件发生*/
for (int i = 0; i < 5; i++)
{
if (pollfds[i].revents & POLLIN)
{
pollfds[i].revents = 0; // 找到后 revents 置0
memset(buffer, 0, MAXBUF);
read(pollfds[i].fd, buffer, MAXBUF); // 读取数据
puts(buffer);
}
}
}
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
# 优点
poll
使用pollfd
数组来代替select
中的bitmap
,数组没有1024的限制,可以一次管理更多的client
。它和select
的主要区别就是,去掉了select
只能监听1024个文件描述符的限制。- 当
pollfds
数组中有事件发生,相应的revents
置位为1,遍历的时候又置位回零,实现了pollfd
数组的重用
# 缺点
poll
解决select
缺点中的前两条,其本质原理还是select
的方法,还存在select
中原来的问题
pollfds
数组拷贝到了内核态,仍然有开销poll
并没有通知用户态哪一个socket
有数据,仍然需要O(n)
的遍历
# 解决的问题
- 解决了
bitmap
大小限制 - 解决了
&readfds
不可重用的问题
# epoll
可以在 Linux 官网或者man epoll
命令查看具体说明。
# 执行流程
epoll
是非阻塞的,它的执行流程:
- 当有数据的时候,会把相应的文件描述符置位,但是
epoll
没有revent
标志位,所以并不是真正的置位。这时候会把有数据的文件描述符放到队首 epoll
会返回有数据的文件描述符的个数- 根据返回的个数,读取前
N
个文件描述符即可 - 读取和处理数据
struct epoll_event events[5];
/*epoll_create 在内核开辟一块空间,原来存放 epoll 中 fd 的数据结构(红黑树)*/
int epfd = epoll_create(10);
// ...
// 模拟5个客户端
for (int i = 0; i < 5; i++)
{
/*epoll 中 fd 的数据结构和 poll 的差不多,只是没有了 revents*/
static struct epoll_event ev;
memset(&client, 0, sizeof(client));
addrlen = sizeof(client);
ev.data.fd = accept(sockfd, (struct sockaddr *)&client, &addrlen);
/*epoll_ctl 把每一个 socket 的 fd 数据结构放到 epoll_create 创建的内存空间中(加入到红黑树)*/
epoll_ctl(epfd, EPOLL_CTL_ADD, ev.data.fd, &ev);
}
while (1)
{
puts("round again");
/*epoll_wait 阻塞,只有当 epoll_create 中创建的内存空间中的 fd 有事件发生,才会把这些 fd 放在就绪列表中,返回就绪 fd 的个数*/
nfds = epoll_wait(epfd, events, 5, 10000);
/*遍历就绪列表,读取数据*/
for (int i = 0; i < nfds; i++)
{
memset(buffer, 0, MAXBUF);
read(events[i].data.fd, buffer, MAXBUF);
puts(buffer);
}
}
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
# 使用epoll编程主流程骨架
三步调用
epoll_create
创建一个epoll
句柄int epoll_create(int size);
1epoll_ctl
向内核添加、修改或删除要监控的文件描述符int epoll_ctl(int epfd, int op, int fd, struct epoll_event *_Nullable event);
1
2epoll_wait
发起类似select()
调用int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
1
2
骨架实例:
// 创建一个 epoll句柄
int epfd = epoll_create(1000);
// 将 listen_fd 添加进 epoll 中
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &listen_event);
while (1)
{
// 阻塞等待 epoll 中 的fd 触发
int active_cnt = epoll_wait(epfd, events, 1000, -1);
for (i = 0; i < active_cnt; i++)
{
if (evnets[i].data.fd == listen_fd)
{
// accept. 并且将新accept 的fd 加进epoll中.
}
else if (events[i].events & EPOLLIN)
{
// 对此fd 进行读操作
}
else if (events[i].events & EPOLLOUT)
{
// 对此fd 进行写操作
}
}
}
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
# 触发模式
水平触发
水平触发的主要特点是,如果用户在监听epoll
事件,当内核有事件的时候,会拷贝给用户态事件,但是如果用户只处理了一次,那么剩下没有处理的会在下一次epoll_wait再次返回该事件。
这样如果用户永远不处理这个事件,就导致每次都会有该事件从内核到用户的拷贝,耗费性能,但是水平触发相对安全,最起码事件不会丢掉,除非用户处理完毕。
边缘触发
边缘触发,相对跟水平触发相反,当内核有事件到达, 只会通知用户一次,至于用户处理还是不处理,以后将不会再通知。这样减少了拷贝过程,增加了性能,但是相对来说,如果用户马虎忘记处理,将会产生事件丢的情况。
# 优点
- 解决了
select
和poll
的问题 - 连接数没有限制
- 减少了用户态和内核态的文件句柄拷贝,因为客户端
fd
已经在epoll_ctl
交给内核 - 减少了对可读可写文件句柄的遍历,因为
epoll
只返回可读可写的文件句柄 - IO 性能不会随着监听的文件描述的数量增长而下降
- 使用红黑树存储
fd
,以及对应的回调函数,其插入,查找,删除的性能不错,相比于 hash,不必预先分配很多的空间
# 三者的区别
select | poll | epoll | |
---|---|---|---|
操作方式 | 遍历 | 遍历 | 回调 |
数据结构 | bitmap | 数组 | 红黑树 |
最大连接数 | 1024(x86)或2048(x64) | 无上限 | 无上限 |
最大支持文件描述符 | 一搬由最大值限制 | 65535 | 65535 |
fd拷贝 | 每次调用 select,都需要把 fd 集合从用户态拷贝到内核态 | 每次调用 poll,都需要把 fd 集合从用户态拷贝到内核态 | fd 首次调用 epoll_ctl 拷贝,每次调用 epoll_wait 不拷贝 |
工作效率 | 每次调用都进行线性遍历,时间复杂度为 O(n) | 每次调用都进行线性遍历,时间复杂度为 O(n) | 事件通知方式,每当 fd 就绪,系统注册的回调函数就会被调用,将就绪 fd 放到 readList 里面,时间复杂度 O(1) |
# 小结
select
:select
方式,既做到了一个线程处理多个客户端连接(文件描述符),又减少了系统调用的开销(多个文件描述符只有一次select
的系统调用 + N次就绪状态的文件描述符的read
系统调用);poll
:poll
方式解决了select
因为bitmap
大小的限制,最大连接数没有限制;epoll
:epoll
是现在最先进的IO
多路复用器,Redis、Nginx,linux 中的 Java NIO 都使用的是epoll
。