首页 关于
树枝想去撕裂天空 / 却只戳了几个微小的窟窿 / 它透出天外的光亮 / 人们把它叫做月亮和星星
目录

非阻塞IO通信

本系列的第一部分中所有的例程都是阻塞的IO通信。所谓的阻塞IO通信,是指程序在执行accept, read, write/send等调用的时候, 会进入阻塞的状态交出CPU资源。从程序的角度来看,就是它将一直停留在这些系统调用中,直到内核组织好相关数据之后,才会从中返回。基于这种阻塞IO模型,在没有多线程或者多进程的辅助的情况下, 程序的并发处理能力是有限的。

所以我们封装了poll调用,提供了一种多路复用的IO模型。在这种IO模型的加持下,我们实现了Reactor模式的服务器,能够在单线程中提供并发的网络服务。系统调用poll只是给我们提供了一种询问内核的方法, 并不会改变读写IO时的阻塞进程的状态。也就是说,在调用accept、read、write/send的时候,进程依然是阻塞在那里,直到相关数据准备好之后才会返回。

实际上这种阻塞的IO并不利于充分发挥多路复用的性能,因为当进程阻塞在accept, read, write/send, connect等调用的时候,势必会影响到poll等多路复用的轮训,进而延迟对于其它IO的响应。 所以为了更快的响应速度,我们有必要研究一下非阻塞的IO通信。

本文,我们将再次回到最初的echo服务器,研究一下非阻塞的IO编程对于系统的影响。

1. 文件描述符

在初次接触套接字的时候,我们就已经用到了文件描述符,当时并没有解释它是什么, accept/read/write/send/listen等调用都需要一个文件描述符来指示网络连接以及数据读写的对象。现在我们来深入了解一下它在Linux系统下都扮演者什么角色,有什么特性,该如何操作它。 文件是Linux系统中的一个核心的概念。除了我们常见的*.txt, *.jpg之类的存储在硬盘中的电子文件之外,各种设备、网络连接都被抽象成文件, 然后通过open, close, read, write/send之类的系统调用来打开、释放、读写这些系统资源。

对于系统内核而言,每个打开的文件都有一个非负整数来代表,这个非负整数就是所谓的文件描述符。 如下面的代码片段所示,我们在最初的echo服务器中通过调用socket,创建了一个用于监听端口的套接字, 并获得了内核为之分配的文件描述符,将之保存到局部变量listen_fd中。

标志说明
O_RDONLY只读文件
O_WRONLY只写文件
O_RDWR可读、可写
O_APPEND写时追加
O_NONBLOCK非阻塞模式
O_SYNC等待写完成(数据和属性)
O_ASYNC异步I/O
        int listen_fd = socket(AF_INET, SOCK_STREAM, 0);

文件描述符有很多属性,右侧的列表中只罗列了一些常见的标志。其中前三个标志O_RDONLY, O_WRONLY, O_RDWR是互斥的,同时只有一种选择,其它的标志都可以通过位运算置位或者复位。 通过如下的语句查询得到一个0x02的结果。它表示listen_fd对应着一个可读可写的文件,而且是阻塞的。

        int fl = fcntl(listen_fd, F_GETFL);
        printf("fl: 0x%x, O_RDWR: 0x%x, O_NONBLOCK: 0x%x\n", fl, O_RDWR, O_NONBLOCK);

我们至少有两种方式获得一个非阻塞的套接字文件描述符。其一,是在调用socket的时候,增加配置SOCK_NONBLOCK的标志,如下面的第一行代码所示。其二,我们仍然以原来的方式打开套接字, 获取文件描述符之后,通过调用fcntl来修改O_NONBLOCK标志,如下面的2,3行代码所示。

        int listen_fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);
        int fl = fcntl(listen_fd, F_GETFL) | O_NONBLOCK;
        if (-1 == fcntl(listen_fd, F_SETFL, fl)) {
            perror("修改NONBLOCK出错");
            exit(1);
        }
        printf("fl: 0x%x, O_RDWR: 0x%x, O_NONBLOCK: 0x%x\n", fl, O_RDWR, O_NONBLOCK);

