携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第1天,[点击查看活动详情]
笔者准备将这个略微复杂且无比重要概念分为三次文章讲解。
原因一, 对于epoll这种复杂精巧,细节颇多的技术概念,需要通过抽象到具体,整体概览到局部细节的方式去掌握学习。
原因二, 对于枯燥的技术文章来说,过于冗杂的内容输出,即影响笔者的编写思路,也影响读者的阅读体验。
原因三,我懒。
今天的主题:深入理解网络IO五种模型
1. 详解网络IO
1. 网络IO
每次谈到网络 IO,会涉及到两个系统对象,一个是用户空间调用 IO 的进程或者线程,另一个是内核空间的内核系统,比如发生 IO 操作 read 时,它会经历两个阶段:
- 等待数据准备就绪
- 将数据从内核拷贝到进程或者线程中。
因为在以上两个阶段上各有不同的情况,所以出现了多种网络 IO 模型
2. 网络IO模型(5种)
阻塞IO (blocking IO)
当用户进程调用 read 系统调用,kernel 就开始 IO 的第一个阶段:准备数据。
若kernel没有收到一个完整的数据包,则需要等待足够的数据到来,在用户进程这边进程会被阻塞。
直到数据准备好,kernel将数据拷贝到用户内存,然后通知用户进程解除block状态,重新运行。
所以,blocking IO的特点就是在IO执行的两个阶段(等待数据和拷贝数据两个阶段)都被block。
大家第一次接触到的网络编程都是从 listen()、send()、recv() 等接口开始的,这些接口都是阻塞型的。
使用这些接口可以很方便的构建服务器/客户机的模型。
下面是一个简单地“一问一答”的服务器。
int main(int argc, char **argv)
{
int listenfd, connfd, n;
struct sockaddr_in servaddr;
char buff[MAXLNE];
//socket创建
if ((listenfd = socket(AF_INET, SOCK_STREAM, 0)) == -1) {
printf("create socket error: %s(errno: %d)\n", strerror(errno), errno);
return 0;
}
//定义五元组
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(9999);
//绑定listen与五元组
if (bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) == -1) {
printf("bind socket error: %s(errno: %d)\n", strerror(errno), errno);
return 0;
}
if (listen(listenfd, 10) == -1) {
printf("listen socket error: %s(errno: %d)\n", strerror(errno), errno);
return 0;
}
struct sockaddr_in client;
socklen_t len = sizeof(client);
//这里处理了一次accpet
if ((connfd = accept(listenfd, (struct sockaddr *)&client, &len)) == -1) {
printf("accept socket error: %s(errno: %d)\n", strerror(errno), errno);
return 0;
}
printf("========waiting for client's request========\n");
while (1) {
n = recv(connfd, buff, MAXLNE, 0);
if (n > 0) {
buff[n] = '\0';
printf("recv msg from client: %s\n", buff);
send(connfd, buff, n, 0);
} else if (n == 0) {
close(connfd);
}
//close(connfd);
}
close(listenfd);
return 0;
}
非阻塞 IO(non-blocking IO)
Linux 下,可以通过设置 socket 使其变为 non-blocking。
当用户进程发出 read 操作时,如果 kernel 中的数据还没有准备好,那么它并不会 block 用户进程,而是立刻返回一个 error。
从用户进程角度讲 ,它发起一个read 操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个 error时,它就知道数据还没有准备好,于是它可以再次发送 read 操作。一旦 kernel 中的数据 准备好了,并且又再次收到了用户进程的 system call,那么它马上就将数据拷贝到了用 户内存,然后返回。
所以,在非阻塞式 IO 中,用户进程其实是需要不断的主动询问 kernel 数据准备好了没有。
多路复用 IO (IO multiplexing)
有些地方也称这种 IO 方式为事件驱动 IO(event driven IO)。
select/epoll 的好处就在于单个 process 就可以同时处理多个网络连接的 IO。
它的基本原理就是 select/epoll 这个 function会不断的轮询所负责的所有 socket,当某个 socket 有数据到达了,就通知用户进程。
异步 IO(Asynchronous I/O)
Linux 下的 asynchronous IO 用在磁盘 IO 读写操作,不用于网络 IO,从内核 2.6 版本才开始引入。
用户进程发起 read 操作之后,立刻就可以开始去做其它的事。
从 kernel的角度,当它受到一个 asynchronous read 之后,首先它会立刻返回,所以不会对用户进程产生任何 block。
kernel 会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel 会给用户进程发送一个 signal,告诉它 read 操作完成了。
异步 IO 是真正非阻塞的,它不会对请求进程产生任何的阻塞,因此对高并发的网络服务器实现至关重要。
blocking 和 non-blocking 的区别在哪?
调用 blocking IO 会一直 block 住对应的进程直到操作完成,而non-blocking IO 在 kernel 还在准备数据的情况下会立刻返回。
synchronous IO 和 asynchronous IO的区别在哪?
两者的区别就在于 synchronous IO 做 IO operation 时会将进程阻塞。
按照这个定义,之前所述的 blocking IO,non-blocking IO,IO multiplexing 都属于 synchronous IO。
有人可能会说,non-blocking IO 并没有被 block 啊。这里有个非常 “狡猾”的地方,定义中所指的”IO operation”是指真实的 IO 操作,就是例子中的 read 这 个系统调用。non-blocking IO 在执行 read 这个系统调用的时候,如果 kernel 的数据没 有准备好,这时候不会 block 进程。但是当 kernel 中数据准备好的时候,read 会将数据 从 kernel 拷贝到用户内存中,这个时候进程是被 block 了,在这段时间内进程是被 block 的。
而 asynchronous IO 则不一样,当进程发起 IO 操作之后,就直接返回再也不理睬了, 直到kernel发送一个信号,告诉进程说IO完成。在这整个过程中,进程完全没有被block。
信号驱动 IO(signal driven I/O, SIGIO)
这是一个存活于实验室的,很古老网络IO方式。
void do_sigio(int sig) { //sig是信号id,来了一个信号,我们能在这个地方接收数据
struct sockaddr_in cli_addr; //客户端地址
int clilen = sizeof(struct sockaddr_in); //客户端地址长度
int clifd = 0;
char buffer[256] = {0};
//调用recvfrom接收数据,返回接收的长度
int len = recvfrom(sockfd, buffer, 256, 0, (struct sockaddr*)&cli_addr, (socklen_t*)&clilen);
printf("Listen Message : %s\r\n", buffer);
int slen = sendto(sockfd, buffer, len, 0, (struct sockaddr*)&cli_addr, clilen);
}
int main(int argc, char *argv[]) {
sockfd = socket(AF_INET, SOCK_DGRAM, 0); //create a socket
//AF_INET用于指定套接字可以与之通信的地址类型,IP+端口号
//创建套接字的时候必须指定其地址族,然后只能使用该类型的地址与套接字
//SOCK_DGRAM是UDP
//SOCK_STREAM是TCP
struct sigaction sigio_action;
sigio_action.sa_flags = 0;
sigio_action.sa_handler = do_sigio;
sigaction(SIGIO, &sigio_action, NULL); //IO有数据,触发信号sig,就走这个地方,执行do_sigio
//下面这一团写法都是固定的,确定五元组
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(9096);
serv_addr.sin_addr.s_addr = INADDR_ANY;
fcntl(sockfd, F_SETOWN, getpid());
int flags = fcntl(sockfd, F_GETFL, 0);
flags |= O_ASYNC | O_NONBLOCK; //异步,非阻塞
fcntl(sockfd, F_SETFL, flags);
//绑定fd与五元组
bind(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)); //bind a ip address
while(1) sleep(1);
close(sockfd);
return 0;
}
2. 为什么高并发情况下会选择使用IO多路复用(epoll)
使用 select 以后最大的优势是用户可以在一个线程内同时处理多个 socket 的 IO 请求。
用户可以注册多个 socket,然后不断地调用 select 读取被激活的 socket,即可达到 在同一个线程内同时处理多个 IO 请求的目的。
而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。
所以,如果处理的连接数不是很高的话,使用select/epoll 的 web server 不一定比使用 multi-threading + blocking IO 的 webserver 性能更好,可能延迟还更大。select/epoll 的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。
下一篇深入理解epoll