从零到负一

Socket编程基础4 - IO多路复用函数总结

2020/04/29

这篇文章主要是承接上一篇 - 《Socket编程基础3 - 之I/O多路复用》未完结的内容。上一篇文章主要讲了select()以及多路复用的概念,这一篇文章将详细讲解poll()epoll()函数以及这3个函数的对比。本篇内容主要来自网络以及《Linux高性能服务器编程》。

poll()函数

select()函数有一个问题,就是FD_SETSIZE的大小有限,一般是1024。如果我们想要连接更多的socket,那么就需要修改内核(简单调整FD_SETSIZE是不管用的),很容易造成意想不到的问题。poll()函数解决了这个问题,poll()函数支持65535个socket连接,可以满足大部分的需求。

函数API

poll()函数的原型如下:

1
int poll(struct pollfd *fds, nfds_t nfds, int timeout); 

函数成功返回正数,正数表示有多少个socket描述符就绪;fdsstruct pollfd类型的指针,fds就是我们需要关注的socket描述符,我们先来看看这个结构体是什么样的:

1
2
3
4
5
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};

events是我们请求的事件,revents是根据请求返回的事件。请求事件就是我们需要观察是否发生的事件,如果发生了,那么返回事件将不再是0。图1是eventsrevents的汇总:

图1. events和revents的汇总

对于读,我们一般使用POLLIN或者POLLRDNORM;对于写,我们一般用POOLOUTPOLLWRNORM。哪些操作返回读事件哪些返回写事件?

  1. TCP/UDP所有数据都是普通数据;
  2. 读半部分关闭(比如收到对端的FIN),是普通数据;- Linux 2.6.17后,可以使用POLLRDHUP来接收对方断开连接或者对方写半部分关闭。
  3. TCP连接错误,可以是普通数据也可以是POLLERR
  4. 有新连接一般都视为普通数据;
  5. 非阻塞connect完成被认为socket可写。

nfds用于告诉内核我们有多少个fdstimeout指明多久没有响应就超时,设置-1是一直等待,设置0是立刻返回。

poll()函数的优缺点

poll()其实和select()类似,因此这里说的优点是相对于select()的优点,而缺点是相对于epoll()的。

优点:

  1. 前面说过,和select()相比,支持更多的socket描述符;
  2. 请求和返回的事件是相互独立的,不需要自己做额外的操作区分这两者;

缺点:

  1. select()一样,需要将关注的所有socket描述符传给内核,然后内核处理好后再传回来,耗时;
  2. select()一样,都需要遍历所有的socket描述符才知道哪些描述符就绪;对于少量活跃socket的情况,时间复杂度依然是O(n);

epoll()函数

epoll()函数相对于select()poll()而言,做出了重大的改进。epoll()不仅可以支持无上限的socket描述符(实际数量和内存大小有关),而且仅需要O(1)的时间就可以找到就绪的socket描述符。因此在很多情况下,epoll()的效率都是远远高于select()poll()的。

函数API

首先我们来看看如何创建epoll对象:

1
int epoll_create(int size);

函数的参数现在已经没有意义的,为了和旧版本兼容,我们只需要输入一个正数即可。这个函数返回一个文件描述符,该文件描述符就是epoll对象。
有了epoll对象,我们就需要对该对象进行各种设置,要设置epoll对象,我们需要调用:

1
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

epfdepoll对象;op是操作类型,有EPOLL_CTL_ADD, EPOLL_CTL_MODEPOLL_CTL_DEL。它们分别表示添加/修改、删除一个epoll中的socket描述符;fd是我们需要操作的socket描述符;event是一个epoll_event的结构体:

1
2
3
4
5
6
7
8
9
10
11
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 */
};

其中events是我们等待的事件,data是一个联合体,可以是socket描述符,也可以是其他,一般我们用作socket描述符。我们可以用epoll_ctl()设置多个socket描述符,然后用函数epoll_wait()等待事件就绪。该函数的原型如下:

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

