什么是阻塞,非阻塞,异步同步,select,poll,epoll?今天我们用一遍文章解开这多年的迷惑。
首先我们想要通过网络接收消息,是这样的一个步骤。
- 用户空间向内核空间请求网络数据
- 内核空间把网卡数据读取到内核缓冲区
- 将内核缓冲区的数据复制到用户缓冲区
根据我们请求数据的情况不同,以及内核缓冲区到用户缓冲区的不同,分为了阻塞,非阻塞,异步同步的区别。
在《UNIX网络编程》一书中,总结归纳了5种I0模型:
- 阻塞 I0 ( Blocking I0)
- 非阻塞 I0 (Nonblocking I0)
- I0多路复用(I0 Multiplexing)
- 信号驱动I0 (Signal Driven I0 )
- 异步I0 (Asynchronous I0)
阻塞IO
- 用户应用请求内核是否有新的网络数据
- 如果没有数据,就阻塞直到有数据到来
- 等待内核将数据拷贝到用户空间
- 用户应用处理数据
以上可以看出来,根据等待数据的方式不同,分为阻塞和非阻塞。
阻塞IO在请求内核数据的时候,没有数据就会一直阻塞直到获取数据。
非阻塞IO
- 用户应用请求内核是否有新的网络数据
- 如果没有数据,内核直接返回没数据,用户应用可以隔一段时间再来请求。
- 等待内核将数据拷贝到用户空间
- 用户应用处理数据
非阻塞IO在等待内核数据的时候,没有数据就会得到没数据的结果,应用可以进行其他动作。
同步IO
同步IO的主要看内核数据到用户空间的过程是同步进行的就是同步IO
异步IO
异步IO首先是非阻塞IO,区别在于成功标志的时机。异步IO连内核到用户态的数据拷贝都是异步的,直到数据拷贝完成,才会回调一个信号,通知一切已经准备完成。用户应用此时就可以直接处理结果了。
总结
阻塞非阻塞指的是在获取结果上是否会阻塞等待结果完成
同步异步指的是是否会参与IO读写,或者是等待读写成功的回调
redis的IO多路复用
如果是阻塞IO也就是BIO,那么在一个fd(文件描述符)没有数据的时候,就是阻塞一直等待,如果同时有多个fd,对于单线程来说,只能一直等第一个有数据,然后再接着处理第二个,效率很慢。
就像顾客点餐,要一直等到第一个人点完餐,后面的人才有机会。BIO也有个解决办法,一般是增加多线程,每个线程都维护一个fd,就相当于为每个顾客都添加一个点餐台。在fd足够多的情况下,会有大量的线程被创建,线程可是有上限的,开销也大(更多线程需要更多的内存空间)。
如果是非阻塞IO也就是NIO,会有顾客没点完餐,然后造成CPU一直在询问一直空转的情况。
因此引入了IO多路复用模型:利用单个线程来同时监听多个FD,并在某个FD可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源
文件描述符( File Descriptor) :简称FD,是一个从0开始递增的无符号整数,用来关联Linux中的一一个文件。在Linux
中,一切皆文件,例如常规文件、视频、硬件设备等,当然也包括网络套接字(Socket),
这时候每来一个顾客(FD
),我们就会给他一个开关(注册进监听事件
),一个服务员(一个线程
)等待开关亮起(阻塞等待事件
)。有顾客完成,就会按下开关,一定的频率下开关会亮起(事件通知
),服务员会选取按下开关的一批人,给他们点餐(批量处理事件
)。
IO多路复用的实现有select,poll,epoll,我们来看看他们的优缺点。
select
select是Linux中最早的I/O多路复用实现方案,并且windows操作系统上只支持select。这就是为啥window发挥不出redis的最大性能的一个原因。
select函数执行流程
用户
空间创建fd_set,把需要监听的位置置1,比如 1,2,5用户
空间拷贝fd_set(注册的事件集合)到内核空间内核
遍历所有fd文件,并将当前进程挂到每个fd的等待队列中,当某个fd文件设备收到消息后,会唤醒设备等待队列上睡眠的进程,那么当前进程就会被唤醒内核
如果遍历完所有的fd没有I/O事件,则当前进程进入睡眠,当有某个fd文件有I/O事件或当前进程睡眠超时后,当前进程重新唤醒再次遍历所有fd文件内核
有事件产生,会把fd_set中有事件的位置保留为1,没有事件的位置擦除为0.内核
拷贝fd_set给用户空间用户
空间线程被唤醒,遍历fd_set为1的位置,确认是哪些fd有就绪事件,然后开始处理用户
空间处理完事件,再一次将要监听的fd_set设置为1,重复之前的监听动作
根据上面可以很清楚的看出整个执行流程在用户空间和内核空间的切换。
select函数的缺点
- 单个进程所打开的FD是有限制的,通过 FD_SETSIZE 设置,默认1024
- 每次调用 select,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大
- 每次调用select都需要将进程加入到所有监视socket的等待队列,每次唤醒都需要从每个队列中移除
- select函数在每次调用之前都要对参数进行重新设定,这样做比较麻烦,而且会降低性能
- 进程被唤醒后,程序并不知道哪些socket收到数据,还需要遍历一次
poll
poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态, 但是它没有最大连接数的限制,原因是它是基于链表来存储的
poll运行流程
①创建pollfd数组, 向其中添加关注的fd信息,数组大小自定义
②调用poll函数,将pollfd数组拷贝到内核空间,转链表存储,无上限
③内核遍历fd,判断是否就绪
④数据就绪或超时后,拷贝pollfd数组到用户空间,返回就绪fd数量n
⑤用户进程判断n是否大于0
⑥大于0则遍历pollfd数组,找到就绪的fd
与select对比
- select模式中的fd_ set大小固定为1024,而pollfd在内核中采用链表,理论上无上限.
- 监听FD越多,每次遍历消耗时间也越久,性能反而会下降
poll还是没有解决需要遍历判断fd事件的方式,只是增加了监听数量,在fd很多的情况下,性能下降的更加严重
epoll
epoll可以理解为event pool,不同与select、poll的轮询机制,epoll采用的是事件驱动机制,每个fd上有注册有回调函数,当网卡接收到数据时会回调该函数,同时将该fd的引用放入rdlist就绪列表中。
当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可。如果rdlist不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户。
他主要有三个函数,epoll的执行流程
- 调用epoll_create创建一个eventpoll结构体,这个结构体有一个监听事件红黑色,和一个就绪链表(这个链表只会存放就绪fd,避免我们无效的遍历所有fd)
- 调用epoll_ctl向eventpoll中注册一个监听的fd,并且注册上fd对应事件的回调函数。
- 调用epoll_wait开始阻塞等待事件到来
- 内核将监听到的事件添加一份到就绪队列list_head
- 内核唤醒用户线程,并将就绪链表拷贝到用户空间
- 用户应用只需要关心这些就绪的fd事件,直接取出结构体里关联的回调函数进行回调即可处理事件。
对应的redis的server执行流程
- 调用epoll_create创建一个eventpoll结构体
- 调用epoll_ctl向eventpoll中注册一个监听连接的serverSocket,并关联上处理accept事件的函数
- 调用epoll_wait阻塞等待fd事件(等待客户端连接)
- 用户程序被唤醒,事件到来(现在只有连接事件)。根据生成的客户端的FD,调用epoll_ctl注册一个监听,并且关联上处理read事件的函数和处理write事件的函数。
- 继续调用epoll_wait阻塞等待fd事件(等待客户端连接或客户端命令执行请求)
- 用户程序被唤醒,事件到来(连接事件或者命令执行请求),假设是客户端执行请求事件,根据客户端的fd对应的read事件直接调用绑定的回调函数来处理,将结果再写回到fd缓存中。
- 继续调用epoll_wait等待accept,read,write事件。
epoll优点
- EPOLL支持的最大文件描述符上限是整个系统最大可打开的文件数目, 1G内存理论上最大创建10万个文件描述符
- 每个文件描述符上都有一个callback函数,当socket有事件发生时会回调这个函数将该fd的引用添加到就绪列表中,select和poll并不会明确指出是哪些文件描述符就绪,而epoll会。造成的区别就是,系统调用返回后,调用select和poll的程序需要遍历监听的整个文件描述符找到是谁处于就绪,而epoll则直接处理即可
- select、poll采用轮询的方式来检查文件描述符是否处于就绪态,而epoll采用回调机制。造成的结果就是,随着fd的增加,select和poll的效率会线性降低,而epoll不会受到太大影响,除非活跃的socket很多
读事件很好理解,有一个读事件就立马处理请求,怎么理解写事件?
当socket 写缓冲区已满,假如设置了非阻塞I/O,应用程序调用send会返回EAGAIN,告诉应用程序写缓冲区已满,下次再来尝试调用,这时候就有一个尝试的时机问题,应用程序怎么知道socket 缓冲区可写呢?如果频繁调用send,会浪费CPU。这时候,epoll就排上用场了,对socket 设置写事件,并添加到 epoll中,应用程序调用epoll_wait,当该socket 的写缓冲有空余时,就返回对应的写事件,应用程序这时候就可以调用send,发送数据。
所以写事件是用来告诉程序,写缓冲是空余的。一般情况下fd都是有写事件的。但是在写缓冲区满了的时候,就会频繁触发写事件。所以我们可以一开始不监听写事件,直到发现数据量可能大于缓冲区,再监听写事件
参考:高效处理写事件