I/O复用

简介

I/O多路复用(I/O multiplexing)是一种高效的I/O处理技术,它允许单个线程或进程同时监视多个文件描述符的状态,以确定哪些文件描述符已准备好进行I/O操作。

处理场景

  • 客户端程序要同时处理多个socket,非阻塞的connect。
  • 客户端程序要同时处理用户输入和网络连接。比如本章将要讨 论的聊天室程序。
  • TCP服务器要同时处理监听socket和连接socket。
  • 服务器要同时处理TCP请求和UDP请求。比如本章将要讨论的 回射服务器。
  • 服务器要同时监听多个端口,或者处理多种服务。

IO多路复用技术是为了解决高并发场景下的资源利用问题。IO多路复用通过内核级别的机制,如selectpollepoll等,能够在一个线程或进程中同时监控多个IO通道的状态,仅在有事件发生时才进行相应的IO操作。

I/O复用是阻塞I/O

I/O复用虽然能同时监听多个文件描述符,但它本身是阻塞的。并且当多个文件描述符同时就绪时,如果不采取额外的措施,程序就只能按顺序依次处理其中的每一个文件描述符,这使得服务器程序看起来像是串行工作的。如果要实现并发,只能使用多进程或多线程等编程手段。

select

简介

select 通过在用户态和内核态之间拷贝文件描述符集合来实现监视功能,这种设计使得它能够检测哪些文件描述符已经就绪可以进行IO操作。

API

1.select

1
2
int select(int nfds, fd_set *readfds,fd_set *writefds,
fd_set *exceptfds,struct timeval *timeout);

功能:监听文件描述符集合内的fd。

参数

  • nfds, Linux 下 socket 也称 fd,这个参数的值设置成所有需要使用 select 函数检测事件的描述符中的最大 fd 值加 1
  • readfds,需要监听可读事件的 fd 集合
  • writefds,需要监听可写事件的 fd 集合
  • exceptfds,需要监听异常事件 fd 集合
1
2
3
4
5
6
typedef struct
{
__fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];
//等价于long int fds_bits[16]
# define __FDS_BITS(set) ((set)->fds_bits)
} fd_set;

文件描述符集合的实现实际上就是采用的位图,这是一种算法思想,在《编程珠玑》一书中有所提及,感兴趣的读者也可上网搜索。

__fds_bitslong int 型数组,long int 占 8 个字节,每个字节 8 bit,每个 bit 对应一个 fd 的事件状态,0 表示无事件,1 表示有事件,数组长度是 16,因此一共可以表示 8 * 8 * 16 = 1024 个 fd 的状态,可以将其视作长度为1024的bit数组1024select 函数支持的最大 fd 数量。

  • timeout,超时时间,即在这个参数设定的时间内检测这些 fd 的事件,超过这个时间后 select 函数将立即返回。
1
2
3
4
5
6

struct timeval
{
long tv_sec; /* seconds */
long tv_usec; /* microseconds */
};

select 函数的总超时时间是 timeout->tv_sectimeout->tv_usec 之和, 前者的时间单位是秒,后者的时间单位是微秒。

返回值:将所有的文件描述符集合清空

  • 成功:返回就绪描述符总数,如果在超时时间内没有任何文件描述符就绪,select 将返回0。
  • 失败:返回-1,并设置errnoEINTR

select IO复用中主要使用四个宏操作来完成,如下图所示。

select

2.FD_SET

1
void FD_SET(int fd, fd_set *set);

功能:将一个 fd 添加到 fd_set 这个集合中。

参数

  • fd:需要添加的文件描述符
  • set:文件描述符集合

返回值:无

原理实现

1
2
3
4
#define	__FD_ELT(d)	((d) / __NFDBITS)
#define __FD_MASK(d) ((__fd_mask) 1 << ((d) % __NFDBITS))
#define __FD_SET(d, set) \
((void) (__FDS_BITS (set)[__FD_ELT (d)] |= __FD_MASK (d)))

3.FD_CLR

1
void FD_CLR(int fd, fd_set *set);

功能:从文件描述符集合中移除指定文件描述符,从 fd_set 上删除一个 fd,即对应的 bit 位置 0。

参数

  • fd:需要添加的文件描述符
  • set:文件描述符集合

