一、什么是IO?
我们都知道unix世界里、一切皆文件、而文件是什么呢?文件就是一串二进制流而已、不管socket、还是FIFO、管道、终端、对我们来说、一切都是文件、一切都是流、在信息交换的过程中、我们都是对这些流进行数据的收发操作、简称为I/O操作(input and output)、往流中读出数据、系统调用read、写入数据、系统调用write、不过话说回来了、计算机里有这么多的流、我怎么知道要操作哪个流呢?做到这个的就是文件描述符、即通常所说的fd、一个fd就是一个整数、所以对这个整数的操作、就是对这个文件(流)的操作、我们创建一个socket、通过系统调用会返回一个文件描述符、那么剩下对socket的操作就会转化为对这个描述符的操作、不能不说这又是一种分层和抽象的思想。
二、内核态与用户态
在进入IO模型的主题前,我想先引入一个知识点,因为这个点自始至终与IO模型的发展相关联。
你了解操作系统中的用户态和内核态吗?
在Linux技术讨论中经常会用户态和内核态术语脱口而出,可你们想过吗?用户态和内核态代表是什么?
如下图所示

从图上我们可以看出来通过系统调用将Linux整个体系分为用户态和内核态(或者说内核空间和用户空间)。那内核态到底是什么呢?其实从本质上说就是我们所说的内核,它是一种特殊的软件程序,特殊在哪儿呢?它能控制计算机的硬件资源,例如协调CPU资源,分配内存资源、IO操作,并且提供稳定的环境供应用程序运行。
用户态就是提供应用程序运行的空间,为了使应用程序访问到内核管理的资源例如CPU,内存,I/O。内核必须提供一组通用的访问接口,这些接口就叫系统调用。
因此,我们所写的应用程序Golang、Java程序都是运行在用户态中的,为了安全,我们不能直接调用内核中的操作。
例如我要发起一次网络请求(即一次IO操作),它将会触发CPU中断.使得CPU切换当前用户态中的应用程序,并保存当前程序的上下文资源,通过DMA控制器操作外设(网卡),请求网络资源,与此同时,CPU可以空闲,也可以被切换执行其他应用程序。
当网络IO操作完成后,DMA控制器通过总线发送一个中断给CPU,于是CPU从当前正在运行的应用程序切换到上一个执行的用户态应用程序中,并且使用系统调用recv(与此同时CPU再次切换到内核态),将Kernel Buffer中的数据拷贝到用户进程的IO Buffer。拷贝完毕,CPU再切换回用户态应用程序。
因此一次网络IO操作的底层会是CPU在用户态和内核态之间发生切换。并且应用程序调用IO,其实是一次系统调用。即发生在内核空间中的调用。
三、IO模型
(一)、同步阻塞IO (Bklocking IO 也称 BIO)
服务端采用单线程,当accept一个请求后,在recv或send调用阻塞时,将无法accept其他请求(必须等上一个请求处recv或send完),无法处理并发
具体我们可以看下面的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// 伪代码描述 while(1) { // accept阻塞 client_fd = accept(listen_fd) fds.append(client_fd) for (fd in fds) { // recv阻塞(会影响上面的accept) if (recv(fd)) { // logic } } } |
accept()接受客户端连接操作和recv()读取数据操作他们都是同步阻塞的,这将会导致只有连接来临时,它才能继续执行下面的代码。因此如果代码执行到recv() 这一关时,一直没有接收到客户端发来的数据,我们也将无法 accept()客户端。
解决方案:最为传统的解决方案是每Accept()一个连接句柄时,就将开启一个线程,将receive()数据的操作放入至新的线程中执行。
缺点:如果并发量比较大,服务端就会创建大量的线程,而且会有大量的线程阻塞在网络IO上,频繁的线程上下文切换会占用大量的cpu时间片,严重影响服务性能,而且大量的线程也需要占用大量的系统资源
这样就引出著名的C10K
问题,如何在单台服务器上支持并发10K
量级的连接
(二)、同步非阻塞(No-Blocking IO 也称NIO)
同步非阻塞IO需要我们手动设置setNonblocking。服务器端当accept一个请求后,加入fds集合(fd即文件描述符,每次创建socket必定会生成文件描述符),每次轮询一遍fds集合recv(非阻塞)数据,没有数据则立即返回错误。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
// 设置非阻塞 setNonblocking(listen_fd) // 伪代码描述 while(1) { // accept非阻塞(cpu一直忙轮询) client_fd = accept(listen_fd) if (client_fd != null) { // 有人连接则加入到 fds 数组集合 fds.append(client_fd) } else { // 无人连接 } for (fd in fds) { // 设置 recv 非阻塞 setNonblocking(client_fd) // 当从文件描述符中读取到数据时 if (len = recv(fd) && len > 0) { // 具体处理逻辑 // ...... } else { 无读写数据 } } } |
优点:它避免开辟大量的线程,导致线程切换时造成的CPU时间片浪费。
缺点:我们能看到每次轮询所有fd(包括没有发生读写事件的fd)会很浪费cpu。由于我们的应用程序是运行在用户态中,且每一次recv将会是一次系统调用,由于大多数fd没有收到数据,它会导致CPU从用户态和内核态多次无用的来回切换。
(三)、IO多路复用
由于上面的缺陷,操作系统级别提供了一些接口来支持IO多路复用,最老掉牙的是select
和poll
IO多路复用其实就是多个网络IO复用一个线程。
首先是select,我们知道NIO的缺点便是CPU在用户态和内核态多次的无用切换。因此最好的法子是减少这二者的切换的次数。
它的原理是将fds(文件描述符集合)一次性传入给内核,让内核替我们遍历所有文件描述符。而当内核监测到有数据事件时,则会取消阻塞。其实和NIO比起来,原理就是将遍历所有fd的recv操作直接在内核中执行罢了。减少了CPU切换
select
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
int main() { /* * 这里进行一些初始化的设置, * 包括socket建立,地址的设置等, */ fd_set read_fs, write_fs; struct timeval timeout; int max = 0; // 用于记录最大的fd值,在轮询中时刻更新即可 for (int i=0;i<count(fds);i++){ if(fds[i]>max){ max=fds[i] } } // 初始化比特位,它是一个字节数组,长度为 1024,具体作用好比为位图 FD_ZERO(&read_fs); FD_ZERO(&write_fs); for (int i=0;i<count(fds);i++){ // FD_SET 会将文件描述符对应到 read_fs 数组的下标,并且将值设置为 1 ,类似于桶排序的一种位图处理 FD_SET(fds[i],&read_fs) FD_SET(fds[i],&write_fs) } int nfds = 0; // 记录就绪的事件,可以减少遍历的次数 while (1) { // 下面的代码将会阻塞,当且仅当有数据事件触发时才会返回,nfds代表有多少个fd有事件。此时如果触发了读事件会在 read_fd 数组上做标记,write_fd 数组同理对应写事件。 nfds = select(max + 1, &read_fd, &write_fd, NULL, &timeout); // 每次需要遍历所有fd,判断有无读写事件发生 // 此时for循环中的i不仅仅是索引,每一次的i还是一个文件描述符 for (int i = 0; i <= max && nfds; ++i) { // 如果是个 accpet 事件接受到的fd正好等于i,顺手添加到位图中,以便下一次大循环内核的监控 if (i == listenfd) { --nfds; // 这里处理accept事件 FD_SET(i, &read_fd);//将客户端socket加入到集合中 } if (FD_ISSET(i, &read_fd)) { --nfds; // 这里处理read事件 } if (FD_ISSET(i, &write_fd)) { --nfds; // 这里处理write事件 } } } |
select的缺点:
- 单个进程所打开的FD是有限制的,通过FD_SETSIZE设置,默认1024
- 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
- 对socket扫描时是线性扫描,采用轮询的方法,效率较低(高并发时)
Poll
poll与select相比,只是没有fd的限制,其它基本一样,只不过它多了一个pollfd的友好数据结构
Poll的数据结构形式如下
1 2 3 4 5 6 7 8 9 10 |
#include <poll.h> // 数据结构 struct pollfd { int fd; // 需要监视的文件描述符 short events; // 需要内核监视的事件 short revents; // 实际发生的事件 }; // API int poll(struct pollfd fds[], nfds_t nfds, int timeout); |
我们模拟有5个文件描述符,并且只关注数据被写入时,我们去读取数据的情况
伪代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
#define MAX_POLLFD_LEN 4096 用宏定义一个常量 // 数据结构 struct pollfd { int fd; // 文件描述符 short events; // 需要内核监视的事件 short revents; // 实际发生的事件 }; int nfds = 0; pollfd fds[MAX_POLLFD_LEN]; // 把文件描述符集合定义为 pollfd 类型的数组 // 模拟只有 5 个 文件描述符的情况 for (i=0;i<5;i++){ fds[i].fd = accept(.......); // 设置 accept 返回的文件描述符 fds[i].events = POLLIN; // POLLIN 是个常量,代表着内核需要监测写入事件 } while (1) { // poll 函数也是一个阻塞的函数,当且仅有当数据返回时才会继续往下面执行 // 传入的参数 fds (文件描述符的集合) 、 5 代表个数 、timeout nfds = poll(fds, 5, timeout); // 每次需要遍历所有 fd,判断有无读写事件发生 for (int i = 1; i < 5; ++i) { // 如果该实际发生的事件为写入事件 if(fds[i].revents == POLLIN ){ fds[i].revents = 0 // 重置事件 read(fds[i].fd,buffer,maxReadCount) // 读取操作 // 其它操作 ..... } } |
poll的缺点:
- 与select一样每次调用poll,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
- 对socket扫描时是线性扫描,采用轮询的方法,效率较低(高并发时)
小总结:我们从上面的poll和select的代码中可以得知,它们都无法避免需要遍历文件描述符集合。假定最坏的情况下,fds集合数组的最后一个fd有事件发生,我们在程序中最终需要遍历整个fds集合,才能得知是哪几个fd发生了事件。这个算法的时间复杂度是 O(n)级别的
epoll
epoll是最新的一种多路IO复用函数。对应的算法的时间复杂度为 O(1)。
epoll操作过程需要三个接口,分别如下:
1 2 3 |
int epoll_create(int size);//创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大 int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout); |
1. int epoll_create(int size);创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大,这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值,参数size并不是限制了epoll所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议。当创建好epoll句柄后,它就会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。
2. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);函数是对指定描述符fd执行op操作。- epfd:是epoll_create()的返回值。- op:表示op操作,用三个宏来表示:添加EPOLL_CTL_ADD,删除EPOLL_CTL_DEL,修改EPOLL_CTL_MOD。分别添加、删除和修改对fd的监听事件。- fd:是需要监听的fd(文件描述符)- epoll_event:是告诉内核需要监听什么事,struct epoll_event结构如下:
3. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);等待epfd上的io事件,最多返回maxevents个事件。参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size,参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时。
伪代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 |
int main(int argc, char* argv[]) { #define IPADDRESS "127.0.0.1" #define PORT 8787 #define MAXSIZE 1024 #define LISTENQ 5 #define FDSIZE 1000 #define EPOLLEVENTS 100 listenfd = socket_bind(IPADDRESS,PORT); /* * 在这里进行一些初始化的操作, * 比如初始化数据和socket等。 */ // 内核中创建ep对象 epfd=epoll_create(256); // 实例化一个 epoll_event 即事件对象 struct epoll_event ev // 实例化一个接收事件的数组 struct epoll_event events[MaxSize] // 我们只监听写入事件 EPOLLIN ev.events=EPOLLIN // 绑定 fd 到 epoll_event ev.data.fd=fd // 需要监听的 fd 放到ep中 epoll_ctl(epfd,EPOLL_CTL_ADD,fd,&ev); while(1) { // 下面这个 epoll_wait 是阻塞代码, // 当有事件返回时才会停止阻塞 // 它仅仅会将产生的事件的 fd 添加进 events 数组中,因此我们后续只需要遍历 events 而不需要遍历整个 fds nfds = epoll_wait(epfd,events,20,0); for(i=0;i<nfds;++i) { if(events[i].data.fd==listenfd) { // 这里处理accept事件 connfd = accept(listenfd); // 接收新连接写到内核对象中 epoll_ctl(epfd,EPOLL_CTL_ADD,connfd,&ev); } else if (events[i].events&EPOLLIN) { // 这里处理read事件 read(sockfd, BUF, MAXLINE); //读完后准备写 epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev); } else if(events[i].events&EPOLLOUT) { // 这里处理write事件 write(sockfd, BUF, n); //写完后准备读 epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev); } } } return 0; } |
epoll缺点:只允许工作在Linux下
epoll应用
- redis
- nginx
epoll的两大模式
- 水平触发(LT)
- 边缘触发(ET)
LT模式下,只要这个fd还有数据可读,每次 epoll_wait都会返回它的事件,提醒用户程序去操作。
ET模式下,它只会提示一次,直到下次再有数据流入之前都不会再提示了,无论fd中是否还有数据可读。所以在ET模式下,read一个fd的时候一定要把它的buffer读完。
个人偏向使用ET模式,因为在LT模式下,如果fd还有数据可读,epoll_wait都会返回它的事件,这会导致CPU不停的在内核态与用户态之间频繁切换,造成性能损耗。
(四)、信号驱动IO(signal-driven IO)
信号驱动IO(signal-driven IO),使用信号机制,让内核在描述符就绪时发送SIGIO信号通知用户进程。整个过程是先通过sigaction系统调用安装一个信号处理函数。该系统调用将立即返回,用户进程继续工作,也就是说它没有被阻塞。当数据报准备好读取时,内核就为该进程产生一个SIGIO信号,我们随后可以在信号处理函数中调用recvfrom读取内核空间准备好的数据。特点:第一阶段(等待数据报到达期间)进程不被阻塞。
盗用知乎的一张图,它大概长这样:

(五)、异步IO (也称为 AIO)
上面几种都是同步IO。同步和异步的概念就在于是否是应用程序主动调用read()方法将内核中的数据拷贝进用户空间,而主动调用read这个系统调用是同步阻塞的。而异步IO就体现在完成两边数据同步的阶段中由内核线程默默完成数据的同步任务,而不是同步IO中的用户态应用程序主动调用read方法来拷贝一份数据到用户空间。所以这里没阻塞,称之为异步IO
最后的吐槽:其实好多内容都是东拼西凑的,一方面是自己的语言组织能力有限,因此引用了好多别人的文章,这一刻理解网络编程中的IO,成就感还是有的。
文章评论(0)