fcntl是一个非常重要的系统调用,通过它我们可以查询并修改一个已经打开的文件的性质。可以通过指令$ man fcntl来查询该调用的帮助文档。 如右侧的截图所示,fcntl至少有两个参数,其中fd是将要处理的文件描述符,cmd则是具体的操作指令。根据cmd的不同,我们可能还需要提供第三个参数arg。

Unix环境高级编程(APUE)一书中,把fcntl的功能总结成为5种:

  1. 复制一个现有的操作符(cmd=F_DUPFD)
  2. 获取或设置文件描述符的描述标志(cmd=F_GETFD, cmd = F_SETFD)
  3. 获取或设置文件描述符的状态标志(cmd=F_GETFL, cmd = F_SETFL)
  4. 获取或设置异步I/O所有权(cmd=F_GETOWN, cmd = F_SETOWN)
  5. 获取或设置记录锁(cmd=F_GETLK, cmd = F_SETLK, cmd= F_SETLKW)

刚刚,我们就是通过F_GETFL获取了文件描述符listen_fd的状态标志的。然后通过位与运算添加了非阻塞模式的状态设置,最终通过F_SETFL更新listen_fd所描述的文件,让其进入非阻塞模式。 关于fcnt更新的介绍,可以参考man或者APUE,这里就不在细述了。

2. 非阻塞的Accept套接字

现在,我们在初版echo服务器的基础上,把listen_fd改成非阻塞的模式,来看一下会产生什么样的影响。

        #include <netinet/in.h>
        #include <arpa/inet.h>
        #include <stdio.h>
        #include <string.h>
        #include <unistd.h>
        
        #include <iostream>;
        
        int main() {
            int listen_fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);
            if (-1 == listen_fd) {
                perror("创建socket失败");
                exit(1);
            }
        
            struct sockaddr_in serv_addr;
            memset(&serv_addr, 0, sizeof(serv_addr));
            serv_addr.sin_family = AF_INET;
            serv_addr.sin_port = htons(65530);
            serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
        
        
            if (bind(listen_fd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0) {
                perror("bind failed");
                exit(1);
            }
        
            if (listen(listen_fd, 10) < 0) {
                perror("listen failed");
                exit(1);
            }
        
            while (1) {
                struct sockaddr_in cli_addr;
                socklen_t len = sizeof(cli_addr);
                int conn_fd = accept(listen_fd, (struct sockaddr *)&cli_addr, &len);
                if (-1 == conn_fd) {
                    perror("连接失败");
                    exit(1);
                } else {
                    char buf[1024];
                    while (1) {
                        int nread = read(conn_fd, buf, 1024);
                        if (nread <= 0)
                            break;
                        std::cout << "nread = " << nread << std::endl;
                        send(conn_fd, buf, nread, 0);
                    }
        
                    close(conn_fd);
                }
            }
            close(listen_fd);
        }

如上面的代码所示,我们在构造套接字的时候,就把文件描述符设置成非阻塞的模式。读者可以把这个代码拷贝一份放到XiaoTuNetBox/test的目录下,然后回到XiaoTuNetBox下make编译一下。 如果读者将之保存为"t_echo_server_1.cpp",那么make之后就会在XiaoTuNetBox/build目录下生成一个"t_echo_server_1.exe"的可执行文件。运行之,就会像下面右图那样,直接gg了。 我们追查一下代码,会看到程序在如下的if分支里报错退出了。

int conn_fd = accept(listen_fd, (struct sockaddr *)&cli_addr, &len);
if (-1 == conn_fd) {
    perror("连接失败");
    exit(1);
}

