概述
为了讲解4种IO模型:
- 同步阻塞IO
- 同步非阻塞IO
- IO多路复用
- 异步IO
需要提前知道一些概念,如:用户空间和内核空间,直接IO和缓存IO,同步和异步,阻塞和非阻塞,并发和并行(比较相关的概念)等。
用户空间和内核空间
现在操作系统都采用虚拟寻址,处理器先产生一个虚拟地址,通过地址翻译成物理地址(内存的地址),再通过总线的传递,最后处理器拿到某个物理地址返回的字节。
对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方)。操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。
为了保证用户进程不能直接操作内核,保证内核的安全,操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。
针对Linux操作系统而言:将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF)供内核使用,称为内核空间。而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF)供各个进程使用,称为用户空间。
直接I/O和缓存I/O
文件系统IO分为DirectIO(直接IO)和BufferIO(缓存IO),BufferIO也叫Normal IO(标准IO)。大多数文件系统的默认IO操作都是缓存IO。
读操作:操作系统检查内核的缓冲区有没有需要的数据,如果已经缓存,则直接从缓存中返回;否则从磁盘中读取,然后缓存在操作系统的缓存中。
写操作:将数据从用户空间复制到内核空间的缓存中。这时对用户程序来说写操作就已经完成,至于什么时候再写到磁盘中由操作系统决定,除非显示地调用sync同步命令。
以 write 为例,数据会先被拷贝进程缓冲区,在拷贝到操作系统内核的缓冲区中,然后才会写到存储设备中。
同步和异步
同步:发出一个功能调用时,在没有得到结果之前,该调用就不返回或继续执行后续操作。
简单来说,同步就是必须一件事一件事做,等前一件做完才能做下一件事。
如:B/S模式中的表单提交,具体过程是:客户端提交请求->等待服务器处理->处理完毕返回,在这个过程中客户端(浏览器)不能做其他事。
异步:当一个异步过程调用发出后,调用者在没有得到结果之前,就可以继续执行后续操作。当这个调用完成后,一般通过状态、通知和回调来通知调用者。对于异步调用,调用的返回并不受调用者控制。
通知调用者的三种方式:
- 状态:即调用者需要轮询监听被调用者的状态,效率会很低
- 通知:当被调用者执行完成后,发出通知告知调用者,无需消耗太多性能
- 回调:与通知类似,当被调用者执行完成后,会调用调用者提供的回调函数
如:B/S模式中的ajax请求,具体过程是:客户端发出ajax请求->服务端处理->处理完毕执行客户端回调,在客户端(浏览器)发出请求后,仍然可以做其他的事。
同步和异步的区别:
总结来说,同步和异步的区别:请求发出后,是否需要等待结果,才能继续执行其他操作。
同步和异步都是基于应用程序私操作系统处理IO事件所采用的方式,比如:
同步:是应用程序要直接参与IO读写的操作。
异步:所有的IO读写交给操作系统去处理,应用程序只需要等待通知。
同步方式在处理IO事件的时候,必须阻塞在某个方法上面等待IO事件完成(阻塞IO事件或者通过轮询IO事件的方式)。
对于异步来说,所有的IO读写都交给操作系统。此时可以去做其他的事情,并不需要去完成真正的IO操作,当操作完成IO后会给应用程序一个通知。
阻塞和非阻塞
阻塞和非阻塞这两个概念与程序(线程)等待消息通知(无所谓同步或者异步)时的状态有关。也就是说阻塞与非阻塞主要是程序(线程)等待消息通知时的状态角度来说的。
阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态。
阻塞调用是指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。
非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程。
阻塞与非阻塞:阻塞指的是用户空间程序的执行状态。
- 阻塞是指用户空间(调用线程)一直在等待,而不能干别的事情;
- 非阻塞是指用户空间(调用线程)拿到内核返回的状态值就返回自己的空间
阻塞和非阻塞是进程在访问数据时,数据是否准备就绪的一种处理方式,比如当数据没准备就绪时:
- 阻塞:往往需要等待缓冲区中的数据准备好过后才处理其他的事情,否则一直等待在那里;
- 非阻塞:当进程访问数据缓冲区时,如果数据没有准备好则直接返回,不会等待。如果数据已经准备好,也直接返回。
同步/异步与阻塞/非阻塞
- 同步阻塞
效率最低,实际程序中就是未对fd设置O_NONBLOCK
标志位的读写操作; - 异步阻塞
异步操作是可以被阻塞住的,只不过它不是在处理消息时阻塞,而是在等待消息通知时被阻塞。如select函数,假如传入的最后一个timeout参数为NULL,那么如果所关注的事件没有一个被触发,程序就会一直阻塞在这个select调用处。 - 同步非阻塞
实际上是效率低下的,想象下你一边干别的事情一边看消息到了没有,如果把干别的事情和观察下载完成情况的位置看成是程序的两个操作的话,程序需要在这两种不同的行为之间来回切换,很多人会写阻塞的读写操作,但是别忘可以对fd设置O_NONBLOCK
标志位,这样就可以将同步操作变成非阻塞的。 - 异步非阻塞
效率更高,因为等待下载完成是你(等待者)的事情,而通知你则是消息触发机制的事情,程序没有在两种不同的操作中来回切换。
fd、select、poll、epoll
fd
File Descriptor的简写,文件描述符。Linux内核将所有外部设备都看做一个文件。因此,可将与外部设备的所有操作视为文件操作。对文件的读写,都通过调用内核提供的系统调用,内核返回一个fd。对socket的读写也会有相应的描述符,称为socketfd(socket文件描述符)。描述符就是一个数字,指向内核中一个结构体(文件路径,数据区等属性)。应用程序对文件的读写就通过对描述符的读写完成。
select
基本原理:select 函数监视的文件描述符分3类,分别是writefds、readfds、和exceptfds。调用后select函数会阻塞,直到有描述符就绪(有数据 可读、可写、或者有except),或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。当select函数返回后,可以通过遍历fdset,来找到就绪的描述符。
缺点:
- 最大缺点就是单个进程所打开的FD是有一定限制的,它由FDSETSIZE设置,32位机默认是1024个,64位机默认是2048。一般来说这个数目和系统内存关系很大,”具体数目可以cat /proc/sys/fs/file-max察看”。
- 对socket进行扫描时是线性扫描,即采用轮询的方法,效率较低。当套接字比较多时,每次select()都要通过遍历FDSETSIZE个Socket来完成调度,不管哪个Socket是活跃的,都遍历一遍。这会浪费很多CPU时间。如果能给套接字注册某个回调函数,当他们活跃时,自动完成相关操作,那就避免了轮询”,这正是epoll与kqueue做的。
- 需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大。
poll
基本原理:poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。
基于链表来存储,没有最大连接数的限制。但同样有缺点:
- 大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义;
- 水平触发:如果报告fd后没有被处理,那么下次poll时会再次报告该fd。
注意:从上面看,select和poll都需要在返回后,通过遍历文件描述符来获取已经就绪的socket。事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。
epoll
epoll是在2.6内核中提出的,是select和poll的增强版本。相对于select和poll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。
基本原理:epoll支持水平触发和边缘触发,最大的特点在于边缘触发,它只告诉进程哪些fd刚刚变为就绪态,并且只会通知一次。epoll使用事件就绪通知方式,通过epollctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epollwait便可以收到通知。
epoll优点:
- 没有最大并发连接的限制,能打开的FD的上限远大于1024,1G内存能监听约10万个端口;
- 效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。只有活跃可用的FD才会调用callback函数;即epoll最大的优点就在于它只与活跃连接有关;
- 内存拷贝,利用
mmap()
文件映射内存加速与内核空间的消息传递,减少复制开销 - 线程安全
epoll有个致命的缺点:只有Linux支持?
BSD上的对应实现是kqueue。
关于内存拷贝,参考零拷贝技术:mmap和sendfile。
并发和并行
并发:Concurrent,在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行,但任一个时刻点上只有一个程序在处理机上运行。
当有多个线程在操作时,如果系统只有一个CPU,则它根本不可能真正同时进行一个以上的线程,它只能把CPU运行时间划分成若干个时间段,再将时间段分配给各个线程执行,在一个时间段的线程代码运行时,其它线程处于挂起状。
并行:Parallel,当系统有一个以上CPU时,则线程的操作有可能非并发。当一个CPU执行一个线程时,另一个CPU可以执行另一个线程,两个线程互不抢占CPU资源,可以同时进行。
并发和并行的区别:
你吃饭吃到一半,电话来了,你一直到吃完饭以后才去接,这就说明你不支持并发也不支持并行。
你吃饭吃到一半,电话来了,你停下吃饭去接电话,接完后继续吃饭,这说明你支持并发。
你吃饭吃到一半,电话来了,你一边打电话一边吃饭,这说明你支持并行。
并发的关键是你有处理多个任务的能力,不一定要同时。
并行的关键是你有同时处理多个任务的能力。
它们最关键的区别:是否是『同时』。
水平触发和边缘触发
在非阻塞I/O模型中,水平触发和边缘触发是两种不同的事件通知模式。它们定义I/O多路复用机制(如 select、poll、epoll)如何通知应用程序I/O事件的发生。
水平触发
Level-Triggered,LT,是I/O默认的工作方式,应用程序无需特殊设置即可使用。当文件描述符上有可用事件(如可读或可写)时,I/O多路复用机制会不断通知应用程序,直到该事件被处理。
特点:
- 即使应用程序没有立即处理事件,系统也会持续通知;
- 更加简单直观,易于实现。
适用场景
- 对代码复杂度要求较低的应用;
- 在数据未完全处理时,仍需要重复通知以确保后续处理。
优点:实现简单,事件不会丢失。
缺点:可能会重复通知,导致效率较低。
边缘触发
Edge-Triggered,ET,一种更高效的通知方式,需要应用程序显式设置。系统只在事件状态从不可用到可用的边缘变化时通知应用程序。如果应用程序没有处理完所有数据,系统不会再次通知,直到下一次边缘变化。
特点
- 通知次数少,只在边缘变化时触发;
- 应用程序需要主动且彻底地处理事件,否则可能导致数据丢失。
适用场景
- 高性能场景,特别是高并发网络服务;
- 开发者可确保每次通知后完全处理数据或将事件状态维护好。
优点:减少系统调用和通知次数,提高性能。
缺点:实现复杂,容易因未完全处理数据而遗漏事件。
对比
特性 | 水平触发(LT) | 边缘触发(ET) |
---|---|---|
通知方式 | 数据可用时不断通知 | 状态边缘变化时一次性通知 |
实现复杂度 | 简单,事件不会丢失 | 复杂,需彻底处理数据,避免丢失事件 |
系统资源使用 | 通知次数多,效率较低 | 通知次数少,效率高 |
适用场景 | 低复杂度场景; 小型或中等规模的网络服务,开发团队对I/O的掌控不熟悉 | 高性能场景; 高性能服务器,如Nginx,对系统资源利用率要求高。 |
优势 | 稳定性更高,即使处理逻辑有遗漏,数据也不会丢失 | 减少系统开销和上下文切换,但需要开发者完全控制事件处理逻辑 |
IO模型
对于一次IO访问,它会经历两个阶段:
- 等待数据准备就绪
- 将数据从内核拷贝到进程中
读函数:分为等待系统可读和真正执行读
写函数:分为等待网卡可写和真正执行写
同步IO是指用户空间的线程是主动发起IO请求的一方,内核空间是被动接受方。异步IO则反过来,是指系统内核是主动发起IO请求的一方,用户空间的线程是被动接受方。
有如下几个IO模型
- BIO:Blocking IO,同步阻塞,一个请求对应一个线程,如FileInputStream等,
- NIO:New IO,同步非阻塞,需要定时轮询,如FileChannel
- AIO:Asynchronous non-blocking IO,异步非阻塞
- IO多路复用
- 信号驱动IO
在JDK1.4之前,IO类库是阻塞IO;从JDK1.4开始,引进新的异步IO类库,即NIO,NIO的目标是支持非阻塞IO,因此也叫非阻塞IO(Non-Blocking IO);JDK1.4以前的阻塞式IO为OIO(Old IO)。NIO弥补原来面向流的OIO同步阻塞的不足,它为标准Java代码提供高速的、面向缓冲区的IO。
等待就绪的阻塞是不使用CPU的,是在空等。真正的读写操作的阻塞是使用CPU的,真正在干活,而且这个过程非常快,属于内存拷贝,宽带通常在1GB/s级别以上,基本不耗时。
以socket.read()
为例:
- BIO:如果TCP RecvBuffer里没有数据,函数会一直阻塞,直到收到数据,返回读到的数据
- NIO:如果TCP RecvBuffer有数据,就把数据从网卡读到内存,并且返回给用户;反之则直接返回0,永远不会阻塞
- AIO:不但等待就绪是非阻塞的,连数据从网卡到内存的过程也是异步的。
NIO特点:主要的socket读、写、注册和接收函数,在等待就绪阶段都是非阻塞的,真正的IO操作是同步阻塞的,消耗CPU,性能非常高。
BIO
Linux中,默认情况下所有的socket都是blocking,模型如下:
当用户进程调用recvfrom
触发系统调用,内核开始IO的第一个阶段:等待数据准备。对于network io来说,很多时候数据在一开始还没有到达(比如,还没有收到一个完整的UDP包),此时内核就要等待足够的数据到来。而在用户进程这边,整个进程会被阻塞。当内核一直等到数据准备好,它就会将数据从内核中拷贝到用户内存,然后内核返回结果,用户进程才解除block的状态,重新运行起来。
BIO特点就是在IO执行的两个阶段都被阻塞。
NIO
Linux下,可通过设置socket使其变为non-blocking。
当用户进程调用recvfrom时,系统不会阻塞用户进程,而是立刻返回一个ewouldblock响应。用户并不需要等待,马上得到一个结果。用户进程发现响应为ewouldblock时,意味着数据还没准备好,可以去做其他的事情。并且时不时发送请求,即轮询发送recvfrom。在内核数据准备好后的下一次用户进程recvfrom请求时,将数据拷贝到用户内存,然后返回。
应用程序不断轮询内核,看看是否已经准备好某些操作,会浪费CPU时间。
AIO
最高级的一种IO模型。当用户进程向内核发起某个操作后,会立刻得到返回,并把所有的任务都交给内核去完成(包括将数据从内核拷贝到用户自己的缓冲区),内核完成后,只需返回一个信号告诉用户进程已经完成即可。
IO多路复用
有些资料也称这种IO为Event Driven IO。select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。基本原理就是select/epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达,就通知用户进程。模型图:
当用户进程调用select,整个进程会被block,而同时,内核会监视所有select负责的socket,当任何一个socket中的数据准备就绪,select就会返回。此时用户进程再调用read操作,将数据从内核拷贝到用户进程。
从系统调用角度来看,IO多路复用涉及两个system call (select和recvfrom),而blocking IO只调用一个system call(recvfrom)。但select的优势在于它可以同时处理多个连接。因此,如果并发量不高时,即连接数不多时,使用select/epoll的Servlet服务器(如对各种IO模型都提供支持的Tomcat)不一定比使用多线程+BIO模式的Tomcat服务器(其他配置相同的情况下)性能更好,可能延迟还更大。select/epoll的优势在于能处理更多的连接。
Reactor反应器设计模式,Java中的Selector选择器和Linux中的epoll都是这种模型。
信号驱动IO
Signal Driven IO,《UNIX网络编程卷1》有这个概念。
用户进程不是阻塞的。首先用户进程建立SIGIO信号处理程序,并通过系统调用sigaction执行一个信号处理函数,这时用户进程便可以处理其他程序。一旦数据准备好,系统便为该进程生成一个SIGIO信号,去通知它数据已经准备就绪。于是用户进程便调用recvfrom把数据从内核拷贝出来,并返回结果。
总结
五种IO模型的对比
图片来自于《UNIX网络编程卷1》
从同步或异步,阻塞或非阻塞两个维度来划分:
面试题
常见面试题:
- BIO,NIO,AIO的区别
- 什么是阻塞IO以及非阻塞IO
- Reactor和Proactor IO设计模式是什么
- NIO底层select、poll和epoll实现的区别
- Java种NIO的几个核心组成部分和作用分别是什么
- Redis、Netty、Tomcat的线程模型与NIO的联系是什么?
Java NIO三个核心类:Channel、Buffer和Selector。
拓展
异步和多线程区别
异步是目的,多线程是实现这个目的一种方法。
多线程和异步操作两者都可以达到避免调用线程阻塞的目的,从而提高软件的可响应性。甚至有时候就认为多线程和异步操作是等同概念。
异步
所有的程序最终都会由计算机硬件来执行。硬盘、光驱、网卡、声卡、显卡的技术规格中都有明确DMA的模式指标。DMA,直接内存访问,即拥有DMA功能的硬件在和内存进行数据交换时可以不消耗CPU资源。只要CPU在发起数据传输时发送一个指令,硬件就开始自己和内存交换数据,在传输完成之后硬件会触发一个中断来通知操作完成。这些无须消耗CPU时间的I/O操作正是异步操作的硬件基础。所以即使在DOS这样的单进程(无线程概念)系统中也同样可以发起异步的DMA操作。
因为异步操作无须额外的线程负担,并且使用回调的方式进行处理,在设计良好的情况下,处理函数可以不必使用共享变量(即使无法完全不用,最起码可以减少共享变量的数量),减少死锁的可能。当然异步操作也并非完美无暇。编写异步操作的复杂程度较高,程序主要使用回调方式进行处理,且难以调试。
多线程
线程不是一个计算机硬件的功能,而是操作系统提供的逻辑功能,线程本质上是进程中一段并发运行的代码,需要操作系统投入CPU资源来运行和调度。
优点:线程中的处理程序依然是顺序执行,符合普通人的思维习惯,编程简单。
缺点:线程的滥用会给系统带来上下文切换的额外负担,线程间的共享变量可能造成死锁。