网络IO模型

在学习JAVA NIO的时候有学习到相关的BIO、NIO、AIO的概念,以及一直对阻塞、非阻塞、异步等IO模型的知识感到非常模糊,要清楚JAVA的NIO是什么,以及基础的操作系统的网络IO模型,在此总结下网络IO模型的基础知识。

不得不感叹信息爆炸的时代,想学点东西但是到处都是误人子弟的文章和copy的内容,但是自己还想快餐式的学点重点,先简单学习下,真正细致的学习还是要看看《UNIX网络编程》。

几个基础概念

什么是IO

我们都知道unix世界里、一切皆文件、而文件是什么呢?文件就是一串二进制流而已、不管socket、还是FIFO、管道、终端、对我们来说、一切都是文件、一切都是流、在信息交换的过程中、我们都是对这些流进行数据的收发操作、简称为I/O操作(input and output)、往流中读出数据、系统调用read、写入数据、系统调用write、不过话说回来了、计算机里有这么多的流、我怎么知道要操作哪个流呢?做到这个的就是文件描述符、即通常所说的fd、一个fd就是一个整数、所以对这个整数的操作、就是对这个文件(流)的操作、我们创建一个socket、通过系统调用会返回一个文件描述符、那么剩下对socket的操作就会转化为对这个描述符的操作、不能不说这又是一种分层和抽象的思想。

IO的交互

IO模型00

通常用户进程中的一个完整IO分为两阶段:

  • 用户空间 <-----> 内核空间
  • 内核空间 <-----> 设备空间

IO模型00

内核空间中存放的是内核代码和数据、而进程的用户空间中存放的是用户程序的代码和数据、不管是内核空间还是用户空间、它们都处于虚拟空间中、Linux使用两级保护机制:0级供内核使用、3级供用户程序使用。

操作系统和驱动程序运行在内核空间、应用程序运行在用户空间、两者不能简单地使用指针传递数据、因为Linux使用的虚拟内存机制、其必须通过系统调用请求kernel来协助完成IO动作、内核会为每个IO设备维护一个缓冲区、用户空间的数据可能被换出、当内核空间使用用户空间指针时、对应的数据可能不在内存中。

对于一个输入操作来说、进程IO系统调用后、内核会先看缓冲区中有没有相应的缓存数据、没有的话再到设备中读取、因为设备IO一般速度较慢、需要等待、内核缓冲区有数据则直接复制到进程空间。

所以、对于一个网络输入操作通常包括两个不同阶段:

  1. 等待网络数据到达网卡 –> 读取到内核缓冲区
  2. 从内核缓冲区复制数据 –> 用户空间

IO有内存IO、网络IO和磁盘IO三种、通常我们说的IO指的是后两者

用户空间、内核空间

现在操作系统都是采用虚拟存储器,那么对32位操作系统而言,它的寻址空间(虚拟储存空间)为4G(2的32次方)。操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核,保证内核的安全,操作系统将虚拟空间划分为两个部分,一个部分为内核空间,一部分为用户空间。

如何分配这两个空间的大小也是有讲究的,如windows 32位操作系统,默认的用户空间:内核空间的比例是1:1;而在32位Linux系统中的默认比例是3:1(3G用户空间,1G内核空间)。

进程切换

为了控制进程的执行,内核必须要有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为成为进程的切换。任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。

进程切换的过程,会经过下面这些变化:

  1. 保存处理机上下文,包括程序计数器和其他寄存器。
  2. 更新PCB信息。
  3. 将进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列。
  4. 选择另外一个进程执行,并更新PCB。
  5. 更新内存管理的数据结构。
  6. 恢复处理机上下文。

缓存IO

缓存IO又称称为标准IO,大多数文件系统的默认IO操作都是缓存IO。在Linux的缓存IO机制中,操作系统会将IO的数据缓存在文件系统的页缓存(page cache)。也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓存区拷贝到应用程序的地址空间中。

这种做法的缺点就是,需要在应用程序地址空间和内核进行多次拷贝,这些拷贝动作所带来的CPU以及内存开销是非常大的。

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

