最近经过对ucore操做系统的学习,让我打开了操做系统内核这一黑盒子,与以前所学知识结合起来,解答了长久以来困扰个人关于I/O的一些问题。html
1. 为何redis能以单工做线程处理高达几万的并发请求?java
2. 什么是I/O多路复用?为何redis、nginx、nodeJS以及netty等以高性能著称的服务器其底层都利用了I/O多路复用技术?node
3. 非阻塞I/O为何会流行起来,在许多场景下取代了传统的阻塞I/O?linux
4. 非阻塞I/O真的是银弹吗?为何即便在为海量用户提供服务的,追求高性能的互联网公司中依然有那么多的服务器在传统的阻塞IO模型下工做?nginx
5. 什么是协程?为何Go语言这么受欢迎?程序员
在这篇博客中,将介绍不一样层面、不一样I/O模型的原理,并尝试着给出我对上述问题的回答。若是你也或多或少的对上述问题感到疑惑,但愿这篇博客能为你提供帮助。web
I/O模型和硬件、操做系统内核息息相关,博客中会涉及到诸如保护模式、中断、特权级、进程/线程、上下文切换、系统调用等关于操做系统、硬件相关的概念。因为计算机中的知识是按照层次组织起来的,若是对这些相对底层的概念不是很了解的话可能会影响对总体内容的理解。能够参考一下我关于操做系统、硬件学习相关的博客:x86汇编学习、操做系统学习(持续更新中)。redis
软件的功能老是构建在硬件上的,计算机中的I/O本质上是CPU/内存与外设(网卡、磁盘等)进行数据的单向或双向传输。数据库
从外设读入数据到CPU/内存称做Input输入,从CPU/内存中写出数据到外设称做Output输出。编程
要想理解软件层次上的不一样I/O模型,必须先对其基于的硬件I/O模型有一个基本的认识。硬件I/O模型大体能够分为三种:程序控制I/O、中断驱动I/O、使用DMA的I/O。
程序控制I/O模型中,经过指令控制CPU不断的轮询外设是否就绪,当硬件就绪时一点一点的反复读/写数据。
从CPU的角度来讲,程序控制I/O模型是同步、阻塞的(同步指的是I/O操做依然是处于程序指令控制,由CPU主导的;阻塞指的是在发起I/O后CPU必须持续轮询完成状态,没法执行别的指令)。
程序控制I/O的优势:
硬件结构简单,编写对应程序也简单。
程序控制I/O的缺点:
十分消耗CPU,持续的轮训令宝贵的CPU资源无谓的浪费在了等待I/O完成的过程当中,致使CPU利用率不高。
为了解决上述程序控制I/O模型对CPU资源利用率不高的问题,计算机硬件的设计者令CPU拥有了处理中断的功能。
在中断驱动I/O模型中,CPU发起对外设的I/O请求后,就直接去执行别的指令了。当硬件处理完I/O请求后,经过中断异步的通知CPU。接到读取完成中断通知后,CPU负责将数据从外设缓冲区中写入内存;接到写出完成中断通知后,CPU须要将内存中后续的数据接着写出交给外设处理。
从CPU的角度来讲,中断驱动I/O模型是同步、非阻塞的(同步指的是I/O操做依然是处于程序指令控制,由CPU主导的;非阻塞指的是在发起I/O后CPU不会停下等待,而是能够执行别的指令)。
中断驱动I/O的优势:
因为I/O老是相对耗时的,比起经过程序控制I/O模型下CPU不停的轮训。在等待硬件I/O完成的过程当中CPU能够解放出来执行另外的命令,大大提升了I/O密集程序的CPU利用率。
中断驱动I/O的缺点:
受制于硬件缓冲区的大小,一次硬件I/O能够处理的数据是相对有限的。在处理一次大数据的I/O请求中,CPU须要被反复的中断,而处理读写中断事件自己也是有必定开销的。
为了解决中断驱动I/O模型中,大数据量的I/O传输使得CPU须要反复处理中断的缺陷,计算机硬件的设计者提出了基于DMA模式的I/O(DMA Direct Memory Access 直接存储器访问)。DMA也是一种处理器芯片,和CPU同样也能够访问内存和外设,但DMA芯片是被设计来专门处理I/O数据传输的,所以其成本相对CPU较低。
在使用DMA的I/O模型中,CPU与DMA芯片交互,指定须要读/写的数据块大小和须要进行I/O数据的目的内存地址后,便异步的处理别的指令了。由DMA与外设硬件进行交互,一次大数据量的I/O须要DMA反复的与外设进行交互,当DMA完成了总体数据块的I/O后(完整的将数据读入到内存或是完整的将某一内存块的数据写出到外设),再发起DMA中断通知CPU。
从CPU的角度来讲,使用DMA的I/O模型是异步、非阻塞的(异步指的是整个I/O操做并非由CPU主导,而是由DMA芯片与外设交互完成的;非阻塞指的是在发起I/O后CPU不会停下等待,而是能够执行别的指令)。
使用DMA的I/O优势:
比起外设硬件中断通知,对于一次完整的大数据内存与外设间的I/O,CPU只须要处理一次中断。CPU的利用效率相对来讲是最高的。
使用DMA的I/O缺点:
1. 引入DMA芯片令硬件结构变复杂,成本较高。
2. 因为DMA芯片的引入,使得DMA和CPU并发的对内存进行操做,在拥有高速缓存的CPU中,引入了高速缓存与内存不一致的问题。
总的来讲,自DMA技术被发明以来,因为其极大减小了CPU在I/O时的性能损耗,已经成为了绝大多数通用计算机的硬件标配。随着技术的发展又出现了更先进的通道I/O方式,至关于并发的DMA,容许并发的处理涉及多个不一样内存区域、外设硬件的I/O操做。
介绍完硬件的I/O模型后,下面介绍这篇博客的重点:操做系统I/O模型。
操做系统帮咱们屏蔽了诸多硬件外设的差别,为应用程序的开发者提供了友好、统一的服务。为了不应用程序破坏操做系统内核,CPU提供了保护模式机制,使得应用程序没法直接访问被操做系统管理起来的外设,而必须经过内核提供的系统调用间接的访问外设。关于操做系统I/O模型的讨论针对的就是应用程序与内核之间进行I/O交互的系统调用模型。
' 操做系统内核提供的I/O模型大体能够分为几种:同步阻塞I/O、同步非阻塞I/O、同步I/O多路复用、异步非阻塞I/O(信号驱动I/O用的比较少,就不在这里展开了)。
咱们已经知道,高效的硬件层面I/O模型对于CPU来讲是异步的,但应用程序开发者老是但愿在执行完I/O系统调用后能同步的返回,线性的执行后续逻辑(例如当磁盘读取的系统调用返回后,下一行代码中就能直接访问到所读出的数据)。但这与硬件层面耗时、异步的I/O模型相违背(程序控制I/O过于浪费CPU),所以操做系统内核提供了基于同步、阻塞I/O的系统调用(BIO)来解决这一问题。
举个例子:当线程经过基于BIO的系统调用进行磁盘读取时,内核会令当前线程进入阻塞态,让出CPU资源给其它并发的就绪态线程,以便更有效率的利用CPU。当DMA完成读取,异步的I/O中断到来时,内核会找到先前被阻塞的对应线程,将其唤醒进入就绪态。当这个就绪态的线程被内核CPU调度器选中再度得到CPU时,便能从对应的缓冲区结构中获得读取到的磁盘数据,程序同步的执行流便能顺利的向下执行了。(感受好像线程卡在了那里不动,过了一会才执行下一行,且指定的缓冲区中已经有了所需的数据)
下面的伪代码示例中参考linux的设计,将不一样的外设统一抽象为文件,经过文件描述符(file descriptor)来统一的访问。
BIO伪代码实例 :
// 建立TCP套接字并绑定端口8888,进行服务监听 listenfd = serverSocket(8888,"tcp"); while(true){ // accept同步阻塞调用 newfd = accept(listenfd); // read会阻塞,所以使用线程异步处理,避免阻塞accpet(通常使用线程池) new thread(()->{ // 同步阻塞读取数据 xxx = read(newfd); ... dosomething // 关闭链接 close(newfd); }); }
BIO模型的优势:
BIO的I/O模型因为同步、阻塞的特性,屏蔽了底层实质上异步的硬件交互方式,令程序员能够编写出简单易懂的线性程序逻辑。
BIO模型的缺点:
1. BIO的同步、阻塞特性在简单易用的同时,也存在一些性能上的缺陷。因为BIO在等待I/O完成的时间中,线程虽然被阻塞不消耗CPU,但内核维护一个系统级线程自己也是有必定的开销(维护线程控制块、内核线程栈空间等等)。
2. 不一样线程在调度时的上下文切换CPU开销较大,在现在大量用户、高并发的互联网时代愈来愈成为web服务器性能的瓶颈。线程上下文切换自己须要须要保存、恢复现场,同时还会清空CPU指令流水线,以及令高速缓存大量失效。对于一个web服务器,若是使用BIO模型,服务器将至少须要1:1的维护同等数量的系统级线程(内核线程),因为持续并发的网络数据交互,致使不一样线程因为网络I/O的完成事件被内核反复的调度。
在著名的C10K问题的语境下,一台服务器须要同时维护1W个并发的tcp链接和对等的1W个系统级线程。量变引发质变,1W个系统级线程调度引发的上下文切换和100个系统级线程的调度开销彻底不一样,其将耗尽CPU资源,令整个系统卡死,崩溃。
BIO交互流程示意图:
BIO模型简单易用,但其阻塞内核线程的特性使得其已经不适用于须要处理大量(1K以上)并发网络链接场景的web服务器了。为此,操做系统内核提供了非阻塞特性的I/O系统调用,即NIO(NonBlocking-IO)。
针对BIO模型的缺陷,NIO模型的系统调用不会阻塞当前调用线程。但因为I/O本质上的耗时特性,没法当即获得I/O处理的结果,NIO的系统调用在I/O未完成时会返回特定标识,表明对应的I/O事件还未完成。所以须要应用程序按照必定的频率反复调用,以获取最新的IO状态。
NIO伪代码实例 :
// 建立TCP套接字并绑定端口8888,进行服务监听 listenfd = serverSocket(8888,"tcp"); clientFdSet = empty_set(); while(true){ // 开启事件监听循环 // accept同步非阻塞调用,判断是否接收了新的链接 newfd = acceptNonBlock(listenfd); if(newfd != EMPTY){ // 若是存在新链接将其加入监听链接集合 clientFdSet.add(newfd); } // 申请一个1024字节的缓冲区 buffer = new buffer(1024); for(clientfd in clientFdSet){ // 非阻塞read读 num = readNonBlock(clientfd,buffer); if(num > 0){ // 读缓冲区存在数据 data = buffer; ... dosomething if(needClose(data)){ // 关闭链接时,移除当前监听的链接 clientFdSet.remove(clientfd); } } ... dosomething // 清空buffer buffer.clear(); } }
NIO模型的优势:
NIO由于其非阻塞的特性,使得一个线程能够处理多个并发的网络I/O链接。在C10K问题的语境下,理论上能够经过一个线程处理这1W个并发链接(对于多核CPU,能够建立多个线程在每一个CPU核心中分摊负载,提升性能)。
NIO模型的缺点:
NIO克服了BIO在高并发条件下的缺陷,但原始的NIO系统调用依然有着必定的性能问题。在上述伪代码示例中,每一个文件描述符对应的I/O状态查询,都必须经过一次NIO系统调用才能完成。
因为操做系统内核利用CPU提供的保护模式机制,使内核运行在高特权级,而令用户程序运行在执行、访问受限的低特权级。这样设计的一个好处就是使得应用程序没法直接的访问硬件,而必须由操做系统提供的系统调用间接的访问硬件(网卡、磁盘甚至电源等)。执行系统调用时,须要令应用线程经过系统调用陷入内核(即提升应用程序的当前特权级CPL,使其可以访问受保护的硬件),并在系统调用返回时恢复为低特权级,这样一个过程在硬件上是经过中断实现的。
经过中断实现系统调用的效率远低于应用程序本地的函数调用,所以原始的NIO模式下经过系统调用循环访问每一个文件描述符I/O就绪状态的方式是低效的。
NIO交互流程示意图:
为了解决上述NIO模型的系统调用中,一次事件循环遍历进行N次系统调用的缺陷。操做系统内核在NIO系统调用的基础上提供了I/O多路复用模型的系统调用。
I/O多路复用相对于NIO模型的一个优化即是容许在一次I/O状态查询的系统调用中,一次传递复数个文件描述符进行批量的I/O状态查询。在一次事件循环中只须要进行一次I/O多路复用的系统调用就能获得所传递文件描述符集合的I/O状态,减小了原始NIO模型中没必要要的系统调用开销。
多路复用I/O模型大体能够分为三种实现(虽然不一样操做系统在最终实现上略有不一样,但原理是相似的,示例代码以linux内核举例):select、poll、epoll。
select I/O多路复用器容许应用程序传递须要监听事件变化的文件描述符集合,监听其读/写,接受链接等I/O事件的状态。
select系统调用自己是同步、阻塞的,当所传递的文件描述符集合中都没有就绪的I/O事件时,执行select系统调用的线程将会进入阻塞态,直到至少一个文件描述符对应的I/O事件就绪,则唤醒被select阻塞的线程(能够指定超时时间来强制唤醒并返回)。唤醒后得到CPU的线程在select系统调用返回后能够遍历所传入的文件描述符集合,处理完成了I/O事件的文件描述符。
select伪代码示例:
// 建立TCP套接字并绑定端口8888,进行服务监听 listenfd = serverSocket(8888,"tcp"); fdNum = 1; clientFdSet = empty_set(); clientFdSet.add(listenfd); while(true){ // 开启事件监听循环 // man 2 select(查看linux系统文档) // int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout); // 参数nfds:一共须要监听的readfds、writefds、exceptfds中文件描述符个数+1 // 参数readfds/writefds/exceptfds: 须要监听读、写、异常事件的文件描述符集合 // 参数timeout:select是同步阻塞的,当timeout时间内都没有任何I/O事件就绪,则调用线程被唤醒并返回(ret=0) // timeout为null表明永久阻塞 // 返回值ret: // 1.返回大于0的整数,表明传入的readfds/writefds/exceptfds中共有ret个被激活(须要应用程序本身遍历), // 2.返回0,在阻塞超时前没有任何I/O事件就绪 // 3.返回-1,出现错误 listenReadFd = clientFdSet; // select多路复用,一次传入须要监听事件的全量链接集合(超时时间1s) result = select(fdNum+1,listenReadFd,null,null,timeval("1s")); if(result > 0){ // 若是服务器监听链接存在读事件 if(IN_SET(listenfd,listenReadFd)){ // 接收并创建链接 newClientFd = accept(listenfd); // 加入客户端链接集合 clientFdSet.add(newClientFd);
fdNum++; } // 遍历整个须要监听的客户端链接集合 for(clientFd : clientFdSet){ // 若是当前客户端链接存在读事件 if(IN_SET(clientFd,listenReadFd)){ // 阻塞读取数据 data = read(clientfd); ... dosomething if(needClose(data)){ // 关闭链接时,移除当前监听的链接 clientFdSet.remove(clientfd);
fdNum--; } } } } }
select的优势:
1. select多路复用避免了上述原始NIO模型中无谓的屡次查询I/O状态的系统调用,将其聚合成集合,批量的进行监听并返回结果集。
2. select实现相对简单,windows、linux等主流的操做系统都实现了select系统调用,跨平台的兼容性好。
select的缺点:
1. 在事件循环中,每次select系统调用都须要从用户态全量的传递所须要监听的文件描述符集合,而且select返回后还须要全量遍历以前传入的文件描述符集合的状态。
2. 出于性能的考量,内核设置了select所监听文件描述符集合元素的最大数量(通常为1024,可在内核启动时指定),使得单次select所能监听的链接数受到了限制。
3. 抛开性能的考虑,从接口设计的角度来看,select将系统调用的参数与返回值混合到了一块儿(返回值覆盖了参数),增长了使用者理解的困难度。
I/O多路复用交互示意图:
poll I/O多路复用器在使用上和select大同小异,也是经过传入指定的文件描述符集合以及指定内核监听对应文件描述符上的I/O事件集合,但在实现的细节上基于select作了必定的优化。
和select同样,poll系统调用在没有任何就绪事件发生时也是同步、阻塞的(能够指定超时时间强制唤醒并返回),当返回后要判断是否有就绪事件时,也同样须要全量的遍历整个返回的文件描述符集合。
poll伪代码示例:
/* // man 2 poll(查看linux系统文档) // 和select不一样将参数events和返回值revents分开了 struct pollfd { int fd; // file descriptor 对应的文件描述符 short events; // requested events 须要监听的事件 short revents; // returned events 返回时,就绪的事件 }; // 参数fds,要监听的poolfd数组集合 // 参数nfds,传入fds数组中须要监听的元素个数 // 参数timeout,阻塞的超时时间(传入-1表明永久阻塞) int poll(struct pollfd *fds, nfds_t nfds, int timeout); //events/revents是位图表示的 //revents & POLLIN == 1 存在就绪的读事件 //revents & POLLOUT == 1 存在就绪的写事件 //revents & POLLHUP == 1 存在对端断开链接或是通讯完成事件 */ // 建立TCP套接字并绑定端口8888,进行服务监听 listenfd = serverSocket(8888,"tcp"); MAX_LISTEN_SIZE = 100; struct pollfd fds[MAX_LISTEN_SIZE]; // 设置服务器监听套接字(监听读事件) fds[0].fd = listenfd; fds[0].events = POLLIN; fds[0].revents = 0; // 客户端链接数一开始为0 int clientCount = 0; while(true){ // poll同步阻塞调用(超时时间-1表示永久阻塞直到存在监听的就绪事件) int ret = poll(fds, clientCount + 1, -1); for (int i = 0; i < clientCount + 1; i++){ if(fds[i].fd == listenfd && fds[i].revents & POLLIN){ // 服务器监听套接字读事件就绪,创建新链接 clientCount++; fds[clientCount].fd = conn; fds[clientCount].events = POLLIN | POLLRDHUP ; fds[clientCount].revents = 0; }else if(fds[i].revents & POLLIN){ // 其余连接可读,进行读取 read(fds[i].fd); ... doSomething }else if(fds[i].revents & POLLRDHUP){ // 监听到客户端链接断开,移除该链接 fds[i] = fds[clientCount]; i--; clientCount--; // 关闭该链接 close(fd); } } }
poll的优势:
1. poll解决了select系统调用受限于内核配置参数的限制问题,能够同时监听更多文件描述符的I/O状态(但不能超过内核限制当前进程所能拥有的最大文件描述符数目限制)。
2. 优化了接口设计,将参数与返回值的进行了分离。
poll的缺点:
1. poll优化了select,但在处理大量闲置链接时,即便真正产生I/O就绪事件的活跃文件描述符数量不多,依然免不了线性的遍历整个监听的文件描述符集合。每次调用时,须要全量的将整个感兴趣的文件描述符集合从用户态复制到内核态。
2. 因为select/poll都须要全量的传递参数以及遍历返回值,所以其时间复杂度为O(n),即处理的开销随着并发链接数n的增长而增长,而不管并发链接自己活跃与否。但通常状况下即便并发链接数不少,大量链接都产生I/O就绪事件的状况并很少,更多的状况是1W的并发链接,可能只有几百个是处于活跃状态的,这种状况下select/poll的性能并不理想,还存在优化的空间。
epoll是linux系统中独有的,针对select/poll上述缺点进行改进的高性能I/O多路复用器。
针对poll系统调用介绍中的第一个缺点:在每次事件循环时都须要从用户态全量传递整个须要监听的文件描述符集合。
epoll在内核中分配内存空间用于缓存被监听的文件描述符集合。经过建立epoll的系统调用(epoll_create),在内核中维护了一个epoll结构,而在应用程序中只须要保留epoll结构的句柄就可对其进行访问(也是一个文件描述符)。能够动态的在epoll结构的内核空间中增长/删除/更新所要监听的文件描述符以及不一样的监听事件(epoll_ctl),而没必要每次都全量的传递须要监听的文件描述符集合。
针对select/poll的第二个缺点:在系统调用返回后经过修改所监听文件描述符结构的状态,来标识文件描述符对应的I/O事件是否就绪。每次系统调用返回时,都须要全量的遍历整个监听文件描述符集合,而不管是否真的完成了I/O。
epoll监听事件的系统调用完成后,只会将真正活跃的、完成了I/O事件的文件描述符返回,避免了全量的遍历。在并发的链接数很大,但闲置链接占比很高时,epoll的性能大大优于select/poll这两种I/O多路复用器。epoll的时间复杂度为O(m),即处理的开销不随着并发链接n的增长而增长,而是仅仅和监控的活跃链接m相关;在某些状况下n远大于m,epoll的时间复杂度甚至能够认为近似的达到了O(1)。
经过epoll_wait系统调用,监听参数中传入对应epoll结构中关联的全部文件描述符的对应I/O状态。epoll_wait自己是同步、阻塞的(能够指定超时时间强制唤醒并返回),当epoll_wait同步返回时,会返回处于活跃状态的完成I/O事件的文件描述符集合,避免了select/poll中的无效遍历。同时epoll使用了mmap机制,将内核中的维护的就绪文件描述符集合所在空间映射到了用户态,令应用程序与epoll的内核共享这一区域的内存,避免了epoll返回就绪文件描述符集合时的一次内存复制。
epoll伪代码示例:
/** epoll比较复杂,使用时大体依赖三个系统调用 (man 7 epoll) 1. epoll_create 建立一个epoll结构,返回对应epoll的文件描述符 (man 2 epoll_create) int epoll_create(); 2. epoll_ctl 控制某一epoll结构(epfd),向其增长/删除/更新(op)某一其它链接(fd),监控其I/O事件(event) (man 2 epoll_ctl) op有三种合法值:EPOLL_CTL_ADD表明新增、EPOLL_CTL_MOD表明更新、EPOLL_CTL_DEL表明删除 int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); 3. epoll_wait 令某一epoll同步阻塞的开始监听(epfd),感兴趣的I/O事件(events),所监听fd的最大个数(maxevents),指定阻塞超时时间(timeout) (man 2 epoll_wait) int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout); */ // 建立TCP套接字并绑定端口8888,进行服务监听 listenfd = serverSocket(8888,"tcp"); // 建立一个epoll结构 epollfd = epoll_create(); ev = new epoll_event(); ev.events = EPOLLIN; // 读事件 ev.data.fd = listenfd; // 经过epoll监听服务器端口读事件(新链接创建请求) epoll_ctl(epollfd,EPOLL_CTL_ADD,listenfd,ev); // 最大监听1000个链接 MAX_EVENTS = 1000; listenEvents = new event[MAX_EVENTS]; while(true){ // 同步阻塞监听事件 // 最多返回MAX_EVENTS个事件响应结果 // (超时时间1000ms,标识在超时时间内没有任何事件就绪则当前线程被唤醒,返回值nfd将为0) nfds = epoll_wait(epollfd, listenEvents, MAX_EVENTS, 1 * 1000); for(n = 0; n < nfds; ++n){ if(events[n].data.fd == listenfd){ // 当发现服务器监听套接字存在可读事件,创建新的套接字链接 clientfd = accept(listenfd); ev.events = EPOLLIN | EPOLLET; ev.data.fd = clientfd; // 新创建的套接字链接也加入当前epoll的监听(监听读(EPOLLIN)/写(EPOLLET)事件) epoll_ctl(epollfd,EPOLL_CTL_ADD,clientfd,ev); } else{ // 不然是其它链接的I/O事件就绪,进行对应的操做 ... do_something } } }
epoll的优势:
epoll是目前性能最好的I/O多路复用器之一,具备I/O多路复用优势的状况下很好的解决了select/poll的缺陷。目前linux平台中,像nginx、redis、netty等高性能服务器都是首选epoll做为基础来实现网络I/O功能的。
epoll的缺点:
1. 常规状况下闲置链接占比很大,epoll的性能表现的很好。可是也有少部分场景中,绝大多数链接都是活跃的,那么其性能与select/poll这种基于位图、数组等简单结构的I/O多路复用器相比,就不那么有优点了。由于select/poll被诟病的一点就是一般状况下进行了无谓的全量检查,而当活跃链接数占比一直超过90%甚至更高时,就再也不是浪费了;相反的,因为epoll内部结构比较复杂,在这种状况下其性能比select/poll还要低一点。
2. epoll是linux操做系统下独有的,使得基于epoll实现的应用程序的跨平台兼容性受到了必定影响。
windows和linux都支持了select系统调用,但linux内核在以后又实现了epoll这一更高性能的I/O多路复用器来改进select。
windows没有模仿linux,而是提供了被称为IOCP(Input/Output Completion Port 输入输出完成端口)的功能解决select性能的问题。IOCP采用异步非阻塞IO(AIO)的模型,其与epoll同步非阻塞IO的最大区别在于,epoll调用完成后,仅仅返回了就绪的文件描述符集合;而IOCP则在内核中自动的完成了epoll中本来应该由应用程序主动发起的I/O操做。
举个例子,当监听到就绪事件开始读取某一网络链接的请求报文时,epoll依然须要经过程序主动的发起读取请求,将数据从内核中读入用户空间。而windows下的IOCP则是经过注册回调事件的方式工做,由内核自动的将数据放入指定的用户空间,当处理完毕后会调度激活注册的回调事件,被唤醒的线程能直接访问到所须要的数据。
这也是为何BIO/NIO/IO多路复用被称为同步I/O,而IOCP被称为异步I/O的缘由。
同步I/O与异步I/O的主要区别就在于站在应用程序的视角看,真正读取/写入数据时是不是由应用程序主导的。若是须要用户程序主动发起最终的I/O请求就被称为同步I/O;而若是是内核自动完成I/O后通知用户程序,则被称为异步I/O。(能够类比在前面硬件I/O模型中,站在CPU视角的同步、异步I/O模型,只不过这里CPU变成了应用程序,而外设/DMA变成了操做系统内核)
AIO的优势:
AIO做为异步I/O,由内核自动的完成了底层一整套的I/O操做,应用程序在事件回调通知中能直接获取到所需数据。内核中能够实现很是高效的调度、通知框架。拥有前面NIO高性能的优势,又简化了应用程序的开发。
AIO的缺点:
由内核全盘控制的全自动I/O虽然可以作到足够高效,可是在一些特定场景下性能并不必定能超过由应用程序主导的,通过深度优化的代码。像epoll在支持了和select/poll同样的水平触发I/O的同时,还支持了更加细致的边缘触发I/O,容许用户自主的决定当I/O就绪时,是否须要当即处理或是缓存起来等待稍后再处理。(就像java等支持自动内存垃圾回收的语言,即便其垃圾收集器通过持续的优化,在大多数状况下性能都很不错,但却依然没法达到和通过开发人员反复调优,手动回收内存的C、C++等语言实现的程序同样的性能)
(截图自《Unix网络编程 卷1》)
1. 同步I/O包括了同步阻塞I/O和同步非阻塞I/O,而异步I/O中因为异步阻塞I/O模型没有太大价值,所以提到异步I/O(AIO)时,默认指的就是异步非阻塞I/O。
2. 在I/O多路复用器的工做中,当监听到对应文件描述符I/O事件就绪时,后续进行的读/写操做既能够是阻塞的,也能够是非阻塞的。若是是都以阻塞的方式进行读/写,虽然实现简单,但若是某一文件描述符须要读写的数据量很大时将耗时较多,可能会致使事件循环中的其它事件得不到及时处理。所以截图中的阻塞读写数据部分并不许确,须要辩证的看待。
计算机技术的发展看似突飞猛进,但本质上有两类目标指引着其前进。一是尽量的加强、压榨硬件的性能,提升机器效率;二是尽量的经过持续的抽象、封装简化软件复杂度,提升程序员的开发效率。计算机软件的发展方向必须至少须要知足其中一种目标。
从上面关于操做系统内核I/O模型的发展中能够看到,最初被普遍使用的是易理解、开发简单的BIO模型;但因为互联网时代的到来,web服务器系统面临着C10K问题,须要能支持海量的并发客户端链接,所以出现了包括NIO、I/O多路复用、AIO等技术,利用一个内核线程管理成百上千的并发链接,来解决BIO模型中一个内核线程对应一个网络链接的工做模式中,因为处理大量链接致使内核线程上下文频繁切换,形成CPU资源耗尽的问题。上述的第一条原则指引着内核I/O模型的发展,使得web服务器可以得到更大的链接服务吞吐量,提升了机器效率。
但非阻塞I/O真的是天衣无缝的吗?
有着非阻塞I/O模型开发经验的程序员都知道,正是因为一个内核线程管理着成百上千个客户端链接,所以在整个线程的执行流中不能出现耗时、阻塞的操做(好比同步阻塞的数据库查询、rpc接口调用等)。若是这种操做不可避免,则须要单独使用另外的线程异步的处理,而不能阻塞当前的整个事件循环,不然将会致使其它链接的请求得不到及时的处理,形成饥饿。
对于多数互联网分布式架构下处理业务逻辑的应用程序服务器来讲,在一个网络请求服务中,可能须要频繁的访问数据库或者经过网络远程调用其它服务的接口。若是使用的是基于NIO模型进行工做的话,则要求rpc库以及数据库、中间件等链接的库是支持异步非阻塞的。若是因为同步阻塞库的存在,在每次接受链接进行服务时依然被迫经过另外的线程处理以免阻塞,则NIO服务器的性能将退化到和使用传统的BIO模型同样的地步。
所幸的是随着非阻塞I/O的逐渐流行,上述问题获得了很大的改善。
异步非阻塞库改变了同步阻塞库下程序员习觉得常的,线性的思惟方式,在编码时被迫的以事件驱动的方式思考。逻辑上连贯的业务代码为了适应异步非阻塞的库程序,被迫分隔成多个独立片断嵌套在各个不一样层次的回调函数中。对于复杂的业务而言,很容易出现嵌套为一层层的回调函数,造成臭名昭著的callback hell(回调地狱)。
最先被callback hell折磨的多是客户端程序的开发人员,由于客户端程序须要时刻监听着用户操做事件的产生,一般以基于事件驱动的方式组织异步处理代码。
callback hell伪代码示例:
// 因为互相之间有先后的数据依赖,按照顺序异步的调用A、B、C、D A.dosomething((res)->{ data = res.xxx; B.dosomething(data,(res)->{ data = res.xxx; C.dosomething(data,(res)->{ data = res.xxx D.dosomething(data,(res)->{ // 。。。 有依赖的同步业务越复杂,层次越深,就像一个无底洞 }) }) }) })
异步非阻塞库的使用割裂了代码的连贯结构,使得程序变得难以理解、调试,这一缺陷在堆积着复杂晦涩业务逻辑的web应用程序服务器程序中显得难以忍受。这也是为何现在web服务器仍然有很大一部分依然使用传统的同步阻塞的BIO模型进行开发的主要缘由。经过分布式、集群的方式分摊大量并发的链接,而只在业务相对简单的API网关、消息队列等I/O密集型的中间件程序中NIO才被普遍使用(实在不行,业务服务器集群能够加机器,保证开发效率也一样重要)。
那么就没有什么办法既可以拥有非阻塞I/O支撑海量并发、高吞吐量的性能优点;又可以令程序员以同步方式思考、编写程序,以提升开发效率吗?
解决办法固然是存在的,且相关技术依然在不断发展。上述计算机技术发展的第二个原则指导着这些技术发展,目的是为了简化代码复杂性,提升程序员的效率。
在函数式编程的领域,就一直有着诸多晦涩的“黑科技”(CPS变换、monad等),可以简化callback hell,使得能够以几乎是同步的方式编写实质上是异步执行的代码。例如EcmaScript便在EcmaScript六、EcmaScript7中分别引入了promise和async/await来解决这一问题。
前面提到,传统的基于BIO模型的工做模式最大的优势在于能够同步的编写代码,遇到须要等待的耗时操做时可以被同步阻塞,使用起来简单易懂。但因为1:1的维护内核线程在处理海量链接时因为频繁的内核线程上下文切换而力不从心,催生了非阻塞I/O。
而因为上述非阻塞I/O引发的代码复杂度的增长,计算机科学家们想到了很早以前就在操做系统概念中提出,但一直没有被普遍使用的另外一种线程实现方式:用户级线程。
用户级线程顾名思义,就是在用户级实现的线程,操做系统内核对其是无感知的。用户级线程在许多方面与你们所熟知的内核级线程类似,都有着本身独立的执行流,和进程中的其它线程共享内存空间。
用户级线程与内核级线程最大的一个区别就在于因为操做系统对其无感知,所以没法对用户级线程进行基于中断的抢占式调度。要使得同一进程下的不一样用户级线程可以协调工做,必须当心的编写执行逻辑,以互相之间主动让渡CPU的形式工做,不然将会致使一个用户级线程持续不断的占用CPU,而令其它用户级线程处于饥饿状态,所以用户级线程也被称为协程,即互相协做的线程。
用户级线程不管如何是基于至少一个内核线程/进程的,多个用户级线程能够挂载在一个内核线程/进程中被内核统一的调度管理。
(截图自《现代操做系统》)
协程能够在遇到I/O等耗时操做时选择主动的让出CPU,以实现同步阻塞的效果,令程序执行流转移到另外一个协程中。因为多个协程能够复用一个内核线程,每一个协程所占用的开销相对内核级线程来讲很是小;且协程上下文切换时因为不须要陷入内核,其切换效率也远比内核线程的上下文切换高(开销近似于一个函数调用)。
最近很流行的Go语言就是因为其支持语言层面的协程而备受推崇。程序员能够利用一些语言层面提供的协程机制编写高效的web服务器程序(例如在语句中添加控制协程同步的关键字)。经过在编译后的最终代码中加入对应的协程调度指令,由协程调度器接手,控制协程同步时在耗时I/O操做发生时主动的让出CPU,并在处理完毕后能被调度回来接着执行。Go语言经过语言层面上对协程的支持,下降了编写正确、协调工做的协程代码的难度。
Go编写的高性能web服务器若是运行在多核CPU的linux操做系统中,通常会建立m个内核线程和n个协程(m正比与CPU核心数,n远大于m且正比于并发链接数),底层每一个内核线程依然能够利用epoll IO多路复用器处理并发的网络链接,并将业务逻辑处理的任务转交给用户态的协程(gorountine)。每一个协程能够在不一样的内核线程(CPU核心)中被来回调度,以得到最大的CPU吞吐量。
使用协程,程序员在开发时可以编写同步阻塞的耗时I/O代码,又不用担忧高并发状况下BIO模型中的性能问题。能够说协程兼顾了程序开发效率与机器执行效率,所以愈来愈多的语言也在语言层面或是在库函数中提供协程机制。
在经过虚拟机做为中间媒介,操做系统平台无关的语言中(好比java),虚拟机做为应用程序与操做系统内核的中间层,能够对应用程序进行各方面的优化,令程序员能够轻松编写出高效的代码。
有大牛在知乎的一篇回答中提到过,其曾经领导团队在阿里巴巴工做时在java中实现了透明的协程。但彷佛没有和官方标准达成统一所以并无对外开放。
若是可以在虚拟机中提供高效、用户透明的协程机制,使得本来基于BIO多线程的服务器程序无需改造便自动的得到了支持海量并发的能力,那真是太强了Orz。
经过对ucore操做系统源码级的研究学习,加深了我对操做系统原理书中各类抽象概念的理解,也渐渐理解了一些关于各类I/O模型的问题。
一方面,经过对操做系统I/O模型的总结,使得我对于上层应用程序如java中的nio和netty中的非阻塞的编程风格有了更深的理解,再也不像以前只习惯于BIO编程那样感到奇怪,而是以为很是天然。另外一方面,又意识到了本身还有太多的不足。
站在操做系统I/O模型这一层面,向上看,依然对基于nio的各类中间件不太熟悉,不了解在具体实践中如何利用好NIO这一利器,写出鲁棒、高效的代码;向下看,因为ucore为了尽量的简化实验课的难度,省略了不少的功能没有实现,致使我对于操做系统底层是如何实现网络协议栈、如何实现nio和io多路复用器的原理知之甚少,暂时只能将其看成黑盒子看待,不少地方可能理解的有误差。令我在拓宽知识面的同时,感叹知道的越多就越感受本身无知,但人老是要向前走的,在学习中但愿尽可能能作到知其然而知其因此然。经过对ucore操做系统的学习,使得我对于操做系统内核的学习再也不感到恐惧,在认知学习概念中就是从恐惧区转为了学习区。之后有机会的话,能够经过研究早期的linux内核源码来解答我关于I/O模型底层实现的一系列问题。
这篇博客是这一段时间来对操做系统学习的一个阶段性总结,直接或间接的回答了博客开头的几个问题,但愿能帮到对操做系统、I/O模型感兴趣的人。这篇文章中还存在许多理解不到位的地方,请多多指教。