Linux之多路转接
一.五种IO模型
在我们以前的学习中经常会提到IO这个概念,无论在C语言时学到的printf和scanf还是在C++中使用的cin和cout都是IO。而IO简单来说就是计算机的写入和读取也就是数据的流动,例如我们从内存中读取数据那么对内存来说这就是个out的过程对我们来说就是个in的过程。所以IO是有主体的对于不同的主体来说是in还是out也就不同。
除了有I和O之分IO模型也有不同的类型大致是分为五种:阻塞型IO,非阻塞型IO,信号驱动型IO,多路转接型IO,异步型IO。我们一边讲述他们的不同之处一边用例子来让大家更好的理解。
- 阻塞型IO
阻塞型IO是我们接触最多的IO模型,我们使用的printf和scanf都是阻塞型IO,例如我们调用scanf后程序就会一直阻塞等待我们输入数据所以阻塞型IO的特征就是在内核将数据准备好之前, 系统调用会一直等待. 所有的套接字, 默认都是阻塞方式。我们可以把它想象成在钓鱼的时候我们一直看着浮漂直到浮漂上下移动然后拉杆。
- 非阻塞型IO
在理解了阻塞型IO后非阻塞型IO也很好理解就是如果内核还未将数据准备好, 系统调用仍然会直接返回, 并且返回EWOULDBLOCK 错误码。所以我们使用非阻塞型IO不可能只调用一次而是利用循环来判断数据是否准备好,这种方式叫做轮询,但是这种方式对CPU资源浪费的比较严重所以只会在特定的场景下使用。同样在钓鱼的场景下就类似于我们不会一直观察浮漂而是间隔性的循环观察直到浮漂上下移动。
- 信号驱动型IO
信号驱动型IO也很好理解即内核将数据准备好的时候, 使用 SIGIO 信号通知应用程序进行 IO操作。所以信号驱动型IO既不会阻塞也不会轮询而是利用信号来形成一种提示的方式从而达到IO的操作,在钓鱼界呢就类似于我们把一个铃铛绑在浮漂上所以我们不会去观察浮漂而是等到铃铛响我们就拉杆。
- 多路转接型IO
要知道无论是读取还是写入我们都是向文件进行操作所以我们肯定要用到对应的文件描述符而上面三种IO模型都是只能对一个文件描述符进行调用,但是只要我们使用多路转接型IO的话我们就可以同时对多个文件描述符进行调用只要这些文件描述符有一个的数据准备好了我们既可以进行IO操作。所以虽然从流程图上看起来和阻塞 IO 类似. 实际上最核心在于 IO 多路转接能够同时等待多个文件描述符的就绪状态。钓鱼界的话就相同于别人是只有一个鱼竿但是我们用一百个鱼竿来钓鱼只要有一个杆子的浮漂动了我们就直接拉杆。
- 异步型IO
异步型IO不太好理解所以我们先用钓鱼的例子来让大家有一定的概念,异步IO类似于当你想要钓鱼时你让你小弟去给你钓而你去干其他的事情,等到小弟钓到鱼后他再把鱼交过你。所以你只需要派出任务即可不需要你自己去执行任务,在有了这个例子后我们就可以大致了解异步型IO是怎么实现的了,当应用程序想要进行IO操作时它只需要调用函数即可内核会在数据准备完成并且数据拷贝完成后通知应用程序来处理。所以异步IO是由内核在数据拷贝完成时, 通知应用程序(而信号驱动是告诉应用程序何时可以开始拷贝数据)。
这五种IO模型其中的原理各不相同需要大家结合例子来更好的理解。而从这五种IO模型中我们可以看出来IO时最花时间的是等待以及拷贝,在实际使用中我们等待的时间远远超过拷贝的时间所以想要让IO更加的高效那就要想办法来缩短等待的时间。
二.高级IO
在IO中我们还需要理解什么是同步通信什么是异步通信以及阻塞和非阻塞还有他们的区别是什么
2.1同步通信与异步通信
在提到同步和异步时大家可能会想到我们之前在多进程多线程时提到过的同步和互斥机制,但是我们要知道多进程多线程中的同步和互斥与IO中的同步异步是完全不同的概念。
- 多进程多线程中的同步和互斥是进程或者线程之间的制约关系,是为了完成某个任务导致多个线程有着不同的工作次序,次序低的线程就必须等待高次序的线程工作完成后再运行。
- IO中的同步是指在发出了一个调用后在没有得到结果之前调用者就必须一直等待直到结果出现才能返回,换句话说就是调用者必须主动等待调用结果。
- IO中的异步则是完全相反,在调用者发出调用后调用者不需要进行等待而是可以直接返回之后就可以继续运行下去完成其他的操作所以异步IO是没有调用结果的。调用者没有调用结果不代表之后就没有他的事情了被调用者会根据自己的状态来通知调用者完成对数据的处理操作。
2.2阻塞与非阻塞
IO中的阻塞和非阻塞很好理解,我们关注它是阻塞的还是非阻塞的实际上就是在关心程序在等待调用结果的状态。
- 阻塞就是在调用结果返回之前当前程序被挂起并且调用线程只会在得到调用结果后返回。
- 非阻塞是指在没有得到调用结果时程序不会被挂起仍然可以执行其他的功能。
三.多路转接
在五种IO型模型中我们需要重点关注第四种也就是多路转接型IO,这个模型在我们日后完成各种项目时经常会被使用到。
我们先再理解一下多路转接是什么意思,在上面讲述IO模型时我们提到多路转接其实就是让我们可以同时关注多个文件描述符这样只要其中一个文件描述符准备好了我们就可以完成IO操作。所以多路转接的核心就是让我们可以监视多个文件描述符。
我们有三个函数可以完成这个目的分别是:select,poll,epoll。这三个函数的底层逻辑各不相同所以优缺点也很显著。
3.1select
select 系统调用是用来让我们的程序监视多个文件描述符的状态变化的,并且在调用了select函数后程序会停在select处进行等待直到多个文件描述符中有一个或者多个发生了状态的转变。
第一个参数比较好理解剩下的四个参数则需要我们深入的说一下到底是如何完成如何设置的。
- readfds,writefds,exceptfds
这三个参数是利用fd_set这个结构体来完成的所以我们先来看看这个结构体中包含了什么成员
从源码中我们可以看到fd_set其实就是一个整型数组而已或者我们也可以把它当作一个位图其中每一位都是表示想要关注的文件描述符,只要将对应位的值设为1即代表关注这个文件描述符的某个事件。同时我们可以发现fd_set是一个输入输出函数所以既是用户用来传入到系统中代表关注文哪些文件描述符的哪些事件的也是系统传出给用户代表哪些文件描述符的哪些事件已经准备就绪的。
并且系统为了方便我们对位图进行操作还设置了一套函数
- timeout
timeout同样是一个结构体,我们先看它的源码再来解析
timeout的成员变量很简单所以它的作用就是用来计时的。我们设置timeout时一般有三种方式:NULL,0,准确值。当timeout为NULL时代表我们不设立时间select会一直等待直到有文件描述符状态改变,timeout为0时代表我们只检测各个文件描述符的状态检测完成立刻返回不进行等待,准确值就是如果在指定的时间段里没有事件发生,select 将超时返回。 - 返回值
执行成功时返回文件描述符中改变状态的个数,当返回值为0时代表select的等待时间超过了设立的timeout时间,最后如果select发生错误了就返回-1,同时错误原因会存在errno中。
在了解了这些参数后我们还要对select的作用要有理解,我们要知道select只负责等待检测关注的文件描述符的关心事件是否完成,并不负责拷贝。拷贝的工作还需要我们自己调用read,write等函数来完成。
3.1.1select的特点
- select可以监控的文件描述符是有数量限制的这个大小我们可以直接在自己电脑上使用sizeof(fd_set)来获得,如果得到的数据是512就代表你可监控的文件描述符数量是512*8=4096因为fd_set是一个位图每一位都是一个文件描述符。
- 在将文件描述符添加到select的关注集时我们还需要另外创建一个位图array来保存放到关注集中的文件描述符,一是因为在select返回后需要用array来和fd_set进行FD_ISSET的判断。二是因为select会把你之前加入到关注集中但是没有事件发生的文件描述符清零所以每次重新调用select时需要你从array读取文件描述符再加入到关注集中同时还可以得到最大的文件描述符当作select的第一个参数。
3.1.2select的缺点
- 每次调用 select, 都需要手动设置 fd 集合, 从接口使用角度来说也非常不便。
- 每次调用 select,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大。
- 同时每次调用 select 都需要在内核遍历传递进来的所有 fd,这个开销在 fd 很多时很大。
- select 支持的文件描述符数量太小。
3.2poll
select的缺点过于明显而且很影响使用所以又产生了一种新的多路转接函数poll。
这次的poll函数的参数比select少了一些其实也就是把select的一些参数结合在了一起。对于nfds和timeout我们不需要再介绍了,只需要再了解一下fds是如何实现的即可。
- fds
其中events和revents的取值有这些
- 返回值
返回值小于0说明poll调用出错了,返回值等于0说明poll等待超时了,返回值大于0说明poll关注的文件描述符的关注事件就绪了。
3.2.1poll的优缺点
优点
- pollfd 结构包含了要监视的 event 和发生的 event,不再使用 select“参数-值”传
递的方式. 接口使用比 select 更方便。 - poll 并没有最大数量限制 (但是数量过大后性能也是会下降)。
缺点
- 和 select 函数一样,poll 返回后,需要轮询 pollfd 来获取就绪的描述符。
- 每次调用 poll 都需要把大量的 pollfd 结构从用户态拷贝到内核中。
- 同时连接的大量客户端在一时刻可能只有很少的处于就绪状态, 因此随着监视
的描述符数量的增长, 其效率也会线性下降。
3.3epoll
在了解了poll的缺点后我们知晓poll在处理大量的文件描述符时效率会直线下降所以为了解决这个问题我们将其改进升级成了epoll。
epoll它几乎具备了之前所说的一切优点,被公认为 Linux2.6 下性能最好的多路 I/O 就绪通知方法。同时epoll也就不像select和poll一样只有一个函数调用而是有三个系统调用来完成多路转接。
- epoll_create
- epoll_ctl
我们可以发现event同样是一个结构体,所以我们还需要来看看它是怎么进行存储事件的。
我们只需要关注events如何设置事件即可,我们是使用宏的集合的方式来设置事件。epoll的关注事件有这几类
- EPOLLIN : 表示对应的文件描述符可以读 (包括对端 SOCKET 正常关闭)
- EPOLLOUT : 表示对应的文件描述符可以写
- EPOLLPRI : 表示对应的文件描述符有紧急的数据可读 (这里应该表示有带外
数据到来) - EPOLLERR : 表示对应的文件描述符发生错误
- EPOLLHUP : 表示对应的文件描述符被挂断
- EPOLLET : 将 EPOLL 设为边缘触发(Edge Triggered)模式, 这是相对于水平
触发(Level Triggered)来说的 - EPOLLONESHOT:只监听一次事件, 当监听完这次事件之后, 如果还需要继
续监听这个 socket 的话, 需要再次把这个 socket 加入到 EPOLL 队列里
- epoll_wait
3.3.1epoll的优点
- 接口使用方便: 虽然拆分成了三个函数, 但是反而使用起来更方便高效. 不需要每次循环都设置关注的文件描述符, 也做到了输入输出参数分离。
- 数据拷贝轻量: 只在合适的时候调用 EPOLL_CTL_ADD 将文件描述符结构拷贝到内核中, 这个操作并不频繁(而 select/poll 都是每次循环都要进行拷贝)。
- 事件回调机制: 避免使用遍历, 而是使用回调函数的方式, 将就绪的文件描述符结构加入到就绪队列中, epoll_wait 返回直接访问就绪队列就知道哪些文件描述符就绪. 这个操作时间复杂度O(1). 即使文件描述符数目很多, 效率也不会受到影响。
- 没有数量限制: 文件描述符数目无上限。
3.4LT模式和ET模式
在介绍epoll的事件时有一个EPOLLET,它的作用是将epoll的工作模式从LT模式改变成ET模式,那么这两种模式到底有什么区别呢?
我们可以用一个例子来先让大家理解一下,在我们上课的时候如果你睡着了那么老师可能会有这两种行为:一是一直喊你如果第一次你没醒就再喊第二次第三次第四次直到你醒,二是就喊你一次如果你还不醒就不管你了。这两种的区别其实就是LT模式和ET模式的差别,老师一直喊你就是LT模式老师只喊你一次就是ET模式。
想要单用这个例子来理解可能还是很勉强所以我们换一个角度从LT模式和ET模式的全称来再解释一次。LT模式的全称是Level Triggered也就是水平触发模式,ET模式的全称是Edge Triggered也就是边缘触发模式,如果有学过数电模电的同学可能就听说过水平触发和边缘触发这两个概念,边缘触发只有在低电位转变为高电位以及高电位转变为低电位时才会出现而水平触发是只要你处于高电位就会一直出现。
那么在通信中什么是水平触发什么是边缘触发呢?
- LT模式
- epoll的默认状态就是LT模式
- 当 epoll 检测到 socket 上事件就绪的时候, 可以不立刻进行处理. 或者只处理一部分。
- 当缓冲区有2kb的数据时我们第一次调用epoll_wait时它会直接返回因为已经准备好读事件,那么在我们读取了1kb后再次调用epoll_wait时它仍然会立刻返回并且通知读事件已经准备就绪直到我们将缓冲区的数据全部读取完成后再调用epoll_wait才不会立刻返回而是等待新事件就绪。
- LT模式就是只要有事件就绪就会一直提醒你让你进行处理。
- 支持阻塞读写和非阻塞读写
- ET模式
- 我们可以使用EPOLLET标志来让epoll进入ET模式
- 当 epoll 检测到 socket 上事件就绪时, 必须立刻处理。
- 当缓冲区中有2kb的数据时我们第一次调用epoll_wait时他会立刻返回并且通知你读事件已经准备就绪,那么如果我们只读取了1kb的数据后再次调用epoll_wait时它就不会返回了直到有新的事件就绪即有新数据传输到缓冲区中。
- ET模式就是只会在你第一个调用epoll_wait时提醒你让你进行处理,再次调用就不会再进行提醒了除非有新的事件就绪。所以想要让处于ET模式的epoll_wait进行返回并且提醒就只有在事件产生变化时。
- ET模式下在文件描述符的事件就绪后就只有一次的处理机会
- ET的性能比LT高了不少因为epoll_wait返回的次数变少了
- 只支持非阻塞的读写
select和poll都是工作在LT模式下的而epoll可以选择是ET模式还是LT模式。
那么我们将LT模式和ET模式进行对比会发现他们俩各有各的优点无法说谁一定胜过谁,使用ET模式是会让性能提高但是也变相的要求程序员要将缓冲区中的数据一次性处理完毕。
并且如果LT也能实现在第一次返回时就将缓冲区的数据处理完毕的话那么ET模式和LT模式的性能就没有差距了。
而且我们使用ET模式的话代码的复杂度会显著提高也是因为我们需要将数据一次性处理完成。所以ET模式胜也是一次性处理完成数据败也是此。
3.4.1ET模式和非阻塞读写
LT模式就可以完成阻塞读写和非阻塞读写那么为什么ET模式就只能是非阻塞读写呢?
首先我们要理解将文件描述符设置成非阻塞模式这不是接口上的要求而是工程上的要求,换一句话说如果文件描述符是阻塞的话是不会产生代码报错的而是会导致程序无法运行下去。我们用一个例子来讲述其中的门道。
假设一个服务器是ET模式并且它的文件描述符是阻塞模式的话并且客户端发送请求到服务器后必须接收到服务器的应答才会再次发送请求。
在客户端发送一个10k的请求到服务器上后服务器使用阻塞式的read读取了1k的数据(read无法保证将数据全部读取完毕),剩下的9k数据会在缓冲区中。
在这之后由于服务器是ET模式的所以第二次调用epoll_wait时不会立刻返回并且提醒服务器读事件就绪了。这就导致服务器不会再从缓冲区读取数据从而需要客户端再次发送请求来让读事件发送变化来再次读取数据,同时客户端由于服务器没有将数据读取完毕所以没有发送应答导致客户端也无法再发送请求。所以客户端和服务器都在等待对方的进一步动作但是双方又都无法进行下一步动作从而变成两个人全部卡住了。
所以为了解决这个问题我们就必须让文件描述符是非阻塞的方式同时让服务器用轮询的方法来一次性读取完全缓冲区的数据也就是一次性读取完请求。
4.5epoll的使用场景
epoll是一个具有高性能的多路转接方法但是如果没有妥善的处理的话很容易造成适得其反的效果,所以epoll的使用场景一般是适用于多连接, 且多连接中只有一部分连接比较活跃时。例如一个需要处理上万个客户端的请求的服务器也就是各种互联网APP的入口服务器,这种服务器会有巨量的客户端发送请求所以使用epoll可以大幅度的增加效率。如果是我们自己书写的系统内部的只有几个连接的服务器就不是很适合用epoll来完成。