epfdepoll对象;events是指向epoll_event数组的指针;maxevents是最多返回多少个socket描述符的请求;timeout是多长时间超时。如果成功,函数将返回有几个就绪的描述符;如果没有就绪的,返回0;错误返回-1。

LT和ET模式

LT是Level Trigger,ET是Edge Trigger(让我想起了DFF)。它们的主要区别在于:LT模式下,如果对就绪的描述符不处理(或者说没有处理完),那么下次调用epoll_wait()时,它还是会通知该描述符就绪;而在ET模式下,我们必须对就绪的描述符进行处理,因为epoll_wait()不会再次对该描述符进行通知。因此,在ET模式下,我们将大大降低调用epoll_wait()的次数。

这里的处理指的是是否读出buffer中的数据,是否向buffer写入数据等。

根据彻底学会使用epoll(二)——ET和LT的触发方式一文作者地总结,有以下几种情况会在ET模式下唤醒epoll_wait()函数。

对于读操作:

  1. buffer由空变成不空;
  2. buffer写入新的数据;
  3. buffer不为空,用户使用EPOLL_CTL_MOD修改数据可读。

对于写操作:

  1. buffer有满变成不满;
  2. buffer数据减少;
  3. buffer不满,用户使用EPOLL_CTL_MOD修改数据可写。

在LT模式下,epoll_wait()被唤醒的条件就完全不同。

对于读操作:

  1. buffer中数据没读完。

对于写操作:

  1. buffer没有写满。大多数情况下buffer是不满的,因此在用LT模式时:写操作只有在需要时候才添加到wait list中,不用时候要从wait list中删除,避免事件写事件总是就绪(select()poll()是LT模式,也要注意写操作)。

根据之前select()函数的知识,可写是buffer剩余空间一般大于2048字节才满足可写,这里应该也是遵循相同的逻辑。

ET和FT代码示例

下面我们通过几个简单的例子来看看ET和LT的区别,以下例子均来自博文彻底学会使用epoll(三)——ET的读操作实例分析

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
#include <unistd.h>
#include <iostream>
#include <sys/epoll.h>
using namespace std;

// 这个例子使用EPOLL_CTL_MOD更新socket描述符的模式为EPOLLIN | EPOLLET,这么做会唤醒epoll_wait(),
// 因此会不断输出"hello world!"
int main(void)
{
int epfd, nfds;
struct epoll_event ev, events[5]; //ev用于注册事件,数组用于返回要处理的事件
epfd = epoll_create(1);
ev.data.fd = STDIN_FILENO;
ev.events = EPOLLIN | EPOLLET; //使用ET模式
epoll_ctl(epfd, EPOLL_CTL_ADD, STDIN_FILENO, &ev); //注册epoll事件

for ( ; ;) {
nfds = epoll_wait(epfd, events, 5, -1);
for (int i = 0; i < nfds;i++) {
if (events[i].data.fd == STDIN_FILENO) {
cout << "hello world!" << endl;
ev.data.fd = STDIN_FILENO;
ev.events = EPOLLIN | EPOLLET; //使用ET模式
epoll_ctl(epfd, EPOLL_CTL_MOD, STDIN_FILENO, &ev); //重新MOD事件
}
}
}
}

// 这个例子使用LT模式从stdin读取数据,但因为读取stdin后,buffer并没有变为0,因此epoll_wail()会被不断
// 唤醒。因此这个函数也会不断打印”hello world!“。如果使用ET模式,那么在buffer满之前,我们每从stdin输入一次,就
// 输出一次"hello world!"。
int main(void)
{
int epfd, nfds;
struct epoll_event ev, events[5]; //ev用于注册事件,数组用于返回要处理的事件
epfd = epoll_create(1);
ev.data.fd = STDIN_FILENO;
ev.events = EPOLLIN;
epoll_ctl(epfd, EPOLL_CTL_ADD, STDIN_FILENO, &ev); //注册epoll事件

for ( ; ; ) {
nfds = epoll_wait(epfd, events, 5, -1);
for (int i = 0;i < nfds;i++) {
if (events[i].data.fd == STDIN_FILENO)
cout << "hello world!" << endl;
}
}
}