同步与异步:描述的是用户线程与内核的交互方式,同步指用户线程发起IO请求后需要等待或者轮询内核IO操作完成后才能继续执行;而异步是指用户线程发起IO请求后仍然继续执行,当内核IO操作完成后会通知用户线程,或者调用用户线程注册的回调函数。

阻塞与非阻塞:描述是用户线程调用内核IO操作的方式,阻塞是指IO操作需要彻底完成后才返回到用户空间;而非阻塞是指IO操作被调用后立即返回给用户一个状态值,无需等到IO操作彻底完成。

并行与并发

先弄明白并发和并行的区别:比如去某部门办事需要依次去几个窗口,办事大厅的人数就是并发数,而窗口的个数就是并行度。就是说并发是同时进行的任务数(如同时服务的http请求),而并行数就是可以同时工作的物理资源数量(如cpu核数)。

通过合理调度任务的不同阶段,并发数可以远远大于并行度。这就是区区几个CPU可以支撑上万个用户并发请求的原因。在这种高并发的情况下,为每个用户请求创建一个进程或者线程的开销非常大。而同步非阻塞方式可以把多个IO请求丢到后台去,这样一个CPU就可以服务大量的并发IO请求。

Linux IO模型

网络IO的本质就是socket的读取,socket在linux系统被抽象为流,IO可以理解为对流的操作。文章开始的时候也提到了,对于一次IO访问(以read为例),数据会先被拷贝到操作系统内核的缓冲区,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间中。所以说,当一个read操作发生时,它会经历两个阶段:

  • 第一个阶段:等待数据准备。
  • 第二个阶段:将数据从内核拷贝到进程中

对于socket流而言:

  • 第一步:通常涉及等待网络上的数据分组到达,然后复制到内核的某个缓冲区。
  • 第二步:把数据从内核缓冲区复制到应用进程缓冲区。

当然,如果内核空间的缓冲区中已经有数据了,那么就可以省略第一步。至于为什么不能直接让磁盘控制器把数据送到应用程序的地址空间中呢?最简单的一个原因就是应用程序不能直接操作底层硬件。

网络应用需要处理的无非就是两大类问题,网络IO,数据计算。相对于后者,网络IO的延迟,给应用带来的性能瓶颈大于后者。网络IO的模型大致分为如下五种:

阻塞IO(blocking I/O)

IO模型01

在这个模型中,应用程序为了执行这个read操作,会调用相应的一个system call,将系统控制权交给内核,然后就进行等待(这个等待的过程就是被阻塞了),内核开始执行这个system call,执行完毕后会向应用程序返回响应,应用程序得到响应后,就不再阻塞,并进行后面的工作。

用户进程调用了recvfrom这个系统调用,内核就开始了IO的第一个阶段:准备数据。对于网络IO来说,很多时候数据在一开始还没有到达(比如、还没有收到一个完整的UDP包),这个时候内核就要等待足够的数据到来,而在用户进程这边,整个进程会被阻塞。当内核一直等到数据准备好了,它就会将数据从内核中拷贝到用户内存,然后返回结果,用户进程才解除阻塞的状态,重新运行起来,几乎所有的程序员第一次接触到的网络编程都是从listen()、send()、recv()等接口开始的,这些接口都是阻塞型的。

blocking IO的特点就是在IO执行的两个阶段(等待数据和拷贝数据两个阶段)都被阻塞了。

  • 典型应用:阻塞Socket、Java BIO
  • 优点:进程阻塞挂起不消耗CPU资源,及时响应每个操作。实现难度低,开发应用较容易。
  • 缺点:对用户来说处于等待就要付出性能代价。适用并发量小的网络应用开发。不适用并发量大的应用。

非阻塞IO(noblocking I/O)

IO模型02

当用户进程发出read操作时,调用相应的system call,这个system call会立即从内核中返回。但是在返回的这个时间点,内核中的数据可能还没有准备好,也就是说内核只是很快就返回了system call,只有这样才不会阻塞用户进程,对于应用程序,虽然这个IO操作很快就返回了,但是它并不知道这个IO操作是否真的成功了,为了知道IO操作是否成功,应用程序需要主动的循环去问内核。

  • 缺点:进程轮询(重复)调用、消耗CPU的资源

