I/O模型与多路复用


同步、异步、阻塞、非阻塞

同步 & 异步

同步与异步是针对多个事件(线程/进程)来说的。

同步异步可以理解为多个事件的执行方式和执行时机如何,是串行等待还是并行执行。同步中依赖事件等待被依赖事件的完成,然后触发自身开始执行,异步 中依赖事件不需要等待被依赖事件,可以和被依赖事件并行执行,被依赖事件执行完成后,可以通过回调、通知等方式告知依赖事件。

阻塞 & 非阻塞

阻塞与非阻塞是针对单一事件(线程/进程)来说的。

阻塞与非阻塞可以理解为单个事件在发起其他调用以后,自身的状态如何,是苦苦等待还是继续干自己的事情。非阻塞虽然能提高CPU利用率,但是也带来了系统线程切换的成本,需要在CPU执行时间和系统切换成本之间好好估量一下。

同步阻塞

应用程序执行系统调用,应用程序会一直阻塞,直到系统调用完成。应用程序处于不再消费CPU而只是简单等待响应的状态。当响应返回时,数据被移动到用户空间的缓冲区,应用程序解除阻塞。 同步阻塞I/O模型.png-31.4kB

同步非阻塞

设备以非阻塞形式打开,I/O操作不会立即完成,read操作可能会返回一个错误代码。应用程序可以执行其他操作,但需要请求多次I/O操作,直到数据可用。 未命名文件 (1).png-53.8kB 同步非阻塞形式实际上是效率低下的,因为:

异步非阻塞

应用程序的其他处理任务与I/O任务重叠进行。读请求会立即返回,说明请求已经成功发起,应用程序不被阻塞,继续执行其它处理操作。当read响应到达,将数据拷贝到用户空间,产生信号或者执行一个基于线程回调函数完成I/O处理。应用程序不用在多个任务之间切换。 未命名文件 (2).png-33.2kB

非阻塞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,来找到就绪的描述符。

缺陷

poll

select轮询所有待监听的描述符机制类似,但poll使用pollfd结构表示要监听的描述符。

int poll(struct pollfd *fds, nfds_t nfds, int timeout)

struct pollfd
{
    short events;
    short revents;
};

pollfd结构包括了events(要监听的事件)和revents(实际发生的事件)。而且也需要在函数返回后遍历pollfd来获取就绪的描述符。 相对于selectpoll已不存在最大文件描述符限制。

epoll

epoll针对以上selectpoll的主要缺点做出了改进, 主要包括三个主要函数,epoll_create, epoll_ctl, epoll_wait

int epoll_create(int size)
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)

当就绪,会调用回调函数,把就绪的文件描述符和事件加入一个就绪链表,并拷贝到用户空间内存,应用程序不用亲自从内核拷贝。类似于在信号中注册所有的发送者和接收者,或者Task中注册所有任务的handler。

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)

select & poll & epoll比较

表面上看epoll的性能最好,但是在连接数少并且链接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll的通知机制需要很多函数回调。

select效率低是一位每次都需要轮询,但效率低也是相对的,也可通过良好的设计改善

基本概念补充

用户空间与内核空间

现在操作系统采用虚拟存储器,对32位操作系统,寻址空间为4G。为了保证用户程序不能直接操作内核,保证内核安全,操作系统将虚拟空间分为两部分,一部分为内核空间,一部分为用户空间。针对Linux操作系统,将最高的1G字节给内核使用,将较低的3G字节给用户进程使用。

进程切换

为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换。 从一个进程切换到另一个进程,需要经过以下变化:

文件描述符

文件描述符用于表示指向文件引用的抽象画概念。在形式上是一个非负整数,实际上是一个索引值,指向内核为每一个进程维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回个文件描述符。

缓存I/O

缓存I/O又被称作标准I/O,大多数文件系统的默认I/O操作都是缓存I/O。Linux的缓存I/O机制中,操作系统会将I/O的数据缓存在文件系统的页缓存中,也就是说数据会先被拷贝到内核空间,然后才会由内核空间到用户空间。

参考阅读:

浅析 I/O 模型及其设计模式

聊聊同步、异步、阻塞与非阻塞

select、epoll和poll

IO - 同步,异步,阻塞,非阻塞