首页 「网络编程101」提升效率,减少等待!
文章
取消

「网络编程101」提升效率,减少等待!

上一篇文章实现的简单服务器中,所有的操作都是阻塞式的。服务器运行时,要等待一个I/O事件完成后才能继续下一个I/O事件。当有多个I/O事件要处理时,这些I/O事件要排队处理。即使后来的I/O事件是已就绪状态,可以立马处理完成的,也无法得到服务器的优先响应。例如,服务器要处理来自两个客户端的请求。当前处理的请求在使用write向客户端返回消息时,由于TCP的发送缓冲区满了而陷入等待,而另一个客户端的请求处理则可以立即完成。由于write的阻塞,另一个客户端也迟迟无法得到响应。

利用I/O多路复用技术,可以让需要长时间等待的I/O事件让出位置,让就绪的I/O事件优先得到CPU的处理。Linux中,可以使用selectpollepoll进行I/O多路复用,本文将介绍后两者。

poll

poll1的函数定义如下,其中与之交互的关键结构体为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为:POLLINPOLLOUT,表示文件可读和可写。此外,POLLERRPOLLHUP表示有错误产生。有时,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

epoll2是和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_create13只有一个参数flags,可选值为0EPOLL_CLOEXECCLOEXEC可以在进程有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_ctl4epoll实例交互,进行监控事件的增删改。

  • 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。注意dataunion类型,所以只能选取一种保存,使用时要注意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_wait5可以查询发生的事件,结果以指针形式返回,存放在events指向的内存空间里。每次查询的最大事件数为maxevents。在返回结果时,之前存入epoll_eventdata字段的数据也会一并返回,这段数据不会被修改,是为了方便用户编程设置的。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会立即返回,一般返回EWOULDBLOCKEAGAIN错误。对于写也是类似的,若缓冲区不足以写完全部数据,则能写多少写多少,然后立即返回。需要在用户态判断读写的完成情况,决定后续操作是再发起一次读写。

I/O模型

非阻塞I/O可以与I/O多路复用一起使用。对于阻塞、非阻塞、I/O多路复用的模型如图所示:

-16355717570561 阻塞式I/O6

-16355717570573 非阻塞式I/O6

-16355717570575 I/O多路复用,可搭配阻塞或非阻塞的文件描述符6

阻塞与非阻塞,同步与异步

阻塞指线程在I/O过程中是否会被挂起,直到内核完成所有操作(包括准备缓冲区和数据在内核态与用户态之间的复制)。

非阻塞则是“尽最大可能”完成I/O操作,若有数据可以读写则进行读写,否则立即返回。用户程序往往需要用轮询的方式与非阻塞I/O打交道(即使是用epoll这样的高效I/O多路复用,也无法需要改变轮询的事实)。

同步和异步则是对于数据在用户态和内核态之间复制的过程而言的。无论是阻塞、非阻塞还是非阻塞+多路复用,在内核准备好数据,并真正开始读写数据时,读写的过程都是同步的。即用户态需要等待数据读写完成后才能返回。如果内核的数据复制耗时很长,及时是非阻塞I/O的进程也会等待很长的时间。而异步I/O可以解决这一问题,其I/O模型如下:

-16355717570579 异步I/O6

用户态程序发起读写数据请求,在内核准备好后,内核自动进行I/O,并用信号的方式通知用户程序数据读写完毕。在这期间,用户程序不会在任何地方陷入长时间等待。

上面提到的4种I/O模型之间的对比可以总结如下:

-163557175705811 几种I/O模型的对比6

总结

本文介绍了pollepoll的基本用法,讨论了epoll中边缘触发与条件触发的区别,其本质在于,同一个事件在发生后未被用户进程响应时,之后是否还会继续通知此事件。之后,介绍了Linux中的I/O模型,讨论了同步I/O与异步I/O的区别。在4种常见的I/O模型中,阻塞I/O、非阻塞I/O、多路复用都是同步I/O,意味着即使开启非阻塞文件描述符并用pollepoll时,I/O仍然是同步的。由于epoll已经足够高效,异步I/O在Linux中并不常用。

完整代码仓库

「网络编程101」系列全部代码可在我的GitHub代码仓库中查看:Captor: An easy-to-understand Reactor in C++

欢迎提出各类宝贵的修改意见和issues,指出其中的错误和不足!

最后,感谢你读到这里,希望我们都有所收获!

References

本文由作者按照 CC BY 4.0 进行授权

「网络编程101」来封装一个简单的TCP服务吧!

「网络编程101」事件驱动,有模有样!