原理实现

1
2
#define __FD_CLR(d, set) \
((void) (__FDS_BITS (set)[__FD_ELT (d)] &= ~__FD_MASK (d)))

4.FD_ZERO

1
void FD_ZERO(fd_set *set);

功能:清空文件描述符集合,fd_set 中所有的 fd 都清掉,即将所有 bit 位置 0。

参数

  • fd:需要添加的文件描述符
  • set:文件描述符集合

返回值:无

5.FD_ISSET

1
2
3
int  FD_ISSET(int fd, fd_set *set);
#define __FD_ISSET(d, set) \
((__FDS_BITS (set)[__FD_ELT (d)] & __FD_MASK (d)) != 0)

功能:检测对应的 bit 上是否置位,判断指定文件描述符是否可读。

参数

  • fd:需要添加的文件描述符
  • set:文件描述符集合

返回值:无

原理实现

1
2
#define __FD_ISSET(d, set) \
((__FDS_BITS (set)[__FD_ELT (d)] & __FD_MASK (d)) != 0)

实例

1
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/select.h>
#include <unistd.h>
#include <netdb.h> // for getnameinfo
#include <fcntl.h>
#include <netinet/tcp.h> //for marco TCP_NODELAY

#include <arpa/inet.h> //for inet_addr
#include <sys/socket.h>

#define SERVERPORT 6666
#define MAX_CLIENTS 10

const int MAXLINE = 100;
const int BUF_LEN = 100;

/*简单的echo实现*/
int echo(int clientfd){
char buf[BUF_LEN];
ssize_t recv_len = 0;
/*接收指定Client Socket发出的数据*/
memset(buf,0,sizeof(buf));
if((recv_len = recv(clientfd,buf,BUF_LEN,0))<= 0)
return recv_len;

printf("Recevice data from client[%d]: %s",clientfd, buf);
send(clientfd,buf,recv_len,0);
return recv_len;
}

int createTCPServer(int port){
/*配置Server Sock信息*/
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;

//INADDR_ANY = 0.0.0.0监听本机所有地址
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(port);

/*创建Server Socket*/
int srvfd = 0;
if((srvfd = socket(AF_INET,SOCK_STREAM,0))== -1){
printf("Create socket file descriptor ERROR\n");
exit(-1);
}

int yes = 1;
//SO_REUSEADDR防止TIME_WAIT问题
setsockopt(srvfd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));// Best effort.

if(bind(srvfd,
(struct sockaddr*)&server_addr,
sizeof(server_addr))==-1)
{
printf("Bind socket ERROR.\n");
exit(-1);
}

if(listen(srvfd,10) == -1){
printf("Listen socket ERROR.\n");
exit(-1);
}
return srvfd;
}

int acceptClient(int listenfd){
/*初始化Client socket 信息*/
struct sockaddr_storage client_addr;
bzero(&client_addr, sizeof(client_addr));
socklen_t clientlen = sizeof(client_addr);
int connfd = accept(listenfd,(struct sockaddr*)(&client_addr),&clientlen) ;
char client_hostname[MAXLINE] , client_port[MAXLINE];
if(connfd == -1)
{
printf("Accept connection from client ERROR.\n");
return -1;
}
socketSetNonBlockNoDelay(connfd); // Pretend this will not fail.
getnameinfo((struct sockaddr*)&client_addr,clientlen,client_hostname,MAXLINE,
client_port,MAXLINE,0);
printf("Connected to (%s %s)\n",client_hostname,client_port);

return connfd;
}


