Java 并发编程解析 | 如何正确理解线程机制中常见的I/O模型,各自主要用来解决什么问题?

1,726 阅读34分钟

苍穹之边,浩瀚之挚,眰恦之美; 悟心悟性,善始善终,惟善惟道! —— 朝槿《朝槿兮年说》

写在开头

作为一名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函数,主要形式:

 sendfile(int out_fd,int in_fd,off_t * offset,size_t count)

参数描述:

  • out_fd:待写入内容的文件描述符,一般为accept的返回值
  • in_fd:待读出内容的文件描述符,一般为open的返回值
  • offset:指定从读入文件流的哪个位置开始读,如果为空,则使用读入文件流的默认位置,一般设置为NULL
  • count:两文件描述符之间的字节数,一般给struct stat结构体的一个变量,在struct stat中可以设置文件描述符属性

⚠️[特别注意]:

in_fd规定指向真实的文件,不能是socket等管道文件描述符,一般使open返回值,而out_fd则是socket描述符

(三). 主动(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领域中的多线程模型的认识,才能更好地指导我们掌握并发编程。