// 这是写的例子,stdout作为输出,因为每次输出后缓存都清空(行缓存),因此epoll_wait()每次
// 都就绪。因此会不断输出"hello world!"。
#include <unistd.h>
#include <iostream>
#include <sys/epoll.h>
using namespace std;
int main(void)
{
int epfd, nfds;
struct epoll_event ev, events[5]; //ev用于注册事件,数组用于返回要处理的事件
epfd=epoll_create(1);
ev.data.fd=STDOUT_FILENO;
ev.events=EPOLLOUT|EPOLLET; //监听读状态同时设置ET模式
epoll_ctl(epfd,EPOLL_CTL_ADD,STDOUT_FILENO,&ev);
for ( ; ; ) {
nfds=epoll_wait(epfd,events,5,-1);
for (int i = 0 ; i < nfds; i++) {
if (events[i].data.fd == STDOUT_FILENO)
cout<<"hello world!"<<endl;
}
}
}

其他例子可以访问彻底学会使用epoll(四)——ET的写操作实例分析

EPOLLONESHOT事件

如果我们只希望一个线程(or processes, if child processes have inherited the epoll file descriptor across fork(2))访问socket,我们可以设置socket描述符的EPOLLONESHOT事件。对于设置了该事件的socket描述符,这个socket描述符只能就绪一次,因此即使发生了线程(进程)切换,我们也不用担心这个描述符再次就绪。我们需要手动解除这个事件才能让其他线程(进程)可以接收到该描述符的就绪事件。

epoll()问题总结

ET模式下如何正确读写

ET模式下,每次事件只会被响应一次,因此我们需要在这一次响应中完成读写操作。一般做法就是将socket描述符设置为non-blocking,然后通过while()循环来读/写,直到返回0或者EAGAIN(在non-blocking的设置下表示buffer读完了或者buffer满了),或者EWOULDBLOCK或者EINPROGRESS。注意,如果读/写长度大于buffer,那么可能出现buffer为空/满的时候数据其实还没传输完毕。这种情况下,我们需要额外逻辑来处理。

为什么要将socket描述符设置为non-blocking

如果不是non-blocking,那么读/写完后就会被read/write函数阻塞,这样其他的socket描述符也被阻塞了。我们不希望在read/write被阻塞,而是在epoll_wait()处阻塞,这样其他就绪的socket描述符就可以进行自己的工作了。

ET模式下accept()函数的正确使用

如果有多个socket连接同时到来,ET模式下的epoll_wait()只响应一次,那么accept()函数只能处理一个连接,其他处于就绪队列的连接就不会被处理了。正确的做法是使用while()循环来accept()连接,直到所有连接都被处理。

多路复用下accept()的正确用法

accept()要用non-blocking,因为要确保进程不能被accept()阻塞,我们需要进程被select(), poll()或者epoll_wait()阻塞。如果被accept()阻塞,那么多路复用就失效了,因为其他socket描述符根本不会被响应。

三种多路复用的比较

select()poll()在活动socket描述符少但总的socket描述符多时,效率低;epoll_wait()在这种情况下效率高。在活动描述符多的情况下,epoll_wait()则并不一定比select()poll()强。图2是这三个函数的对比:

图2. 三种多路复用函数的对比

参考

彻底学会使用epoll(一)——ET模式实现分析

CATALOG
  1. 1. poll()函数
    1. 1.1. 函数API
    2. 1.2. poll()函数的优缺点
  2. 2. epoll()函数
    1. 2.1. 函数API
    2. 2.2. LT和ET模式
      1. 2.2.1. ET和FT代码示例
      2. 2.2.2. EPOLLONESHOT事件
    3. 2.3. epoll()问题总结
      1. 2.3.1. ET模式下如何正确读写
        1. 2.3.1.1. 为什么要将socket描述符设置为non-blocking?
      2. 2.3.2. ET模式下accept()函数的正确使用
      3. 2.3.3. 多路复用下accept()的正确用法
  3. 3. 三种多路复用的比较
    1. 3.1. 参考