int main(int argc, char ** argv){
int listenfd = createTCPServer(SERVERPORT);
fprintf(stdout,"open listening fd = %d\n",listenfd);
socketSetNonBlockNoDelay(listenfd);
int clientfds[MAX_CLIENTS] = {0};
int max_clients = 0;
int max_fd = 0;

fd_set read_fds;
FD_ZERO(&read_fds);
// 添加监听描述符
FD_SET(listenfd, &read_fds);
// 添加标准输入描述符
FD_SET(STDIN_FILENO, &read_fds);
while(1){
int retval = 0;

struct timeval tv;
tv.tv_sec = 1;
tv.tv_usec = 0;

fd_set ready_fds = read_fds;

max_fd = max_clients;
if(max_fd < listenfd) max_fd = listenfd;

retval = select(max_fd+1, &ready_fds, 0, 0, &tv);
if (retval == -1) {
perror("select() error");
exit(1);
} else if (retval) {
/*监听描述符,添加连接描述符*/
if(FD_ISSET(listenfd, &ready_fds)){
int clientfd = acceptClient(listenfd);
if(clientfd < 0)
exit(-1);
FD_SET(clientfd,&read_fds); //添加可读文件描述符
clientfds[clientfd] = clientfd;
if(clientfd > max_clients)
max_clients = clientfd;
}

for(int i = 0 ;i <= max_clients;i++){
if(clientfds[i] == 0)
continue;

if(FD_ISSET(i, &ready_fds)){
if(echo(clientfds[i]) == -1){
printf("client(fd=%d) disconnect\n", clientfds[i]);
FD_CLR( clientfds[i], &read_fds);
close( clientfds[i]);
clientfds[i] = 0;
}

}
}
if(FD_ISSET(STDIN_FILENO, &ready_fds)){
char std_buf[BUF_LEN];
memset(std_buf, 0, BUF_LEN);
fgets(std_buf,BUF_LEN,stdin);
fprintf(stdout,"stdout> %s\n",std_buf);
}
}
}
/*关闭监听描述符*/
close(listenfd);
return 0;
}

select的特点

优点

  1. 可扩展性:select可以同时监视多个文件描述符(通常是套接字),这使得程序能够同时处理多个连接,提高了系统的吞吐量和效率。
  2. 非阻塞性:select(本身是阻塞的)允许程序在等待I/O操作完成时继续执行其他任务,而不是被阻塞。这意味着程序可以在等待数据到达时处理其他请求或执行其他任务,提高了系统的并发性能。

缺点

  1. 文件描述符限制:select默认的最大文件描述符数为1024,这在处理大规模并发连接时有所限制。当出现fd = 1500这样的描述符时,会出现错误。
  2. 性能开销:每次调用select都需要将文件描述符集合从用户态拷贝到内核态,并且需要在内核中遍历所有文件描述符,这在文件描述符数量较多时会带来很大的性能开销。
  3. 使用不便:在每次调用之后,就绪的文件描述符需要从集合中移除,这意味着每次循环都需要重新设置这些集合。
  4. 轮询效率低:select通过不断轮询来检测就绪的文件描述符,这种方式在文件描述符数量较多且大多数未就绪时效率较低。

poll

简介

select有一个致命的缺陷就是文件描述符大小受限,其默认最大值为1024,因此出现了 poll 技术用于突破限制。

poll 通过维护一个文件描述符集合来检查各个文件描述符的状态,从而有效管理多个IO流。这种机制允许单个线程或进程同时处理多个IO操作,提高了系统的性能和资源利用率。

poll 使用一个结构体数组来作为监听集合,这个数组中的每一个元素都记录了一个文件描述符及其对应的事件。

1
2
3
4
5
struct pollfd {
int fd; /* 待检测事件的 fd */
short events; /* 关心的事件组合 */
short revents; /* 检测后的得到的事件类型 return events*/
};

当调用poll函数时,系统会遍历这个数组,检查每个文件描述符是否有相应的事件发生。如果有,poll函数将返回就绪的文件描述符数量及具体哪些描述符就绪;如果没有,系统将根据设定的超时时间决定是继续等待还是返回超时信息。

poll事件

事件宏事件描述是否可以作为输入(events)是否可以作为输出(revents)
POLLIN数据可读(包括普通数据&优先数据)
POLLOUT数据可写(普通数据&优先数据)
POLLRDNORM等同于 POLLIN
POLLRDBAND优先级带数据可读(一般用于 Linux 系统)
POLLPRI高优先级数据可读,例如 TCP 带外数据
POLLWRNORM等同于 POLLOUT
POLLWRBAND优先级带数据可写
POLLRDHUPTCP连接被对端关闭,或者关闭了写操作,由 GNU 引入
POPPHUP挂起
POLLERR错误
POLLNVAL文件描述符没有打开

API

1
2
#include <poll.h>
int poll(struct pollfd* fds, nfds_t nfds, int timeout);

功能:创建pollfd数组,其用于管理查找socket。

