目录
一 IO的两种状态
1 概述
等待就绪:阻塞和非阻塞。阻塞和非阻塞关注的是程序在等待调用结果时的状态。阻塞是在结果返回之前,线程一直挂起;非阻塞是指结果是否就绪,立即返回,而该调用不会阻塞当前线程。
数据操作:同步(内核给应用上报的是读写就绪事件,应用自己读写)和异步(内核给应用上报的是读写完成事件)。同步和异步关注的是消息通知机制。IO模型中:同步IO是说,IO的读写操作,在IO事件发生后,由应用程序来完成;异步IO是说,用户可以直接对IO执行读写操作,这些操作告诉内核用户。
并发中的同步异步:逻辑上的区分:代码按照时间顺序线性执行为同步;代码的执行需要系统事件的驱动为异步。通常把并发的同步称做同步线程,把并发的异步称为异步线程。把上层的业务由同步执行,如缓存,数据库等;下层的业务(驱动的操作,网络的操作),交给异步线程执行。
以上来自《Linux高性能服务器编程》,是目前看过的最核心的关于阻塞、非阻塞、同步和异步的解释。
2 场景
同步/异步主要针对C端:
同步:所谓同步,就是在c端发出一个功能调用时,在没有得到结果之前,该调用就不返回。也就是必须一件一件事做,等前一件做完了才能做下一件事。
例如普通B/S模式(同步):提交请求->等待服务器处理->处理完毕返回 这个期间客户端浏览器不能干任何事。
异步:异步的概念和同步相对。当c端一个异步过程调用发出后,调用者不能立刻得到结果。实际处理这个调用的部件在完成后,通过状态、通知和回调来通知调用者。
例如 ajax请求(异步): 请求通过事件触发->服务器处理(这是浏览器仍然可以作其他事情)->处理完毕。
阻塞/非阻塞主要针对S端:
阻塞:阻塞调用是指调用结果返回之前,当前线程会被挂起(线程进入非可执行状态,在这个状态下,cpu不会给线程分配时间片,即线程暂停运行)。函数只有在得到结果之后才会返回。
有人也许会把阻塞调用和同步调用等同起来,实际上他是不同的。对于同步调用来说,很多时候当前线程还是激活的,只是从逻辑上当前函数没有返回而已。 例如,我们在socket中调用recv函数,如果缓冲区中没有数据,这个函数就会一直等待,直到有数据才返回。而此时,当前线程还会继续处理各种各样的消息。
快递的例子:比如到你某个时候到A楼一层(假如是内核缓冲区)取快递,但是你不知道快递什么时候过来,你又不能干别的事,只能死等着。但你可以睡觉(进程处于休眠状态),因为你知道快递把货送来时一定会给你打个电话(假定一定能叫醒你)。
非阻塞:非阻塞和阻塞的概念相对应,指在不能立刻得到结果之前,该函数不会阻塞当前线程,而会立刻返回。
还是等快递的例子:如果用忙轮询的方法,每隔5分钟到A楼一层(内核缓冲区)去看快递来了没有。如果没来,立即返回。而快递来了,就放在A楼一层,等你去取。
对象的阻塞模式和阻塞函数调用:对象是否处于阻塞模式和函数是不是阻塞调用有很强的相关性,但是并不是一一对应的。阻塞对象上可以有非阻塞的调用方式,我们可以通过一定的API去轮询状 态,在适当的时候调用阻塞函数,就可以避免阻塞。而对于非阻塞对象,调用特殊的函数也可以进入阻塞调用。函数select就是这样的一个例子。
3 总结
- 同步,就是我客户端(c端调用者)调用一个功能,该功能没有结束前,我(c端调用者)死等结果。
- 异步,就是我(c端调用者)调用一个功能,不需要知道该功能结果,该功能有结果后通知我(c端调用者)即回调通知。
同步/异步主要针对C端, 但是跟S端不是完全没有关系,同步/异步机制必须S端配合才能实现。同步/异步是由c端自己控制,但是S端是否阻塞/非阻塞, C端完全不需要关心。 - 阻塞, 就是调用我(s端被调用者,函数),我(s端被调用者,函数)没有接收完数据或者没有得到结果之前,我不会返回。
- 非阻塞, 就是调用我(s端被调用者,函数),我(s端被调用者,函数)立即返回,通过select通知调用者。
- 同步IO和异步IO的区别就在于:数据访问的时候进程是否阻塞!
- 阻塞IO和非阻塞IO的区别就在于:应用程序的调用是否立即返回!
- 同步和异步都只针对于本机SOCKET而言的。
- 同步和异步,阻塞和非阻塞,有些混用,其实它们完全不是一回事,而且它们修饰的对象也不相同。
- 阻塞和非阻塞是指当server端的进程访问的数据如果尚未就绪,进程是否需要等待,简单说这相当于函数内部的实现区别,也就是未就绪时是直接返回还是等待就绪;而同步和异步是指client端访问数据的机制,同步一般指主动请求并等待I/O操作完毕的方式,当数据就绪后在读写的时候必须阻塞(区别就绪与读写二个阶段,同步的读写必须阻塞),异步则指主动请求数据后便可以继续处理其它任务,随后等待I/O,操作完毕的通知,这可以使进程在数据读写时也不阻塞。(等待"通知")
4 node.js里面的描述:
线程在执行中如果遇到磁盘读写或网络通信(统称为 I/O 操作),通常要耗费较长的时间,这时操作系统会剥夺这个线程的CPU 控制权,使其暂停执行,同时将资源让给其他的工作线程,这种线程调度方式称为 阻塞。当I/O 操作完毕时,操作系统将这个线程的阻塞状态解除,恢复其对CPU的控制权,令其继续执行。这种I/O 模式就是通常的同步式I/O (Synchronous I/O) 或阻塞式I/O (Blocking I/O)。
相应地,异步式I/O (Asynchronous I/O)或非阻塞式I/O (Non-blocking I/O)则针对所有I/O 操作不采用阻塞的策略。当线程遇到I/O 操作时,不会以阻塞的方式等待I/O 操作的完成或数据的返回,而只是将I/O 请求发送给操作系统,继续执行下一条语句。当操作系统完成I/O 操作时,以事件的形式通知执行I/O 操作的线程,线程会在特定时候处理这个事件。为了处理异步I/O,线程必须有事件循环,不断地检查有没有未处理的事件,依次予以处理。阻塞模式下,一个线程只能处理一项任务,要想提高吞吐量必须通过多线程。而非阻塞模式下,一个线程永远在执行计算操作,这个线程所使用的CPU 核心利用率永远是100,I/O 以事件的方式通知。在阻塞模式下,多线程往往能提高系统吞吐量,因为一个线程阻塞时还有其他线程在工作,多线程可以让CPU 资源不被阻塞中的线程浪费。而在非阻塞模式下,线程不会被I/O 阻塞,永远在利用CPU。多线程带来的好处仅仅是在多核CPU 的情况下利用更多的核,而Node.js的单线程也能带来同样的好处。这就是为什么Node.js 使用了单线程、非阻塞的事件编程模式。
二 文件描述符就绪条件
1 读
a. 有数据可读,专业的说法是:套接字接收缓冲区中的数据字节数大于等于套接字接收缓冲区低潮限度的当前值。可以使用套接字选项SO_RCVLOWAT来设置低潮限度,对于TCP和UDP套接字,其值缺省为1。
b. 通信的对方关闭,也就是说接收了FIN的TCP连接。对这样的套接字的读将不阻塞且返回0(即文件结束符)。
c. 套接字是一个监听套接字且已完成的连接数为非0,即连接建立后可读。
d. 有一个套接字错误待处理。对这样的套接字的读操作将不阻塞且返回一个错误(-1),errno则设置成明确的错误条件。这些待处理的错误也可以通过指定套接口选项SO_ERROR调用getsockopt来取得并清除。
2 写
a. 缓冲区可写,专业的说法是:套接字发送缓冲区中的可用字节数大于等于套接字发送缓冲区低潮限度的当前值,且或者套接字已连接或者套接字不要求连接(例如UDP套接字),对于TCP和UDP套接字,其缺省值一半为2048
b. 连接的写这一半关闭。对这样的套接字的写操作将产生信号SIGPIPE。
c. socket使用非阻塞connect连接成功或者失败(超时)之后。
d. 有一个套接字错误待处理。对这样的套接字的读操作将不阻塞且返回一个错误(-1),errno则设置成明确的错误条件。这些待处理的错误也可以通过指定套接口选项SO_ERROR调用getsockopt来取得并清除。
3 异常
接受到带外数据。
三 5种IO模型
本节内容来自这里。
五种IO模型包括:阻塞IO、非阻塞IO、IO复用、信号驱动IO、异步IO。其中,前四个被称为同步IO。同步有阻塞和非阻塞之分,异步没有,它一定是非阻塞的。真正的异步IO需要 CPU 的深度参与。换句话说,只有用户线程在操作IO的时候根本不去考虑IO的执行,全部都交给CPU去完成,而自己只有在等待一个完成信号的时候,才是真正的异步IO。
1 阻塞IO
2 非阻塞IO
3 IO复用
4 信号驱动IO
5 异步IO
6 区别
当一个网络IO的 read 操作发生时,它会经历两个阶段:1、等待数据准备 (Waiting for the data to be ready);2、将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)。
这些IO Model的区别就是在两个阶段上各有不同的情况:
阻塞IO(blocking IO):线程阻塞以等待数据,然后将数据从内核拷贝到进程,返回结果之后才解除阻塞状态。也就是说两个阶段都被block了。
非阻塞IO(non-blocking IO):当对一个非阻塞socket执行读操作时,如果kernel中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个error。用户进程需要不断地主动进行read操作,一旦数据准备好了,就会把数据拷贝到用户内存。也就是说,第一阶段并不会阻塞线程,但第二阶段拷贝数据还是会阻塞线程。
IO复用(IO multiplexing):这种IO方式也称为event driven IO。通过使用select/poll/epoll在单个进程中同时处理多个网络连接的IO。例如,当用户进程调用了select,那么整个进程会被block,通过不断地轮询所负责的所有socket,当某个socket的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。在IO复用模型中,实际上对于每一个socket,一般都设置成为non-blocking,但是,整个用户进程其实是一直被block的,先是被select函数block,再是被socket IO第二阶段block。
同步IO(synchronous IO):POSIX中的同步IO定义是:A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes。也就是说同步IO在IO操作完成之前会阻塞线程,按照这个定义,之前所述的blocking IO,non-blocking IO,IO multiplexing都属于synchronous IO。(non-blocking IO也属于同步IO是因为它在真正拷贝数据时也会阻塞线程)
异步IO(asynchronous IO):POSIX中的异步IO定义是:An asynchronous I/O operation does not cause the requesting process to be blocked。在linux异步IO中,用户进程发起read操作之后,直接返回,去做其它的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。也就是说两个阶段都不会阻塞线程。它就像是用户进程将整个IO操作交给了他人(kernel)完成,然后他人做完后发信号通知。在此期间,用户进程不需要去检查IO操作的状态,也不需要主动的去拷贝数据。
四 高性能IO模型
BIO:同步阻塞->一个线程只为一个用户服务。锯齿状服务器。
AIO:同步非阻塞->IO复用产生的多线程。
NIO:异步非阻塞。
五 半同步/半异步,半同步/半反应堆,领导者/追随者模式
1 半同步/半反应堆
2 半同步/半异步:
3 领导者/追随者模式:
1、有若干个线程(一般组成线程池)用来处理大量的事件;
2、有一个线程作为领导者,等待事件的发生;其他的线程作为追随者,仅仅是睡眠;
3、假如有事件需要处理,领导者会从追随者中指定一个新的领导者,自己去处理事件;
4、唤醒的追随者作为新的领导者等待事件的发生;
5、处理事件的线程处理完毕以后,就会成为追随者的一员,直到被唤醒成为领导者;
6、假如需要处理的事件太多,而线程数量不够(能够动态创建线程处理另当别论),则有的事件可能会得不到处理。
六 基于驱动的reactor模式
在linux上实现一个异步非阻塞的preactor模型?基于事件驱动的reactor模型很强大吗?它的并发瓶颈在哪里?
Reactor
包含如下角色:
Handle
句柄:用来标识 socket
连接或是打开文件;
Synchronous Event Demultiplexer
:同步事件多路分解器:由操作系统内核实现的一个函数;用于阻塞等待发生在句柄集合上的一个或多个事件;(如 select/epoll
);
Event Handler
:事件处理接口;
Concrete Event HandlerA
:实现应用程序所提供的特定事件处理逻辑;
Reactor
:反应器,定义一个接口,实现以下功能:
1)供应用程序注册和删除关注的事件句柄;
2)运行事件循环;
3)有就绪事件到来时,分发事件到之前注册的回调函数上处理;
- 应用启动,将关注的事件handle注册到Reactor中;
- 调用Reactor,进入无限事件循环,等待注册的事件到来;
- 事件到来,select返回,Reactor将事件分发到之前注册的回调函数中处理。
Reactor模式的缺点貌似也是显而易见的:
- 相比传统的简单模型,Reactor增加了一定的复杂性,因而有一定的门槛,并且不易于调试。
- Reactor模式需要底层的Synchronous Event Demultiplexer支持,比如Java中的Selector支持,操作系统的select系统调用支持,如果要自己实现Synchronous Event Demultiplexer可能不会有那么高效。
- Reactor模式在IO读写数据时还是在同一个线程中实现的,即使使用多个Reactor机制的情况下,那些共享一个Reactor的Channel如果出现一个长时间的数据读写,会影响这个Reactor中其他Channel的相应时间,比如在大文件传输时,IO操作就会影响其他Client的相应时间,因而对这种操作,使用传统的Thread-Per-Connection或许是一个更好的选择,或则此时使用Proactor模式。
Proactor主动器模式包含如下角色
Handle 句柄;用来标识socket连接或是打开文件;
Asynchronous Operation Processor:异步操作处理器;负责执行异步操作,一般由操作系统内核实现;
Asynchronous Operation:异步操作
Completion Event Queue:完成事件队列;异步操作完成的结果放到队列中等待后续使用
Proactor:主动器;为应用程序进程提供事件循环;从完成事件队列中取出异步操作的结果,分发调用相应的后续处理逻辑;
Completion Handler:完成事件接口;一般是由回调函数组成的接口;
Concrete Completion Handler:完成事件处理逻辑;实现接口定义特定的应用处理逻辑。
应用程序启动,调用异步操作处理器提供的异步操作接口函数,调用之后应用程序和异步操作处理就独立运行;应用程序可以调用新的异步操作,而其它操作可以并发进行;
应用程序启动Proactor主动器,进行无限的事件循环,等待完成事件到来;
异步操作处理器执行异步操作,完成后将结果放入到完成事件队列;
主动器从完成事件队列中取出结果,分发到相应的完成事件回调函数处理逻辑中。
主动和被动
以主动写为例:
Reactor
将 handle
放到 select()
,等待可写就绪,然后调用write()写入数据;写完处理后续逻辑;
Proactor
调用aoi_write后立刻返回,由内核负责写操作,写完后调用相应的回调函数处理后续逻辑;
可以看出,Reactor被动的等待指示事件的到来并做出反应;它有一个等待的过程,做什么都要先放入到监听事件集合中等待handler可用时再进行操作;
Proactor
直接调用异步读写操作,调用完后立刻返回;
实现
Reactor
实现了一个被动的事件分离和分发模型,服务等待请求事件的到来,再通过不受间断的同步处理事件,从而做出反应;
Proactor
实现了一个主动的事件分离和分发模型;这种设计允许多个任务并发的执行,从而提高吞吐量;并可执行耗时长的任务(各个任务间互不影响)
优点
Reactor
实现相对简单,对于耗时短的处理场景处理高效;
操作系统可以在多个事件源上等待,并且避免了多线程编程相关的性能开销和编程复杂性;
事件的串行化对应用是透明的,可以顺序的同步执行而不需要加锁;
事务分离:将与应用无关的多路分解和分配机制和与应用相关的回调函数分离开来;
Proactor
性能更高,能够处理耗时长的并发场景;
缺点
Reactor
处理耗时长的操作会造成事件分发的阻塞,影响到后续事件的处理;
Proactor
实现逻辑复杂;依赖操作系统对异步的支持,目前实现了纯异步操作的操作系统少,实现优秀的如windows IOCP
,但由于其windows系统用于服务器的局限性,目前应用范围较小;而Unix/Linux系统对纯异步的支持有限,应用事件驱动的主流还是通过select/epoll来实现;
适用场景
Reactor
:同时接收多个服务请求,并且依次同步的处理它们的事件驱动程序;
Proactor
:异步接收和同时处理多个服务请求的事件驱动程序;
七 定时器
时间是怎么计时的?什么是时间轮和时间堆的算法?这样计时准确吗?
一个滴答的时间称为时间轮的槽间隔 si(心搏时间)
,时间轮共N个槽,因此运转一周时间为N*si
,每个槽指向一条定时器链表(每条链表上的定时器具有相同特征,即他们的定时时间相差几个 (0,1,2...)N*si)
。
假如现在指针指向槽cs,我们要添加一个定时时间为ti的定时器,则该定时器会被插入槽ts对应的链表中去:
t
s
=
(
c
s
+
(
t
i
/
s
i
)
)
ts = (cs+(ti/si))%N
ts=(cs+(ti/si))
可以看出,时间轮使用哈希表的思想将定时器散列到不同的链表上。
原始的时间轮:
上图中的轮子有 8 个 bucket,每个 bucket 代表未来的一个时间点。我们可以定义每个 bucket 代表一秒,那么 bucket [1] 代表的时间点就是“1 秒钟以后”,bucket [8] 代表的时间点为“8 秒之后”。Bucket 存放着一个 timer 链表,链表中的所有 Timer 将在该 bucket 所代表的时间点触发。中间的指针被称为 cursor。这样的一个时间轮工作如下:
加入Timer:如果新 Timer 在时间点 6 到期,它就被加入 bucket[6] 的 timer 链表。定位 bucket[6] 是一个数组访问的过程,因此这个操作是 O(1) 的。
删除Timer:类似的,删除 Timer 也是 O(1) 的。比如删除一个 6 秒钟后到期的 timer,直接定位到 bucket[6], 然后在链表中删除一个元素是 O(1) 的。
处理Timer的逻辑在时钟中断程序中,每次时钟中断产生时,cursor 增加一格,然后中断处理代码检查 cursor 所指向的 bucket,假如该 bucket 非空,则触发该 bucket 指向的 Timer 链表中的所有 Timer。这个操作也是 O(1) 的。
全都是 O(1) 操作?那这个算法岂不是完美的?可惜不是,我们的这个时间轮有一个限制:新 Timer 的到期时间必须在 8 秒之内。这显然不能满足实际需要,在 Linux 系统中,我们可以设置精度为 1 个 jiffy 的定时器,最大的到期时间范围可以达到 (2^32-1/2 ) 个 jiffies(一个很大的值)。如果采用上面这样的时间轮,我们需要很多个 bucket,需要巨大的内存消耗。这显然是不合理的。
为了减少 bucket 的数量,时间轮算法提供了一个扩展算法,即 Hierarchy 时间轮。图 1 里面的轮实际上也可以画成一个数组,
时间轮的另一种表示
Hierarchy 时间轮将单一的 bucket 数组分成了几个不同的数组,每个数组表示不同的时间精度,下图是其基本思路:
Hierarchy 时间轮
这样的一个分层时间轮有三级,分别表示小时,分钟和秒。在 Hour 数组中,每个 bucket 代表一个小时。采用原始的时间轮,如果我们要表示一天,且 bucket 精度为 1 秒时,我们需要 246060=86,400 个 bucket;而采用分层时间轮,我们只需要 24+60+60=144 个 bucket。
让我们简单分析下采用这样的数据结构,Timer 的添加/删除/处理操作的复杂度。
添加Timer
根据其到期值,Timer 被放到不同的 bucket 数组中。比如当前时间为 (hour:11, minute:0, second:0),我们打算添加一个 15 分钟后到期的 Timer,就应添加到 MINUTE ARRAY 的第 15 个 bucket 中。这样的一个操作是 O(m) 的,m 在这里等于 3,即 Hierarchy 的层数。
添加 15 分钟到期 Timer
删除Timer:Timer本身有指向 bucket 的指针,因此删除 Timer 是 O(1) 的操作,比如删除我们之前添加的 15 分钟后到期的 Timer,只需要从该 Timer 的 bucket 指针读取到 MINUTE ARRAY Element 15 的指针,然后从该 List 中删除自己即可。
定时器处理:每个时钟中断产生时(时钟间隔为 1 秒),将 SECOND ARRAY 的 cursor 加一,假如 SECOND ARRAY 当前 cursor 指向的 bucket 非空,则触发其中的所有 Timer。这个操作是 O(1) 的。
八 事件驱动和消息驱动
事件:按下鼠标,按下键盘,按下手柄,将U盘插入USB接口,都将产生事件。比如说按下鼠标左键,将产生鼠标左键被按下的事件。当有事件发生时,将事件添加到事件队列之中,事件处理程序读取事件队列,对各事件进行相应的处理。
消息:当鼠标被按下,产生了鼠标按下事件,windows侦测到这一事件的发生,随即发出鼠标被按下的消息到消息队列中,这消息附带了一系列相关的事件信息,比如鼠标哪个键被按了,在哪个窗口被按的,按下点的坐标是多少?如此等等。
非事件驱动的程序,在需要等待某个条件触发时,会不断地检查这个条件,直到条件满足,这是很浪费cpu时间的。而事件驱动的程序,则有机会释放cpu从而进入睡眠态(注意是有机会,当然程序也可自行决定不释放cpu),当事件触发时被操作系统唤醒,这样就能更加有效地使用cpu。
一个典型的事件驱动的程序,就是一个死循环,并以一个线程的形式存在,这个死循环包括两个部分,第一个部分是按照一定的条件接收并选择一个要处理的事件,第二个部分就是事件的处理过程。程序的执行过程就是选择事件和处理事件,而当没有任何事件触发时,程序会因查询事件队列失败而进入睡眠状态,从而释放cpu。事件驱动的程序,必定会直接或者间接拥有一个事件队列,用于存储未能及时处理的事件。
事件驱动的程序,还有一个最大的好处,就是可以按照一定的顺序处理队列中的事件,而这个顺序则是由事件的触发顺序决定的,这一特性往往被用于保证某些过程的原子化。
事件模式耦合高,同模块内好用;消息模式耦合低,跨模块好用。事件模式集成其它语言比较繁琐,消息模式集成其他语言比较轻松。事件是侵入式设计,霸占你的主循环;消息是非侵入式设计,将主循环该怎样设计的自由留给用户。
九 消息队列服务器
消息队列服务器,用于“消息队列”(即众所周知的 MSMQ),可以为客户端计算机提供消息队列、路由选择和目录服务的计算机。
十 并发编程
编程语言是如何实现并发的之操作系统篇
编程语言是如何实现并发的之并发模型篇
并发编程的七个模型
【专家坐堂】四种并发编程模型简介
并发编程之经典并发模型梳理
终于有人把NIO与异步编程给一次性讲明白了!