同步、异步、阻塞、非阻塞
同步 & 异步
同步与异步是针对多个事件(线程/进程)来说的。
- 如果事件A需要等待事件B的完成才能完成,这种串行执行机制可以说是同步的,这是一种可靠的任务序列,要么都成功,要么都失败。
- 如果事件B的执行不需要依赖事件A的完成结果,这种并行的执行机制可以说是异步的。事件B不确定事件A是否真正完成,所以是不可靠的任务序列。
同步异步可以理解为多个事件的执行方式和执行时机如何,是串行等待还是并行执行。同步中依赖事件等待被依赖事件的完成,然后触发自身开始执行,异步
中依赖事件不需要等待被依赖事件,可以和被依赖事件并行执行,被依赖事件执行完成后,可以通过回调、通知等方式告知依赖事件。
阻塞 & 非阻塞
阻塞与非阻塞是针对单一事件(线程/进程)来说的。
- 对于阻塞,如果一个事件在发起一个调用之后,在调用结果返回之前,该事件会被一直挂起,处于等待状态。
- 对于非阻塞,如果一个事件在发起调用以后,无论该调用当前是否得到结果,都会立刻返回,不会阻塞当前事件。
阻塞与非阻塞可以理解为单个事件在发起其他调用以后,自身的状态如何,是苦苦等待还是继续干自己的事情。非阻塞虽然能提高CPU利用率,但是也带来了系统线程切换的成本,需要在CPU执行时间和系统切换成本之间好好估量一下。
同步阻塞
应用程序执行系统调用,应用程序会一直阻塞,直到系统调用完成。应用程序处于不再消费CPU而只是简单等待响应的状态。当响应返回时,数据被移动到用户空间的缓冲区,应用程序解除阻塞。
同步非阻塞
设备以非阻塞形式打开,I/O操作不会立即完成,read操作可能会返回一个错误代码。应用程序可以执行其他操作,但需要请求多次I/O操作,直到数据可用。
同步非阻塞形式实际上是效率低下的,因为:
- 应用程序需要在不同的任务之间切换。异步非阻塞是你只需要执行当前任务,系统调用会主动通知你,不用频繁切换。
- 数据在内核中变为可用到调用read返回数据之间存在时间间隔,会造成整体数据吞吐量降低
异步非阻塞
应用程序的其他处理任务与I/O任务重叠进行。读请求会立即返回,说明请求已经成功发起,应用程序不被阻塞,继续执行其它处理操作。当read响应到达,将数据拷贝到用户空间,产生信号或者执行一个基于线程回调函数完成I/O处理。应用程序不用在多个任务之间切换。
非阻塞I/O和异步I/O区别在于,在非阻塞I/O中,虽然进程大部分时间不会被block,但是需要不停的去主动check,并且当数据准备完成以后,也需要应用程序主动调用recvfrom将数据拷贝到用户空间;异步I/O则不同,就像是应用程序将整个I/O操作交给了内核完成,然后由内核发信号通知。期间应用程序不需要主动去检查I/O操作状态,也不需要主动从内核空间拷贝数据到用户空间。
非阻塞I/O看起来是non-blocking的,但是只是在内核数据没准备好时,当数据准备完成,recvfrom需要从内核空间拷贝到用户空间,这个时候其实是被block住的。而异步I/O是当进程发起I/O操作后,再不用主动去请求,知道内核数据准备好并发出信号通知,整个过程完全没有block。
几种常用I/O模型
BIO
阻塞同步I/O模型,服务器需要监听端口号,客户端通过IP和端口与服务器简历TCP连接,以同步阻塞的方式传输数据。服务端设计一般都是 客户端-线程
模型,新来一个客户端连接请求,就新建一个线程处理连接和数据传输
当客户端连接较多时就会大大消耗服务器的资源,线程数量可能超过最大承受量
伪异步I/O
与BIO类似,只是将客户端-线程
的模式换成了线程池,可以灵活设置线程池的大小。但这只是对BIO的一种优化手段,并没有解决线程连接的阻塞问题。
NIO
同步非阻塞I/O模型,利用selector多路复用器轮询为每一个用户创建连接,这样就不用阻塞用户线程,也不用每个线程忙等待。只使用一个线程轮询I/O事件,比较适合高并发,高负载的网络应用,充分利用系统资源快速处理请求返回响应消息,是和连接较多连接时间I/O任务较短
AIO
异步非阻塞,需要操作系统内核线程支持,一个用户线程发起一个请求后就可以继续执行,内核线程执行完系统调用后会根据回调函数完成处理工作。比较适合较多I/O任务较长的场景。
I/O多路复用
多路复用的本质是同步非阻塞I/O,多路复用的优势并不是单个连接处理的更快,而是在于能处理更多的连接。
I/O编程过程中,需要同时处理多个客户端接入请求时,可以利用多线程或者I/O多路复用技术进行处理。
I/O多路复用技术通过把多个I/O的阻塞复用到同一个select阻塞上,一个进程监视多个描述符,一旦某个描述符就位, 能够通知程序进行读写操作。因为多路复用本质上是同步I/O,都需要应用程序在读写事件就绪后自己负责读写。
最大的优势是系统开销小,不需要创建和维护额外线程或进程。
- 应用场景
- 服务器需要同时处理多个处于监听状态或者多个连接状态的套接字
- 需要同时处理多种网络协议的套接字
- 一个服务器处理多个服务或协议
目前支持多路复用的系统调用有select
, poll
, epoll
。
select
监视多个文件句柄的状态变化,程序会阻塞在select处等待
,直到有文件描述符就绪或超时。
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout)
可以监听三类文件描述符,writefds
(写状态), readfds
(读状态), exceptfds
(异常状态)。
我们在select
函数中告诉内核需要监听的不同状态的文件描述符以及能接受的超时时间,函数会返回所有状态下就绪的描述符的个数,并且可以通过遍历fdset
,来找到就绪的描述符。
缺陷
- 每次调用
select
,都需要把待监控的fd集合从用户态拷贝到内核态,当fd很大时,开销很大。 - 每次调用
select
,都需要轮询一遍所有的fd,查看就绪状态。 -
select
支持的最大文件描述符数量有限,默认是1024
poll
与select
轮询所有待监听的描述符机制类似,但poll使用pollfd结构表示要监听的描述符。
int poll(struct pollfd *fds, nfds_t nfds, int timeout)
struct pollfd
{
short events;
short revents;
};
pollfd
结构包括了events
(要监听的事件)和revents
(实际发生的事件)。而且也需要在函数返回后遍历pollfd
来获取就绪的描述符。
相对于select
,poll
已不存在最大文件描述符限制。
epoll
epoll
针对以上select
和poll
的主要缺点做出了改进,
主要包括三个主要函数,epoll_create
, epoll_ctl
, epoll_wait
。
- epoll_create:创建epoll句柄,会占用一个fd值,使用完成以后,要关闭。
int epoll_create(int size)
-
epoll_ctl:提前注册好要监听的事件类型,监听事件(文件可写,可读,挂断,错误)。不用每次都去轮询一遍注册的fd,而只是通过
epoll_ctl
把所有fd拷贝进内核一次,并为每一个fd指定一个回调函数。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
当就绪,会调用回调函数,把就绪的文件描述符和事件加入一个就绪链表,并拷贝到用户空间内存,应用程序不用亲自从内核拷贝。类似于在信号中注册所有的发送者和接收者,或者Task中注册所有任务的handler。
-
epoll_wait:监听epoll_ctl中注册的文件描述符和事件,在就绪链表中查看有没有就绪的fd,不用去遍历所有fd。
相当于直接去遍历结果集合,而且百分百命中,不用每次都去重新查找所有的fd,用户索引文件的事件复杂度为O(1)
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)
select & poll & epoll比较
- 每次调用
select
都需要把所有要监听的文件描述符拷贝到内核空间一次,fd很大时开销会很大。epoll
会在epoll_ctl()中注册,只需要将所有的fd拷贝到内核事件表一次,不用再每次epoll_wait()时重复拷贝 - 每次
select
需要在内核中遍历所有监听的fd,直到设备就绪;epoll
通过epoll_ctl
注册回调函数,也需要不断调用epoll_wait
轮询就绪链表,当fd或者事件就绪时,会调用回调函数,将就绪结果加入到就绪链表。 -
select
能监听的文件描述符数量有限,默认是1024;epoll
能支持的fd数量是最大可以打开文件的数目,具体数目可以在/proc/sys/fs/file-max查看 -
select
,poll
在函数返回后需要查看所有监听的fd,看哪些就绪,而epoll只返回就绪的描述符,所以应用程序只需要就绪fd的命中率是百分百。
表面上看epoll的性能最好,但是在连接数少并且链接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll的通知机制需要很多函数回调。
select效率低是一位每次都需要轮询,但效率低也是相对的,也可通过良好的设计改善
基本概念补充
用户空间与内核空间
现在操作系统采用虚拟存储器,对32位操作系统,寻址空间为4G。为了保证用户程序不能直接操作内核,保证内核安全,操作系统将虚拟空间分为两部分,一部分为内核空间,一部分为用户空间。针对Linux操作系统,将最高的1G字节给内核使用,将较低的3G字节给用户进程使用。
进程切换
为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换。
从一个进程切换到另一个进程,需要经过以下变化:
- 保存处理机上下文,包括程序计数器和其他寄存器
- 更新PCB信息
- 把进程的PCB移入相应队列,如就绪、在某事件阻塞队列
- 选择另一个进程执行,并更新PCB
- 更新内存管理的数据结构
- 回复处理机上下文
文件描述符
文件描述符用于表示指向文件引用的抽象画概念。在形式上是一个非负整数,实际上是一个索引值,指向内核为每一个进程维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回个文件描述符。
缓存I/O
缓存I/O又被称作标准I/O,大多数文件系统的默认I/O操作都是缓存I/O。Linux的缓存I/O机制中,操作系统会将I/O的数据缓存在文件系统的页缓存中,也就是说数据会先被拷贝到内核空间,然后才会由内核空间到用户空间。
参考阅读:
浅析 I/O 模型及其设计模式
聊聊同步、异步、阻塞与非阻塞
select、epoll和poll
IO - 同步,异步,阻塞,非阻塞