参数

  • fds:指向一个结构体数组的首个元素的指针,每个数组元素都是一个 struct pollfd 结构,用于指定检测某个给定的 fd 的条件。
  • nfds:参数 fds 结构体数组的长度,nfds_t本质是unsigned long int
  • timeout:表示 poll 函数的超时时间,单位为毫秒

返回值

  • 成功:返回就绪描述符总数,如果在超时时间内没有任何文件描述符就绪,poll 将返回0。
  • 失败:返回-1,并设置errnoEINTR

实例

1
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/poll.h>
#include <unistd.h>
#include <netdb.h> // for getnameinfo
#include <fcntl.h>
#include <netinet/tcp.h> //for marco TCP_NODELAY

#include <arpa/inet.h> //for inet_addr


#define SERVERPORT 6666
#define MAX_CLIENTS 16

....//函数如省略函数如select部分

int main(int argc, char ** argv){
int listenfd = createTCPServer(SERVERPORT);
fprintf(stdout,"open listening fd = %d\n",listenfd);

struct pollfd client[MAX_CLIENTS];

client[0].fd = listenfd;
client[0].events = POLLRDNORM;
for (int i = 1; i < MAX_CLIENTS; i++)
client[i].fd = -1;

int maxfd = 0;
while(1){
int nready = poll(client, maxfd+1, 1000);
int i;
if (client[0].revents & POLLRDNORM) { /* new client connection */
int connfd = acceptClient(listenfd);

for (i = 1; i < MAX_CLIENTS; i++){
if (client[i].fd < 0) {
client[i].fd = connfd; /* save descriptor */
break;
}
}
if (i == MAX_CLIENTS)
exit(-1);

client[i].events = POLLRDNORM;
if (i > maxfd)
maxfd = i; /* max index in client[] array */

if (--nready <= 0)
continue; /* no more readable descriptors */
}
for (i = 1; i <= maxfd; i++) { /* check all clients for data */
int clientfd;
if ( (clientfd = client[i].fd) < 0)
continue;
if (client[i].revents & (POLLRDNORM | POLLERR)) {
if(echo(clientfd) <= 0){
printf("client(fd=%d) disconnect\n", client[i].fd );
close( client[i].fd );
client[i].fd = 0;
}
if (--nready <= 0)
break; /* no more readable descriptors */
}
}
}
/*关闭监听描述符*/
close(listenfd);
return 0;
}

poll的特点

优点

  • 相比于 selectpoll 在处理大数目的文件描述符的时候速度更快;
  • poll 没有最大连接数的限制,原因是它是基于链表来存储的;
  • 在调用 poll 函数时,只需要对参数进行一次设置就好了。

缺点

  • 在调用 poll 函数时,不管有没有有意义,大量的 fd 的数组被整体在用户态和内核地址空间之间复制;
  • select 函数一样,poll 函数返回后,需要遍历 fd 集合来获取就绪的 fd,这样会使性能下降;
  • 同时连接的大量客户端在一时刻可能只有很少的就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。
  • 时间精度上没有 select 精确。

epoll

简介

epoll是Linux内核为处理大批量文件描述符而作了改进的poll,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率。

epoll理解为event poll,是一种事件驱动的I/O模型,可以用来代替select和poll模型。

epoll的优势在于它可以同时处理大量的文件描述符,而且不会随着文件描述符数量的增加而降低效率。

API

epoll主要使用三个api分别为:

1.epoll_create

1
2
#include <sys/epoll.h>
int epoll_create(int size);

功能:创建epoll文件

参数

  • size:从 Linux 2.6.8 开始,参数 size 被自动忽略,但是该值仍需要一个大于 0 的整数。

返回值

  • 成功:返回epoll文件描述符
  • 失败:返回-1,并设置errno

2.epoll_ctrl

1
2
3
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd,
struct epoll_event *event);

功能:增加,删除,修改epoll事件,epoll事件会存储于内核epoll结构体红黑树中。

参数

  • epdf:epoll文件描述符
  • op:操作码,表示监听描述符控制的动作。
    • EPOLL_CTL_ADD:插入事件
    • EPOLL_CTL_DEL:删除事件
    • EPOLL_CTL_MOD:修改事件
  • fd:需要监听的文件描述符
  • event:告诉内核需要监听的事件,struct epoll_event为epoll事件结构体。

