如何正确理解线程机制中常见的 I/O 模型,各自主要用来解决什么问题?
苍穹之边,浩瀚之挚,眰恦之美; 悟心悟性,善始善终,惟善惟道! —— 朝槿《朝槿兮年说》
写在开头
作为一名 Java Developer,我们都清楚地知道,主要从搭载 Linux 系统上的服务器程序来说,使用 Java 编写的是”单进程-多线程"程序,而用 C++语言编写的,可能是“单进程-多线程”程序,“多进程-单线程”程序或者是“多进程-多线程”程序。
从一定程度上 来说,主要由于 Java 程序并不直接运行在 Linux 系统上,而是运行在 JVM(Java 虚拟机)上,而一个 JVM 实例是一个 Linux 进程,每一个 JVM 都是一个独立的“沙盒”,JVM 之间相互独立,互不通信。
所以,Java 程序只能在这一个进程里面,开发多个线程实现并发,而 C++直接运行在 Linux 系统上,可以直接利用 Linux 系统提供的强大的进程间通信(Inter-Process Communication,IPC),很容易创建多个进程,并实现进程间通信。
当然,我们可以明确的是,“多进程-多线程”程序是”单进程-多线程"程序和“多进程-单线程”程序的组合体。无论是 C++开发者在 Linux 系统中使用的 pthread,还是 Java 开发者使用的 java.util.concurrent(JUC)库,这些线程机制的都需要一定的线程 I/O 模型来做理论支撑。
所以,接下来,我们就让我们一起探讨和揭开常见的线程 I/O 模型的神秘面纱,针对那些盘根错落的枝末细节,才能让我们更好地了解和正确认识 ava 领域中的线程机制。
基本概述
I/O 模型是指计算机涉及 I/O 操作时使用到的模型。
一般分析 Java 领域中的线程 I/O 模型是何物时,需要先理解一下什么是 I/O 模型 ?
I/O 模型是为解决各种问题而提出的,与之相关的概念有线程(Thread),阻塞(Blocking),非阻塞(Non-Blocking) ,同步(Synchronous) 和异步(Asynchronous) 等。
按照一定意义上说,I/O 模型可以分为阻塞 I/O(Blocking IO,BIO),非阻塞 I/O(Non-Blocking IO,NIO)两大类。
当然,需要注意的是,计算机的 I/O 还包括各种设备的 I/O,比如网络 I/O,磁盘 I/O,键盘 I/O 和鼠标 I/O 等。
一般来说,程序在执行 I/O 操作时,需要从内核空间复制数据,但是内核空间的数据需要较长时间的的准备,由此可能会导致用户空间产生阻塞。
应用程序处于用户空间,一个应用程序对应着一个进程,而进程中包含了缓冲区(Buffer),因此这里又对应着一个缓冲 I/O(Buffered I/O),其中:
当需要进行 I/O 操作时,需要通过内核空间来执行相应的操作,比如,内核空间负责于键盘,磁盘,网络等控制器进行通信。
当内核空间得到不同设备的控制器发送过来的数据后,会将数据复制到用户空间提供给用户程序使用。
由此可见,I/O 模型 是人与计算机实现沟通和交流的主要通信模型。
特别注意的是,这里的尤其指出网络 I/O 模型。由于网络 I/O 模型存在诸多概念性的东西,有操作系统层面的,也有应用层架构层面的,在不同的层面表示的意思也千差万别,需要我们仔细甄别。
在网路 I/O 模型中,我们会经常听到阻塞和非阻塞,同步和异步等相关的概念,而且也会混淆这个概念,其中最常见的三个问题:
首先,认为非阻塞 I/0(Non-Blocking IO) 和异步 I/O(Asynchronous IO) 是同一个概念
其次,认为 Linux 系统中的 select,poll,epoll 等这类 I/O 多路复用是异步 I/O(Asynchronous IO) 模型
最后,存在一种 I/O 模型叫异步阻塞 I/O(Asynchronous Blocking IO))模型,实际上并没有这种模型
由此可见,其实造成这三个问题的主要原因就是,我们在讨论的时候,有的是站在 Linux 操作系统层面说的,有的是站在在 Java 的 JDK 层面来说的,甚至有的是站在上层框架(中间件 Netty,Tomcat,Nginx,C++中的 asio)封装的模型来说的。
综上所述,针对于不同的层面,需要我们仔细辨析和甄别,这才能让我们理解得更加透彻。
一. Linux 操作系统中的 I/O 模型
现在操作系统都是采用虚拟存储器,那么对 32 位操作系统而言,它的寻址空间(虚拟存储空间)为 4G(2 的 32 次方)。
操心系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。
针对 linux 操作系统而言,为了保证用户进程不能直接操作内核,保证内核的安全,操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。其中:
内核空间(Kernel Space):将最高的 1G 字节(从虚拟地址 0xC0000000 到 0xFFFFFFFF),供内核使用,是 Linux 内核的运行空间。
用户空间(User Space):将较低的 3G 字节(从虚拟地址 0x00000000 到 0xBFFFFFFF),供各个进程使用,是用户程序的运行空间。
每个进程可以通过系统调用进入内核,因此,Linux 内核由系统内的所有进程共享。
于是,从具体进程的角度来看,每个进程可以拥有 4G 字节的虚拟空间,其中内核空间和用户空间是隔离的,即使用户的程序崩溃,内核也不受影响。
但是,在 CPU 的所有指令中,有些指令是非常危险的,如果错用,将导致系统崩溃,比如清内存、设置时钟等。如果允许所有的程序都可以使用这些指令,那么系统崩溃的概率将大大增加。
由于 CPU 将指令分为特权指令和非特权指令,对于那些危险的指令,只允许操作系统及其相关模块使用,普通应用程序只能使用那些不会造成灾难的指令。比如 Intel 的 CPU 将特权等级分为 4 个级别:Ring0~Ring3。
其实 Linux 系统只使用了 Ring0 和 Ring3 两个运行级别(Windows 系统也是一样的)。当进程运行在 Ring3 级别时被称为运行在用户态,而运行在 Ring0 级别时被称为运行在内核态。
由此可见,由于有了用户空间和内核空间概念,其 linux 内部结构可以分为三部分,从最底层到最上层依次是:硬件(Hardware Platfrom)–>内核空间(Kernel Space)–>用户空间(User Space)。
(一). 基本定义
由于,应用程序处于用户空间,一个应用程序对应着一个进程,当需要进行 I/O 操作时,需要通过内核空间来执行相应的操作,而当内核空间得到不同设备的控制器发送过来的数据后,会将数据复制到用户空间提供给用户程序使用。
其间表示着,会有一个进程切换的动作,主要概念就是:当进程运行在内核空间时就处于内核态,而进程运行在用户空间时则处于用户态,其中:
在内核态下,进程运行在内核地址空间中,此时 CPU 可以执行任何指令。运行的代码也不受任何的限制,可以自由地访问任何有效地址,也可以直接进行端口的访问。
在用户态下,进程运行在用户地址空间中,被执行的代码要受到 CPU 的诸多检查,它们只能访问映射其地址空间的页表项中规定的在用户态下可访问页面的虚拟地址,且只能对任务状态段(TSS)中 I/O 许可位图(I/O Permission Bitmap)中规定的可访问端口进行直接访问。
但是,对于以前的 DOS 操作系统来说,是没有内核空间、用户空间以及内核态、用户态这些概念的。可以认为所有的代码都是运行在内核态的,因而用户编写的应用程序代码可以很容易的让操作系统崩溃掉。
而对于 Linux 来说,通过区分内核空间和用户空间的设计,隔离了操作系统代码(操作系统的代码要比应用程序的代码健壮很多)与应用程序代码。即便是单个应用程序出现错误也不会影响到操作系统的稳定性,这样其它的程序还可以正常的运行。
所以,区分内核空间和用户空间本质上是要提高操作系统的稳定性及可用性,而进程切换是为了控制进程的执行,内核必须有能力挂起正在 CPU 上运行的进程,并恢复以前挂起的某个进程的执行。
一般情况下,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。
从一个进程的运行转到另一个进程上运行,这个过程中基本会做如下操作:
保存处理器上下文,包括程序计数器和其他寄存器。
更新 PCB 信息
把进程的 PCB 移入相应的队列,如就绪、在某事件阻塞等队列
选择另一个进程执行,并更新其 PCB
更新内存管理的数据结构
恢复处理器上下文
特别需要注意的是,进程切换势必要考虑调用者等待被调用者返回调用结果时的状态和消息通知机制、状态等问题,这个其实就是对应阻塞与非阻塞,同步与异步的关心的本质问题:
首先,对于阻塞与非阻塞的角度来说,是调用者等待被调用者返回调用结果时的状态:
阻 塞:调用结果返回之前,调用者会被挂起(不可中断睡眠态),调用者只有在得到返回结果之后才能继续;
非阻塞:调用者在结果返回之前,不会被挂起;即调用不会阻塞调用者,调用者可以继续处理其他的工作;
其次,对于同步与异步的角度来说,关注的是消息通知机制、状态:
同 步:调用发出之后不会立即返回,但一旦返回则是最终结果;
异 步:调用发出之后,被调用方立即返回消息,但返回的并非最终结果;被调用者通过状态、通知机制等来通知调用者,会通过回调函数处理;
综上所述,这便为我们理解和掌握 Linux 系统中 I/O 模型奠定了基础。接下来,我们主要来看看 Linux 系统中的网路 I/O 模型和文件操作 I/O 模型。
(二). 网路 I/O 模型
Linux 的内核将所有外部设备都看做一个文件来操作(一切皆文件),对一个文件的读写操作会调用内核提供的系统命令,返回一个 file descriptor(fd,文件描述符)。而对一个 socket 的读写也会有响应的描述符,称为 socket fd(socket 文件描述符),描述符就是一个数字,指向内核中的一个结构体(文件路径,数据区等一些属性)。
根据 UNIX 网络编程对 I/O 模型的分类来说,Linux 系统中的网路 I/O 模型主要分为同步阻塞 IO(Blocking I/O,BIO),同步非阻塞 IO(Non-Blocking I/O,NIO),IO 多路复用(I/O Multiplexing),异步 IO(Asynchronous I/O,AIO)以及信号驱动式 I/O(Signal-Driven I/O)等 5 种模型,其中:
1.同步阻塞 IO(BIO)
同步阻塞式 I/O(BIO)模型是最常用的一个模型,也是最简单的模型。默认情况下,所有文件操作都是阻塞的。
在 Linux 中,同步阻塞式 I/O(BIO)模型下,所有的套接字默认情况下都是阻塞的。
比如 I/O 模型下的套接字接口:在进程空间中调用 recvfrom,其系统调用直到数据包到达且被复制到应用进程的缓冲区中或者发生错误时才返回,在此期间一直等待。
进程在调用 recvfrom 开始到它返回的整段时间内都是被阻塞的,所以叫阻塞 I/O 模型。
进程在向内核调用执行 recvfrom 操作时阻塞,只有当内核将磁盘中的数据复制到内核缓冲区(内核内存空间),并实时复制到进程的缓存区完毕后返回;或者发生错误时(系统调用信号被中断)返回。
在加载数据到数据复制完成,整个进程都是被阻塞的,不能处理的别的 I/O,此时的进程不再消费 CPU 时间,而是等待响应的状态,从处理的角度来看,这是非常有效的。
这种 I/O 模型下,执行的两个阶段进程都是阻塞的,其中:
第一阶段(阻塞):①:进程向内核发起系统调用(recvfrom);当进程发起调用后,进程开始挂起(进程进入不可中断睡眠状态),进程一直处于等待内核处理结果的状态,此时的进程不能处理其他 I/O,亦被阻塞。②:内核收到进程的系统调用请求后,此时的数据包并未准备好,此时内核亦不会给进程发送任何消息,直到磁盘中的数据加载至内核缓冲区;
第二阶段(阻塞):③:内核再将内核缓冲区中的数据复制到用户空间中的进程缓冲区中(真正执行 IO 过程的阶段),直到数据复制完成。④:内核返回成功数据处理完成的指令给进程;进程在收到指令后再对数据包进程处理;处理完成后,此时的进程解除不可中断睡眠态,执行下一个 I/O 操作。
综上所述,在 Linux 中,同步阻塞式 I/O(BIO)模型最典型的代表就是阻塞方式下的 read/write 函数调用。
2.同步非阻塞 IO(NIO)
同步非阻塞 IO(NIO)模型是进程在调用 recvfrom 从应用层到内核的时候,就直接返回一个 WAGAIN 标识或 EWOULDBLOCK 错误,一般都对非阻塞 I/O 模型进行轮询检查这个状态,看内核是不是有数据到来。
在 Linux 中,同步非阻塞 IO(NIO)模型模型下,进程在向内核调用函数 recvfrom 执行 I/O 操作时,socket 是以非阻塞的形式打开的。
也就是说,进程进行系统调用后,内核没有准备好数据的情况下,会立即返回一个错误码,说明进程的系统调用请求不会立即满足。
在进程发起 recvfrom 系统调用时,进程并没有被阻塞,内核马上返回了一个 error。
进程在收到 error,可以处理其他的事物,过一段时间在次发起 recvfrom 系统调用;其不断的重复发起 recvfrom 系统调用,这个过程即为进程轮询(polling)。
轮询的方式向内核请求数据,直到数据准备好,再复制到用户空间缓冲区,进行数据处理。
需要注意的是,复制过程中进程还是阻塞的。
一般情况下,进程采用轮询(polling)的机制检测 I/O 调用的操作结果是否已完成,会消耗大量的 CPU 时钟周期,性能上并不一定比阻塞式 I/O 高。
这种 I/O 模型下,执行的第一阶段进程都是非阻塞的,第二阶段进程都是阻塞的,其中:
第一阶段(非阻塞):①:进程向内核发起 IO 调用请求,内核接收到进程的 I/O 调用后准备处理并返回“error”的信息给进程;此后每隔一段时间进程都会想内核发起询问是否已处理完,即轮询,此过程称为为忙等待;②:内核收到进程的系统调用请求后,此时的数据包并未准备好,此时内核会给进程发送 error 信息,直到磁盘中的数据加载至内核缓冲区;
第二阶段(阻塞):③:内核再将内核缓冲区中的数据复制到用户空间中的进程缓冲区中(真正执行 IO 过程的阶段,进程阻塞),直到数据复制完成。④:内核返回成功数据处理完成的指令给进程;进程在收到指令后再对数据包进程处理;
综上所述,在 Linux 中,同步非阻塞 IO(NIO)模型模型最典型的代表就是以 O_NONBLOCK 参数打开 fd,然后执行 read/write 函数调用。
3.IO 多路复用(I/O Multiplexing)
IO 多路复用(I/O Multiplexing)模型也被称为事件驱动式 I/O 模型(Event Driven I/O),Linux 提供 select/poll,进程通过将一个或多个 fd 传递给 select 或 poll 系统调用,阻塞在 select 操作上,这样,select/poll 可以帮我们侦测多个 fd 是否处于就绪状态。select/poll 是顺序扫描 fd 是否就绪,而且支持的 fd 数量有限,因此它的使用受到了一些制约。Linux 还提供一个 epoll 系统调用,epoll 使用基于事件驱动方式代替顺序扫描,因此性能更高。当有 fd 就绪时,立即回调函数 rollback。
在 Linux 中,IO 多路复用(I/O Multiplexing)模型模型下,每一个 socket,一般都会设置成 non-blocking。
进程通过调用内核中的 select()、poll()、epoll()函数发起系统调用请求。
selec/poll/epoll 相当于内核中的代理,进程所有的请求都会先请求这几个函数中的某一个;此时,一个进程可以同时处理多个网络连接的 I/O。
select/poll/epoll 这个函数会不断的轮询(polling)所负责的 socket,当某个 socket 有数据报准备好了(意味着 socket 可读),就会返回可读的通知信号给进程。
用户进程调用 select/poll/epoll 后,进程实际上是被阻塞的,同时,内核会监视所有 select/poll/epoll 所负责的 socket,当其中任意一个数据准备好了,就会通知进程。
只不过进程是阻塞在 select/poll/epoll 之上,而不是被内核准备数据过程中阻塞。
此时,进程再发起 recvfrom 系统调用,将数据中内核缓冲区拷贝到内核进程,这个过程是阻塞的。
虽然 select/poll/epoll 可以使得进程看起来是非阻塞的,因为进程可以处理多个连接,但是最多只有 1024 个网络连接的 I/O;本质上进程还是阻塞的,只不过它可以处理更多的网络连接的 I/O 而已。
这种 I/O 模型下,执行的第一阶段进程都是阻塞的,第二阶段进程都是阻塞的,其中:
第一阶段(阻塞在 select/poll 之上):①:进程向内核发起 select/poll 的系统调用,select 将该调用通知内核开始准备数据,而内核不会返回任何通知消息给进程,但进程可以继续处理更多的网络连接 I/O;②:内核收到进程的系统调用请求后,此时的数据包并未准备好,此时内核亦不会给进程发送任何消息,直到磁盘中的数据加载至内核缓冲区;而后通过 select()/poll()函数将 socket 的可读条件返回给进程
第二阶段(阻塞):③:进程在收到 SIGIO 信号程序之后,进程向内核发起系统调用(recvfrom);④:内核再将内核缓冲区中的数据复制到用户空间中的进程缓冲区中(真正执行 IO 过程的阶段),直到数据复制完成。⑤:内核返回成功数据处理完成的指令给进程;进程在收到指令后再对数据包进程处理;处理完成后,此时的进程解除不可中断睡眠态,执行下一个 I/O 操作。
4.异步 IO(AIO)
异步 IO(AIO)模型是告知内核启动某个操作,并让内核在整个操作完成后(包括数据的复制)通知进程。信号驱动 I/O 模型通知的是何时可以开始一个 I/O 操作,异步 I/O 模型有内核通知 I/O 操作何时已经完成。
在 Linux 中,异步 IO(AIO)模型中,进程会向内核请求 air_read(异步读)的系统调用操作,会把套接字描述符、缓冲区指针、缓冲区大小和文件偏移一起发给内核,当内核收到后会返回“已收到”的消息给进程,此时进程可以继续处理其他 I/O 任务。
也就是说,在第一阶段内核准备数据的过程中,进程并不会被阻塞,会继续执行。
第二阶段,当数据报准备好之后,内核会负责将数据报复制到用户进程缓冲区,这个过程也是由内核完成,进程不会被阻塞。
复制完成后,内核向进程递交 aio_read 的指定信号,进程在收到信号后进行处理并处理数据报向外发送。
在进程发起 I/O 调用到收到结果的过程,进程都是非阻塞的。
从一定程度上说,异步 IO(AIO)模型可以说是在信号驱动式 I/O 模型的一个特例。
这种 I/O 模型下,执行的第一阶段进程都是非阻塞的,第二阶段进程都是非阻塞的,其中:
第一阶段(非阻塞):①:进程向内核请求 air_read(异步读)的系统调用操作,会把套接字描述符、缓冲区指针、缓冲区大小和文件偏移一起发给内核,当内核收到后会返回“已收到”的消息给进程②:内核将磁盘中的数据加载至内核缓冲区,直到数据报准备好;
第二阶段(非阻塞):③:内核开始复制数据,将准备好的数据报复制到进程内存空间,知道数据报复制完成④:内核向进程递交 aio_read 的返回指令信号,通知进程数据已复制到进程内存中
5.信号驱动式 I/O(Signal-Driven I/O)
信号驱动式 I/O(Signal-Driven I/O)模型是指首先开启套接口信号驱动 I/O 功能,并通过系统调用 sigaction 执行一个信号处理函数(此系统调用立即返回,进程继续工作,非阻塞)。当数据准备就绪时,就为改进程生成一个 SIGIO 信号,通过信号回调通知应用程序调用 recvfrom 来读取数据,并通知主循环函数处理树立。
在 Linux 中,信号驱动式 I/O(Signal-Driven I/O)模型中,进程预先告知内核,使得某个文件描述符上发生了变化时,内核使用信号通知该进程。
在信号驱动式 I/O 模型,进程使用 socket 进行信号驱动 I/O,并建立一个 SIGIO 信号处理函数。
当进程通过该信号处理函数向内核发起 I/O 调用时,内核并没有准备好数据报,而是返回一个信号给进程,此时进程可以继续发起其他 I/O 调用。
也就是说,在第一阶段内核准备数据的过程中,进程并不会被阻塞,会继续执行。
当数据报准备好之后,内核会递交 SIGIO 信号,通知用户空间的信号处理程序,数据已准备好;此时进程会发起 recvfrom 的系统调用,这一个阶段与阻塞式 I/O 无异。
也就是说,在第二阶段内核复制数据到用户空间的过程中,进程同样是被阻塞的。
这种 I/O 模型下,执行的第一阶段进程都是非阻塞的,第二阶段进程都是阻塞的,其中:
第一阶段(非阻塞):①:进程使用 socket 进行信号驱动 I/O,建立 SIGIO 信号处理函数,向内核发起系统调用,内核在未准备好数据报的情况下返回一个信号给进程,此时进程可以继续做其他事情②:内核将磁盘中的数据加载至内核缓冲区完成后,会递交 SIGIO 信号给用户空间的信号处理程序;
第二阶段(阻塞):③:进程在收到 SIGIO 信号程序之后,进程向内核发起系统调用(recvfrom);④:内核再将内核缓冲区中的数据复制到用户空间中的进程缓冲区中(真正执行 IO 过程的阶段),直到数据复制完成。⑤:内核返回成功数据处理完成的指令给进程;进程在收到指令后再对数据包进程处理;处理完成后,此时的进程解除不可中断睡眠态,执行下一个 I/O 操作。
(二). 文件操作 I/O 模型
在 Linux 系统中的网路 I/O 模型,按照文件操作 IO 来说,主要分为缓冲 IO(Buffered I/O),直接 IO(Direct I/O),内存映射(Memory-Mapped,mmap),零拷贝(Zero Copy)等 4 种模型,其中:
1.缓冲 IO(Buffered I/O)
缓冲 IO(Buffered I/O) 是指在内存里开辟一块区域里存放的数据,主要用来接收用户输入和用于计算机输出的数据以减小系统开销和提高外设效率的缓冲区机制。
缓存 I/O 又被称作标准 I/O,大多数文件系统的默认 I/O 操作都是缓存 I/O。在 Linux 的缓存 I/O 机制中,数据先从磁盘复制到内核空间的缓冲区,然后从内核空间缓冲区复制到应用程序的地址空间。
总的来说,缓冲区是内存空间的一部分,在内存中预留了一定的存储空间,用来暂时保存输入和输出等 I/O 操作的一些数据,这些预留的空间就叫做缓冲区。
而 buffer 缓冲区和 Cache 缓存区都属于缓冲区的一种 buffer 缓冲区存储速度不同步的设备或者优先级不同的设备之间的传输数据,比如键盘、鼠标等;
此外,buffer 一般是用在写入磁盘的;Cache 缓存区是位于 CPU 和主内存之间的容量较小但速度很快的存储器,Cache 保存着 CPU 刚用过的数据或循环使用的数据;Cache 缓存区的运用一般是在 I/O 的请求上
缓存区按性质分为两种,一种是输入缓冲区,另一种是输出缓冲区。
对于 C、C++程序来言,类似 cin、getchar 等输入函数读取数据时,并不会直接从键盘上读取,而是遵循着一个过程:cingetchar --> 输入缓冲区 --> 键盘,
我们从键盘上输入的字符先存到缓冲区里面,cingetchar 等函数是从缓冲区里面读取输入;
那么相对于输出来说,程序将要输出的结果并不会直接输出到屏幕当中区,而是先存放到输出缓存区,然后利用 coutputchar 等函数将缓冲区中的内容输出到屏幕上。
cin 和 cout 本质上都是对缓冲区中的内容进行操作。
使用缓冲区机制的主要可以解决的问题,主要有:
减少 CPU 对磁盘的读写次数: CPU 读取磁盘中的数据并不是直接读取磁盘,而是先将磁盘的内容读入到内存,也就是缓冲区,然后 CPU 对缓冲区进行读取,进而操作数据;计算机对缓冲区的操作时间远远小于对磁盘的操作时间,大大的加快了运行速度
提高 CPU 的执行效率: 比如说使用打印机打印文档,打印的速度是相对比较慢的,我们操作 CPU 将要打印的内容输出到缓冲区中,然后 CPU 转手就可以做其他的操作,进而提高 CPU 的效率
合并读写: 比如说对于一个文件的数据,先读取后写入,循环执行 10 次,然后关闭文件,如果存在缓冲机制,那么就可能只有第一次读和最后一次写是真实操作,其他的操作都是在操作缓存
但是,在缓存 I/O 机制中,DMA 方式可以将数据直接从磁盘读到页缓存中,或者将数据从页缓存直接写回到磁盘上,而不能直接在应用程序地址空间和磁盘之间进行数据传输。
这样,数据在传输过程中需要在应用程序地址空间(用户空间)和缓存(内核空间)之间进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。
在 Linux 中,缓冲区分为三大类:全缓冲、行缓冲、无缓冲,其中:
全缓冲;只有在缓冲区被填满之后才会进行 I/O 操作;最典型的全缓冲就是对磁盘文件的读写。
行缓冲;只有在输入或者是输出中遇到换行符的时候才会进行 I/O 操作;这忠允许我们一次写一个字符,但是只有在写完一行之后才做 I/O 操作。一般来说,标准输入流(stdin)和标准输出流(stdout)是行缓冲。
无缓冲;标准 I/O 不缓存字符;其中表现最明显的就是标准错误输出流(stderr),这使得出错信息尽快的返回给用户。
2.直接IO(Direct I/O)
直接 IO(Direct I/O)是指应用程序直接访问磁盘数据,而不经过内核缓冲区,也就是绕过内核缓冲区,自己管理 IO 缓存区,这样做的目的是减少一次内核缓冲区到用户程序缓存的数据复制。
直接 IO 就是在应用层 Buffer 和磁盘之间直接建立通道。这样在读写数据的时候就能够减少上下文切换次数,同时也能够减少数据拷贝次数,从而提高效率。
引入内核缓冲区的目的在于提高磁盘文件的访问性能,因为当进程需要读取磁盘文件时,如果文件内容已经在内核缓冲区中,那么就不需要再次访问磁盘。而当进程需要向文件写入数据是,实际上只是写到了内核缓冲区便告诉进程已经写成功,而真正写入磁盘是通过一定的策略进行延时的。
然而,对于一些较复杂的应用,比如数据库服务器,他们为了充分提高性能。希望绕过内核缓冲区,由自己在用户态空间时间并管理 IO 缓冲区,包括缓存机制和写延迟机制等,以支持独特的查询机制,比如数据库可以根据加合理的策略来提高查询缓存命中率。另一方面,绕过内核缓冲区也可以减少系统内存的开销,因为内核缓冲区本身就在使用系统内存。
3.内存映射(Memory-Mapped,mmap)
内存映射(Memory-Mapped I/O,mmap)是指把物理内存映射到进程的地址空间之内,这些应用程序就可以直接使用输入输出的地址空间,从而提高读写的效率。
内存映射(Memory-mapped I/O)是将磁盘文件的数据映射到内存,用户通过修改内存就能修改磁盘文件。
Linux 提供了 mmap()函数,用来映射物理内存。在驱动程序中,应用程序以设备文件为对象,调用 mmap()函数,内核进行内存映射的准备工作,生成 vm_area_struct 结构体,然后调用设备驱动程序中定义的 mmap 函数。
4.零拷贝(Zero Copy)
零拷贝(Zero Copy)技术是指计算机执行操作时,CPU 不需要先将数据从某处内存复制到另一个特定区域,这种技术通常用于通过网络传输文件时节省 CPU 周期和内存带宽。
在此之前,我们需要知道什么是拷贝?拷贝主要是指把数据从一块内存中复制到另外一块内存中。
零拷贝(Zero Copy)是一种 I/O 操作优化技术,主要是指计算机执行操作时,CPU 不需要先将数据从某处内存复制到另一个特定区域,通常用于通过网络传输文件时节省 CPU 周期和内存带宽,还可以减少上下文切换以及 CPU 的拷贝时间。
但是需要注意的是,零拷贝技术实际实现并没有具体的标准,主要取决于操作系统如何实现和完全依赖于操作系统是否支持?一般来说,操作系统支持,就可以零拷贝;否则就没有办法做到零拷贝。
一般来说,当我们需要把一些本地磁盘的文件(File)中的数据发送到网络的时候,对于默认的标准 i/O 来说,Read 操作流程:磁盘->内核缓冲区->用户缓冲区-->应用程序内存 和 Write 操作流程:磁盘<-内核缓冲区<-用户缓冲区<-应用程序内存,整个过程中数据拷贝会有 6 次拷贝,3 次 Read 操作,3 次 Write 操作。
如果不用零拷贝,一般来说,主要采用如下两种方式实现:
第一种实现方式:利用直接 I/O 实现:磁盘->内核缓冲区->应用程序内存->Socket 缓冲区->网络,整个过程中数据拷贝会有 4 次拷贝,2 次 Read 操作,2 次 Write 操作,内存拷贝是 2 次。
第二种实现方式:利用内存映射文件(mmnp)实现:磁盘->内核缓冲区->Socket 缓冲区->网络,整个过程中数据拷贝会有 3 次拷贝,2 次 Read 操作,1 次 Write 操作,内存拷贝是 1 次。
如果使用零拷贝技术实现的话,磁盘->内核缓冲区->网络,整个过程中数据拷贝会有 2 次拷贝,1 次 Read 操作,1 次 Write 操作,内存拷贝是 0 次。
由此可见,零拷贝是从内存的角度来说,数据在内存中没有发生过数据拷贝,只在内存和 I/O 之间传输。
在 Linux 中,系统提供了 sendfile 函数来实现零拷贝,主要形式:
参数描述:
out_fd:待写入内容的文件描述符,一般为 accept 的返回值
in_fd:待读出内容的文件描述符,一般为 open 的返回值
offset:指定从读入文件流的哪个位置开始读,如果为空,则使用读入文件流的默认位置,一般设置为 NULL
count:两文件描述符之间的字节数,一般给 struct stat 结构体的一个变量,在 struct stat 中可以设置文件描述符属性
⚠️[特别注意]:
in_fd 规定指向真实的文件,不能是 socket 等管道文件描述符,一般使 open 返回值,而 out_fd 则是 socket 描述符
在 Java 中,FileChannel 提供 transferTo(和 transferFrom)方法来实现 sendFile 功能。
(三). 主动(Reacror)与被动(Proactor)I/O 模型
主动与被动 I/O 模型是指网络 I/O 模型中的基于 Reacror 模式与 Proactor 模式等两种设计模式设计的 I/O 模型,算是所有网络 I/O 模型的抽象模型。
除了上述提到的网络 I/O 模型,还有基于 Reacror 模式与 Proactor 模式等两种设计模式设计的 I/O 模型,是网络框架的基本设计模型。
不论是操作系统的网络 I/O 模型的设计,还是上层框架中的网络 I/O 模型的设计,都是基于这两种设计模式来设计的。其中:
1.Reacror 模式
Reacror 模式是主动模式,主要是指应用程序不断轮询,访问操作系统,或者网络框架,网络 I/O 模型是否就绪。
在 Linux 系统中,其 select,poll 和 epoll 等网络 I/O 模型都是 Reacror 模式下的产生物。需要在应用程序里面一只有一个循环来轮询。其中,Java 中的 NIO 模型也是属于这种模式。
在 Reacror 模式下,实际的 网络 I/O 请求操作都是在应用程序下执行的。
2.Proactor 模式
Proactor 模式是被动模式,主要是指应用程序网络 I/O 操作请求全部托管和交付给操作系统或者网络框架来实现。
在 Proactor 模式下,实际的 网络 I/O 请求操作都是在应用程序下执行,之后再回调到应用程序。
(四). 服务器编程 I/O 模型
服务器编程 I/O 模型是指一个服务器会有 1+N+M 个线程,主要有 1 个监听线程,N 个 I/O 线程,M 个 Worker 线程,因此也称为 1+N+M 服务器编程模型。
在 1+N+M 服务器编程模型中,监听线程->对应每一个客户端 socket 建立和连接,I/O 线程->对应 N 的个数通常是以 CPU 核数作为参考,而 Worker 线程>M 的个数根据实际业务场景的数据上层决定。其中:
监听线程: 主要负责 Accept 事件的注册和处理。和每一个新进来的客户端建立 socket 连接,然后把 socket 连接转接交给 I/O 线程,完成结束后继续监听新的客户端请求。
I/O 线程:主要负责每个 socket 连接上面 read/write 事件的注册和实际的 socket 的读写。负责把读到的请求放入 Requset 队列,最后托管交给 Worker 线程处理。
Worker 线程:主要是纯粹的业务线程,没有 socket 连接上的 read(读)/write(写)操作。Worker 线程处理完请求最后写入响应 Response 队列,最终交给 I/O 线程返回客户端。
实际上,在 linux 系统中 epoll 和 Java 中的 NIO 模型,以及基于 Netty 的开发的网络框架,都是按照 1+N+M 服务器编程模型来做的。
写在最后
I/O 模型是为解决各种问题而提出的,主要涉及有线程(Thread),阻塞(Blocking),非阻塞(Non-Blocking) ,同步(Synchronous) 和异步(Asynchronous) 等相关的概念。
按照一定意义上说,I/O 模型可以分为阻塞 I/O(Blocking IO,BIO),非阻塞 I/O(Non-Blocking IO,NIO)两大类。
在 Linux 系统中,其中:
根据 UNIX 网络编程对 I/O 模型的分类来说,网路 I/O 模型主要分为同步阻塞 IO(Blocking I/O,BIO),同步非阻塞 IO(Non-Blocking I/O,NIO),IO 多路复用(I/O Multiplexing),异步 IO(Asynchronous I/O,AIO)以及信号驱动式 I/O(Signal-Driven I/O)等 5 种模型。
按照文件操作 IO 来说,主要分为缓冲 IO(Buffered I/O),直接 IO(Direct I/O),内存映射(Memory-Mapped,mmap),零拷贝(Zero Copy)等 4 种模型。
其中,在文件操作 I/O 中,我们需要区别对待拷贝和映射:拷贝主要是指把数据从一块内存中复制到另外一块内存中,而映射只是持有数据的一份引用(或者叫地址),数据本身只有一份。
除此之外,网络 I/O 模型,还有基于 Reacror 模式与 Proactor 模式等两种设计模式设计的 I/O 模型,是网络框架的基本设计模型。
以及,一个服务器会有 1+N+M 个线程,主要有 1 个监听线程,N 个 I/O 线程,M 个 Worker 线程,因此也称为 1+N+M 服务器编程模型。
综上所述,只有正确和清楚地知道这个基础指导,才能加深我们对 Java 领域中的多线程模型的认识,才能更好地指导我们掌握并发编程。
版权声明: 本文为 InfoQ 作者【PivotalCloud】的原创文章。
原文链接:【http://xie.infoq.cn/article/8144efb8985c91afb07da9abc】。文章转载请联系作者。
评论