这就是说accept调用直接就报错退出了。查看手册$ man accept,可以找到类似右图的一段话,说如果套接字是非阻塞的, 并且在调用accept的时候没有连接就会产生一个EAGAIN或者EWOULDBLOCK的错误。所以如下面的代码所示,我们再次检查一下errno,如果是这两个错误,就直接continue。

if (-1 == conn_fd) {
    int eno = errno;
    if (EAGAIN & eno || EWOULDBLOCK & eno)
        continue;
    perror("连接失败");
    exit(1);
}

这回echo服务器并没有直接报错退出,如果我们运行一个echo客户端,就会发现服务器可以工作了。 现在我们了解到,处于非阻塞状态下的套接字,调用accept的时候会直接返回,如果没有新的连接就会产生一个EAGAIN的错误。我们在检查到该错误后,通过continue回到while循环最初的位置,再次调用accept。

这个过程其实耗费了大量的CPU在查询是否有新的连接。上图是在没有客户端连接的情况下运行的top指令,可以看到t_echo_server_1把一个核给占满了。显然这并不是一个高效的方式。 更好的方式应该是和多路复用的IO模型一起使用,只有在select/poll/epoll等系统调用发现套接字上有新的连接返回后,才调用accept尝试建立新的连接。

3. 非阻塞的接收

当我们成功通过accept获得一个新的连接之后,就会再次获得一个文件描述符,这个描述符对应着我们刚刚建立起的连接。通过对它进行读写,就可以完成该连接上的数据收发操作。 调用accept默认返回的文件描述符是阻塞的,我们可以通过fcntl将之改为非阻塞的模式。

        int conn_fd = accept(listen_fd, (struct sockaddr *)&cli_addr, &len);
        int fl = fcntl(conn_fd, F_GETFL) | O_NONBLOCK;
        if (-1 == fcntl(conn_fd, F_SETFL, fl)) {
            perror("修改NONBLOCK出错");
            exit(1);
        }
        printf("fl: 0x%x, O_RDWR: 0x%x, O_NONBLOCK: 0x%x\n", fl, O_RDWR, O_NONBLOCK);

除此之外,通过man accept查看手册发现还有一个叫做accept4的调用,它与accept的作用是一样的,都是用来接受新连接的。所不同的是, accept4有四个输入参数,其原型如下面的代码片段所示。最后的参数flags为0时,与accept的功能完全一致。

        int accept4(int sockfd, struct sockaddr *addr, socklen_t *addrlen, int flags);

flags有两个可选的特性:SOCK_NONBLOCK和SOCK_CLOEXEC。如果设置了SOCK_NONBLOCK那么成功调用accept4之后就会得到一个非阻塞的描述符。SOCK_CLOEXEC与fork进程有关,这里暂不做介绍了。

如下面的代码片段所示,我们在例程上做了些微的改动,采用accept4的方式获得非阻塞的文件描述符来表示一个网络连接。如果成功接受一个连接, 就会用局部变量conn_fd来记录该连接的文件描述符。检查该变量是否为-1,判定连接是否成功建立起来,在第6行开始的while循环中尝试读取网络数据。

        int conn_fd = accept4(listen_fd, (struct sockaddr *)&cli_addr, &len, SOCK_NONBLOCK | SOCK_CLOEXEC);
        if (-1 != conn_fd) {
            int fl = fcntl(conn_fd, F_GETFL);
            printf("fl: 0x%x, O_RDWR: 0x%x, O_NONBLOCK: 0x%x\n", fl, O_RDWR, O_NONBLOCK);
            char buf[1024];
            while (1) {
                int nread = read(conn_fd, buf, 1024);
                std::cout << "nread = " << nread << std::endl;
                if (nread <= 0)
                    break;
                send(conn_fd, buf, nread, 0);
            }
            close(conn_fd);
        } else { }

在阻塞的模式下,上述的代码逻辑没有什么问题。程序执行到read调用就会阻塞,直到连接上有数据进来才会返回。但在非阻塞模式下,read调用会立即返回-1,表示出错,进而退出while循环。 以至于每当我们成功的建立起一次连接之后,都会立即将之关闭,也就不可能接收到数据上来。