返回值

  • 成功:返回0。

  • 失败:返回-1,并设置errno

epoll_event结构体

1
2
3
4
5
6
7
8
9
10
11
struct epoll_event {
__uint32_t events; /* epoll 事件 */
epoll_data_t data; /* 用户传递的数据 */
}

typedef union epoll_data {
void *ptr;
int fd;//文件描述符
uint32_t u32;
uint64_t u64;
} epoll_data_t;

epoll事件列表EPOLL_EVENTS

1
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
32
enum EPOLL_EVENTS
{
EPOLLIN = 0x001, //socket可读。

EPOLLPRI = 0x002, //socket有紧急数据。

EPOLLOUT = 0x004, //socket可写。

EPOLLRDNORM = 0x040,

EPOLLRDBAND = 0x080,

EPOLLWRNORM = 0x100,

EPOLLWRBAND = 0x200,

EPOLLMSG = 0x400,

EPOLLERR = 0x008, //socket文件出错。

EPOLLHUP = 0x010, //socket文件被挂起。

EPOLLRDHUP = 0x2000, //socket文件被关闭或者关闭读端。

EPOLLEXCLUSIVE = 1u << 28,

EPOLLWAKEUP = 1u << 29,

EPOLLONESHOT = 1u << 30, //单次模式,执行完epoll_wait后需重新调用epoll_ctl注册事件。

EPOLLET = 1u << 31 //边缘触发,默认为水平触发。
};

3.epoll_wait

1
2
int epoll_wait(int epfd, struct epoll_event *event, 
int maxevents, int timeout);

功能:监听epoll事件

参数

  • epfd:epoll文件描述符
  • events:epoll事件数组
  • maxevents:epoll事件数组长度
  • timeout:超时时间
    • 小于0:一直等待
    • 等于0:立即返回
    • 大于0:等待超时时间返回,单位毫秒

返回值

  • 成功:返回就绪事件个数,返回0表示超时还没有事件就绪
  • 失败:返回-1,并设置errno

epoll实现原理

epoll_linux

相关数据结构

  • socket等待队列(sk_wq):epoll_ctl添加socket后,其socket的sk_wq中会存储一个epoll等待项,并注册回调函数,当数据到达时,中断程序会调用回调函数。
    • 每一个socket都有一个sk_wq队列,这个队列中会存储epoll、poll、select等队列等待项
  • eventpoll等待队列(wq):用于阻塞当前进程,用于epoll_wait未检测到就绪epoll事件节点的情况。
    • 阻塞进程:调用epoll_wait时,rdllist没有数据,创建一个等待项并将其添加到wq中并阻塞当前进程。
    • 唤醒进程:软中断数据就绪的时候会通过wq来找到阻塞在 epoll 对象上的用户进程,并将其唤醒。
  • 就绪队列(rdllist):就绪队列用于存储就绪epoll事件节点,用户通过epoll_wait函数获取就绪epoll事件节点。当有的连接就绪的时候,内核会把就绪的连接放到 rdllist 链表里。
    • 优点:应用进程只需要判断链表就能找出就绪连接,而不用去遍历整颗红黑树树。
  • 红黑树(rbr) :eventpoll 内部使用了一棵红黑树用于存储通过epoll_ctl函数注册的epoll事件节点(epitem),可以处理海量连接的高效修改、插入和删除。
    • 注意:红黑树只用于管理节点,它并不参与数据接收的过程,相对与selectpoll轮询所有的描述符集合更高效。

epoll_create创建eventpoll对象

当调用epoll_create时候,内核会创建一个 struct eventpoll 的内核对象。并同样把它关联到当前进程的已打开文件列表中。

1
2
3
4
5
6
7
8
9
10
11
12
13
// file:fs/eventpoll.c
struct eventpoll {

//sys_epoll_wait用到的等待队列
wait_queue_head_t wq;

//接收就绪的描述符都会放到这里
struct list_head rdllist;

//每个epoll对象中都有一颗红黑树
struct rb_root rbr;
...
}

epoll_ctl 添加 socket

只考虑 EPOLL_CTL_ADD 添加 socket,不考虑删除与修改的情况。

