非阻塞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种:
- 复制一个现有的操作符(cmd=F_DUPFD)
- 获取或设置文件描述符的描述标志(cmd=F_GETFD, cmd = F_SETFD)
- 获取或设置文件描述符的状态标志(cmd=F_GETFL, cmd = F_SETFL)
- 获取或设置异步I/O所有权(cmd=F_GETOWN, cmd = F_SETOWN)
- 获取或设置记录锁(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模型配合使用,这样既能保证并发的服务效率, 也能降低系统资源的占用。