阻塞和非阻塞
当应用程序调用阻塞I/O完成某个操作时,应用程序会被挂起,等待内核完成操作。此时,内核所做的事情是将 CPU 时间切换给其他有需要的进程,网络应用程序在这种情况下就会得不到 CPU 时间做该做的事情。(即针对阻塞I/O执行的系统调用总是等待到事件发生为止)
当应用程序调用非阻塞I/O时完成某个操作时,内核立即返回,不会把 CPU 时间切换给其他进程,应用程序在返回后,可以得到足够的 CPU 时间继续完成其他事情。(即针对非阻塞I/O执行的系统调用总是立即返回,而不管事件是否已经发生)
非阻塞I/O可以被用到读操作、写操作、接收连接操作和发起连接操作上。
阻塞和非阻塞的概念能应用于所有文件描述符,而不仅仅是socket。我们称阻塞的文件描述符为阻塞I/O,称非阻塞的文件描述符为非阻塞I/O。
I/O复用机制是最常用的I/O通知机制,应用程序通过I/O复用函数向内核注册一组事件,内核通过I/O复用函数把其中就绪的事件通知给应用程序。
而Linux上常用的I/O复用函数 select、poll 和 epoll_wait,其本身是阻塞的,它们能提高程序效率的原因在于它们具有同时监听多个I/O事件的能力。
非阻塞I/O
读操作
如果套接字对应的接收缓冲区没有数据可以读,那么在非阻塞I/O情况下,read调用立即返回,一般返回 EWOULDBLOCK 或者 EAGAIN 的出错信息。此时,需要小心处理这些错误信息,例如可以再次调用read操作,而不是直接将这些信息作为错误返回。(需要进行轮询处理)
写操作
在阻塞I/O模式下,write函数返回的字节数,和输入的参数总是一样。这是因为阻塞I/O模式下,write函数需要等到全部数据都写入发送缓冲区中才返回。
在非阻塞I/O模式下,如果套接字发送缓冲区不能再容纳更多字节了,那么操作系统内核会尽最大可能从应用程序拷贝数据到发送缓冲区中,并立即从write函数调用返回(拷贝动作发生的瞬间,可能一个字符也没拷贝,也可能所有字符都拷贝完成)。此时,write返回的数值,就可以告诉应用程序到底有多少数据成功拷贝到了发送缓冲区中。如果数据没有全部被写入,那么应用程序需要再次调用write函数,以输出未完成拷贝的字节。
write 等函数是可以同时作用到阻塞 I/O 和非阻塞 I/O 上的,为了复用一个函数,处理非阻塞和阻塞 I/O 多种情况,设计出了写入返回值,并用这个返回值表示实际写入的数据大小。
可以看到,非阻塞I/O和阻塞I/O的处理方式是不一样的:
非阻塞I/O:拷贝-> 返回 -> 再拷贝 -> 再返回
阻塞I/O:拷贝-> 直到所有数据成功拷贝至发送缓冲区 -> 返回
在实际中,可以通过循环的方式写入数据,而不区别阻塞I/O和非阻塞I/O。(此时阻塞I/O只循环一次就结束)
/* 向文件描述符fd写入n字节数 */
ssize_t writen(int fd, const void * data, size_t n)
{
size_t nleft;
ssize_t nwritten;
const char *ptr;
ptr = data;
nleft = n;
//如果还有数据没被拷贝完成,就一直循环
while (nleft > 0) {
if ( (nwritten = write(fd, ptr, nleft)) <= 0) {
/* 这里EAGAIN是非阻塞non-blocking情况下,通知我们再次调用write() */
if (nwritten < 0 && errno == EAGAIN)
nwritten = 0;
else
return -1; /* 出错退出 */
}
/* 指针增大,剩下字节数变小*/
nleft -= nwritten;
ptr += nwritten;
}
return n;
}
read和write在阻塞和非阻塞模式下的特性:
- read总是在接收缓冲区中有数据时就立即返回,而不是等到应用程序给定的数据充满才返回(不论阻塞还是非阻塞,只要有数据都立即返回)。当接收缓冲区为空时,阻塞模式会一直等待,非阻塞模式立即返回-1,并带有EWOULDBLOCK 或 EAGAIN 错误
- 阻塞模式下,write 只有在发送缓冲区足以容纳应用程序的输出字节时才返回;而非阻塞模式下,则是能写入多少就写入多少,并返回实际写入的字节数。(阻塞模式下,如果对方关闭了套接字,此时调用write会立即返回(会收到RST报文),并通过返回值告诉应用程序实际写入的字节数,如果再次对这样的套接字进行 write 操作,就会返回失败(会收到SIGPIPE信号))
accept操作
在之前的学习中,将accept和I/O多路复用select、poll一起使用时,如果监听套接字上有事件发生,说明有连接建立完成,此时调用accept肯定可以返回已连接套接字,而不会阻塞。
但是,考虑一种极端情况:
在客户端程序中,通过设置 SO_LINGER 选项,使得调用close关闭套接字后,会立即发送一个RST报文给服务器端。
struct linger ling;
ling.l_onoff = 1;
ling.l_linger = 0;
setsockopt(socket_fd, SOL_SOCKET, SO_LINGER, &ling, sizeof(ling));
close(socket_fd);
而在服务器端,使用select进行I/O多路复用,但是监听套接字是阻塞模式的,当监听套接字上有事件发生,通过设置一个休眠时间(5秒左右),模拟高并发下的场景。
if (FD_ISSET(listen_fd, &readset)) {
printf("listening socket readable\n");
sleep(5);
struct sockaddr_storage ss;
socklen_t slen = sizeof(ss);
int fd = accept(listen_fd, (struct sockaddr *) &ss, &slen);
此时,当监听套接字上有事件发生时,并没有马上调用accept函数。但是,此时客户端在连接建立后立即close,然后发送了RST报文到服务器端,服务器端收到RST后,内核会从自己的已完成队列中删除该连接。当休眠时间结束时,调用accept,由于没有已完成连接(假设也没有其他的已完成连接),那么accept会一直阻塞,并且该线程此时也无法对其他I/O事件进行分发,相当于服务器无法对其他I/O进行服务。
所以,考虑到上述极端情况,我们必须要将监听套接字设置为非阻塞的,此时对于accept的返回值,需要正确地处理各种看似异常的错误(EWOULDBLOCK , EAGAIN等)。
connect操作
在非阻塞套接字上调用connect函数,会立即返回一个 EINPROGRESS 错误(因为调用connect需要进行三次握手的操作,对于非阻塞的套接字只能立即返回一个错误信息)。此时TCP三次握手正常进行,应用程序可以继续进行其他初始化的事情。当连接建立成功或失败时,通过I/O多路复用 可以进行连接的状态检测。
具体地:可以通过调用select、poll等函数来监听这个连接失败的socket上的可写事件。当select、poll等函数返回后,再利用getsockopt来读取错误码并清除该socket上的错误。如果错误码是0,表示连接成功建立,否则连接失败。
总结
在非阻塞I/O模式下,如果采用轮询的方式来判断事件是否发送会引起CPU占用率高,所以一般将非阻塞I/O和I/O多路复用技术搭配使用,只有当非阻塞I/O事件发生时, 才调用对应事件的处理函数,这样可以提高程序的健壮性和稳定性。
非阻塞网络编程中需要设计应用层buffer。 发送数据时,如果操作系统内核无法接收全部数据,那么需要将剩余数据放入应用层buffer,避免阻塞。读取数据时,由于TCP的流特性,需要在应用层进行报文解析,通过将数据放入应用层buffer可以方便解析,否则,需要不断的进行数据的读取,直至解析到完整的报文。
fcntl 函数
函数原型
#include<unistd.h>
#include<fcntl.h>
int fcntl(int fd, int cmd);
int fcntl(int fd, int cmd, long arg);
int fcntl(int fd, int cmd ,struct flock* lock);
fcntl系统调用可以用来对已打开的文件描述符进行各种控制操作以改变已打开文件的的各种属性
参数
1)fd代表欲设置的文件描述符
2)cmd代表欲操作的指令
F_DUPFD /* 用来查找大于或等于参数arg的最小且仍未使用的文件描述符, 并且复制参数fd的文件描述符 */
F_GETFD /* 获取文件描述符标志 */
F_SETFD /* 设置文件描述符标志 */
F_GETFL /* 获取文件状态标志 */
F_SETFL /* 设置文件状态标志,即设置文件打开方式为arg指定方式 */
F_GETLK /* 获取文件锁 */
F_SETLK /* 设置文件锁 */
F_SETLKW /* 类似F_SETLK,但是等待返回 */
F_GETOWN /* 获取当前接收SIGIO 和 SIGURG 信号的进程ID和进程组ID */
F_SETOWN /* 设置当前接收SIGIO 和 SIGURG 信号的进程ID和进程组ID */
文件记录锁是fcntl函数的主要功能。
记录锁:实现只锁文件的某个部分,并且可以选择是阻塞方式还是非阻塞方式。
当fcntl用于管理文件记录锁的操作时,第三个参数为指向一个 struct flock 结构体的指针。
返回值
成功则返回0,失败返回-1,错误原因存于errno
通过fcntl函数将套接字设置为非阻塞
fcntl(fd, F_SETFL, O_NONBLOCK); /* # define O_NONBLOCK 04000 */