假设我们现在和客户端们的多个连接的 socket 都创建好了,也创建好了 epoll 内核对象。在使用 epoll_ctl 注册每一个 socket 的时候,内核会做如下三件事情:

  1. 分配一个红黑树节点对象 epitem

  2. 添加等待事件到 socket等待队列中,注册回调函数为 ep_poll_callback

  3. epitem 插入到 epoll 对象的红黑树里

epoll_wait等待接收

epoll_wait 被调⽤时它观察 eventpoll->rdllist 链表⾥有没有数据即可。有数据就返回,没有数据就创建⼀个等待队列项,将其添加到 eventpoll 的等待队列上,然后把⾃⼰阻塞掉就完事。流程如下:

  1. 判断就绪队列上有没有事件就绪,有则返回epoll事件,反之执行步骤2。
  2. 定义等待事件并关联当前进程(current),添加到等待队列(eventpoll->wq)
  3. 让出CPU 主动进⼊睡眠状态

epoll_wait

数据到达过程

  1. 查找就绪回调函数,当 socket 上数据就绪时候,找到 epoll_ctl 添加 socket 时在其上设置的回调函数 ep_poll_callback
  2. 执⾏ socket 就绪回调函数,软中断调用回调函数,ep_poll_callback 根据等待任务队列项上的额外的 base 指针可以找到 epitem, 进⽽也可以找到 eventpoll对象。
    • 把⾃⼰的 epitem 添加到 epoll 的就绪队列中。
    • 查看 eventpoll 对象上的等待队列⾥是否有等待项,如果有等待项,那就查找到等待项⾥设置的回调函数,并调用。
  3. 执⾏ epoll 就绪通知,在default_wake_function 中找到等待队列项⾥的进程描述符,然后唤醒。当进程醒来后,继续从 epoll_wait 时暂停的代码继续执⾏。把 rdllist 中就绪的事件返回给⽤户进程。

总结

  • ⽤户进程内核态。进⾏调⽤ epoll_wait 等函数时会将进程陷⼊内核态来执⾏。这部分 代码负责查看接收队列,以及负责把当前进程阻塞掉,让出 CPU。
  • 硬软中断上下⽂。在这些组件中,将包从⽹卡接收过来进⾏处理,然后放到 socket 的 接收队列。对于 epoll 来说,再找到 socket 关联的 epitem,并把它添加到 epoll 对象 的就绪链表中。 这个时候再捎带检查⼀下 epoll 上是否有被阻塞的进程,如果有唤醒 之

epoll常见问题

1.LT模式和ET模式区别

epoll对文件描述符的操作有两种模式:LT(Level Trigger,水平触发)模式和ET(Edge Trigger,边缘触发)模式。

LT模式是默认的工作模式,如果用户在监听epoll事件,当内核有事件的时候,会拷贝给用户态事件,但是如果用户只处理了一次,那么剩下没有处理的会在下一次epoll_wait再次返回该事件。

ET模式,当内核有事件到达, 只会通知用户一次,至于用户处理还是不处理,以后将不会再通知。这样减少了拷贝过程,增加了性能。则只会在缓冲区满足特定情况下才会触发epoll_wait获取epoll事件

LT模式只不过比ET模式多执行了一个步骤,就是当epoll_wait获取完就绪队列epoll事件后,LT模式会再次将epoll事件添加到就绪队列。

LT与ET模式的优缺点

模式优点缺点
LT通过频繁触发保证数据的完整性频繁的用户态和内核态切换消耗大量的系统资源
ET只触发一次,减少用户态与内核态切换,减少资源消耗一次触发无法保证读取的完整的数据

2.epoll为何高效

  • eventpoll等待队列机制,当就绪队列没有epoll事件时主动让出CPU,阻塞进程,提高CPU利用率。
  • socket等待队列机制,只有接收到数据时才会将epoll事件插入就绪队列,唤醒进程获取epoll事件。
  • 红黑树提高epoll事件增加,删除,修改效率。
  • 任务越多,进程出让CPU概率越小,进程工作效率越高,所以epoll非常适合高并发场景。

3.epoll是阻塞的吗?

当没有 IO 事件的时候,也就是当epoll_wait未检测到epoll事件时, epoll 也是会阻塞掉当前进程。这个是合理的,因为没有事情可做了占着 CPU 也没啥意义。epoll 本身是阻塞的,但⼀般会把 socket 设置成⾮阻塞。

