IO多路复用的原理
定义
IO多路复用是指操作系统提供了一种机制,可以同时监控多个IO流的状态,从而实现对多个IO流的异步处理。其原理是利用操作系统提供的select、poll、epoll等系统调用,通过将多个IO流的文件描述符(fd)添加到一个等待队列中,让操作系统在IO流有数据到达时通知应用程序进行读写操作,避免了应用程序在等待IO操作完成时的阻塞。
理解上来说:
- IO 多路复用是一种同步IO模型,实现一个线程可以监视多个文件句柄;
- 一旦某个文件句柄就绪,就能够通知应用程序进行相应的读写操作;
- 没有文件句柄就绪就会阻塞应用程序,交出CPU。
(注:多路是指网络连接,复用指的是同一个线程)
需要预先知道以下的基础概念:
- 用户空间和内核空间
- 进程切换
- 进程的阻塞
- 进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得了CPU资源),才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用CPU资源的。正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语。
- 文件描述符
- 一个非负整数,一个索引值。指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。
- 缓存 I/O
- 称为标准I/O,大多数文件系统的默认I/O操作都是缓存I/O。在Linux的缓存I/O机制中,操作系统会将I/O的数据缓存在文件系统的页缓存中,即数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。
没有IO多路复用的情况下,有两种实现方式:BIO(同步阻塞)、NIO(非同步阻塞)
BIO同步阻塞代码示例:
一个简单的服务器程序,该程序使用阻塞的套接字来接受客户端连接并回显客户端发送的消息
#include <iostream>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#define PORT 8080
#define BUFFER_SIZE 1024
int main() {
int server_fd, new_socket;
struct sockaddr_in address;
int addrlen = sizeof(address);
char buffer[BUFFER_SIZE] = {0};
// 创建套接字文件描述符
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
perror("socket failed");
exit(EXIT_FAILURE);
}
// 为服务器地址结构体赋值
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(PORT);
// 绑定套接字到端口
if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
perror("bind failed");
close(server_fd);
exit(EXIT_FAILURE);
}
// 监听端口
if (listen(server_fd, 3) < 0) {
perror("listen failed");
close(server_fd);
exit(EXIT_FAILURE);
}
std::cout << "Server listening on port " << PORT << std::endl;
while (true) {
// 接受客户端连接
if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)) < 0) {
perror("accept failed");
close(server_fd);
exit(EXIT_FAILURE);
}
std::cout << "Connection established" << std::endl;
// 读取客户端发送的数据
int valread = read(new_socket, buffer, BUFFER_SIZE);
if (valread < 0) {
perror("read failed");
close(new_socket);
continue;
}
std::cout << "Received: " << buffer << std::endl;
// 将数据回显给客户端
send(new_socket, buffer, valread, 0);
std::cout << "Echo message sent" << std::endl;
// 关闭连接
close(new_socket);
}
// 关闭服务器套接字
close(server_fd);
return0;
}
主要过程是:
- 创建套接字: 使用
socket()
函数创建一个套接字,使用IPv4(AF_INET
)和面向连接的TCP协议(SOCK_STREAM
)。 - 绑定套接字: 将创建的套接字绑定到指定的端口(
PORT
),以便服务器可以接收该端口上的连接。 - 监听连接: 使用
listen()
函数将套接字设置为监听模式,允许最多3个待处理连接(3
)。 - 接受连接: 使用
accept()
函数阻塞并等待客户端连接。当一个客户端连接到来时,accept()
返回一个新的套接字描述符,用于与该客户端通信。 - 读取和回显数据: 使用
read()
函数从客户端套接字中读取数据,并使用send()
函数将数据回显给客户端。 - 关闭连接: 每次处理完客户端请求后,关闭客户端套接字。
NIO非同阻塞示例:
一个简单的服务器程序,该程序使用非阻塞的套接字和select
系统调用来处理多个客户端连接。
#include <iostream>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/select.h>
#define PORT 8080
#define BUFFER_SIZE 1024
// 设置套接字为非阻塞模式
void setNonBlocking(int sockfd) {
int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);
}
int main() {
int server_fd, new_socket, max_sd;
struct sockaddr_in address;
int addrlen = sizeof(address);
char buffer[BUFFER_SIZE];
fd_set readfds;
// 创建套接字文件描述符
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
perror("socket failed");
exit(EXIT_FAILURE);
}
// 为服务器地址结构体赋值
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(PORT);
// 绑定套接字到端口
if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
perror("bind failed");
close(server_fd);
exit(EXIT_FAILURE);
}
// 监听端口
if (listen(server_fd, 3) < 0) {
perror("listen failed");
close(server_fd);
exit(EXIT_FAILURE);
}
// 设置服务器套接字为非阻塞模式
setNonBlocking(server_fd);
std::cout << "Server listening on port " << PORT << std::endl;
// 清空读文件描述符集合
FD_ZERO(&readfds);
FD_SET(server_fd, &readfds);
max_sd = server_fd;
while (true) {
fd_set copy_fds = readfds;
// 使用select来监视文件描述符集合
int activity = select(max_sd + 1, ©_fds, nullptr, nullptr, nullptr);
if (activity < 0 && errno != EINTR) {
perror("select error");
}
// 检查是否有新的连接
if (FD_ISSET(server_fd, ©_fds)) {
if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)) < 0) {
perror("accept failed");
exit(EXIT_FAILURE);
}
std::cout << "New connection, socket fd is " << new_socket << std::endl;
// 添加新的套接字到集合中
setNonBlocking(new_socket);
FD_SET(new_socket, &readfds);
if (new_socket > max_sd) {
max_sd = new_socket;
}
}
// 处理现有连接的数据
for (int i = 0; i <= max_sd; ++i) {
if (FD_ISSET(i, ©_fds) && i != server_fd) {
int valread = read(i, buffer, BUFFER_SIZE);
if (valread == 0) {
// 客户端关闭连接
std::cout << "Host disconnected, socket fd is " << i << std::endl;
close(i);
FD_CLR(i, &readfds);
} else if (valread > 0) {
buffer[valread] = '\0';
std::cout << "Received: " << buffer << " from socket fd " << i << std::endl;
send(i, buffer, valread, 0);
}
}
}
}
// 关闭服务器套接字
close(server_fd);
return 0;
}
说明:
- 设置非阻塞模式: 使用
fcntl()
函数将套接字设置为非阻塞模式,这样accept()
和read()
调用将不会阻塞。 - 创建套接字: 使用
socket()
函数创建一个套接字,使用IPv4(AF_INET
)和面向连接的TCP协议(SOCK_STREAM
)。 - 绑定套接字: 将创建的套接字绑定到指定的端口(
PORT
),以便服务器可以接收该端口上的连接。 - 监听连接: 使用
listen()
函数将套接字设置为监听模式,允许最多3个待处理连接(3
)。 - 使用select监视文件描述符: 使用
select()
系统调用来监视多个文件描述符,以便处理多个客户端连接而不会阻塞。将服务器套接字和所有客户端套接字添加到文件描述符集合中。 - 接受新连接: 如果服务器套接字变为可读,意味着有新的客户端连接到来,使用
accept()
接受连接并将其套接字设置为非阻塞模式,然后添加到文件描述符集合中。 - 处理客户端数据: 如果客户端套接字变为可读,使用
read()
读取数据,并将数据回显给客户端。 - 关闭连接: 当客户端关闭连接时,从文件描述符集合中移除相应的套接字并关闭它。
(编译运行)
g++ -o nio_server nio_server.cpp
./nio_server
IO多路复用
服务器端采用单线程通过 select/poll/epoll
等系统调用获取 fd 列表,遍历有事件的 fd 进行 accept/recv/send
,使其能支持更多的并发连接请求。
大致的伪代码如下所示:
// 初始化服务器套接字
server_fd = socket(AF_INET, SOCK_STREAM, 0)
bind(server_fd, server_address, port)
listen(server_fd, backlog)
setNonBlocking(server_fd)
// 初始化文件描述符集合
FD_ZERO(&readfds)
FD_SET(server_fd, &readfds)
max_sd = server_fd
while true do
copy_fds = readfds
// 使用select监视文件描述符集合
activity = select(max_sd + 1, ©_fds, NULL, NULL, timeout)
if activity < 0 and errno != EINTR then
print("select error")
exit()
// 检查是否有新的连接
if FD_ISSET(server_fd, ©_fds) then
new_socket = accept(server_fd, address, addrlen)
setNonBlocking(new_socket)
FD_SET(new_socket, &readfds)
if new_socket > max_sd then
max_sd = new_socket
end if
print("New connection, socket fd is ", new_socket)
end if
// 处理现有连接的数据
for i from 0 to max_sd do
if FD_ISSET(i, ©_fds) and i != server_fd then
valread = read(i, buffer, BUFFER_SIZE)
if valread == 0 then
// 客户端关闭连接
print("Host disconnected, socket fd is ", i)
close(i)
FD_CLR(i, &readfds)
else if valread > 0 then
buffer[valread] = '\0'
print("Received: ", buffer, " from socket fd ", i)
send(i, buffer, valread, 0)
end if
end if
end for
end while
// 关闭服务器套接字
close(server_fd)
IO多路复用实现的三种方式
1.select
特点:
它仅仅知道有I/O事件发生了,却并不知道是哪那几个流(可能有一个,多个,甚至全部),我们只能无差别轮询所有流,找出能读出数据,或者写入数据的流,对他们进行操作。所以select具有O(n)的无差别轮询复杂度,同时处理的流越多,无差别轮询时间就越长。
select调用过程
+--------------------+
| Initialize server |
| socket |
+----------+---------+
|
v
+----------+---------------------------+
| Initialize fd_set |
| - FD_ZERO(&readfds) |
| - FD_SET(server_fd, &readfds) |
| - max_fd = server_fd |
+----------+---------------------------+
|
v
+----------+----------------------------+
| Main loop |
| |
| while (true) { |
| fd_set copy_fds = readfds; |
| |
| // Call select |
| int activity = select(max_fd + 1, |
| ©_fds, |
| NULL, |
| NULL, |
| NULL); |
| |
| if (activity < 0 && errno != EINTR) |
| error("select error"); |
| |
| // Check for new connections |
| if (FD_ISSET(server_fd, ©_fds)) {|
| int new_socket = accept(server_fd, |
| &address, |
| &addrlen); |
| setNonBlocking(new_socket); |
| FD_SET(new_socket, &readfds); |
| if (new_socket > max_fd) { |
| max_fd = new_socket; |
| } |
| print("New connection"); |
| } |
| |
| // Check all clients for data |
| for (int i = 0; i <= max_fd; ++i) { |
| if (FD_ISSET(i, ©_fds) && i != server_fd) { |
| int valread = read(i, buffer, BUFFER_SIZE); |
| if (valread == 0) { |
| // Client disconnected |
| close(i); |
| FD_CLR(i, &readfds); |
| } else if (valread > 0) { |
| buffer[valread] = '\0'; |
| send(i, buffer, valread, 0); |
| } |
| } |
| } |
| } |
+----------------------------------------+
- 初始化服务器套接字:
- 创建服务器套接字并将其绑定到指定端口。
- 使服务器套接字进入监听模式。
- Socket(套接字) 可以看成是两个网络应用程序进行通信时,各自通信连接中的端点,这是一个逻辑上的概念。
- 初始化文件描述符集合:
- 使用
FD_ZERO
宏初始化文件描述符集合。 - 将服务器套接字文件描述符添加到集合中。
- 记录最大文件描述符,以便
select
调用时使用。
- 使用
- 主循环:
- 复制文件描述符集合,以便传递给
select
调用。这样可以保留原始集合。 - 调用
select
,等待至少一个文件描述符变为可读、可写或发生异常。
- 复制文件描述符集合,以便传递给
- 检查新连接:
- 如果服务器套接字变为可读,意味着有新的客户端连接到来。
- 接受新的连接,并将新连接的套接字设置为非阻塞模式。
- 将新连接的套接字添加到文件描述符集合中,并更新最大文件描述符。
- 检查客户端数据:
- 遍历所有文件描述符,检查哪些变为可读。
- 对于每个可读的客户端套接字,读取数据并将其回显给客户端。
- 如果读取返回0,表示客户端关闭连接,关闭套接字并从文件描述符集合中移除。
select
是一个阻塞调用,直到有文件描述符变为就绪或超时。使用非阻塞模式可以防止accept
和read
调用阻塞服务器进程。每次调用select
时都需要传入文件描述符集合的副本,因为select
调用会修改传入的集合。
select的函数接口如下:
#include <sys/select.h>
#include <sys/time.h>
#define FD_SETSIZE 1024
#define NFDBITS (8 * sizeof(unsigned long))
#define __FDSET_LONGS (FD_SETSIZE/NFDBITS)
// 数据结构 (bitmap)
typedef struct {
unsigned long fds_bits[__FDSET_LONGS];
} fd_set;
// API 函数声明
int select(
int max_fd,
fd_set *readset,
fd_set *writeset,
fd_set *exceptset,
struct timeval *timeout
);
// 宏定义
#define FD_ZERO(set) \
do { \
unsigned long *__fds_bits = (set)->fds_bits; \
for (int i = 0; i < __FDSET_LONGS; i++) { \
__fds_bits[i] = 0; \
} \
} while (0)
#define FD_SET(fd, set) \
((set)->fds_bits[(fd)/NFDBITS] |= (1UL << ((fd) % NFDBITS)))
#define FD_ISSET(fd, set) \
((set)->fds_bits[(fd)/NFDBITS] & (1UL << ((fd) % NFDBITS)))
#define FD_CLR(fd, set) \
((set)->fds_bits[(fd)/NFDBITS] &= ~(1UL << ((fd) % NFDBITS)))
fd_set
:- 使用一个位图数组
fds_bits
来表示文件描述符的集合。每个文件描述符对应一个位。
- 使用一个位图数组
FD_ZERO(fd_set \*set)
:- 清空文件描述符集合,将所有位设为0。
FD_SET(int fd, fd_set \*set)
:- 将文件描述符
fd
添加到集合中,将对应的位设为1。
- 将文件描述符
FD_ISSET(int fd, fd_set \*set)
:- 检查文件描述符
fd
是否在集合中,检查对应的位是否为1。
- 检查文件描述符
FD_CLR(int fd, fd_set \*set)
:- 从集合中删除文件描述符
fd
,将对应的位清零。
- 从集合中删除文件描述符
使用示例:
int main() {
/*
* 这里进行一些初始化的设置,
* 包括socket建立,地址的设置等,
*/
fd_set read_fs, write_fs;
struct timeval timeout;
int max = 0; // 用于记录最大的fd,在轮询中时刻更新即可
// 初始化比特位
FD_ZERO(&read_fs);
FD_ZERO(&write_fs);
int nfds = 0; // 记录就绪的事件,可以减少遍历的次数
while (1) {
// 阻塞获取
// 每次需要把fd从用户态拷贝到内核态
nfds = select(max + 1, &read_fd, &write_fd, NULL, &timeout);
// 每次需要遍历所有fd,判断有无读写事件发生
for (int i = 0; i <= max && nfds; ++i) {
if (i == listenfd) {
--nfds;
// 这里处理accept事件
FD_SET(i, &read_fd);//将客户端socket加入到集合中
}
if (FD_ISSET(i, &read_fd)) {
--nfds;
// 这里处理read事件
}
if (FD_ISSET(i, &write_fd)) {
--nfds;
// 这里处理write事件
}
}
}
2.poll
poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态, 但是它没有最大连接数的限制,原因是它是基于链表来存储的.
函数API:
#include <poll.h>
// 数据结构
struct pollfd {
int fd; // 需要监视的文件描述符
short events; // 需要内核监视的事件
short revents; // 实际发生的事件
};
// API
int poll(struct pollfd fds[],
nfds_t nfds,
int timeout);
使用示例:
// 先宏定义长度
#define MAX_POLLFD_LEN 4096
int main() {
/*
* 在这里进行一些初始化的操作,
* 比如初始化数据和socket等。
*/
int nfds = 0;
pollfd fds[MAX_POLLFD_LEN];
memset(fds, 0, sizeof(fds));
fds[0].fd = listenfd;
fds[0].events = POLLRDNORM;
int max = 0; // 队列的实际长度,是一个随时更新的,也可以自定义其他的
int timeout = 0;
int current_size = max;
while (1) {
// 阻塞获取
// 每次需要把fd从用户态拷贝到内核态
nfds = poll(fds, max+1, timeout);
if (fds[0].revents & POLLRDNORM) {
// 这里处理accept事件
connfd = accept(listenfd);
//将新的描述符添加到读描述符集合中
}
// 每次需要遍历所有fd,判断有无读写事件发生
for (int i = 1; i < max; ++i) {
if (fds[i].revents & POLLRDNORM) {
sockfd = fds[i].fd
if ((n = read(sockfd, buf, MAXLINE)) <= 0) {
// 这里处理read事件
if (n == 0) {
close(sockfd);
fds[i].fd = -1;
}
} else {
// 这里处理write事件
}
if (--nfds <= 0) {
break;
}
}
}
}
注意:
poll它没有最大连接数的限制,原因是它是基于链表来存储的,但是同样有缺点:
- 每次调用 poll ,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大;
- 对 socket 扫描是线性扫描,采用轮询的方法,效率较低(高并发时)
3.epoll
epoll可以理解为event poll,不同于忙轮询和无差别轮询,epoll会把哪个流发生了怎样的I/O事件通知我们。所以我们说epoll实际上是事件驱动(每个事件关联上fd)的,此时我们对这些流的操作都是有意义的。(复杂度降低到了O(1))
epoll函数接口
当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关。eventpoll结构体如下所示:
#include <sys/epoll.h>
// 数据结构
// 每一个epoll对象都有一个独立的eventpoll结构体
// 用于存放通过epoll_ctl方法向epoll对象中添加进来的事件
// epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可
struct eventpoll {
/*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
struct rb_root rbr;
/*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/
struct list_head rdlist;
};
// API
int epoll_create(int size); // 内核中间加一个 ep 对象,把所有需要监听的 socket 都放到 ep 对象中
int epoll_ctl(int epfd,
int op,
int fd,
struct epoll_event *event); // epoll_ctl 负责把 socket 增加、删除到内核红黑树
int epoll_wait(int epfd,
struct epoll_event * events,
int maxevents,
int timeout);// epoll_wait 负责检测可读队列,没有可读 socket 则阻塞进程
每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件。这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是lgn,其中n为红黑树元素个数)。
而所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当相应的事件发生时会调用这个回调方法。这个回调方法在内核中叫ep_poll_callback,它会将发生的事件添加到rdlist双链表中。
在epoll中,对于每一个事件,都会建立一个epitem结构体,如下所示:
struct epitem{
struct rb_node rbn;//红黑树节点
struct list_head rdllink;//双向链表节点
struct epoll_filefd ffd; //事件句柄信息
struct eventpoll *ep; //指向其所属的eventpoll对象
struct epoll_event event; //期待发生的事件类型
}
epoll的优点
没有最大并发连接的限制,能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口);
效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。只有活跃可用的FD才会调用callback函数;即Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll;
内存拷贝,利用mmap()文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销。
epoll只能工作在 linux 下,是epoll的唯一缺点
总结:
select
特点
- 接口:POSIX 标准的一部分,在大多数操作系统中都可用。
- 实现:使用固定大小的位图表示文件描述符集合,最大文件描述符数量受
FD_SETSIZE
限制,通常为 1024。 - 调用:每次调用
select
时都需要重新初始化文件描述符集合,并从用户态拷贝到内核态。返回后,还需要重新遍历文件描述符集合以确定哪个文件描述符就绪。 - 性能:当监视大量文件描述符时,性能下降明显,因为每次都需要遍历整个文件描述符集合。
优缺点
- 优点:简单、广泛支持。
- 缺点:受限于文件描述符数量,效率低。
poll
特点
- 接口:POSIX 标准的一部分,在大多数操作系统中都可用。
- 实现:使用一个链表或数组来存储文件描述符及其事件,支持的文件描述符数量没有限制。
- 调用:每次调用
poll
时都需要将文件描述符集合从用户态拷贝到内核态。返回后,需要遍历数组以确定哪个文件描述符就绪。 - 性能:当监视大量文件描述符时,性能也会下降,但不像
select
那样显著。
优缺点
- 优点:不受文件描述符数量限制,接口灵活。
- 缺点:每次调用都需要遍历文件描述符集合,效率不高。
epoll
特点
- 接口:Linux 特有,不是 POSIX 标准的一部分。
- 实现:使用红黑树管理文件描述符和事件,使用就绪链表保存就绪事件。通过
epoll_create
创建一个epoll
实例,通过epoll_ctl
添加、删除或修改监视的文件描述符,通过epoll_wait
等待事件。 - 调用:文件描述符只需注册一次,后续操作在内核中进行,无需每次都从用户态拷贝到内核态。返回时,只需遍历就绪链表。
- 性能:适用于大量并发连接,性能高效。
优缺点
- 优点:支持大规模文件描述符,效率高。
- 缺点:仅在 Linux 上可用,接口较复杂。
区别总结
- 文件描述符数量:
select
:受限于FD_SETSIZE
,通常为 1024。poll
:无数量限制。epoll
:无数量限制。
- 性能:
select
:每次调用都需要遍历整个文件描述符集合,性能较低。poll
:每次调用都需要遍历文件描述符数组,性能较低,但比select
好。epoll
:使用就绪链表,性能高效,适用于大规模并发连接。
- 接口复杂度:
select
:接口简单,容易使用。poll
:接口灵活,但每次调用需要重新设置。epoll
:接口较复杂,但性能最优。
select
:简单易用,但性能和文件描述符数量有限。poll
:比select
更灵活,无文件描述符数量限制,但性能较低。epoll
:Linux 特有,性能高效,适用于大规模并发连接,但接口较复杂。
根据具体需求选择合适的 I/O 多路复用机制。对于高并发的应用,推荐使用 epoll
。