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置为POLLINpoll函数返回- 循环遍历,查找哪个
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。