通过$ man read,我们会发现read同样会产生一个EAGAIN或者EWOULDBLOCK的错误,如右侧的截图所示。 我们对异常退出(-1 == nread)的条件语句进行调整,检查错误号,当看到这两个错误时就continue重新开始循环。为了节省篇幅,这里省略了其它错误以及nread为0的情况的讨论。

        int conn_fd = accept4(listen_fd, (struct sockaddr *)&cli_addr, &len, SOCK_NONBLOCK | SOCK_CLOEXEC);
        if (-1 != conn_fd) {
            char buf[1024];
            while (1) {
                int nread = read(conn_fd, buf, 1024);
                if (-1 == nread) {
                    if (EAGAIN & eno || EWOULDBLOCK & eno)
                        continue;
                    else
                        break;
                }
                send(conn_fd, buf, nread, 0);
            }
            close(conn_fd);
        } else { }

4. 非阻塞的发送

阻塞和非阻塞状态是对文件描述符的限定,所以一般情况下一个套接字如果设定为非阻塞,那么它对读写都是有效的。上面非阻塞读套接字的例子中,其实我们夹带了一个非阻塞发送的过程。

个人理解,这个非阻塞write/send实际上是把将要发送的数据丢给系统内核之后就直接返回了,至于数据什么时候发出去,对方是否收到了就不是应用程序需要考虑的事情了。 如果内核的发送缓冲区不够了,非阻塞write/send就会直接报错EWOULDBLOCK或者EAGAIN,同时把缓冲区填满并返回填充的字节数量。对于阻塞形式的write/send调用,进程就会睡眠直到有足够的缓存空间为止。

可以预见,非阻塞的write/send调用会很快返回,应用程序可以及时的处理其它事务。如下面的代码所示,我们先不管会不会出现什么异常,直接把接收的数据发送100次,并增加一些计时的语句, 来对比一下非阻塞和阻塞调用的返回时间。

        std::chrono::steady_clock::time_point t1 = std::chrono::steady_clock::now();
        for (int i = 0; i < 100; i++)
            send(conn_fd, buf, nread, 0);
        std::chrono::steady_clock::time_point t2 = std::chrono::steady_clock::now();
        std::chrono::duration<double> td = std::chrono::duration_cast<std::chrono::duration<double>>(t2 - t1);
        std::cout << "send td: " << td.count() << std::endl;

下面左图是阻塞形式的调用结果,基本上是0.4ms完成100次的调用;右侧则是非阻塞形式的调用,基本上0.1ms完成。其实这么对比并没有什么具体的意义,只是想看看非阻塞调用会很快返回。 这并不意味着非阻塞形式的调用通信更快,通信速度的瓶颈更多时候是网速的大小。非阻塞的调用可以保证进程能够更快的从系统调用中返回,来处理其它事务。

(a). 阻塞send (b). 非阻塞send

对于非阻塞的发送而言,系统缓冲就会麻烦一点,而且还需要跟EWOULDBLOCK或EAGAIN错误配合使用。以后为了提供大流量的服务,发送大数据包,我们将专门设计数据缓冲结构,处理分包发送机制, 到那时我们再回过头来看非阻塞发送。

5. 完

本文中,我们初步的研究了一下非阻塞的网络通信。由于非阻塞的系统调用,会导致accept和read在没有接收到新的连接或数据就直接返回,所以需要根据errno确定错误类型,并重新调用之。 本文的示例并不是一种好的调用方式,因为它耗费了大量的CPU资源。通常这种非阻塞的IO方式都会与select/poll/epoll这样的复用IO模型配合使用,这样既能保证并发的服务效率, 也能降低系统资源的占用。




Copyright @ 高乙超. All Rights Reserved. 京ICP备16033081号-1