要谈多路复用,当然还是要照搬,采用鞭尸的思路,先说说传统网络IO的缺点,使用拉踩的方式撑起复用IOAdvantage的概念。为了方便理解,以下代码均为伪代码,明白什么意思即可。让我们去阻塞IO服务器编写以下代码以处理客户端连接和请求的数据。listenfd=套接字();//打开一个网络通信端口bind(listenfd);//绑定监听(listenfd);//监听while(1){connfd=accept(listenfd);//块连接建立intn=read(connfd,buf);//块读取数据doSomeThing(buf);//对读取的数据做一些事情close(connfd);//关闭连接,循环等待下一个连接}这段代码会执行stumbling,像这样。可以看到服务器上的线程阻塞在两个地方,一个是accept函数,一个是read函数。如果我们展开read函数的细节,会发现它分两个阶段被阻塞。这是传统的阻塞IO。整体流程如下图所示。因此,如果本次连接的客户端还没有发送数据,服务器线程就会一直阻塞在read函数上不返回,无法接受其他客户端连接。这绝对不行。非阻塞IO要解决以上问题,关键是改造read函数。有一个比较巧妙的方法就是每次都创建一个新的进程或者线程来调用read函数,进行业务处理。while(1){connfd=accept(listenfd);//阻塞连接建立pthread_create(doWork);//创建一个新线程}voiddoWork(){intn=read(connfd,buf);//块读取数据doSomeThing(buf);//对读取的数据做一些事情close(connfd);//关闭连接,循环等待下一个连接}这样,当一个客户端建立连接后,可以立即等待新客户端结束连接,而不会阻塞在原客户端的读请求上。不过这个不叫非阻塞IO,而是使用多线程,让主线程不会卡在read函数上,不会宕机。操作系统提供的读取功能仍然是阻塞的。所以真正的非阻塞IO不可能是我们用户层耍的花招,而是必须要求操作系统给我们提供非阻塞读功能。这个读函数的作用是,如果没有数据到达(到达网卡并复制到内核缓冲区),它立即返回一个错误值(-1),而不是阻塞等待。操作系统提供了这样的功能,只需要在调用read之前将文件描述符设置为非阻塞即可。fcntl(connfd,F_SETFL,O_NONBLOCK);intn=read(connfd,buffer)!=SUCCESS);这样,用户线程需要循环调用read,直到返回值不为-1,然后才开始处理业务。这里我们注意到一个细节。非阻塞读是指这个阶段在数据到达之前是非阻塞的,即数据到达网卡之前,或者到达网卡但还没有复制到内核缓冲区之前。当数据已经到达内核缓冲区时,此时调用read函数仍然处于阻塞状态,需要等待数据从内核缓冲区复制到用户缓冲区后再返回。整体流程如下图所示。IO多路复用为每个client创建一个线程,server端的线程资源很容易耗尽。当然,有一个聪明的方法,我们可以在接受客户端连接后,将这个文件描述符(connfd)放入一个数组中。fdlist.add(connfd);然后新建线程不断遍历数组,调用每个元素的非阻塞read方法。while(1){for(fd<--fdlist){if(read(fd)!=-1){doSomeThing();}}}这样,我们成功地用一个线程处理了多个客户端连接。你不觉得这有点多路复用吗?但是这和我们用多线程把阻塞IO变成非阻塞IO是一样的。这种遍历方法只是我们用户想出的一个技巧。read每次遍历系统调用都返回-1,还是很浪费资源。在while循环中进行系统调用,就像你在做分布式项目时,在while中进行rpc请求一样,不划算。所以,我们还是要恳求操作系统的老大给我们提供一个功能,有这样的效果。我们通过系统调用传递一批文件描述符给内核,由内核层遍历,真正解决这个问题。selectselect是操作系统提供的系统调用函数。通过它,我们可以向操作系统发送一个文件描述符数组,让操作系统遍历它,判断哪些文件描述符可以读写,然后告诉我们处理:selectsystem调用的函数定义如下。intselect(intnfds,fd_set*readfds,fd_set*writefds,fd_set*exceptfds,structtimeval*timeout);//nfds:监听文件描述符集合中最大的文件描述符加1//readfds:监听read的到来data文件描述符集合,传入和传出参数//writefds:监听写数据到达文件描述符集合,传入和传出参数//exceptfds:监听到达文件描述符集合,传入和传出参数的异常发生//timeout:定时阻塞监听时间,3种情况//1.NULL,永远等待//2.设置timeval,等待固定时间//3.将timeval中的时间设置为0,查看描述字后立即返回,轮询server的代码是这样写的。首先,一个线程不断地接受客户端连接,并将套接字文件描述符放入一个列表中。while(1){connfd=accept(listenfd);fcntl(connfd,F_SETFL,O_NONBLOCK);fdlist.add(connfd);}然后,另一个线程不再遍历自己,而是调用select将这批文件描述符列表交给操作系统去遍历。while(1){//传递一堆文件描述符列表给select函数//如果有就绪的文件描述符则返回,nready表示有多少个就绪nready=select(list);...}但是,select函数返回后,用户仍然需要遍历刚刚提交给操作系统的列表。但是,操作系统会标记就绪的文件描述符,用户层不会再有无意义的系统调用开销。while(1){nready=select(list);//用户层还是需要遍历,但是无效的系统调用少了很多for(fd<--fdlist){if(fd!=-1){//只读就绪文件描述符read(fd,buf);//总共只有nready个就绪描述符,不需要遍历太多if(--nready==0)break;}}}如刚才动画中所述,直观效果如下。(同一个动画消耗你两次流量,你生气了吗?)可以看到几个细节:1.select调用需要传入fd数组,需要复制一份到内核。在高并发场景下,这样的副本消耗的资源非常惊人。(可以优化为不copy)2.select仍然通过内核层遍历检查文件描述符的就绪状态。它是一个同步过程,但没有系统调用切换上下文的开销。(内核层可以针对异步事件通知进行优化)3.select只返回可读的文件描述符个数,具体可读的还是需要用户自己遍历。(可以优化为只返回准备好的文件描述符给用户,不需要用户做无效遍历)整个select的流程图如下。可以看出,这样一来,一个线程处理了多个客户端连接(文件描述符),减少了系统调用的开销(多个文件描述符对一个文件描述符只有一次select系统调用+n次ready状态read系统调用).pollpoll也是操作系统提供的系统调用函数。intpoll(structpollfd*fds,nfds_tnfds,inttimeout);结构pollfd{intfd;/*文件描述符*/shortevents;/*监控事件*/shortrevents;;它与select的主要区别是去掉了select只能监控1024个文件描述符的限制。epollepoll是最后的大佬,解决了select和poll的一些问题。还记得上面提到的select的三个细节吗?1、select调用需要传入fd数组,需要拷贝到内核中。这样一个副本在高并发场景下消耗的资源是惊人的。(可以优化为不copy)2.select仍然通过内核层遍历检查文件描述符的就绪状态。它是一个同步过程,但没有系统调用切换上下文的开销。(内核层可以针对异步事件通知进行优化)3.select只返回可读的文件描述符个数,具体可读的还是需要用户自己遍历。(可以优化为只返回准备好的文件描述符给用户,不需要用户做无效遍历)所以epoll主要针对这三点进行了改进。1、内核中保存了一组文件描述符,用户不需要每次都重新输入,只需要告诉内核修改什么即可。2、内核不再通过轮询的方式寻找就绪的文件描述符,而是通过异步IO事件唤醒。3、内核只会将带有IO事件的文件描述符返回给用户,用户不需要遍历整个文件描述符集合。具体来说,操作系统提供了这三个功能。第一步是创建一个epollhandleintepoll_create(intsize);第二步是向内核添加、修改或删除要监视的文件描述符。intepoll_ctl(intepfd,intop,intfd,structepoll_event*event);第三步类似于启动select()调用intepoll_wait(intepfd,structepoll_event*events,intmaxevents,inttimeout);使用它,其内部原理就如丝般顺滑如下。如果想继续了解epoll的底层原理,推荐阅读飞哥的《图解 | 深入揭秘 epoll 是如何实现 IO 多路复用的!》,从linux源码层面逐行讲解epoll的实现原理,并配有大量易懂的-看懂图片,非常适合源码控的朋友阅读。后记用通俗易懂的话概括了它。这一切都始于这样一个事实,即读取功能由操作系统提供并且是阻塞的。我们称之为阻塞IO。为了打破这种情况,程序员在用户态使用多线程来防止主线程卡死。后来操作系统发现这个需求比较大,于是在操作系统层面提供了非阻塞读取的功能,让程序员可以在一个线程中完成多个文件描述符的读取,这就是非阻塞IO。但是读取多个文件描述符需要遍历。当高并发场景越来越多的时候,用户态遍历的文件描述符越来越多,相当于在while循环中进行越来越多的系统调用。.后来操作系统发现这个场景有很大的需求,于是在操作系统层面提供了这样一种遍历文件描述符的机制,这就是IO多路复用。多路复用有三个作用,第一个是select,然后发明了poll解决了select文件描述符的限制,再发明了epoll解决了select的三个缺点。因此,IO模型的演进实际上是时代的变迁,迫使操作系统在自己的内核中加入更多的功能。如果你养成这种思维,就很容易在网上发现一些错误。比如很多文章都说多路复用之所以高效,是因为一个线程可以监听多个文件描述符。这显然是众所周知的,但我不知道为什么。多路复用的效果完全可以通过用户态遍历文件描述符,调用其非阻塞读函数来实现。多路复用之所以快,是因为操作系统提供了这样一个系统调用,让原来while循环中的多个系统调用变成了一个系统调用+内核层遍历这些文件描述符。就像我们平时写业务代码,把原来的while循环改成调整http接口进行批量处理,改成让对方提供一个批量添加http接口,然后我们用一个rpc请求完成批量添加。这是有原因的。阅读原文:你叫这个破玩意IO多路复用技术交流QQ群:816425449