多路复用IO(I/O multiplexing)

IO模型04

多个的进程的IO可以注册到一个复用器(select)上,当用户进程调用该select,select会监听所有注册进来的IO。如果select所有监听的IO在内核缓冲区都没有可读数据,select调用进程会被阻塞,而当任一IO在内核缓冲区中有可数据时,select调用就会返回,而后select调用进程可以自己或通知另外的进程(注册进程)来再次发起读取IO,读取内核中准备好的数据,多个进程注册IO后,只有一个select调用进程被阻塞。

IO复用相对阻塞和非阻塞更难简单说明,所以额外解释一段。其实IO复用模型和阻塞IO模型并没有太大的不同,事实上还更差一些,因为这里需要使用两个系统调用(select和 recvfrom),而阻塞IO模型只有一次系统调用(recvfrom)。但是,用select的优势在于它可以同时处理多个连接,所以如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用多线程加阻塞IO的web server性能更好,可能延迟还更大,select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。

在IO复用模型中,对于每一个socket一般都设置成为非阻塞。但是,如上图所示,整个用户的进程其实是一直被阻塞的,只不过进程是被select这个函数阻塞,而不是被socket IO给阻塞。

调用system call之后,并不等待内核的返回结果而是立即返回。虽然返回结果的调用函数是一个异步的方式,但应用程序会被像select、poll和epoll等具有多个文件描述符的函数阻塞住,一直等到这个system call有结果返回了,再通知应用程序。这种情况,从IO操作的实际效果来看,异步阻塞IO和第一种同步阻塞IO是一样的,应用程序都是一直等到IO操作成功之后(数据已经被写入或者读取),才开始进行下面的工作。不同点在于异步阻塞IO用一个select函数可以为多个文件描述符提供通知,提供了并发性。举个例子:例如有一万个并发的read请求,但是网络上仍然没有数据,此时这一万个read会同时各自阻塞,现在用select、poll、epoll这样的函数来专门负责阻塞同时监听这一万个请求的状态,一旦有数据到达了就负责通知,这样就将一万个等待和阻塞转化为一个专门的函数来负责与管理。

多路复用技术应用于JAVA NIO的核心类库多路复用器Selector中,目前支持I/O多路复用的系统调用有select、pselect、poll、epoll,在linux编程中有一段时间一直在使用select做轮询和网络事件通知的,但是select支持一个进程打开的socket描述符(FD)收到了限制,一般为1024,由于这一限制,现在使用了epoll代替了select,而epoll支持一个进程打开的FD不受限制。

异步IO与同步IO的区别在于:同步IO是需要应用程序主动地循环去询问是否有数据,而异步IO是通过像select等IO多路复用函数来同时检测多个事件句柄来告知应用程序是否有数据。

了解了前面三种IO模式,在用户进程进行系统调用的时候,他们在等待数据到来的时候,处理的方式是不一样的,直接等待、轮询、select或poll轮询,两个阶段过程:

  • 第一个阶段有的阻塞,有的不阻塞,有的可以阻塞又可以不阻塞。
  • 第二个阶段都是阻塞的。

从整个IO过程来看,他们都是顺序执行的,因此可以归为同步模型,都是进程自动等待且向内核检查状态。

IO多路复用究竟是同步阻塞还是异步阻塞模型,这里来展开说说:

同步是需要主动等待消息通知,而异步则是被动接受消息通知,通过回调、通知、状态等方式来被动获取消息。IO多路复用在阻塞到select阶段时,用户进程是主动等待并调用select函数来获取就绪状态消息,并且其进程状态为阻塞。所以IO多路复用是同步阻塞模式。

  • 典型应用:Java NIO、Nginx(epoll、poll、select)

信号驱动IO(signal blocking I/O)

IO模型03

信号驱动式IO就是指进程预先告知内核,向内核注册一个信号处理函数,然后用户进程返回不阻塞,当内核数据就绪时会发送一个信号给进程,用户进程便在信号处理函数中调用IO读取数据,从图中明白实际IO内核拷贝到用户进程的过程还是阻塞的,信号驱动式IO并没有实现真正的异步,因为通知到进程之后,依然是由进程来完成IO操作。

