Linux I/O复用技术:select, poll, epoll使用区别
I/O复用简介
传统的编程模型中,要确定某个文件描述符是否发生关心的事件,需要对其进行轮询。一旦要监听的文件描述符数量众多,可能会导致效率很低。
I/O 复用技术能有效减少需要轮询的文件描述符数量,将其缩减至1个,即I/O复用的系统调用本身,同时,程序也能监听多个文件描述符。这对提高程序性能很重要。I/O复用本身是阻塞的,并不能让程序并发运行。但I/O复用通过监听文件描述符事件,如果事件就绪,就通知应用程序执行相应的处理流程;如果没有就绪事件,就阻塞在等待事件就绪的一个select/poll/epoll 调用上,而不是每个文件描述符,从而实现并发运行。
使用I/O复用技术的常见场景:
- 客户端程序要同时处理多个socket,比如非阻塞connect;
- 客户端程序要同时处理多个用户输入和网络连接,比如网络聊天室;
- TCP服务器要同时处理监听socket和连接socket。这是I/O复用使用最多的场合;
- 服务器要同时处理TCP请求和UDP请求;
- 服务器要同时监听多个端口,或者处理多种服务,比如xinetd服务器;
简单来说,I/O复用就是适合应用程序程序要同时处理多个IO事件,而IO事件通常也不是一次性完成的,需要一个过程来完成。
Linux上,实现I/O复用的系统调用有3个:select、poll、epoll。
文件描述符就绪
谈这3个I/O复用前,先了解一下什么是文件描述符就绪。哪些情况下文件描述符就绪?
所谓文件描述符就绪,是指文件描述符对应文件可读、可写或者出现异常。
例如,在网络编程中,下列情况下socket可读:
1)socket 内核接收缓冲区的字节数 >= 低水平位标记SO_RCVLOWAT。此时,可无阻塞read该socket,返回的字节数>0;
2)socket 通信对端关闭连接。此时,对该socket的read操作将返回0;
3)监听socket 上有新的连接请求;
4)socket上有未处理的错误。可使用getsockopt来读取和清除该错误(SO_ERROR);
下列情况下socket可写:
1)socket 内核发送缓冲区中可用字节数 >= 低水平位标记SO_SNDLOWAT。此时,可无阻塞地write该socket,返回字节数>0;
2)socket的写操作被关闭。对写操作被关闭的socket执行写操作,将触发一个SIGPIPE信号;
3)socket使用非阻塞connect连接成功或失败(超时)之后;
4)socket上有未处理的错误。此时,可用使用getsockopt来读取和清除该错误(SO_ERROR);
socket能处理的异常情况只有一种:socket上收到带外数据(out-of-band data)。
当然,I/O复用不仅用于监听socket,还可以用于监听外部设备,本地管道、消息队列、UNIX Domain Socket(域套接字)、timerfd(Linux特有定时器)、eventfd(Linux特有事件通知)等等有对应fd存在的地方。
select系统调用
用途:在一段指定时间内,监听用户感兴趣的文件描述符上的可读、可写和异常事件。(3个文件描述符集合,用数组表示)
特点:
1)要设置监听的fd时,分别设置3个集合:readfs、writefd、exceptfds,分别用于监听读事件、写事件、异常事件 这3类事件。
2)用一组宏定义FD_ZERO/FD_SET/FD_CLR/FD_ISSET,对监听的fd进行操作。
3)每个while循环里面,都需要为select重新设置监听的3类集合。将fd集合从用户态拷贝到内核态,在监听的fd数量较多时,开销也会比较大。
4)监听的fd数量有上限限制(默认1024)。这是select使用场景的重要限制。
5)监听到有就绪事件时,不知道具体是哪个,需要用FD_ISSET对所有fd逐个检测,从而判断具体是哪个fd发生就绪事件。这是select性能相比epoll较低重要原因。
select函数原型
/* According to POSIX.1-2001 */
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
#include <sys/select.h>
int pselect(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, const struct timespec *timeout, const sigset_t *sigmask);
- 参数
1)ndfs: 指定被监听的文件描述符总数。通常被设置为select监听的所有文件描述符最大值 + 1,因为文件描述符是从0开始计数的。
2)readfds, writefds, exceptfds: 分别指向可读、可写和异常等事件对于的文件描述符集合。应用程序调用select函数时,通过这3个参数传入自己感兴趣的文件描述符。select调用返回3个集合中文件描述符就绪的总数,由内核修改,用于通知应用程序有就绪事件。这3个参数是fd_set*类型。
#include <typesizes.h>
#define __FD_SETSIZE 1024
#include <sys/select.h>
#define FD_SETSIZE __FD_SETSIZE
typedef long int __fd_mask;
#define __NFDBITS (8 * (int) sizeof(__fd_mask) ) /* 1个long int类型数 8byte, 每1byte 8个bit */
typedef struct {
__fd_mask fds_bits[__FD_SETSIZE / __NFDBITS]; /* 数组长度(byte数)为 (fd最大值+1) / (8 * sizeof(long int) ) */
#define __FDS_BITS(set) ((set)->fds_bits)
} fd_set;
fd_set结构体包含一个整型数组,该数组的每个元素的每个bit位标记一个文件描述符。fd_set能容纳的文件描述符数量由FD_SETSIZE指定,这也限制了select能同时处理的文件描述符的总量。
由于操作繁琐,可以使用下面一系列宏来访问fd_set结构体中的bit位:
#include <sys/select.h>
void FD_CLR(int fd, fd_set *set); /* 清除set的所有位 */
int FD_ISSET(int fd, fd_set *set); /* 设置set的位fd */
void FD_SET(int fd, fd_set *set); /* 清除set的位fd */
void FD_ZERO(fd_set *set); /* 测试set的位fd是否被设置 */
3)timeout 用于设置select()超时时间。timeval是值-结果参数,内核将修改它,以告诉应用程序select等待了多久。不过,不能完全信任select返回的timeout值,如调用失败时timeout值不确定。
timeval结构体定义:
#include <sys/time.h>
struct timeval {
long tv_sec; /* seconds */
long tv_usec; /* microseconds */
};
- 返回值
如果timeout.tv_sec和.tv_usec都传递0,则select将立即返回;如果timeout传递NULL,则select将一直阻塞,直到监听的某个文件描述符就绪。
select成功返回就绪(可读、可写、异常)文件描述符的总数。
如果在超时时间内,没有任何文件描述符就绪,select将返回0;出错时,返回-1并设置errno;
如果在select等待期间,程序接收到信号,则select立即返回-1,并设置errno为EINTR。
select调用模型
int fd1, fd2, fd3, ..., fdn;
...
int max_fd = fdn; // 要监听的文件描述符中, 数值上最大值, 要求 < 1024 (0..1023)
struct timeval timeout = {sec, usec}; // select超时时间
while(1) {
// 重新设置3个集合readfds, writefds, exceptionfds
FD_SET(fdi1..fdi2, &readfds);
FD_SET(fdj1..fdj2, &writefds);
FD_SET(fdk1..fdk2, &exceptionfds);
int n = select(max_fd + 1, readfds, writefds, exceptionfds, &timeout);
if (n < 0) {
// error
}
else {
// 逐个检测文件描述符就绪事件, 如果检测到监听的事件发生, 就调用相应的处理事件代码
if (FD_ISSET(fdi1, readfds)) {
// 处理事件
}
...
if (FD_ISSET(fdi2, readfds)) {
// 处理事件
}
if (FD_ISSET(fdj1, writefds)) {
// 处理事件
}
...
if (FD_ISSET(fdj2, writefds)) {
// 处理事件
}
if (FD_ISSET(fdk1, exceptionfds)) {
// 处理事件
}
...
if (FD_ISSET(fdk2, exceptionfds)) {
// 处理事件
}
}
}
示例:处理带外数据
socket上接收到普通数据和带外数据,都将使select返回,但socket处于不同的就绪状态:前者处于可读状态,后者处于异常状态。下面的程序描述了select如何同时处理二者:
只展示使用select的核心部分代码,详细代码见Giteeselect_outoufband.c | Gitee地址
// from https://gitee.com/fortunely/linuxstudy/blob/master/advancedio/select/select_outoufband.c
int main()
{
...
int connfd = accept(listenfd, ...);
char buf[1024];
fd_set read_fds;
fd_set exception_fds;
FD_ZERO(&read_fds);
FD_ZERO(&exception_fds);
while (1) {
memset(buf, '\0', sizeof(buf));
FD_SET(connfd, &read_fds);
FD_SET(connfd, &exception_fds);
ret = select(connfd + 1, &read_fds, NULL, &exception_fds, NULL); // 有几个事件(fd)就绪, select返回值就是多少
if (ret < 0) {
printf("select failure\n");
break;
}
// 遍历select监听事件数组, 判断事件是否就绪, 如果就绪, 就处理事件
/* 对于可读事件,采用普通的recv函数读取数据 */
if (FD_ISSET(connfd, &read_fds)) {
ret = recv(connfd, buf, sizeof(buf) - 1, 0);
if (ret <= 0) break;
buf[ret] = '\0'; /* buf末尾添加null终结符,转化为字符串 */
printf("get %d bytes of normal data: %s\n", ret, buf);
}
/* 对于异常事件,采用MSG_OOB标志的recv函数读取带外数据 */
else if (FD_ISSET(connfd, &exception_fds)) {
ret = recv(connfd, buf, sizeof(buf) - 1, MSG_OOB);
if (ret < 0)
break;
buf[ret] = '\0';
printf("get %d bytes of oob data: %s\n", ret, buf);
}
}
...
}
POLL系统调用
poll类似于select,也是在指定时间内轮询一定数量的文件描述符,以测试其中是否有就绪者。
特点:
1)不受监听的文件描述符数量上限 (select是1024)的限制。
2)poll接受一个pollfd结构数组作为要监听的文件描述符集合,以参数传入。 pollfd结构包含要监听的文件描述符、事件类型,以及实际发生的就绪事件。
3)要监听的数组本身,不要每个循环都重新设置。但同select,每次循环都要将监听的fd集合,作为poll参数,从用户态传拷贝到内核态。
4)就绪事件发生时,同select,要对每个监听的文件描述符逐一进行检测。这是导致poll相比epoll更低效的重要原因。
poll函数原型
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
#define _GNU_SOURCE /* See feature_test_macros(7) */
#include <poll.h>
int ppoll(struct pollfd *fds, nfds_t nfds, const struct timespec *timeout_ts, const sigset_t *sigmask);
- 参数
1)fds pollfd结构的数组,指定所有感兴趣的文件描述符上发生的可读、可写、异常事件。pollfd结构定义:
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
}
poll支持的事件类型(pollfd.events/.revents支持的值):
事件 | 描述 | 可作为输入? | 可作为输出? |
---|---|---|---|
POLLIN | 数据(包括普通数据和优先数据)可读 | 是 | 是 |
POLLRDNORM | 普通数据可读,等同于POLLIN | 是 | 是 |
POLLRDBAND | 优先级带数据可读(Linux不常用) | 是 | 是 |
POLLPRI | 高优先级数据可读,比如TCP带外数据 | 是 | 是 |
POLLOUT | 数据(包括普通数据和优先级数据)可写 | 是 | 是 |
POLLWRNORM | 普通数据可写,等同于POLLOUT | 是 | 是 |
POLLWRBAND | 优先级带数据可写 | 是 | 是 |
PLLRDHUP | TCP连接被对方关闭,或者对方关闭了写操作。它由GNU引入 | 是 | 是 |
POLLERR | 发生了错误 | 否 | 是 |
POLLHUP | 挂起。比如管道的写端被关闭后,读端描述符上将收到POLLHUP事件 | 否 | 是 |
POLLNVAL | 无效的请求,一般是文件描述符(fd)没有打开 | 否 | 是 |
如何区分socket上接收到的,是有效数据,还是对方关闭连接的请求?
有两种方式:
方式一:根据recv调用,返回值如果是0,说明对方关闭了连接请求。
方式二:Linux 2.6.17后,GNU为poll系统调用增加POLLRDHUP事件,在socket上接收到对方关闭连接的请求之后触发。不过使用POLLRDHUP事件时,需要住代码的最开始处定义_GNU_SOURCE。
2)nfds 指定被监听事件集合fds的大小。类型nfds_t定义:
typedef unsigned long int nfds_t;
3)timeout 指定poll的超时值,单位ms。当timeout为-1时,poll调用将永远阻塞,直到某个事件发生;当timeout为0时,poll调用将立即返回。
- 返回值
含义同select返回值。
poll调用模型
struct pollfd *fds = (struct pollfd*)malloc(sizeof(struct pollfd) * fds_num);
// e.g.
fds[0].fd = STDIN_FILENO; // 要监听的文件描述符
fds[0].events = POLLIN; // 设置请求事件
// 无需设置.revents, 该值由内核维护
// 设置 fds[1..fds_num-1] 文件描述符及请求事件
int timeout = num; // 超时时间, 单位: ms
while (1) {
do {
int n = poll(fds, fds_num, num);
} while(n == -1 && errno == EINTR); // 多设置一层do-while, 是为了信号唤醒后能恢复poll调用
if (n >) { // 监听到有文件描述符就绪, 对所有监听事件逐一检测
if (fds[0].events == fds[0].revents) { // 只有请求事件与返回事件一致时, 才说明是poll监听到的就绪事件
// 处理事件
}
...
if (fds[fds_num - 1].events = fds[fds_num - 1].revents) {
// 处理事件
}
}
}
free(fds);
poll示例:同时监听键盘输入事件和鼠标移动事件
完整代码见 Gitee地址 poll.c | Gitee
// from https://gitee.com/fortunely/linuxstudy/blob/master/advancedio/poll/poll.c
int main() {
int ret;
char buf1[100];
int buf2 = 0;
struct pollfd fds[POLLFD_NUM];
// 具体是哪个mouse, 可以cat /dev/input/mouse? 进行测试
int mousefd = open("/dev/input/mouse1", O_RDONLY);
fds[0].fd = STDIN_FILENO; // 标准输入文件描述符,进程启动时已默认打开
fds[0].events = POLLIN; // 请求事件
fds[1].fd = mousefd;
fds[1].events = POLLIN; // 请求事件
while (1) {
do {
// ret = poll(fds, POLLFD_NUM, -1); // 第三个参数timeout = -1 无限期等待
ret = poll(fds, POLLFD_NUM, 3000); // 超时时间3000ms
}while(ret == -1 && errno == EINTR);
if (ret > 0) { // 有动静的fd数量
if (fds[0].events == fds[0].revents) {// 请求事件与返回的实际事件一致
memset(buf1, 0, sizeof buf1);
ret = read(fds[0].fd, buf1, sizeof buf1);
}
if (fds[1].events == fds[1].revents) {
buf2 = 0;
ret = read(fds[1].fd, &buf2, sizeof buf2); // 注意buf2是一个int变量,而非地址
}
}
else if (ret == 0) printf("time out\n");
}
close(mousefd);
}
epoll系统调用
内核事件表
epoll 是Linux 特有I/O复用函数,实现上和使用上与select、poll有很大差异:
首先,epoll使用一组函数来完成任务,而不是单个函数。
其次,epoll把用户关心的文件描述符上的事件放在内核里的一个事件表中,从而无须像select和poll那样每次调用都要重复传入文件描述符集或事件集。但epoll需要使用一个额外的文件描述符,来唯一标识内核中的这个事件表。
epoll函数组
epoll_create 函数
这个文件描述符,如何创建?
使用如下epoll_create函数创建:
#include <sys/epoll.h>
int epoll_create(int size);
int epoll_create1(int flags);
size:现在不起作用,只是给内核一个提示,告诉它事件表需要多大。该函数返回的文件描述符将用作其他所有epoll系统调用的第一个参数,以指定要访问的内核事件表。
epoll_ctl 函数
epoll_ctl用来操作epoll的内核事件表:
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epfd:由epoll_create创建文件描述符,对应内核中一个epoll监听事件表。
fd:要操作的文件描述符,op参数指定操作类型。操作类型有3种:
- EPOLL_CTL_ADD 往事件表中注册fd上的事件;
- EPOLL_CTL_MOD 修改fd上的注册事件;
- EPOLL_CTL_DEL 删除fd上的注册事件;
event:指定要监听的事件,是epoll_event结构类型指针。epoll_event定义:
struct epoll_event {
__unit32_t events; /* epoll事件类型 */
epoll_data_t data; /* 用户数据 */
};
其中,events成员描述事件类型。epoll支持的事件类型和poll几部相同。表示epoll事件类型的宏是在poll对应的事件类型宏前加“E”,比如epoll的数据可读事件是EPOLLIN。但epoll有2个额外的事件类型:EPOLLET,EPOLLONESHOT。它们对于epoll的高效运作非常关键。data成员用于存储用户数据,其类型epoll_data_t定义:
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
epoll_data_t 是一个联合体,其4个成员使用最多的是fd,它指定事件所从属的目标文件描述符。ptr成员可用来指定与fd相关的用户数据。但由于epoll_data_t是一个联合体,不能同时使用ptr成员和fd成员。
epoll_ctl成功时返回0,失败返回-1并设置errno。
epoll_wait函数
epoll系列系统调用主要接口epoll_wait函数,它在一段超时时间内等待一组文件描述符上的事件,其原型:
#include <sys/epoll.h>
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);
成功时,返回就绪的文件描述符的个数;失败时,返回-1并设置errno。
timeout: 含义与poll接口的timeout参数相同;
maxevents: 指定最多监听多少个事件,必须 > 0;
epoll_wait函数如果检测到事件,就将所有就绪的事件从内核事件表(由epfd参数指定)中复制到它的第二个参数events指向的数组中。该数组只用于输出epoll_wait检测到的就绪事件,而不像select和poll的数组那样,既用于传入用户注册事件,又用于输出内核检测到的就就绪事件。这样,极大地提高了应用程序索引就绪文件描述符的效率。
poll和epoll在使用上的差别:
/* 如何索引poll返回的就绪文件描述符 */
int ret = poll(fds, MAX_EVENT_NUMBER, -1); /* 阻塞等待监听文件描述符对应事件 */
/* 遍历所有已注册文件描述符,找到其中就绪者 */
for (int i = 0; i < MAX_EVENT_NUBMER; ++i) {
if (fds[i].revents & POLLIN) { /* 判断第i个文件描述符是否就绪 */
int sockfd = fds[i].fd;
/* 处理sockfd */
}
}
/* 如何索引epoll返回的就绪文件描述符 */
int ret = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1); /* 阻塞等待注册的文件描述符 */
/* 只需要遍历就绪的ret文件描述符 */
for (int i = 0; i < ret; i++) {
int sockfd = events[i].data.fd;
/* sockfd 肯定就绪, 直接处理 */
}
epoll调用模型:LT和ET模式
epoll对文件描述符操作,有两种模式:LT(Level Trigger,电平触发)模式,ET(Edge Trigger,边沿触发)模式。LT模式是默认的工作模式,该模式下,epoll相当于一个效率较高的poll。当往epoll内核事件表中注册一个文件描述符上的EPOLLET事件时,epoll将以ET模式来操作该文件描述符。
LT模式
采用LT工作模式的文件描述符,当epoll_wait检测到其上有事件发生并将事件通知应用程序后,应用程序可以不立即处理该事件。这样,当应用程序下一次调用epoll_wait时,epoll_wait还会再次向应用程序通告此事件,直到该事件被处理。
ET模式
采用ET模式的文件描述符,当epoll_wait检测到其上有事件发生,并将事件通知应用程序后,应用程序必须立即处理该事件,因为后续的epoll_wait调用将不再向应用程序通知这一事件。
ET模式在很大程度上降低了同一个epoll事件被重复触发的次数,因此ET模式效率比LT模式要高。
LT和ET模式服务器调用例程
同样,只展示部分关键代码。完整代码,详见Gitee地址 epoll.c | Gitee地址
// from https://gitee.com/fortunely/linuxstudy/blob/master/advancedio/epoll/epoll.c
/* 将文件描述符fd上的EPOLLIN注册到epollfd指示的epoll内核事件表中,参数enable_et指定是否对fd启用ET模式 */
void addfd(int epollfd, int fd, bool enable_et)
{
struct epoll_event event;
event.data.fd = fd;
event.events = EPOLLIN;
if (enable_et) {
event.events |= EPOLLET; // 注意: 这里的.events 添加了EPOLLET标识, 表示对该事件启动ET模式
}
epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &event);
setnonblocking(fd);
}
/* LT模式的工作流程 */
void lt(struct epoll_event* events, int number, int epollfd, int listenfd)
{
char buf[BUFFER_SIZE];
for (int i = 0; i < number; i++) {
int sockfd = events[i].data.fd;
if (sockfd == listenfd) { // 监听连接到listenfd事件就绪, 说明有连接请求
// 处理连接就绪事件
...
int connfd = accept(listenfd, (SA *)&client_address, &client_addrlength); // accept新连接
addfd(epollfd, connfd, false); /* 对connfd禁用ET mode */
}
else if (events[i].events & EPOLLN) { // 其他输入事件
/* 只要 socket读缓存中还有未读出的数据,这段代码就被触发 */
memset(buf, '\0', BUFFER_SIZE);
int ret = recv(sockfd, buf, BUFFER_SIZE - 1, 0);
...
}
else {
printf("something else happened \n");
}
}
}
voi et (struct epoll_event *evets, int number, int epollfd, int listenfd)
{
char buf[BUFFER_SIZE]; // 1024
for (int i = 0 ; i < number; i++) {
int sockfd = events[i].data.fd;
if (sockfd == listenfd) {
struct sockaddr_in client_address;
socklen_t client_addrlength = sizeof(client_address);
addfd(epollfd, connfd, true); /* 对connfd开启ET mode */
}
else if(events[i].event & EPOLLIN) {
/* 这段代码不会被重复触发,所以我们循环读取数据,以确保把socket读缓存中的所有数据读出 */
printf("event trigger once\n");
while (1) {
memset(buf, '\0', BUFFER_SIZE, 0);
int ret = recv(sockfd, buf, BUFFER_SIZE - 1, 0);
if (ret < 0) {
/* 对于非阻塞IO,下面的条件成立表示数据已经全部读取完毕。此后,epoll就能再次触发sockfd上的EPOLLIN事件,以驱动下一次读操作 */
if ((errno == EAGAIN) || (errno == EWOULDBLOCK)) {
printf("read later\n");
break;
}
close(sockfd);
break;
}
else if (ret == 0 )
{
close(sockfd);
}
else {
printf("get %d bytes of content: %s\n", ret, buf);
}
}
}
else {
printf("get %d bytes of contet: %s\n", ret, buf);
}
}
}
int main(int argc, char *argv[])
{
...
int ret = 0;
int listenfd = socket(AF_IENT, SOCK_STREAM, 0);
ret = bind(listenfd, (SA *)&address, sizeof(address));
ret = listen(listenfd, 5);
epoll_event events[MAX_EVENT_NUMBER];
int epollfd = epoll_create(5);
addfd(epollfd, listenfd, true);
while (1) {
int ret = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1);
lt(events, ret, epollfd, listenfd); /* use LT mode*/
// et(events, ret, epollfd, listenfd); /* use ET mode */
}
close(listenfd);
return 0;
}
注意:
1)ET模式下,事件被触发的次数要比LT模式下少很多;
2)每个使用ET模式的文件描述符都应该是非阻塞的。如果文件描述符是阻塞的,那么读或写操作将会因为没有后续的事件,而一直处于阻塞状态(饥渴状态);
EPOLLONESHOT事件
即使使用ET模式,一个socket上的某个事件还是可能被触发多次,在这并发程序中会引起一个文件,比如一个线程(或进程)在读取完某个socket上的数据后开始处理这些数据,而在数据的处理过程中该socket上又有新数据可读(EPOLLIN再次被触发),此时另外一个线程被唤醒来读取这些新的数据。于是出现2个线程同时操作一个socket的局面。
----这不是期望的,我们期望的是:一个socket连接在任一时刻,都只会被一个线程处理。 这点可以使用epoll的EPOLLONESHOT事件实现。
注册了EPOLLONESHOT事件的文件描述符,OS最多触发其上注册的一个可读、可写或异常事件,且只触发一次,除非我们使用epoll_ctl函数重置该文件描述符上注册的EPOLLONESHOT事件。这样,当一个线程在处理某个socket时,其他线程不可能有机会操作该socket。反过来,注册了EPOLLONESHOT事件的socket一旦被某个线程处理完毕,该线程就应该立刻重置这个socket上的EPOLLONESHOT事件,以确保这个socket下一次可读时,其EPOLLIN事件就能被触发,进而让其他工作线程有机会继续处理该socket。
PS:epoll只会触发一次EPOLLONESHOT事件,直到重置该事件,接着允许再触发一次。
在fd上注册EPOLLONESHOT事件方式:
/* 将fd上的EPOLLIN和EPOLLET事件注册到epollfd指示的epoll内核事件表中,参数oneshot指定是否注册fd上的EPOLLONESHOT事件 */
void addfd (int epollfd, int fd, bool oneshot)
{
struct epoll_event event;
event.data.fd = fd;
event.events = EPOLLIN | EPOLLET;
if (oneshot)
event.events |= EPOLLONESHOT;
epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &event);
setnonblocking(fd); // 注意: 如果将fd注册为ET模式(EPOLLET事件), 则文件描述符应设为non-blocking
}
/* 将fd设为non-blocking */
int setnonblocking(int fd)
{
int old_option = fcntl(fd, F_GETFL);
int new_option = old_option | O_NONBLOCK;
fcntl(fd, F_SETFL, new_option);
return old_option;
}
重置fd上的EPOLLONESHOT事件方式:
/* 重置fd上的事件。这样操作之后,尽管fd上的EPOLLONESHOT事件被注册,但是OS仍然会触发fd上的EPOLLIN事件,且只触发一次 */
void reset_oneshot(int epollfd, int fd)
{
struct epoll_event event;
event.data.fd = fd;
event.events = EPOLLIN | EPOLLET | EPOLLONESHOT; // 必须注册EPOLLONESHOT事件, 其他事件根据实际情况决定
epoll_ctl(epollfd, EPOLL_CTL_MOD, fd, &event); // epoll_ctl重置fd上注册的EPOLLONESHOT事件
}
EPOLLONESHOT例程
两个线程同时对注册EPOLLONESHOT事件的同一个非阻塞sockfd,进行阻塞操作recv
演示如何使用epoll对fd重置EPOLLONESHOT事件。同样的只展示部分核心代码,完整代码见epoll_EPOLLONESHOT.c
// from https://gitee.com/fortunely/linuxstudy/blob/master/advancedio/epoll/epoll_EPOLLONESHOT.c
struct fds {
int epollfd;
int sockfd;
};
int main()
{
int listenfd = socekt();
bind(listenfd);
listen(listenfd);
struct epoll_event events[MAX_EVENT_NUMBER]; // 1024
int epollfd = epoll_create(5); /* 5没有意义,但必须>0 */
/* 注意:监听socket listenfd 上是不能注册EPOLLONESHOT事件的,否则应用程序只能处理一个客户连接!因为后续的客户连接请求将不再触发listenfd上的EPOLLIN事件 */
addfd(epollfd, listenfd, false);
while (1) {
int ret = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1);
for (int i = 0; i < ret; i++) {
int sockfd = events[i].data.fd;
if (sockfd == listenfd) {
struct sockaddr_in client_address;
socklen_t client_addrlength = sizeof(client_address);
int connfd = accept(listenfd, (SA *)&client_address, &client_addrlength);
/* 对每个非监听文件描述符都注册EPOLLONESHOT事件 */
addfd(epollfd, connfd, true);
}
else if (events[i].events & EPOLLIN ) { /* 普通可读数据就绪 */
pthread_t thread;
struct fds fds_for_new_worker;
fds_for_new_worker.epollfd = epollfd;
fds_for_new_worker.sockfd = sockfd;
/* 新启动一个工作线程为sockfd服务 */
pthread_create(&thread, NULL, worker, (void *)&fds_for_new_worker);
}
else {
printf("something else happend\n");
}
}
}
}
/* 工作线程 */
void *worker(void *arg)
{
int sockfd = ((struct fds *)arg)->sockfd;
int epollfd = ((struct fds *)arg)->epollfd;
printf("start new thread to receive data on fd: %d\n", sockfd);
char buf[BUFFER_SIZE]; // 1024
memset(buf, '\0', BUFFER_SIZE);
/* 循环读取sockfd上的数据,直到遇到EAGAIN错误 */
while (1) {
int ret = recv(sockfd, buf, BUFFER_SIZE - 1, 0);
if (ret == 0) {
close(sockfd);
printf("foreiner closed the connection\n");
break;
}
else if (ret < 0) {
if (errno == EAGAIN) { // 在ET模式下, 对non-blocking fd调用阻塞操作recv, 该操作可能没有完成就返回, 从而产生该错误码, 但不会破坏socket
reset_oneshot(epollfd, sockfd); // 重置sockfd上注册的EPOLLONESHOT事件以及其他事件
printf("read later\n");
break;
}
}
else {
printf("get content: %s\n", buf);
/* 休眠5秒,模拟数据处理过程 */
sleep(5);
}
}
printf("end thread receiving data on fd: %d\n", sockfd);
}
select/poll/epoll 比较
select、poll、epoll三组I/O复用系统调用,共同点:
1)都能同时监听多个文件描述符。
2)都有超时等待功能,由timeout参数指定超时时间,或者到一个或多个文件描述符上有监听事件发生时返回。
3)返回值是就绪的文件描述符的数量。返回0表示没有事件发生。
那么它们有什么区别?在什么情况下选用哪种系统调用呢?
下面从事件集、最大支持文件描述符数、工作模式和编程模型,等4个方面进一步比较它们的异同,以明确在实际应用中应该选择使用哪个或哪些。
1)事件集
select通过fd_set告诉内核监听哪些文件描述符,不过fd_set并没有将文件描述符和事件绑定,因此需要提供3个这种类型的参数(readfds/writefds/exceptionfds),分别传入要监听的可读、可写、异常事件集。正因为这样,select不能处理更多类型的事件,另一方面由于内核对fd_set集合的在线修改,应用程序下次调用select前不得不重置这3个fd_set,因此每次select调用前,都要重新设置这3个fd_set并拷贝到内核。
poll的事件集pollfd更“聪明”一些。它把文件描述符和事件都定义绑定到一起,任何事件都被统一处理,从而使得编程接口更简洁。并且,内核每次修改的是pollfd结构体的revents成员(return events),而events不变(request events),因此下次调用poll时应用程序无需重置pollfd类型的事件集参数。
由于每次select和poll调用都返回整个用户注册的事件集合(就绪的,和未就绪的),所以应用程序索引就绪文件描述符的时间复杂度O(n)。
epoll采用与select和poll完全不同的方式来管理用户注册的事件:它在内核中维护一个事件表,并提供一个独立的系统调用epoll_ctl,来控制往其中添加、删除、修改事件。这样,每次epoll_wait调用都直接从该内核事件表中取得用户注册的事件,而无需反复从用户控件读入这些事件。epoll_wait系统调用的events参数仅仅用了返回就绪的事件,应用程序索引就绪文件描述符的复杂度O(1)。
PS:epoll_wait 传出的事件集,就是就绪事件集。
2)最大支持文件描述符数
poll和epoll_wait分别用nfds和maxevents参数指定最多监听多少个文件描述符和事件,这2个数制都能达到系统允许打开的最大文件描述符数目,即65535($ cat /proc/sys/fs/file-max
查看);select允许监听的文件描述符的最大数量有限制(通常是1024),虽然用户可以修改该限制,但可能导致不可预期的后果。
3)工作模式
select和poll都只能工作在相对低效的LT模式,而epoll可以工作在ET高效模式。而且epoll还支持EPOLLONESHOT事件,能进一步减少可读、可写和异常等事件被触发的次数。
4)编程模型
select和poll采用的都是轮询的方式,即每次调用都扫描整个注册文件描述符集合,并将其中就绪的文件描述符返回给用户选择,检测就绪事件算法时间复杂度O(n)。
epoll采用回调的方式,内核检测到就绪的文件描述符时,将触发回调函数,回调函数就将该文件描述符上对应的事件插入内核就绪事件队列。内核最后在适当的时机将该就绪事件队列中的内容拷贝到用户控件。因此,epoll_wait无需轮询整个文件描述符集合来检测哪些事件已经就绪,算法时间复杂度O(1)。
当活动连接比较多的时候,epoll_wait效率未必比select和poll高,因为此时回调函数被触发得过于频繁。因此,epoll_wait适用于连接数较多,但活动连接较少的情况。
活动连接多时,会发生频繁回调,占用大量CPU开销。
连接数多时,select、poll每次轮询不得不遍历所有注册事件的集合,浪费大量CPU时间。
总结
系统调用 | select | poll | epoll |
---|---|---|---|
事件集合 | 用户通过3个参数分别传入感兴趣的可读、可写及异常等事件, 内核通过对这些参数的在线修改来返回其中的就绪事件。 这使得每次调用select都要重置这3个集合参数 |
统一处理所有事件类型,因此只需要一个事件集参数。 用户通过pollfd,events传入感兴趣的事件, 内核通过修改pollfd,revents反馈其中就绪的事件 |
内核通过一个事件表直接管理用户感兴趣的所有事件。 因此每次调用epoll_wait时,无须反复传入用户感兴趣的事件。 epoll_wait系统调用的参数events仅用来反馈就绪的事件 |
应用程序索引就绪文件描述符的时间复杂度 | O(n) | O(n) | O(1) |
最大支持文件描述符数 | 一般有最大值限制FD_SETSIZE | 系统支持的最大文件描述符数(如65535), 也就是说不受限于FD_SETSIZE |
系统支持的最大文件描述符数(如65535), 不受限于FD_SETSIZE |
工作模式 | LT | LT | LT和ET |
内核实现和工作效率 | 采用轮询方式检测就绪事件 | 采用轮询方式来检测就绪事件 | 采用回调方式检测就绪事件 |
另附一张从网上找到的一个简单汇总图(具体出处已忘记):