同步/异步/阻塞/非阻塞
同步和异步
这两个概念与消息的通知机制有关。也就是同步与异步主要是从消息通知机制角度来说的。
所谓同步就是一个任务(调用方)的完成需要依赖另外一个任务(被调用方)时,只有等待被依赖的任务(被调用方结束任务)完成后,依赖的任务(调用方整次调用)才能算完成,这是一种可靠的任务序列
所谓异步是不需要等待被依赖的任务(被调用方处理任务)完成,只是通知被依赖的任务要完成什么工作(进行调用),依赖的任务(调用方处理任务)也立即执行,只要自己(调用方)完成了整个任务就算完成了
举一个程序调用的例子, 假如有A和B两个程序, B程序中有一个比较耗时的计算, A程序需要调用B程序的耗时计算
同步和异步, 关注的是消息通知机制角度, 即B程序的角度, 也就是B程序被调用后:
是继续执行B程序中的耗时计算, 等计算完成后再返回最终的计算结果(这是同步)
还是B程序立马返回一个结果, 告诉A程序我接收到了这个计算任务, 让A程序现在不需要等待B程序的结果(这是异步)
如果按上面的异步来的话, 那么A程序怎么知道B程序的最终的计算结果呢?有三种方式:
-
状态:
这种方式需要A程序每隔一段时间来查看B程序的执行状态, 查看B程序是否已经完成了计算, 若完成了则能拿到计算结果
-
通知:
这种方式是在B程序执行完成后, 通知A程序, 将结果发送给A程序
-
回调:
这种方式和上面的通知类似, 也是B程序执行完成后, 会通知A程序, 然后A程序执行他后续的回调函数
阻塞与非阻塞
阻塞和非阻塞这两个概念与程序等待消息通知时的状态有关(无所谓这个消息通知是同步还是异步的)。也就是说阻塞与非阻塞主要是程序等待消息通知时的状态角度来说的
回到上面的A程序调用B程序的例子, 假如A程序的代码逻辑是调用3次B程序, 这次的关注的角度是A程序这边, 也就是A程序第一次调用了B程序后:
是等待B程序的结果, 不管B程序的结果是立马返回的还是需要等待一段时间才返回, 总之一定要等待B程序的返回, 等到了返回之后才继续执行第二次调用B程序的逻辑(这是阻塞)
还是不等待B程序的结果, 直接执行第二次调用B程序的逻辑(这是非阻塞). 也就是不等待B的返回结果, 一股脑把三次调用请求全部发出去.
四种组合
之前听的比较多的是同步阻塞或者异步非阻塞, 认为同步一定是阻塞的, 异步一定是非阻塞的, 现在知道了同步异步和阻塞非阻塞所关注的角度不一样后, 就可以把同步和阻塞给区分开, 异步和非阻塞给区分开. 这四种都可以两两组合
还是回到A需要三次调用B的例子
同步阻塞
第一次A调用B后, 等待B的最终的执行结果(同步), 才执行第二次调用(阻塞), 以此类推
同步非阻塞
第一次A调用B后, 不等待B的结果, 立马执行第二次调用(非阻塞), 但是同时也在等待B程序的返回结果(同步), 可以理解为调用和等待是两个事件, 执行时需要来回切换
异步阻塞
第一次A调用B后, 等待(阻塞)B立马返回的一个结果(异步), 然后再发送第二次调用, 这里A还是需要等待B的返回才能发送下一个调用. 也就是异步操作也是可以被阻塞住的,只不过它不是在处理消息时阻塞,而是在等待消息通知时被阻塞
异步非阻塞
第一次A调用B后, B立马返回一个结果(异步), 同时A不等B返回的结果, 立马发送第二次调用(非阻塞), 所有请求发送完成后, 继续A程序的后续业务逻辑, 不需要进行等待B的结果, 若B执行完成后会主动通知给A
举一个生活场景的例子
假如你去银行办理业务, 你现在已经坐在了柜台前面, 告诉业务员你要办理的业务:
同步阻塞: 说完你想办的业务后, 业务员直接去办理业务, 你并不知道业务要办理多久, 此时你也被限制住, 什么也不能干, 不能玩手机, 只能干坐在凳子上, 等待业务员办理完成
同步非阻塞: 说完你想办的业务后, 业务员去办理业务, 你并不知道业务要办理多久, 但是说完你就可以继续在凳子上玩手机干别的事情, 同时也时不时看一下里面业务员办理的情况. 也就是需要在玩手机和查看业务员之间来回进行切换(这种效率也是低下的)
异步阻塞: 说完你想办的业务后, 业务员思考了30秒, 再跟你说这个业务我估算了一下需要十分钟办理完成, 在业务员思考的这30秒内, 你又被限制住, 什么也不能干, 只能等待她思考完给你回复. 当然在后续等待的十分钟的时间内你可以继续玩手机 (即阻塞在的等通知的这段时间内, 而不是处理业务的这段时间内). 你专心致志在玩手机, 突然业务员跟你说十分钟到了, 业务办理完了.
异步非阻塞: 说完你想办的业务后, 你立马掏出手机, 开始玩手机, 同时业务员思考了30秒, 再跟你说这个业务我估算了一下需要十分钟办理完成, 在业务员思考的这30秒和处理业务的十分钟内, 你可以玩手机. 你专心致志在玩手机, 突然业务员跟你说十分钟到了, 业务办理完了.
可以看出, 对于你来说, 同步阻塞情况下效率最低, 你只能干等, 什么都做不了. 异步非阻塞情况下效率最高, 说完你的业务后你就可以玩手机了, 不耽误你的事情
Linux四种常见IO模型
首先了解几个概念
用户态和内核态
在CPU的所有指令中,有些指令是非常危险的,如果错用,将导致系统崩溃,如果允许所有的程序都可以使用这些指令,那么系统崩溃的概率就大大增加。所以,为了安全性和稳定性考虑,linux就区分了内核态和用户态,对于那些比较敏感的操作,就只能让cpu运行在内核态执行。
用户态就是进程运行在用户空间,内核态就是进程运行在内核空间。那么什么样的操作只能运行在内核态呢?
用户态:只能受限的访问内存,无法访问外围设备。
内核态:可以访问内存所有数据
所以,一些对外围设备的访问操作比如硬盘、网卡都只能运行在内核态,此外进程调度、TCP/IP协议栈等也只能工作在内核态。
文件描述符(fd)
文件描述符是一个负整数,实际上,他是一个索引值,指向内核给每一个进程所维护的该进程打开文件的记录表,当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。当我们的进程想要对文件进行读写的时候,就会传递这个文件描述符给内核空间,内核就会根据不同类型的IO对相应的数据进行操作返回。
简单理解也就是内核作为用户态进程和硬盘之间的中间人, 内核给用户态进程分配一个文件的指针(文件描述符), 用户态进程想要操作文件时, 通过文件描述符告诉内核想要操作的是哪个文件(可能有些用词不正确, 但大概意思是没错的)
IO流阶段
对于一次IO访问, 以read为例, 数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间, 即:
第一阶段:等待数据准备
第二阶段:将数据从内核拷贝到进程中
阻塞IO(Broking I/O)
在这个IO模型中,用户空间的应用程序执行一个系统调用(recvform),这会导致应用程序阻塞,什么也不干,直到数据准备好,并且将数据从内核复制到用户进程,最后进程再处理数据,在等待数据到处理数据的两个阶段,整个进程都被阻塞。
非阻塞IO(Nonblocking I/O)
非阻塞的recvform系统调用调用之后,进程并没有被阻塞,内核马上返回给进程,如果数据还没准备好,此时会返回一个error。进程在返回之后,可以干点别的事情,然后再发起recvform系统调用。重复上面的过程,循环往复的进行recvform系统调用。这个过程通常被称之为轮询。轮询检查内核数据,直到数据准备好,再拷贝数据到进程,进行数据处理。需要注意,拷贝数据整个过程,进程仍然是属于阻塞的状态。
IO多路复用(I/O multiplexing)
IO多路复用是针对一个进程就只能处理一个连接的读或者写事件来说的(即上述两种方式), 现如今服务器要处理大量连接, 单进程处理单连接的方式肯定扛不住, 当然也可以采用多进程多线程模式来应对大量的连接, 但是这样很大的系统开销. 而多路复用机制则是只需要一个进程就可以管理多个连接或者是多个文件描述符.
多路IO复用可以使用系统的select,poll,epoll
函数进行连接或者fd的管理监听, 已select
为例, select
会轮询每个连接或者fd, 当发现连接或者fd数据准备好时, 就返回这个fd,然后再进行系统调用,将数据由内核拷贝到用户进程,其中select
的调用过程是阻塞的, 后面的系统调用也是阻塞的.
所以整个I/O多路复用的过程也是阻塞的, 和阻塞IO一致, 只是它能够处理更多的IO连接
异步IO(Async I/O)
用户进程发起aio_read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存, 当这一切都完成之后,kernel会给用户进程发送一个signal或执行一个基于线程的回调函数来完成这次 IO 处理过程,告诉它read操作完成了。
IO模型总结
通过上面的图片,可以发现non-blocking IO和asynchronous IO的区别还是很明显的。在non-blocking IO中,虽然进程大部分时间都不会被block,但是它仍然要求进程去主动的check,并且当数据准备完成以后,也需要进程主动的再次调用recvfrom来将数据拷贝到用户内存。而asynchronous IO则完全不同。它就像是用户进程将整个IO操作交给了他人(kernel)完成,然后他人做完后发信号通知。在此期间,用户进程不需要去检查IO操作的状态,也不需要主动的去拷贝数据。
IO多路复用-select/poll/epoll
select
select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点. select的一个缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024
select会对所管理的文件描述符进行轮询操作, 调用后select函数会阻塞,直到有描述符就绪(有数据 可读、可写、或者有except), 当FD比较多的时候,每次select()都要通过遍历整个FD_SET, 这会浪费很多CPU时间
poll
poll本质上和select没有区别, 唯一的区别是它没有最大连接数的限制, 原因是它是基于链表来存储的
epoll
epoll使用“事件(event)”的就绪通知方式,通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知。
Epoll没有最大并发连接的限制, 也不是通过轮询的方式检测, 它只管“活跃”的连接,而跟连接总数无关
三者区别总结
-
支持一个进程所能打开的最大连接数
-
FD剧增后带来的IO效率问题
-
消息传递方式
- 表面上看epoll的性能最好,但是在连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll的通知机制需要很多函数回调。
- select低效是因为每次它都需要轮询。但低效也是相对的,视情况而定,也可通过良好的设计改善。