I/O模型的一些理解
- 一些基本的概念
- 同步阻塞、同步非阻塞、异步阻塞、异步非阻塞
- 总结概念
- I/O模型
- 一些例子
- 从源头解释
- 从TCP发送数据的流程说起
- 阻塞I/O | 非阻塞I/O
- I/O多路复用
- 信号驱动I/O
- 异步I/O
- 再谈IO模型里面的同步异步
参考连接
参考链接
参考链接
一些基本的概念
-
阻塞(blocking)、非阻塞(non-blocking):可以简单理解为需要做一件事能不能立即得到返回应答,比如read函数
-
同步(synchronous)、异步(asynchronous): 你总是做完一件再去做另一件,不管是否需要时间等待,这就是同步(就是在发出一个功能调用时,在没有得到结果之前,该调用就不返回,即此时不能做下一件事情);异步则反之,你可以同时做几件事,并非一定需要一件事做完再做另一件事(当一个异步过程调用发出后,调用者不能立刻得到结果,此时可以接着做其它事情)。同步可以理解为多个相互之间一定联系的并发执行的进程,有序执行,异步则没有约定一定的次序。
举个更加形象的例子,如果主线程上执行了一系列函数,某个函数A需要与相关线程交互,调用了另一个函数a时:
-
如果立即去执行函数 a,这称为同步。
-
如果没有去执行函数 a,而是将执行函数 a 的时机安排在未来的某个时间,然后马上继续执行函数 A,这称为异步。
-
当执行函数 a 时,直至获得完整的资源之前,都暂停执行当前函数,这称为阻塞。
-
当执行函数 a 时,立即获得瞬时的结果,然后马上继续执行当前函数。如果获得的瞬时资源不是完整的资源,之后周期性发送类似的请求,直至获得完整的资源,这称为非阻塞。
同步阻塞、同步非阻塞、异步阻塞、异步非阻塞
-
同步阻塞:在需要某资源时马上发起请求,并暂停本线程之后的程序,直至获得所需的资源。
-
同步非阻塞:在需要某资源时马上发起请求,且可以马上得到答复,然后继续执行之后的程序。但如果得到的不是完整的资源,之后将周期性地的请求,直至获得所需的资源。
-
异步阻塞:在需要某资源时不马上发起请求,而安排一个以后的时间再发起请求。当到了那时发出请求时,将暂停本线程之后的程序,直至获得所需的资源。在获取资源之后,使用共享信号量、异步回调等方式将结果异步反馈。
-
异步非阻塞:在需要某资源时不马上发起请求,而安排一个以后的时间再发起请求。当到了那时发出请求时,可以马上得到答复,然后继续执行之后的程序。但如果得到的不是完整的资源,之后将周期性地的请求。在最终获取到资源之后,使用共享信号量、异步回调等方式将结果异步反馈。
总结概念
-
异步:把事情推到以后去做
-
阻塞:专心做一件事情
-
同步阻塞:马上专心做一件事情
-
同步非阻塞:一边做一件事情,一边做另一件事情(一心二用)
-
异步阻塞:把问题推到以后专心处理
-
异步非阻塞:把问题推到以后时不时处理一下
I/O模型
IO (Input/Output,输入/输出)即数据的读取(接收)或写入(发送)操作,通常用户进程中的一个完整IO分为两阶段:用户进程空间<–>内核空间、内核空间<–>设备空间(磁盘、网络等)。IO有内存IO、网络IO和磁盘IO三种,通常我们说的IO指的是后两者。
针对网络I/O,可以分成两个阶段,准备阶段和操作阶段。
- 准备阶段:判断是否能够操作(即等待数据是否可用),在内核进程完成的;
- 操作阶段:执行实际的IO调用,数据从内核缓冲区拷贝到用户进程缓冲区。
比如对于一个read操作,它会经历下面两个阶段:
-
等待数据准备,数据是否拷贝到内核缓冲区;
-
将数据从内核拷贝到用户进程空间
重点理解上面这两个阶段
UNP中提到,5种IO模型分别是阻塞IO模型、非阻塞IO模型、IO复用模型、信号驱动的IO模型、异步IO模型;前4种为同步IO操作,只有异步IO模型是异步IO操作。
同步I/O向应用程序通知的是I/O就绪事件,异步I/O向应用程序通知的是I/O完成事件
1、阻塞IO:在准备阶段即同步阻塞,应用进程调用I/O操作时阻塞,只有等待要操作的数据准备好,并复制到应用进程的缓冲区中才返回;
2、非阻塞IO:当应用进程要调用的I/O操作会导致该进程进入阻塞状态时,该I/O调用返回一个错误,一般情况下,应用进程需要利用轮询的方式来检测某个操作是否就绪。数据就绪后,实际的I/O操作会等待数据复制到应用进程的缓冲区中以后才返回;
3、IO复用:多路IO共用一个同步阻塞接口,任意IO可操作都可激活IO操作,这是对阻塞IO的改进(主要是select和poll、epoll,关键是能实现同时对多个IO端口进行监听)。此时阻塞发生在select/poll的系统调用上,而不是阻塞在实际的I/O系统调用上。IO多路复用的高级之处在于:它能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态,select等函数就可以返回。
4、信号驱动IO:注册一个IO信号事件,在数据可操作时通过SIGIO信号通知线程,这应该算是一种异步机制;
以上四种模型在第一阶段即判断是否可操作阶段各不相同,但一旦数据可操作,则切换到同步阻塞模式下执行IO操作,所以都算是同步IO。
5、异步IO: 应用进程通知内核开始一个异步I/O操作,并让内核在整个操作(包含将数据从内核复制到应该进程的缓冲区)完成后通知应用进程。
在UNP里面,根据上述两个阶段,可以进行归类
-
阻塞IO:在两个阶段上面都是阻塞的;
-
非阻塞IO:在第1阶段,程序不断的轮询直到数据准备好,第2阶段还是阻塞的;
-
IO复用:在第1阶段,当一个或者多个IO准备就绪时,通知程序,第2阶段还是阻塞的,在第1阶段还是轮询实现的,只是所有的IO都集中在一个地方,这个地方进行轮询;
-
信号IO:当数据准备完毕的时候,信号通知程序数据准备完毕,第2阶段阻塞;
-
异步IO:1,2都不阻塞
一些例子
阻塞、非阻塞、I/O多路复用:socket可以设置为阻塞和非阻塞两种,结合select、poll、epoll可以实现I/O多路复用
信号I/O:
client.cpp
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <iostream>
volatile sig_atomic_t io_ready = 0;
int sockfd = -1;
void io_handler(int sig) {
io_ready = 1;
}
int main()
{
struct sigaction sa;
char buffer[1024];
// 创建套接字
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
// 向服务器(特定的IP和端口)发起请求
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr)); // 每个字节都用0填充
serv_addr.sin_family = AF_INET; // 使用IPv4地址
serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); // 具体的IP地址
serv_addr.sin_port = htons(1234); // 端口
connect(sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));
// 设置超时时间为5秒
struct timeval timeout;
timeout.tv_sec = 5;
timeout.tv_usec = 0;
setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, (char *)&timeout, sizeof(timeout));
// 设置套接字为非阻塞模式
int flags = fcntl(sockfd, F_GETFL, 0);
if (fcntl(sockfd, F_SETFL, flags | O_NONBLOCK) == -1)
{
perror("fcntl");
close(sockfd);
exit(EXIT_FAILURE);
}
// 设置信号处理函数
sa.sa_handler = io_handler;
sa.sa_flags = 0;
sigemptyset(&sa.sa_mask);
if (sigaction(SIGIO, &sa, NULL) == -1)
{
perror("sigaction");
close(sockfd);
exit(EXIT_FAILURE);
}
// 设置套接字的所有者
if (fcntl(sockfd, F_SETOWN, getpid()) == -1)
{
perror("fcntl F_SETOWN");
close(sockfd);
exit(EXIT_FAILURE);
}
// 启用信号驱动 I/O
if (fcntl(sockfd, F_SETFL, O_ASYNC) == -1)
{
perror("fcntl F_SETFL");
close(sockfd);
exit(EXIT_FAILURE);
}
// 等待接收数据
while (!io_ready)
{
// 模拟进程正在执行其他任务
std::cout << "模拟进程正在执行其他任务"<< std::endl;
sleep(1);
}
// 接收数据
ssize_t len = recv(sockfd, buffer, sizeof(buffer) - 1, 0);
if (len == -1)
{
perror("recv");
close(sockfd);
exit(EXIT_FAILURE);
}
buffer[len] = '\0';
printf("Received: %s\n", buffer);
// 关闭套接字
close(sockfd);
return 0;
}
server.cpp
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <iostream>
int main()
{
// 创建套接字
int serv_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
// 将套接字和IP、端口绑定
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr)); // 每个字节都用0填充
serv_addr.sin_family = AF_INET; // 使用IPv4地址
serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); // 具体的IP地址
serv_addr.sin_port = htons(1234); // 端口
// 设置端口复用
int opt = 1;
setsockopt(serv_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
bind(serv_sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr));
// 进入监听状态,等待用户发起请求
listen(serv_sock, 20);
// 接收客户端请求
struct sockaddr_in clnt_addr;
socklen_t clnt_addr_size = sizeof(clnt_addr);
int clnt_sock = accept(serv_sock, (struct sockaddr *)&clnt_addr, &clnt_addr_size);
// 打印收到的客户端地址端口信息
std::cout << "Client address: " << inet_ntoa(clnt_addr.sin_addr) << ", port: " << ntohs(clnt_addr.sin_port) << std::endl;
sleep(5);
for (int i = 0; i < 1; i++)
{
// 向客户端发送数据
char str[] = "hello client!!!";
write(clnt_sock, str, sizeof(str));
}
sleep(10000);
// 关闭套接字
close(clnt_sock);
close(serv_sock);
return 0;
}
上面的例子中,server就只做一件事,监听客户端连接,一旦建立以后,等待5s再发送数据
而client首先绑定了信号处理函数,当套接字有数据可以读的时候,内核会发出SIGIO信号给进程,然后执行信号处理函数。将io_ready变量设置为1,表示可以读取数据了。然后,主循环中的进程可以跳出循环,执行接收数据的操作。
一般来说都会设置套接字是非阻塞的,但我感觉这里recv实际上已经不会阻塞了?因为肯定是有了数据才会执行到这一步的
从源头解释
从TCP发送数据的流程说起
以两个应用程序通讯为例,我们来了解一下当“A”向"B" 发送一条消息,简单来说会经过如下流程:
第一步:应用A把消息发送到 TCP发送缓冲区。
第二步: TCP发送缓冲区再把消息发送出去,经过网络传递后,消息会发送到B服务器的TCP接收缓冲区。
第三步:B再从TCP接收缓冲区去读取属于自己的数据。
阻塞I/O | 非阻塞I/O
我们把视角切换到上面图中的第三步, 也就是应用B从TCP缓冲区中读取数据。
思考一个问题:
因为应用之间发送消息是间断性的,也就是说在上图中TCP缓冲区还没有接收到属于应用B该读取的消息时,那么此时应用B向TCP缓冲区发起读取申请,TCP接收缓冲区是应该马上告诉应用B 现在没有你的数据,还是说让应用B在这里等着,直到有数据再把数据交给应用B。
把这个问题应用到第一个步骤也是一样,应用A在向TCP发送缓冲区发送数据时,如果TCP发送缓冲区已经满了,那么是告诉应用A现在没空间了,还是让应用A等待着,等TCP发送缓冲区有空间了再把应用A的数据访拷贝到发送缓冲区。
什么是阻塞IO
如果上面的问题你已经思考过了,那么其实你已经明白了什么是阻塞IO了,所谓阻塞IO就是当应用B发起读取数据申请时,在内核数据没有准备好之前,应用B会一直处于等待数据状态,直到内核把数据准备好了交给应用B才结束。
术语描述:在应用调用recvfrom读取数据时,其系统调用直到数据包到达且被复制到应用缓冲区中或者发送错误时才返回,在此期间一直会等待,进程从调用到返回这段时间内都是被阻塞的称为阻塞IO;
流程:
1、应用进程向内核发起recfrom读取数据。
2、准备数据报(应用进程阻塞)。
3、将数据从内核负责到应用空间。
4、复制完成后,返回成功提示。
什么是非阻塞IO
我敢保证如果你已经理解了阻塞IO,那么必定已经知道了什么是非阻塞IO。按照上面的思路,所谓非阻塞IO就是当应用B发起读取数据申请时,如果内核数据没有准备好会即刻告诉应用B,不会让B在这里等待。
术语:非阻塞IO是在应用调用recvfrom读取数据时,如果该缓冲区没有数据的话,就会直接返回一个EWOULDBLOCK错误,不会让应用一直等待中。在没有数据的时候会即刻返回错误标识,那也意味着如果应用要读取数据就需要不断的调用recvfrom请求,直到读取到它要的数据为止。即需要自己实现一个轮询,比如while?又比如epoll ET时候必须设置非阻塞,判断EAGAIN
流程:
1、应用进程向内核发起recvfrom读取数据。
2、没有数据报准备好,即刻返回EWOULDBLOCK错误码。
3、应用进程向内核发起recvfrom读取数据。
4、已有数据包准备好就进行一下 步骤,否则还是返回错误码。
5、将数据从内核拷贝到用户空间。
6、完成后,返回成功提示。
I/O多路复用
如果你已经明白了非阻塞IO的工作模式,那么接下来我们继续了解IO复用模型的产生原因和思路。
思考一个问题:
我们还是把视角放到应用B从TCP缓冲区中读取数据这个环节来。如果在并发的环境下,可能会N个人向应用B发送消息,这种情况下我们的应用就必须创建多个线程去读取数据,每个线程都会自己调用recvfrom 去读取数据。那么此时情况可能如下图:
如上图一样,并发情况下服务器很可能一瞬间会收到几十上百万的请求,这种情况下应用B就需要创建几十上百万的线程去读取数据,同时又因为应用线程是不知道什么时候会有数据读取,为了保证消息能及时读取到,那么这些线程自己必须不断的向内核发送recvfrom 请求来读取数据;
那么问题来了,这么多的线程不断调用recvfrom 请求数据,先不说服务器能不能扛得住这么多线程,就算扛得住那么很明显这种方式是不是太浪费资源了,线程是我们操作系统的宝贵资源,大量的线程用来去读取数据了,那么就意味着能做其它事情的线程就会少。
所以,有人就提出了一个思路,能不能提供一种方式,可以由一个线程监控多个网络请求(我们后面将称为fd文件描述符,linux系统把所有网络请求以一个fd来标识),这样就可以只需要一个或几个线程就可以完成数据状态询问的操作,当有数据准备就绪之后再分配对应的线程去读取数据,这么做就可以节省出大量的线程资源出来,这个就是IO复用模型的思路。
正如上图,IO复用模型的思路就是系统提供了一种函数可以同时监控多个fd的操作,这个函数就是我们常说到的select、poll、epoll函数,有了这个函数后,应用线程通过调用select函数就可以同时监控多个fd,select函数监控的fd中只要有任何一个数据状态准备就绪了,select函数就会返回可读状态,这时询问线程再去通知处理数据的线程,对应线程此时再发起recvfrom请求去读取数据。
术语描述:进程通过将一个或多个fd传递给select,阻塞在select操作上,select帮我们侦测多个fd是否准备就绪,当有fd准备就绪时,select返回数据可读状态,应用程序再调用recvfrom读取数据。
总结:复用IO的基本思路就是通过slect或poll、epoll 来监控多fd ,来达到不必为每个fd创建一个对应的监控线程,从而减少线程资源创建的目的。
信号驱动I/O
复用IO模型解决了一个线程可以监控多个fd的问题,但是select是采用轮询的方式来监控多个fd的,通过不断的轮询fd的可读状态来知道是否有可读的数据,而无脑的轮询就显得有点暴力,因为大部分情况下的轮询都是无效的,所以有人就想,能不能不要我总是去问你是否数据准备就绪,能不能我发出请求后等你数据准备好了就通知我,所以就衍生了信号驱动IO模型。
于是信号驱动IO不是用循环请求询问的方式去监控数据就绪状态,而是在调用sigaction时候建立一个SIGIO的信号联系,当内核数据准备好之后再通过SIGIO信号通知线程数据准备好后的可读状态,当线程收到可读状态的信号后,此时再向内核发起recvfrom读取数据的请求,因为信号驱动IO的模型下应用线程在发出信号监控后即可返回,不会阻塞,所以这样的方式下,一个应用线程也可以同时监控多个fd。
术语描述:首先开启套接口信号驱动IO功能,并通过系统调用sigaction执行一个信号处理函数,此时请求即刻返回,当数据准备就绪时,就生成对应进程的SIGIO信号,通过信号回调通知应用线程调用recvfrom来读取数据。
总结: IO复用模型里面的select虽然可以监控多个fd了,但select其实现的本质上还是通过不断的轮询fd来监控数据状态, 因为大部分轮询请求其实都是无效的,所以信号驱动IO意在通过这种建立信号关联的方式,实现了发出请求后只需要等待数据就绪的通知即可,这样就可以避免大量无效的数据状态轮询操作。
异步I/O
其实经过了上面两个模型的优化,我们的效率有了很大的提升,但是我们当然不会就这样满足了,有没有更好的办法,通过观察我们发现,不管是IO复用还是信号驱动,我们要读取一个数据总是要发起两阶段的请求,第一次发送select请求,询问数据状态是否准备好,第二次发送recevform请求读取数据。
思考一个问题:
也许你一开始就有一个疑问,为什么我们明明是想读取数据,而却非得要先发起一个select询问数据状态的请求,然后再发起真正的读取数据请求,能不能有一种一劳永逸的方式,我只要发送一个请求我告诉内核我要读取数据,然后我就什么都不管了,然后内核去帮我去完成剩下的所有事情?
当然既然你想得出来,那么就会有人做得到,有人设计了一种方案,应用只需要向内核发送一个read 请求,告诉内核它要读取数据后即刻返回;内核收到请求后会建立一个信号联系,当数据准备就绪,内核会主动把数据从内核复制到用户空间,等所有操作都完成之后,内核会发起一个通知告诉应用,我们称这种一劳永逸的模式为异步IO模型。
术语描述: 应用告知内核启动某个操作,并让内核在整个操作完成之后,通知应用,这种模型与信号驱动模型的主要区别在于,信号驱动IO只是由内核通知我们合适可以开始下一个IO操作,而异步IO模型是由内核通知我们操作什么时候完成。
**总结:**异步IO的优化思路是解决了应用程序需要先后发送询问请求、发送接收数据请求两个阶段的模式,在异步IO的模式下,只需要向内核发送一次请求就可以完成状态询问和数拷贝的所有操作。
再谈IO模型里面的同步异步
我们通常会说到同步阻塞IO、同步非阻塞IO,异步IO几种术语,通过上面的内容,那么我想你现在肯定已经理解了什么是阻塞什么是非阻塞了,所谓阻塞就是发起读取数据请求的时,当数据还没准备就绪的时候,这时请求是即刻返回,还是在这里等待数据的就绪,如果需要等待的话就是阻塞,反之如果即刻返回就是非阻塞。
我们区分了阻塞和非阻塞后再来分别下同步和异步,在IO模型里面如果请求方从发起请求到数据最后完成的这一段过程中都需要自己参与,那么这种我们就称为同步请求;反之,如果应用发送完指令后就不再参与过程了,只需要等待最终完成结果的通知,那么这就属于异步。
我们再看同步阻塞、同步非阻塞,他们不同的只是发起读取请求的时候一个请求阻塞,一个请求不阻塞,但是相同的是,他们都需要应用自己监控整个数据完成的过程。而为什么只有异步非阻塞 而没有异步阻塞呢,因为异步模型下请求指定发送完后就即刻返回了,没有任何后续流程了,所以它注定不会阻塞,所以也就只会有异步非阻塞模型了。从常规意义上看,异步阻塞不存在。