网络I/O – 堵塞、非阻塞、同步和异步及源码示例
一. 前言
在网络编程时,阻塞I/O,非阻塞I/O,同步I/O和异步I/O经常被提及。这篇博文中,我将结合相关材料及源码,尝试对这几种I/O模型进行区别和理解。
Richard Stevens大神的《UNIX Network Programming Volume 1: The Sockets Networking API, Third Edition》一书在第六章“I/O Multiplexing: The select and poll Functions”中有对这几种I/O模型的详细描述,人民邮电出版社有中文译本,大家可以参考此书。本篇博客的I/O模型图亦截取于此书。
为便于描述,后续内容将以网络I/O的读操作(read)为例进行说明。
二. 数据包的接收流程
首先,先简单描述正常情况下(抛开诸如DMA等其它不经内核的包接收方式),数据包从进入网卡开始到用户进程收到数据的过程。
为简便,这个过程大致可划分为内核态和用户态过程。
在内核态,数据帧达到网卡,网卡产生中断给内核,内核调用驱动程序读取网卡缓冲区中的数据,拷贝进入内核缓冲区中,并经协议栈进行层级处理。
在用户态,用户进程通过系统调用,读取Socket对应内核缓冲区中的数据,将数据拷贝至用户空间。
总结来说,这样一个读操作包含两个阶段:
(1)内核等待数据就绪
(2)将内核读到的数据拷贝至用户空间
三. 相同与不同
以上说了读操作包含的两个阶段。这两个阶段用户进程的状态和机制,就是区分阻塞I/O和非阻塞I/O,以及同步I/O和异步I/O的关键所在。
先说一下阻塞I/O和非阻塞I/O。阻塞和非阻塞侧重点在于用户进程在等待调用结果(即得到反馈)时的状态,尤其是当内核数据未就绪的时候。
相同点:若数据已经就绪,则阻塞和非阻塞没有区别,读取数据后返回;
不同点:若数据未就绪,阻塞I/O一直等待数据就绪,读取数据后返回;非阻塞I/O则立即返回。
可见,区分阻塞和非阻塞,要在数据未就绪的时候,看二者等待后的状态是否立即返回。
再说一下同步I/O和异步I/O。同步和异步侧重点在于用户进程和待接收数据的消息通信机制,而不论该数据是否已经就绪。
不同点:同步I/O在读取数据时,直到读完数据后才会返回;而异步I/O在发出读数据操作时,直接返回进行其它操作,不论数据是否就绪,且数据的读取不由该读操作负责。
相同点:无
本文后续部分将按照Richard Stevens书上的说明,来简单描述一下5种I/O模型:阻塞I/O,非阻塞I/O,I/O复用,信号驱动I/O,异步I/O。
四. 阻塞I/O(Blocking I/O)
阻塞I/O的模型图如下图所示。
当用户进程调用读操作时,阻塞在内核等待数据就绪状态;若数据就绪,则阻塞在将数据从内核拷贝至用户空间状态。
Linux系统中所有的Socket默认状态下都是阻塞的。
以下为示例源码Server.c。Server进程阻塞在recvfrom函数处,等待客户端数据达到。一旦数据达到,则读取数据,结束。
#include <sys/types.h> #include <sys/socket.h> #include <string.h> #include <unistd.h> #include <netinet/in.h> #include <stdio.h> #include <stdlib.h> #include <time.h> #define MAX_MSG_LENGTH 255 #define SERV_PORT 8888 int main(int argc, char **argv) { int serv_sock_fd; struct sockaddr_in serv_addr, cli_addr; char msg[MAX_MSG_LENGTH] = {0}; int sock_len; time_t start_time, end_time; serv_sock_fd = socket(AF_INET, SOCK_DGRAM, 0); bzero(&serv_addr, sizeof(serv_addr)); serv_addr.sin_family = AF_INET; serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); serv_addr.sin_port = htons(SERV_PORT); if(bind(serv_sock_fd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0){ printf("Bind error!\n"); exit(-1); } sock_len = sizeof(struct sockaddr); start_time = time(NULL); printf("%s", ctime(&start_time)); printf("\tBlocking, waiting for client message\n"); if (-1 != recvfrom(serv_sock_fd, msg, MAX_MSG_LENGTH, 0, (struct sockaddr *)&cli_addr, &sock_len)){ end_time = time(NULL); printf("%s", ctime(&end_time)); printf("\tReceive client message\n"); } close(serv_sock_fd); return 0; }
以下为示例源码Client.c。客户端发送UDP报文给服务器端。
#include <sys/types.h> #include <sys/socket.h> #include <string.h> #include <netinet/in.h> #include <stdio.h> #include <stdlib.h> #include <arpa/inet.h> #include <unistd.h> #define SERV_IP "127.0.0.1" #define SERV_PORT 8888 char msg[] = "hello"; int main(int argc, char **argv) { int cli_sock_fd; struct sockaddr_in serv_addr; int sock_len; bzero(&serv_addr, sizeof(serv_addr)); serv_addr.sin_family = AF_INET; serv_addr.sin_port = htons(SERV_PORT); serv_addr.sin_addr.s_addr = inet_addr(SERV_IP); cli_sock_fd = socket(AF_INET, SOCK_DGRAM, 0); sock_len = sizeof(struct sockaddr); sendto(cli_sock_fd, msg, strlen(msg),0, (struct sockaddr*)&serv_addr, sizeof(serv_addr)); return 0; }
编译指令
#gcc -g Server.c -o Server #gcc -g Client.c -o Client
先运行服务器端,再运行客户端。服务器端输出结果如下:
Thu Mar 31 14:28:33 2016 Blocking, waiting for client message Thu Mar 31 14:28:47 2016 Receive client message
五. 非阻塞I/O(Non-Blocking I/O)
可以将创建的Socket标志位设置为non-blocking,将其转变为非阻塞I/O。但非阻塞I/O的数据未就绪时,对其操作会返回错误提示(常用EWOULDBLOCK/EAGAIN)。依据错误提示,判断Socket是否出错,或者需要进行下一次的读取。
下面给出Server.c代码,Client.c代码和以上一致,无需修改。
#include <sys/types.h> #include <sys/socket.h> #include <string.h> #include <unistd.h> #include <netinet/in.h> #include <stdio.h> #include <stdlib.h> #include <time.h> #include <unistd.h> #include <fcntl.h> #include <errno.h> #define MAX_MSG_LENGTH 255 #define SERV_PORT 8888 int main(int argc, char **argv) { int serv_sock_fd; struct sockaddr_in serv_addr, cli_addr; char msg[MAX_MSG_LENGTH] = {0}; int sock_len; time_t start_time, end_time; int sock_flags; serv_sock_fd = socket(AF_INET, SOCK_DGRAM, 0); sock_flags = fcntl(serv_sock_fd, F_GETFL, 0); fcntl(serv_sock_fd, F_SETFL, sock_flags|O_NONBLOCK); bzero(&serv_addr, sizeof(serv_addr)); serv_addr.sin_family = AF_INET; serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); serv_addr.sin_port = htons(SERV_PORT); if(bind(serv_sock_fd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0){ printf("Bind error!\n"); exit(-1); } sock_len = sizeof(struct sockaddr); for(;;){ start_time = time(NULL); printf("%s", ctime(&start_time)); printf("\tNon Blocking, waiting for client message\n"); if (-1 != recvfrom(serv_sock_fd, msg, MAX_MSG_LENGTH, 0, (struct sockaddr *)&cli_addr, &sock_len)){ end_time = time(NULL); printf("%s", ctime(&end_time)); printf("\tReceive client message\n"); break; }else if (errno == EAGAIN){ sleep(1); }else{ end_time = time(NULL); printf("%s", ctime(&end_time)); printf("\tSocket Error\n"); break; } } close(serv_sock_fd); return 0; }先运行服务器端,再运行客户端。服务器端输出结果如下:
Thu Mar 31 14:51:29 2016 Non Blocking, waiting for client message Thu Mar 31 14:51:30 2016 Non Blocking, waiting for client message Thu Mar 31 14:51:31 2016 Non Blocking, waiting for client message Thu Mar 31 14:51:32 2016 Non Blocking, waiting for client message Thu Mar 31 14:51:33 2016 Non Blocking, waiting for client message Thu Mar 31 14:51:34 2016 Non Blocking, waiting for client message Thu Mar 31 14:51:35 2016 Non Blocking, waiting for client message Thu Mar 31 14:51:35 2016 Receive client message
从运行结果来看,服务器端每次调用recvfrom时,由于没有数据达到,直接返回。直到有数据到达时,读取数据并退出。
六. I/O复用(I/O Multiplexing)
I/O多路复用模型,就是将多路I/O阻塞在同一个地方,等到某个或者某些I/O有事件到达时,就通知进程进行相应Socket的数据读写操作。
I/O复用的好处,就是将原先阻塞在各自I/O读写系统调用的地方,统一迁移到由某个系统调用函数来管理。而数据就绪时,由该系统调用函数负责通知。这样,对I/O的操作就不会阻塞在各自的等待数据就绪上面。
Linux下的select和epoll函数就可以实现I/O复用功能。
此处例子就不给出了。
七. 信号驱动I/O(Signal-driven I/O)
信号驱动I/O需要Socket打开信号驱动I/O模式,并且通过sigaction系统调用注册SIGIO信号处理函数。当数据准备就绪时,内核通过发送SIGIO信号通知用户进程,用户进程通过信号处理函数读取数据。通过信号通知这种方式,用户进程不会因为数据未就绪而被阻塞在I/O上。
让Socket可以工作于信号驱动I/O模式,一般需要完成以下三个步骤:
(1).注册SIGIO信号处理程序
(2).设置Socket所有者
(3).置位Socket的O_ASYNC标志,允许套接字信号驱动I/O。
服务器端源码Server.c如下。
#include <sys/types.h> #include <sys/socket.h> #include <string.h> #include <unistd.h> #include <netinet/in.h> #include <stdio.h> #include <stdlib.h> #include <time.h> #include <unistd.h> #include <fcntl.h> #include <signal.h> #define MAX_MSG_LENGTH 255 #define SERV_PORT 8888 int serv_sock_fd; void handle_sig_io(int sig) { int cli_sock_fd, sock_len; struct sockaddr_in cli_addr; char msg[MAX_MSG_LENGTH]; time_t now_time; if (-1 != recvfrom(serv_sock_fd, msg, MAX_MSG_LENGTH, 0, (struct sockaddr *)&cli_addr, &sock_len)){ now_time = time(NULL); printf("%s", ctime(&now_time)); printf("\tReceive client message\n"); } } int main(int argc, char **argv) { struct sockaddr_in serv_addr, cli_addr; char msg[MAX_MSG_LENGTH] = {0}; int sock_len; time_t start_time, end_time; int sock_flags; struct sigaction sig_io_action; serv_sock_fd = socket(AF_INET, SOCK_DGRAM, 0); sock_flags = fcntl(serv_sock_fd, F_GETFL, 0); fcntl(serv_sock_fd, F_SETFL, sock_flags|O_NONBLOCK); bzero(&serv_addr, sizeof(serv_addr)); serv_addr.sin_family = AF_INET; serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); serv_addr.sin_port = htons(SERV_PORT); memset(&sig_io_action, 0, sizeof(sig_io_action)); sig_io_action.sa_flags = 0; sig_io_action.sa_handler = handle_sig_io; sigaction(SIGIO, &sig_io_action, NULL); fcntl(serv_sock_fd, F_SETOWN, getpid()); sock_flags = fcntl(serv_sock_fd, F_GETFL, 0); sock_flags |= O_ASYNC | O_NONBLOCK; fcntl(serv_sock_fd, F_SETFL, sock_flags); if(bind(serv_sock_fd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0){ printf("Bind error!\n"); exit(-1); } while(1){ sleep(1); start_time = time(NULL); printf("%s", ctime(&start_time)); printf("Main Thread, doing jobs!\n"); } close(serv_sock_fd); return 0; }
先运行服务器端程序,再运行客户端程序。服务器端程序输出如下:
Thu Mar 31 15:44:02 2016 Main Thread, doing jobs! Thu Mar 31 15:44:03 2016 Main Thread, doing jobs! Thu Mar 31 15:44:04 2016 Main Thread, doing jobs! Thu Mar 31 15:44:05 2016 Main Thread, doing jobs! Thu Mar 31 15:44:06 2016 Main Thread, doing jobs! Thu Mar 31 15:44:07 2016 Receive client message Thu Mar 31 15:44:07 2016 Main Thread, doing jobs! Thu Mar 31 15:44:08 2016 Main Thread, doing jobs! Thu Mar 31 15:44:09 2016 Main Thread, doing jobs!可以看到,服务器端不会阻塞在I/O等待上面,而是执行其它操作。当客户端数据到达时,由信号处理函数负责处理。
八. 异步I/O(Asynchronous I/O)
异步I/O是指,当用户进程发起I/O操作时,内核立即给用户进程返回,用户进程不受到任何阻拦,并且可以去完成其它操作。而用户进程所发起的I/O操作,由内核负责进行数据的准备和内核态到用户空间的数据拷贝。当这一切工作都完成时,内核向用户进程发送一个信号,告知读操作已经完成。
可见,以上提到的阻塞I/O,非阻塞I/O和信号I/O,都归属于同步I/O的范畴,而在执行读操作时,都属于阻塞操作的范畴。而异步I/O,则是真正的非阻塞,因为它不会对用户进程产生任何的阻塞。
九. 总结
最后,还是借用Richard Stevens书中的一幅图,来把阻塞I/O,非阻塞I/O,同步I/O,异步I/O进行一下总结。