4.socket为什么要设置为非阻塞?

epoll机制属于IO多路复用机制,这种机制的特点是一个进程处理多路IO请求,如果socket设置成阻塞模式会存在以下几个问题:

  • 一个进程同一时间只能处理一个socket数据,如果socket被阻塞,那么该进程无法处理其他的socket数据,严重影响了性能。
  • 阻塞的本质是进程状态和上下文的切换,频繁的阻塞会把让CPU一直处于上下文切换的状态,导致CPU瞎忙。

实例

1
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/epoll.h>
#include <unistd.h>

#include <arpa/inet.h> //for inet_addr


#define SERVERPORT 6666
#define MAX_EVENTS 128
#define MAX_CLIENTS 16

....//函数如省略函数如select部分

int main(int argc, char ** argv){
int listenfd = createTCPServer(SERVERPORT);
fprintf(stdout,"open listening fd = %d\n",listenfd);

int epfd = epoll_create(1024);
struct epoll_event event,*events;
event.events = EPOLLET | EPOLLIN;
event.data.fd = listenfd;
if(epoll_ctl(epfd,EPOLL_CTL_ADD,listenfd,&event) == -1){
fprintf(stderr, "epoll_ctl add listen fd failed");
exit(1);
}

events = calloc(MAX_EVENTS, sizeof(event));
while(1){
int nready = epoll_wait(epfd,events,MAX_EVENTS,1000);

for(int i = 0 ;i < nready ;i++){
if ((events[i].events & EPOLLERR) ||
(events[i].events & EPOLLHUP) ||
(!(events[i].events & EPOLLIN))) {
fprintf(stderr, "epoll error\n");
if(epoll_ctl(epfd,EPOLL_CTL_DEL,listenfd,&event) == -1){
fprintf(stderr, "epoll_ctl del connect fd failed");
exit(1);
}
close(events[i].data.fd);
continue;
} else if (events[i].data.fd == listenfd){
int connfd = acceptClient(listenfd);
event.data.fd = connfd;
event.events = EPOLLIN | EPOLLET;
if(epoll_ctl(epfd,EPOLL_CTL_ADD,connfd,&event) == -1){
fprintf(stderr, "epoll_ctl add connect fd failed");
exit(1);
}
} else {
int clientfd = events[i].data.fd;
if(echo(clientfd) <= 0){
printf("client(fd=%d) disconnect\n", clientfd);
if(epoll_ctl(epfd,EPOLL_CTL_DEL,clientfd,NULL) == -1){
fprintf(stderr, "epoll_ctl clientfd del fd failed");
}
close( clientfd );
}
}
}
}
free(events);
/*关闭监听描述符*/
close(listenfd);
return 0;
}

总结

三种IO复用函数的比较

系统调用最大支持文件描述符数工作模式内核实现与工作效率
select1024LT采用轮询方式来检测就事件,算法时间复杂度为0(n)
pollcat /proc/sys/fs/file-maxLT采用轮询方式来检测就绪,算法时间复杂度为绪事件,为O(n)
epollcat /proc/sys/fs/file-max支持ET采用回调方式来检测就绪事件,算法时间复杂度0(1)

三个函数的事件集合区别

  • select:用户通过3个参数分别传入感兴趣的可读、可写及异常等事件,内核通过对这些参数的在线修改来反馈其中的就绪事件。这使得用户每次调用select都要重置这3个参数。

  • poll:统一处理所有事件类型,因此只需一个事件集参数。
    用户通过pollfd.events传入感兴趣的事件,内核通过修改pollfd.revents反馈其中就绪的事件。

  • epoll:内核通过一个事件表直接管理用户感兴趣的所有

    事件。因此每次调用epoll_wait时,无须反复传入用户感兴趣的事件。epoll_wait系统调用的参数events仅用来反馈就绪的事件

参考

  1. unix网络编程
  2. linux高性能服务器编程
  3. 13. Linux poll 详解
  4. Libevent 深入浅出
  5. 一篇文章让你真正搞懂epoll机制 (qq.com)
  6. 图解 | 深入揭秘 epoll 是如何实现 IO 多路复用的