上一篇文章实现的简单服务器中,所有的操作都是阻塞式的。服务器运行时,要等待一个I/O事件完成后才能继续下一个I/O事件。当有多个I/O事件要处理时,这些I/O事件要排队处理。即使后来的I/O事件是已就绪状态,可以立马处理完成的,也无法得到服务器的优先响应。例如,服务器要处理来自两个客户端的请求。当前处理的请求在使用write
向客户端返回消息时,由于TCP的发送缓冲区满了而陷入等待,而另一个客户端的请求处理则可以立即完成。由于write
的阻塞,另一个客户端也迟迟无法得到响应。
利用I/O多路复用技术,可以让需要长时间等待的I/O事件让出位置,让就绪的I/O事件优先得到CPU的处理。Linux中,可以使用select
、poll
和epoll
进行I/O多路复用,本文将介绍后两者。
poll
poll
1的函数定义如下,其中与之交互的关键结构体为pollfd
,包含3个字段。
fd
:需要查询的文件描述符,检查是否有事件发生。当fd
为负数时不进行检查;events
:需要检查的事件集合,如读写是否就绪;revents
:即return events,返回当前文件描述符上就绪的事件以及错误信息。
1
2
3
4
5
6
7
8
9
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
此外,nfds
表示pollfd
数组中的元素数量,timeout
表示超时时间,单位毫秒。若等待timeout
时间后还没有事件发生,也直接返回。当timeout
为-1时,poll
会阻塞住,直到有事件产生。
在实践时,常用的events
为:POLLIN
和POLLOUT
,表示文件可读和可写。此外,POLLERR
和POLLHUP
表示有错误产生。有时,POLLERR
也表示可读,因为在收到RST
包时,有的实现会返回POLLERR
,而有的则是POLLRDNORM
(可读)。
poll
函数的返回值为有事件产生的文件描述符个数,对这些文件描述符进行相应操作时不会陷入长时间等待(当然,还是会阻塞执行)。
示例
让我们看看使用poll
多路复用的一个简单的echo
服务:
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
const int MAX_EVENTS = 128;
int main() {
TcpListener listener;
listener.listen(8888, true);
vector<struct pollfd> events(MAX_EVENTS);
events[0].fd = listener.listen_fd();
events[0].events = POLLRDNORM;
// init empty event with fd{-1}
int i;
for (i = 1; i < MAX_EVENTS; i++) {
events[i].fd = -1;
}
int n_ready;
while (true) {
if ((n_ready = poll(events.data(), MAX_EVENTS, -1)) < 0) {
cerr << "poll error: " << strerror(errno) << endl;
exit(1);
}
// check listen_fd first
if (events[0].revents & POLLRDNORM) {
int conn_fd = listener.accept().conn_fd();
for (i = 1; i < MAX_EVENTS; i++) {
if (events[i].fd < 0) {
events[i].fd = conn_fd;
events[i].events = POLLRDNORM;
break;
}
}
if (i == MAX_EVENTS) {
cerr << "can not hold so many clients\n";
}
if (--n_ready <= 0) {
continue;
}
}
for (i = 1; i < MAX_EVENTS; i++) {
int conn_fd;
if ((conn_fd = events[i].fd) < 0) {
continue;
}
if (events[i].revents & POLLHUP) {
cerr << "poll error" << endl;
close(events[i].fd);
events[i].fd = -1;
} else if (events[i].revents & (POLLRDNORM | POLLERR)) {
TcpConnection conn{conn_fd};
string msg;
if (!(msg = conn.blocking_receive_line()).empty()) {
cout << "server received: " << msg;
conn.blocking_send(msg);
} else {
conn.close();
cout << "server closed conn_fd: " << conn_fd << endl;
events[i].fd = -1;
}
if (--n_ready <= 0) {
break;
}
}
}
}
}
epoll
epoll
2是和poll
类似的一种I/O多路复用技术,通过epoll_ctl
注册文件描述符和关联的事件,来进行 I/O事件的通知和分发。与poll
相比,epoll
使用更方便,也更高效。epoll
在查询时仅返回有事件产生的文件描述符,我们在处理返回结果时,不需要像poll
一样逐个遍历描述符数组检查是否有revents
。这种交互方式避免了用户态与内核态之间频繁大量的数据拷贝,提高效率。
在判断事件产生时,epoll提供了条件触发(level-triggered)和边缘触发(edge-triggered)机制。默认情况下为条件触发,使用边缘触发将进一步提升性能。要使用epoll
,我们首先用epoll_create1
创建一个epoll
实例,返回其描述符epfd
,之后用描述符与epoll_ctl
交互,对要监听的文件描述符及其关联事件进行增删改。用epoll_wait
查询事件。
epoll_create1
1
2
3
#include <sys/epoll.h>
int epoll_create1(int flags);
epoll_create1
3只有一个参数flags
,可选值为0
或EPOLL_CLOEXEC
。CLOEXEC
可以在进程有fork
时自动关闭父进程的epfd
,减少文件描述符的引用计数。
epoll_ctl
1
2
3
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
创建完epfd
后,将其传入epoll_ctl
4与epoll
实例交互,进行监控事件的增删改。
op
表示支持的操作类型:EPOLL_CTL_ADD
:注册文件描述符对应的事件;EPOLL_CTL_DEL
:删除文件描述符对应的事件;EPOLL_CTL_MOD
:修改文件描述符对应的事件。fd
即要注册的的文件描述符,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 */
};
epoll_event
结构体中有两个字段,其中events
字段与pollfd
中的events
类似,在epoll
中如下:
EPOLLIN
:表示对应的文件描述字可以读;EPOLLOUT
:表示对应的文件描述字可以写;EPOLLRDHUP
:表示套接字的一端已经关闭,或者半关闭;EPOLLHUP
:表示对应的文件描述字被挂起;EPOLLET
:设置为edge-triggered,默认为level-triggered。
data
字段可保存用户数据,常用的有指针ptr
和事件关联的文件描述符fd
。注意data
为union
类型,所以只能选取一种保存,使用时要注意data
的具体语义,确保存储和使用时保持一致。
epoll_wait
1
2
3
4
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
epoll_wait
5可以查询发生的事件,结果以指针形式返回,存放在events
指向的内存空间里。每次查询的最大事件数为maxevents
。在返回结果时,之前存入epoll_event
中data
字段的数据也会一并返回,这段数据不会被修改,是为了方便用户编程设置的。timeout
参数与poll
类似,单位毫秒,-1表示一直等待到有事件发生才返回。
示例
下面这段程序展示了基于epoll
的echo服务,运行服务后,我们可以在终端输入telnet 0.0.0.0 8888
与之交互:
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
const int MAX_EVENTS = 128;
int main() {
int efd;
struct epoll_event event{};
int n_ready;
vector<struct epoll_event> events(MAX_EVENTS);
TcpListener listener;
listener.listen(8888, true);
efd = epoll_create1(EPOLL_CLOEXEC);
if (efd == -1) {
cerr << "epoll create failed: " << strerror(errno) << endl;
exit(1);
}
event.data.fd = listener.listen_fd();
event.events = EPOLLIN | EPOLLET;
if (epoll_ctl(efd, EPOLL_CTL_ADD, event.data.fd, &event) == -1) {
cerr << "epoll_ctl add failed: " << strerror(errno) << endl;
exit(1);
}
while (true) {
n_ready = epoll_wait(efd, events.data(), MAX_EVENTS, -1);
for (int i = 0; i < n_ready; i++) {
if (events[i].events & (EPOLLERR | EPOLLHUP) ||
!(events[i].events & EPOLLIN)) {
cerr << "epoll error" << endl;
epoll_ctl(efd, EPOLL_CTL_DEL, events[i].data.fd, &events[i]);
close(events[i].data.fd);
} else if (listener.listen_fd() == events[i].data.fd) {
TcpConnection conn = listener.accept();
conn.set_nonblocking();
event.data.fd = conn.conn_fd();
event.events = EPOLLIN | EPOLLET;
if (epoll_ctl(efd, EPOLL_CTL_ADD, event.data.fd, &event) == -1) {
cerr << "epoll_ctl add failed: " << strerror(errno) << endl;
exit(1);
}
} else {
TcpConnection conn{events[i].data.fd};
string msg;
if (!(msg = conn.blocking_receive_line()).empty()) {
cout << "server received: " << msg;
conn.blocking_send(msg);
} else {
epoll_ctl(efd, EPOLL_CTL_DEL, events[i].data.fd, &events[i]);
conn.close();
cout << "server closed conn_fd: " << events[i].data.fd << endl;
}
}
}
}
}
运行结果:
1
2
3
4
5
6
7
8
9
$ telnet 0.0.0.0 8888
Trying 0.0.0.0...
Connected to 0.0.0.0.
Escape character is '^]'.
hello from client
hello from client
$ ./echo_epoll_server.out
server received: hello from client
边缘触发 vs 条件触发
二者的本质区别:边缘触发不会反复通知一个之前已通知,但用户没有处理的事件,仅在事件第一次发生时返回。而条件触发则是事件条件满足时就一直不断地将事件返回给用户。例如,检测到一个套接字可写后,epoll
的边缘触发会返回该事件。用户程序发现暂时没有数据要写入该套接字,没有对该事件做处理。在下一次查询时,epoll
边缘触发不会再次返回这个事件,而条件触发则会返回。
如果用数字信号类比,0表示无事件,1表示有事件,边缘触发就像是从0跳变到1时才通知用户,而条件触发则是处于1状态下时会不断地通知用户。合理使用边缘触发,会获得更高的性能。在下一篇文章中,我们会用具体的编程示例比较二者的区别,体会边缘触发带来的好处以及潜在的坑。
非阻塞的文件描述符
默认情况下,文件描述符是阻塞式的,意味着对文件描述符读写时,若相关I/O资源没有就绪,进程会陷入等待,直到相关资源就绪并完成读写操作后才继续别的工作。
若文件描述符在创建时没有传入非阻塞的flags
选项,在后期我们可以用fcntl
(flag control)将文件描述符设置为非阻塞式的,对其的I/O操作随即变为非阻塞I/O。如果套接字对应的接收缓冲区没有数据可读,在非阻塞模式下对其read
会立即返回,一般返回EWOULDBLOCK
或EAGAIN
错误。对于写也是类似的,若缓冲区不足以写完全部数据,则能写多少写多少,然后立即返回。需要在用户态判断读写的完成情况,决定后续操作是再发起一次读写。
I/O模型
非阻塞I/O可以与I/O多路复用一起使用。对于阻塞、非阻塞、I/O多路复用的模型如图所示:
阻塞式I/O6
非阻塞式I/O6
I/O多路复用,可搭配阻塞或非阻塞的文件描述符6
阻塞与非阻塞,同步与异步
阻塞指线程在I/O过程中是否会被挂起,直到内核完成所有操作(包括准备缓冲区和数据在内核态与用户态之间的复制)。
非阻塞则是“尽最大可能”完成I/O操作,若有数据可以读写则进行读写,否则立即返回。用户程序往往需要用轮询的方式与非阻塞I/O打交道(即使是用epoll
这样的高效I/O多路复用,也无法需要改变轮询的事实)。
同步和异步则是对于数据在用户态和内核态之间复制的过程而言的。无论是阻塞、非阻塞还是非阻塞+多路复用,在内核准备好数据,并真正开始读写数据时,读写的过程都是同步的。即用户态需要等待数据读写完成后才能返回。如果内核的数据复制耗时很长,及时是非阻塞I/O的进程也会等待很长的时间。而异步I/O可以解决这一问题,其I/O模型如下:
异步I/O6
用户态程序发起读写数据请求,在内核准备好后,内核自动进行I/O,并用信号的方式通知用户程序数据读写完毕。在这期间,用户程序不会在任何地方陷入长时间等待。
上面提到的4种I/O模型之间的对比可以总结如下:
几种I/O模型的对比6
总结
本文介绍了poll
和epoll
的基本用法,讨论了epoll
中边缘触发与条件触发的区别,其本质在于,同一个事件在发生后未被用户进程响应时,之后是否还会继续通知此事件。之后,介绍了Linux中的I/O模型,讨论了同步I/O与异步I/O的区别。在4种常见的I/O模型中,阻塞I/O、非阻塞I/O、多路复用都是同步I/O,意味着即使开启非阻塞文件描述符并用poll
与epoll
时,I/O仍然是同步的。由于epoll
已经足够高效,异步I/O在Linux中并不常用。
完整代码仓库
「网络编程101」系列全部代码可在我的GitHub代码仓库中查看:Captor: An easy-to-understand Reactor in C++
欢迎提出各类宝贵的修改意见和issues,指出其中的错误和不足!
最后,感谢你读到这里,希望我们都有所收获!