这和后面的异步IO模型很容易混淆,需要理解IO交互并结合五种IO模型的比较阅读。

在信号驱动式IO模型中,依然不符合POSIX描述的异步IO,只能算是半异步,并且实际中并不常用。

异步IO(asynchronous I/O)

IO模型05

用户进程发起aio_read(POSIX异步IO函数aio_或者lio_开头)操作之后、给内核传递描述符、缓冲区指针、缓冲区大小和read相同的三个参数以及文件偏移(与lseek类似),告诉内核当整个操作完成时,如何通知我们,立刻就可以开始去做其它的事。而另一方面,从内核的角度,当它受到一个aio_read之后,首先它会立刻返回。所以不会对用户进程产生任何阻塞。然后,内核会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,内核会给用户进程发送一个信号,告诉它aio_read操作完成了。

异步IO的工作机制是:告知内核启动某个操作,并让内核在整个操作完成后通知我们。这种模型与信号驱动的IO区别在于,信号驱动IO是由内核通知我们何时可以启动一个IO操作,这个IO操作由用户自定义的信号函数来实现,而异步IO模型是由内核告知我们IO操作何时完成。

这和前面的信号驱动式IO模型很容易混淆,需要理解IO交互并结合五种IO模型的比较阅读。

在异步IO模型中,真正实现了POSIX描述的异步IO,是五种IO模型中唯一的异步模型。

需要操作系统的底层支持,LINUX 2.5 版本内核首现,2.6 版本产品的内核标准特性。

五种IO模型比较

IO模型05

阻塞IO和非阻塞IO的区别在哪?

调用阻塞会一直阻塞住对应的进程直到操作完成,而非阻塞IO在内核还没准备数据的情况下会立刻返回。阻塞和非阻塞关注的是进程在等待调用结果时的状态,阻塞是指调用结果返回之前,当前进程会被挂起。调用进程只有在得到结果才会返回,非阻塞调用指不能立刻得到结果,该调用不会阻塞当前进程。

同步IO和异步IO区别在哪?

两者的区别就在于同步做IO操作的时候会将进程阻塞。按照这个定义,之前所述的阻塞IO、非阻塞IO、IO复用、信号驱动都属于同步IO。有人可能会说,非阻塞IO并没有被阻塞啊,这里有个非常狡猾的地方。定义中所指的IO操作是指真实的IO操作,就是例子中的recvfrom这个系统调用,非阻塞IO在执行recvfrom这个系统调用的时候,如果内核的数据没有准备好,这时候不会阻塞进程。但是,当内核中数据准备好的时候,recvfrom会将数据从内核拷贝到用户内存中,这个时候进程是被阻塞了。信号驱动也是同样的道理,在这段时间内,进程是被阻塞的。而异步IO则不一样,当进程发起IO操作之后,就直接返回再也不理睬了,直到内核发送一个信号,告诉进程说IO完成,在这整个过程中,进程完全没有被阻塞。

同异步IO的根本区别在于:同步IO主动的调用recvfrom来将数据拷贝到用户内存。而异步则完全不同,它就像是用户进程将整个IO操作交给了他人(内核)完成,然后他人做完后发信号通知,在此期间,用户进程不需要去检查IO操作的状态,也不需要主动的去拷贝数据。

POSIX的定义

A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes

An asynchronous I/O operation does not cause the requesting process to be blocked

信号驱动式IO和异步IO的区别?

这里之所以单独拿出来是因为如果还没有清除IO概念很容易混淆,所以理解IO模型之前一定要理解IO概念。如果看完前面两个问题,相信也能理解信号驱动IO与异步IO的区别在于启用异步IO意味着通知内核启动某个IO操作,并让内核在整个操作(包括数据从内核复制到用户缓冲区)完成时通知我们,也就是说,异步IO是由内核通知我们IO操作何时完成,即实际的IO操作也是异步的,信号驱动IO是由内核通知我们何时可以启动一个IO操作,这个IO操作由用户自定义的信号函数来实现。

参考资料

投食入口