前言
这些基础知识能够帮你更好的了解Linux中的5种IO模型。
1.用户空间和内核空间
现在操作系统都是采用虚拟存储器,那么对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方)。操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限
。为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操作系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间
。针对linux操作系统而言,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为内核空间,而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个进程使用,称为用户空间。
2.缓存IO
缓存 IO 又被称作标准 IO,大多数文件系统的默认 IO 操作都是缓存 IO
。在 Linux 的缓存 IO 机制中,操作系统会将 IO 的数据缓存在文件系统的页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间
。
数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作
,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。
3.并行和并发
比如去某部门办事需要依次去几个窗口,办事大厅里的人数就是并发数,而窗口个数就是并行度
。也就是说并发数是指同时进行的任务数(如同时服务的 HTTP 请求)
,而并行数是可以同时工作的物理资源数量(如 CPU 核数)
。
Linux中的IO模型
网络IO的本质是socket的读取,socket在linux系统被抽象为流,IO可以理解为对流的操作
。刚才说了,对于一次IO访问(以read举例),数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间
。所以说,当一个read操作发生时,它会经历两个阶段:
第一阶段:等待数据准备 (Waiting for the data to be ready)。
第二阶段:将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)。
对于socket流而言:
- 第一步:通常涉及等待网络上的数据分组到达,然后被复制到内核的某个缓冲区。
- 第二步:把数据从内核缓冲区复制到应用进程缓冲区。
网络应用需要处理的无非就是两大类问题,网络IO,数据计算
。相对于后者,网络IO的延迟,给应用带来的性能瓶颈大于后者。网络IO的模型大致有如下几种:
- 同步阻塞IO(bloking IO)
- 同步非阻塞IO(non-blocking IO)
- 多路复用IO(multiplexing IO)
- 信号驱动式IO(signal-driven IO)
- 异步IO(asynchronous IO)
其中,前四种也被称为同步IO。虽然非阻塞式 I/O 和信号驱动 I/O 在等待数据阶段不会阻塞,但是在之后的将数据从内核复制到应用进程这个操作会阻塞。
- 同步 I/O: 应用进程在调用 recvfrom 操作时会阻塞。
- 异步 I/O: 不会阻塞。
同步是需要主动等待消息通知,而异步是被动接收消息通知,通过回调、通知等方式来被动获取消息。
关于阻塞/非阻塞、同步/异步的简单理解:
阻塞/非阻塞:我在等你干活的时候我在干啥?
阻塞:啥也不干,死等
非阻塞:可以干别的,但也要时不时问问你的进度
同步/异步:你干完了,怎么让我知道呢?
同步:我只要不问,你就不告诉我
异步:你干完了,直接喊我过来就行
阻塞IO
和非阻塞IO
:这两个概念是程序级别
的。主要描述的是程序请求操作系统IO操作后,如果IO资源没有准备好,那么程序该如何处理的问题: 前者等待;后者继续执行(并且使用线程一直轮询,直到有IO资源准备好了)。同步IO
和非同步IO
:这两个概念是操作系统级别
的。主要描述的是操作系统在收到程序请求IO操作后,如果IO资源没有准备好,该如何响应程序的问题: 前者不响应,直到IO资源准备好以后;后者返回一个标记(好让程序和自己知道以后的数据往哪里通知),当IO资源准备好以后,再用事件机制返回给程序。
一、同步阻塞IO(bloking IO)
同步阻塞 IO 模型是最常用的一个模型,也是最简单的模型
。在linux中,默认情况下所有的socket都是blocking
。它符合人们最常见的思考逻辑。阻塞就是进程 "被" 休息, CPU处理其它进程去了
。
1.网络模型
在这个IO模型中,用户空间的应用程序执行一个系统调用(recvform),这会导致应用程序阻塞,什么也不干,直到数据准备好,并且将数据从内核复制到用户进程,最后进程再处理数据,在等待数据到处理数据的两个阶段,整个进程都被阻塞,不能处理别的网络IO。调用应用程序处于一种不再消费 CPU 而只是简单等待响应的状态
,因此从处理的角度来看,这是非常有效的。在调用recv()/recvfrom()函数时,发生在内核中等待数据和复制数据的过程,大致如下图:
2.流程描述
当用户进程调用了recv()/recvfrom()这个系统调用,kernel就开始了IO的第一个阶段:准备数据
(对于网络IO来说,很多时候数据在一开始还没有到达。比如,还没有收到一个完整的UDP包。这个时候kernel就要等待足够的数据到来)。这个过程需要等待,也就是说数据被拷贝到操作系统内核的缓冲区中是需要一个过程的。而在用户进程这边,整个进程会被阻塞(当然,是进程自己选择的阻塞)。第二个阶段:当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存
,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。
blocking IO的特点就是在IO执行的两个阶段(等待数据、复制数据)都被block了。
优点:能够及时返回数据,无延迟。
缺点:用户性能可能会降低。
二、同步非阻塞 IO(nonblocking IO)
同步非阻塞就是 “每隔一会儿瞄一眼进度条” 的轮询(polling)方式
。在这种模型中,设备是以非阻塞的形式打开的
。这意味着 IO 操作不会立即完成,read 操作可能会返回一个错误代码,说明这个命令不能立即满足(EAGAIN 或 EWOULDBLOCK)。
1.网络模型
在网络IO时候,非阻塞IO也会进行recvform系统调用,检查数据是否准备好,与阻塞IO不一样,“非阻塞将大的整片时间的阻塞分成N多的小的阻塞, 所以进程不断地有机会 ‘被’ CPU光顾”。
也就是说非阻塞的recvform系统调用调用之后,进程并没有被阻塞,内核马上返回给进程,如果数据还没准备好,此时会返回一个error
。进程在返回之后,可以执行其他任务,然后再发起recvform系统调用。重复上面的过程,循环往复的进行recvform系统调用。这个过程通常被称之为轮询
。轮询检查内核数据,直到数据准备好,再拷贝数据到进程,进行数据处理。需要注意,拷贝数据整个过程,进程仍然是属于阻塞的状态
。
在linux下,可以通过设置socket使其变为non-blocking。当对一个non-blocking socket执行读操作时
,流程如图所示:
2.流程描述
当用户进程发出read操作时,如果kernel中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个error。从用户进程角度讲,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存,然后返回。
nonblocking IO的特点是用户进程需要不断的主动询问kernel数据好了没有。
其进程在等待数据阶段不会阻塞,在复制数据阶段仍然会阻塞。
优点:能够在等待数据阶段干其他活了(包括提交其他任务,也就是 “后台” 可以有多个任务在同时执行)。
缺点:任务完成的响应延迟增大了,因为每过一段时间才去轮询一次read操作,而任务可能在两次轮询之间的任意时间完成。这会导致整体数据吞吐量的降低。
三、 IO 多路复用( IO multiplexing)
由于同步非阻塞方式需要不断主动轮询,轮询占据了很大一部分过程,轮询会消耗大量的CPU时间,而 “后台” 可能有多个任务在同时进行,人们就想到了循环查询多个任务的完成状态,只要有任何一个任务完成,就去处理它。如果轮询不是进程的用户态,而是有人帮忙就好了。那么这就是所谓的 “IO 多路复用”
。UNIX/Linux 下的 select、poll、epoll 就是干这个的(epoll 比 poll、select 效率高,做的事情是一样的)。
1.网络模型
IO多路复用有两个特别的系统调用select、poll、epoll函数
。select调用是内核级别的,select轮询相对非阻塞的轮询的区别在于—前者可以等待多个socket,能实现同时对多个IO端口进行监听
,多个连接共用一个阻塞对象,当其中任何一个socket的数据准好了,就能返回进行可读给相应的连接线程
,然后进程再进行recvform系统调用,将数据由内核拷贝到用户进程,当然这个过程是阻塞的
。select或poll调用之后,会阻塞进程,与blocking IO阻塞不同在于,此时的select不是等到socket数据全部到达再处理, 而是有了一部分数据就会调用用户进程来处理
。如何知道有一部分数据到达了呢?监视的事情交给了内核,内核负责数据到达的处理。
I/O复用模型会用到select、poll、epoll函数,这几个函数也会使进程阻塞,但是和阻塞I/O所不同的的,这两个函数可以同时阻塞多个I/O操作
。而且可以同时对多个读操作,多个写操作的I/O函数进行检测,直到有数据可读或可写时(注意不是全部数据可读或可写),才真正调用I/O操作函数。
对于多路复用,也就是轮询多个socket。多路复用既然可以处理多个IO,也就带来了新的问题,多个IO之间的顺序变得不确定了
,当然也可以针对不同的编号。具体流程,如下图所示:
2.流程描述
select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO
。它的基本原理就是select,poll,epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。
当用户进程调用了select,那么整个进程会被block
,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回
。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。
上面的图和blocking IO的图其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个system call (select 和 recvfrom),而blocking IO只调用了一个system call (recvfrom)
。但是,用select的优势在于它可以同时处理多个connection
。
如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延迟还更大。(select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。)
当需要同时处理多个客户端接入请求时,可以利用多线程或者I/O多路复用技术进行处理
。I/O多路复用技术通过把多个I/O的阻塞复用到同一个select的阻塞上,从而使得系统在单线程的情况下可以同时处理多个客户端请求
。与传统的多线程/多进程模型比,I/O多路复用的最大优势是系统开销小
,系统不需要创建新的额外进程或者线程,也不需要维护这些进程和线程的运行,降底了系统的维护工作量,节省了系统资源。
IO multiplexing在等待数据阶段可以阻塞也可以不阻塞(如果检测到有io完成就不用阻塞),但是在复制数据阶段要阻塞。
对比同步非阻塞和多路复用
关于轮询:
在同步非阻塞中,轮询是一个用户态的进程本身不断去轮询自己进程中某一个任务的IO是否完成,在轮询期间可以执行其他任务;而多路复用是一个内核态的线程通过调用select、poll、epoll函数,对某个进程的多个任务的IO(多个进程的任务)进行轮询,如果有一个IO完成就返回。
关于阻塞:
IO多路复用是阻塞在select(实际上可以理解为一个线程,调用select函数后被阻塞,监听到其他连接线程有数据后返回),epoll这样的系统调用之上,而没有阻塞在真正的I/O系统调用如recvfrom之上。
IO多路复用在阻塞到select阶段时,用户进程是主动等待并调用select函数获取数据就绪状态消息,并且其进程状态为阻塞,所以把IO多路复用归为同步阻塞模式
。
注:高并发的程序一般使用同步非阻塞方式而非多线程 + 同步阻塞方式
。
四、信号驱动式IO(signal-driven IO)
信号驱动式I/O:首先我们允许Socket进行信号驱动IO,并安装一个信号处理函数,进程继续运行并不阻塞。当数据准备好时,进程会收到一个SIGIO信号,可以在信号处理函数中调用I/O操作函数处理数据。过程如下图所示:
五、异步非阻塞 IO(asynchronous IO)
1.网络模型
相对于同步IO,异步IO不是顺序执行。用户进程进行aio_read系统调用之后,无论内核数据是否准备好,都会直接返回给用户进程,然后用户态进程可以去做别的事情
。等到socket数据准备好了,内核直接复制数据给进程,然后从内核向进程发送通知
。IO两个阶段,进程都是非阻塞的。
2.流程描述
用户进程发起aio_read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block
。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存(注意没有先将数据拷贝到内核空间,而是直接拷贝到用户空间),当这一切都完成之后,kernel会给用户进程发送一个signal或执行一个基于线程的回调函数来完成这次 IO 处理过程
,告诉它read操作完成了。
在 Linux 中,通知的方式是 “信号”:
如果这个进程正在用户态忙着做别的事,那就强行打断之,调用事先注册的信号处理函数
,这个函数可以决定何时以及如何处理这个异步任务。由于信号处理函数是突然闯进来的,因此跟中断处理程序一样,有很多事情是不能做的,因此保险起见,一般是把事件 “登记” 一下放进队列,然后返回该进程原来在做的事
。
如果这个进程正在内核态忙着做别的事
,例如以同步阻塞方式读写磁盘,那就只好把这个通知挂起来了,等到内核态的事情忙完了,快要回到用户态的时候,再触发信号通知
。
同步阻塞模型需要在 IO 操作开始时阻塞应用程序。这意味着不可能同时重叠进行处理和 IO 操作。
同步非阻塞模型允许处理和 IO 操作重叠进行,但是这需要应用程序根据重现的规则来检查 IO 操作的状态。
这样就剩下异步非阻塞 IO 了,它允许处理和 IO 操作重叠进行,包括 IO 操作完成的通知。
关于异步阻塞
有时我们的 API 只提供异步通知方式,例如在 node.js 里,但业务逻辑需要的是做完一件事后做另一件事
,例如数据库连接初始化后才能开始接受用户的 HTTP 请求。这样的业务逻辑就需要调用者是以阻塞方式来工作
。
为了在异步环境里模拟 “顺序执行” 的效果,就需要把同步代码转换成异步形式,这称为 CPS(Continuation Passing Style)变换
。
总结:
本文在原文聊聊Linux五种IO模型的基础上加了一些自己的理解,原文写的很清晰大家可以去看一看,一些要点我也在本文做了解释,欢迎大家讨论指正。