select, poll和epoll都是IO多路复用的模型,所以在深入了解这三个系统调用之前,需要先简单介绍一下IO多路复用。
IO多路复用是一种复用技术,复用(multiplexing)技术很普遍,例如通信中有多路时分复用(OFDM)、频分复用、码分复用。再例如我们一个办公室的人可以共用办公室的一台打印机,大家在不同的时段使用即可实现复用,这就是时分复用。
“IO多路复用”是指复用了同一个处理线程(在Java中被抽象为选择器selector),由操作系统进行托管,当与选择器绑定的socket满足就绪的条件后,可以直接以事件驱动的形式(即释放select调用处的阻塞)获取到该socket。
对比于同步阻塞IO:服务端(server)通过监听(listen)绑定的端口,不得不通过recvfrom系统调用阻塞在此等待客户端的接入;因此IO多路复用比阻塞式IO并发处理性能明显提高了许多,因为阻塞式的IO不得不处理完该IO连接再处理下一个IO连接,监听线程无法得到复用。相比于非阻塞IO,又免去了多次轮训之苦(轮询意味着始终占用CPU)。各种IO模型的对比如图1.
我们将5种不同的IO模型简单梳理一下:
对比类别 | blocking IO | nonblocking IO | IO multiplexing | signal driven IO | asynchronous IO |
---|---|---|---|---|---|
阻塞在哪 | recvfrom | read | select/poll/epoll | read | - |
交互形式 | 串行,逐条处理 | 轮询,通过状态判断是否有连接到达 | 事件驱动,连接到达时select释放阻塞 | 回调,由SIGIO信号通知数据拷贝 | 回调,数据已经拷贝完毕 |
虽然相比于异步IO,仍然需要通过同步的形式将内核态数据拷贝到用户态,性能上仍然不如异步IO高,但是,却有着更简便的实现方式,因此,仍然是当前实现高并发IO系统的一种主要途径。如PostgreSQL、redis等数据库,再如Netty等都是通过IO多路复用实现的。(截止目前)
IO多路复用的系统调用主要有三个,分别是select, poll 与epoll,当然,这是在Linux上的实现,在其他操作系统上还有不同的实现,例如kqueue,我们此处只论Linux.
从发展进程上看,首先是select 诞生,然后是poll,最后才是epoll. 因此,根据常识,后诞生的系统调用肯定是有改进之处的,所以,我们先从select开始介绍起。
诞生于2000年左右,系统调用的原型函数为:
#include
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
详情地址:
http://man7.org/linux/man-pages/man2/select.2.html
我们前面说过,IO多路复用这种IO模式,它所复用的是处理线程,也就是复用了“哪个IO就绪了,可以处理哪个IO了”这个过程。它是通过监控文件描述符(file descriptor, fd)来实现,如果有一个或以上的文件描述符处于“就绪”(ready)状态,就返回其中一个即可。
那么,这些fd在系统内核中是通过何种数据结构存储的呢?是通过bitmap存放的(数据结构为fd_set),它默认大小是1024(对于64bit有2048个)。因此,对于大于1024的fd,基本效果就不可控了,这客观限制了并发量的上限。
因此,总结一下select的问题:
poll对select有了些许改进,如修正了fd数量的上限等等,但其他改进的幅度不大,此处不详谈。
epoll最初的版本是在2.5.44,后来在2.6的版本后陆续稳定。epoll的诞生就是为了解决历史遗留问题,因此,epoll的优势主要有:
epoll的系统调用函数原型:
#include
/* open an epoll file descriptor */
int epoll_create(int size);
int epoll_create1(int flags);
/* control interface for an epoll file descriptor */
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
/* wait for an I/O event on an epoll file descriptor */
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
int epoll_pwait(int epfd, struct epoll_event *events,
int maxevents, int timeout,
const sigset_t *sigmask);
上述详情参考此处:
http://www.man7.org/linux/man-pages/man7/epoll.7.html
对上述三个epoll的API进行简单理解,就是:
上述API中,有一个关键的数据结构epoll_event,它的定义是:
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event {
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
来看一下官方给出的example:
/*
* While the usage of epoll when employed as a level-triggered interface
* does have the same semantics as poll(2), the edge-triggered usage
* requires more clarification to avoid stalls in the application event
* loop. In this example, listener is a nonblocking socket on which
* listen(2) has been called. The function do_use_fd() uses the new
* ready file descriptor until EAGAIN is returned by either read(2) or
* write(2). An event-driven state machine application should, after
* having received EAGAIN, record its current state so that at the next
* call to do_use_fd() it will continue to read(2) or write(2) from
* where it stopped before.
*/
#define MAX_EVENTS 10
struct epoll_event ev, events[MAX_EVENTS];
int listen_sock, conn_sock, nfds, epollfd;
/* Code to set up listening socket, 'listen_sock',
(socket(), bind(), listen()) omitted */
epollfd = epoll_create1(0);
if (epollfd == -1) {
perror("epoll_create1");
exit(EXIT_FAILURE);
}
ev.events = EPOLLIN;
ev.data.fd = listen_sock;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, listen_sock, &ev) == -1) {
perror("epoll_ctl: listen_sock");
exit(EXIT_FAILURE);
}
for (;;) {
nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);
if (nfds == -1) {
perror("epoll_wait");
exit(EXIT_FAILURE);
}
for (n = 0; n < nfds; ++n) {
if (events[n].data.fd == listen_sock) {
conn_sock = accept(listen_sock,
(struct sockaddr *) &addr, &addrlen);
if (conn_sock == -1) {
perror("accept");
exit(EXIT_FAILURE);
}
setnonblocking(conn_sock);
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = conn_sock;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, conn_sock,
&ev) == -1) {
perror("epoll_ctl: conn_sock");
exit(EXIT_FAILURE);
}
} else {
do_use_fd(events[n].data.fd);
}
}
}
代码的总体注释上面已经给出了,翻译一下,就是说这里的epoll不仅监听了listen获取到的新连接的socket,当新连接的socket接入后,又将新连接的socket的读写作为事件进行监听,当监测到新连接可以进行读写(因为 ev.events = EPOLLIN | EPOLLET)时,则调用do_use_fd() 函数. 当然,在具体的实现上,你可以只监听新到达的socket,然后创建一个新的线程去消费它。
epoll有两种模式提供给用户,分别是LT与ET模式,这两种模式在此处不便展开谈,篇幅会比较长,简单总结一下:
epoll默认的模式是LT. 考虑这样一个场景,通过epoll_wait() 函数获取事件后,如果事件没被处理或者没被处理完,那么这个事件还应不应该继续通知?在LT下是接着通知,在ET下是直接忽略。另外ET仅支持非阻塞的socket. 总之,关于这两个模式,读取数据还好,但是写数据时就比较麻烦了,可以单独再开一篇文章来讨论了。
以上就是对select、epoll的简单剖析,当前的服务器的IO模型主要都是IO多路复用,即有一个主线程用于轮询,获取连接到的socket,当获取到新的接入socket后,通过线程或子进程来消费这个socket. 例如,PostgreSQL的主线程ServerLoop函数,就是阻塞在select() 函数处,当一个新的客户端接入时,创建一个新的子进程,消费该socket. 对于这种数据库场景,很多时候是CPU密集的,因此必须要通过一个新进程来消费socket, 否则巨大的CPU开销会严重影响系统的并发。但是,对于某些重IO轻CPU的场景,其实也可以不必采用这种方式,可以采用类似上述demo代码的形式,在一个线程中完成IO的事件的监听和处理。