网络编程进阶学习笔记--高并发IO的底层原理

前言

本笔记是我对尼恩高并发三部曲的学习笔记,定位为网络编程的进阶学习笔记,看这个笔记的人应当对netty有过基础的学习,没有任何网络里编程经验的同学建议看看我的nety基础系列的文章,在看本系列的文章学习起来可能回更加高效,链接奉上。本系列预计用两个月完成更新。
链接:https://www.yuque.com/u2196512/mgr9wm

高并发IO的底层原理

操作系统中的基础知识

用户态与内核态

系统调用将Linux整个体系分为用户态和内核态

网络编程进阶学习笔记--高并发IO的底层原理_第1张图片

网络编程进阶学习笔记--高并发IO的底层原理_第2张图片

为什么需要划分内核态于用户态

为了安全。
1.限制不同程序之间的访问能力,防止他们从别的数据获取数据,或者获取外围设备的数据,并发送到网络中,所以cpu划分出两个等级----用户态与内核态。
2.在CPU的所有指令中,有一些指令是非常危险的,如果错用,将导致整个系统崩溃。比如:清内存、设置时钟等。这些操作是十分危险的,所以,CPU将指令分为特权指令和非特权指令,对于那些危险的指令,只允许操作系统及其相关模块使用,普通的应用程序只能使用那些不会造成灾难的指令。

内核态与用户态的权限

用户态:只能受限的访问内存,且不允许访问外围设备,占用cpu的能力被剥夺,cpu资源可以被其他程序获取。也就是说用户态具有的权限仅仅是访问本程序的内存

内核态:cpu可以访问内存的所有数据,包括外围设备,例如硬盘,网卡,cpu也可以将自己从一个程序切换到另一个程序。

用户态与内核态的切换

所有用户程序都是运行在用户态的, 但是有时候程序确实需要做一些内核态的事情, 例如从硬盘读取数据, 或者从键盘获取输入等. 而唯一可以做这些事情的就是操作系统(所以我们需要使用操作系统所提供的接口来进行这些操作,我们调用操作系统提供的接口,由操作系统去执行,操作系统也是一个软件,不过他具有内核态的权限), 所以此时程序就需要先操作系统请求以程序的名义来执行这些操作.
这时需要一个这样的机制: 用户态程序切换到内核态, 但是不能控制在内核态中执行的指令
这种机制叫系统调用, 在CPU中的实现称之为陷阱指令(Trap Instruction)
他们的工作流程如下:

  1. 用户态程序将一些数据值放在寄存器中, 或者使用参数创建一个堆栈(stack frame), 以此表明需要操作系统提供的服务.
  2. 用户态程序执行陷阱指令
  3. CPU切换到内核态, 并跳到位于内存指定位置的指令, 这些指令是操作系统的一部分, 他们具有内存保护, 不可被用户态程序访问
  4. 这些指令称之为陷阱(trap)或者系统调用处理器(system call handler). 他们会读取程序放入内存的数据参数, 并执行程序请求的服务
  5. 系统调用完成后, 操作系统会重置CPU为用户态并返回系统调用的结果

当一个任务(进程)执行系统调用而陷入内核代码中执行时,我们就称进程处于内核运行态(或简称为内核态)。此时处理器处于特权级最高的(0级)内核代码中执行。当进程处于内核态时,执行的内核代码会使用当前进程的内核栈。每个进程都有自己的内核栈。当进程在执行用户自己的代码时,则称其处于用户运行态(用户态)。即此时处理器在特权级最低的(3级)用户代码中运行。当正在执行用户程序而突然被中断程序中断时,此时用户程序也可以象征性地称为处于进程的内核态。因为中断处理程序将使用当前进程的内核栈。这与处于内核态的进程的状态有些类似。

内核态与用户态是操作系统的两种运行级别,跟intel cpu没有必然的联系, intel cpu提供Ring0-Ring3三种级别的运行模式,Ring0级别最高,Ring3最低。Linux使用了Ring3级别运行用户态,Ring0作为 内核态,没有使用Ring1和Ring2。Ring3状态不能访问Ring0的地址空间,包括代码和数据。Linux进程的4GB地址空间,3G-4G部 分大家是共享的,是内核态的地址空间,这里存放在整个内核的代码和所有的内核模块,以及内核所维护的数据。用户运行一个程序,该程序所创建的进程开始是运 行在用户态的,如果要执行文件操作,网络数据发送等操作,必须通过write,send等系统调用,这些系统调用会调用内核中的代码来完成操作,这时,必 须切换到Ring0,然后进入3GB-4GB中的内核地址空间去执行这些代码完成操作,完成后,切换回Ring3,回到用户态。这样,用户态的程序就不能 随意操作内核地址空间,具有一定的安全保护作用。
至于说保护模式,是说通过内存页表操作等机制,保证进程间的地址空间不会互相冲突,一个进程的操作不会修改另一个进程的地址空间中的数据。

为什么用户态与内核态切换消耗资源

linux下每个进程的栈有两个,一个是用户态栈,一个是内核态栈。在需要从用户态栈切换到内核的时候,需要进行执行栈的转换,保存用户态的状态,包括寄存器状态,然后执行内核态操作,操作完成后要恢复现场,切换到用户态,这个过程是耗时的。当然这里有很多细节,但是我不懂,只了解宏观上的原因。

应用程序的读写操作是如何实现的

操作系统层面的read系统调用,并不是直接从物理设备把数据读取到应用的内存中; write系统调用,也不是直接把数据写入到物理设备。上层应用无论是调用操作系统的 read,还是调用操作系统的write,都会涉及缓冲区。具体来说,上层应用通过操作系统的 read系统调用,是把数据从内核缓冲区复制到应用程序的进程缓冲区;上层应用通过操作 系统的write系统调用,是把数据从应用程序的进程缓冲区复制到操作系统内核缓冲区。

简单来说,应用程序的IO操作,实际上不是物理设备级别的读写,而是缓存的复制。 read&write两大系统调用,都不负责数据在内核缓冲区和物理设备(如磁盘、网卡等)之 间的交换。这项底层的读写交换操作,是由操作系统内核(Kernel)来完成的。所以,应 用程序中的IO操作,无论是对Socket的IO操作,还是对文件的IO操作,都属于上层应用的 开发,它们的在输入(Input)和输出(Output)维度上的执行流程,都是类似的,都是在内核缓冲区和进程缓冲区之间的进行数据交换。

即一个IO过程包括两个步骤,用户空间的处理与内核空间的处理,我们调用系统的read函数,相当于是将用户态的数据复制到内核缓冲区中,而核心空间处理部分则是read系统调用在linux内核空间中处理的整个过程也即写入硬盘的过程。
下面从这两个角度进行分析

Read系统调用在用户空间的处理过程

Linux 系统调用(SCI,system call interface)的实现机制实际上是一个多路汇聚以及分解的过程,该汇聚点就是 0x80 中断这个入口点(X86 系统结构)。也就是说,所有系统调用都从用户空间中汇聚到 0x80 中断点,同时保存具体的系统调用号。当 0x80 中断处理程序运行时,将根据系统调用号对不同的系统调用分别处理(调用不同的内核函数处理)。

Read系统调用在核心空间的处理过程

0x80 中断处理程序接管执行后,先检察其系统调用号,然后根据系统调用号查找系统调用表,并从系统调用表中得到处理 read 系统调用的内核函数 sys_read ,最后传递参数并运行 sys_read 函数。至此,内核真正开始处理 read 系统调用(sys_read 是 read 系统调用的内核入口)。

为什么需要缓冲区

为了加速,为了让我们的计算机运行的更快。

缓冲区的目的,是为了减少频繁地与设备之间的物理交换。计算机的外部物理设备与 内存与CPU相比,有着非常大的差距,外部设备的直接读写,涉及操作系统的中断。发生 系统中断时,需要保存之前的进程数据和状态等信息,而结束中断之后,还需要恢复之前 的进程数据和状态等信息。为了减少底层系统的频繁中断所导致的时间损耗、性能损耗, 于是出现了内核缓冲区。

网络编程进阶学习笔记--高并发IO的底层原理_第3张图片

有了内核缓冲区,操作系统会对内核缓冲区进行监控,等待缓冲区达到一定数量的时 候,再进行IO设备的中断处理,集中执行物理设备的实际IO操作,通过这种机制来提升系 统的性能。至于具体在什么时候执行系统中断(包括读中断、写中断),则由操作系统的内 核来决定,应用程序不需要关心。

这里设计到一个概念即DMA,直接存储访问技术,下面我们就来讲讲这个DMA

从硬盘复制内容到内核缓冲区不消耗资源吗?(DMA直接存储访问)

为什么需要DMA

DMA(Direct Memory Access,直接存储器访问)。在DMA出现之前,CPU与外设之间的数据传送方式有程序传送方式、中断传送方式。CPU是通过系统总线与其他部件连接并进行数据传输。

程序传送方式

程序传送方式是指直接在程序控制下进行数据的输入/输出操作。分为无条件传送方式和查询(条件传送方式)两种。

1.1.1无条件传送方式

微机系统中的一些简单的外设,如开关、继电器、数码管、发光二极管等,在它们工作时,可以认为输入设备已随时准备好向CPU提供数据,而输出设备也随时准备好接收CPU送来的数据,这样,在CPU需要同外设交换信息时,就能够用IN或OUT指令直接对这些外设进行输入/输出操作。由于在这种方式下CPU对外设进行输入/输出操作时无需考虑外设的状态,故称之为无条件传送方式。

1.1.2查询(有条件)传送方式

查询传送也称为条件传送,是指在执行输入指令(IN)或输出指令(OUT)前,要先查询相应设备的状态,当输入设备处于准备好状态、输出设备处于空闲状态时,CPU才执行输入/输出指令与外设交换信息。为此,接口电路中既要有数据端口,还要有状态端口。
[

](https://blog.csdn.net/zhejfl/article/details/82555634)

中断传送方式

中断传送方式是指当外设需要与CPU进行信息交换时,由外设向CPU发出请求信号,使CPU暂停正在执行的程序,转而去执行数据输入/输出操作,待数据传送结束后,CPU再继续执行被暂停的程序。

以上两种方式,均由CPU控制数据传输,不同的是程序传送方式由CPU来查询外设状态,CPU处于主动地位,而外设处于被动地位。这就是常说的----对外设的轮询,效率低。而中断传送法师则是外设主动向CPU发生请求,等候CPU处理,在没有发出请求时,CPU和外设都可以独立进行各自的工作。 需要进行断点和现场的保护和恢复,浪费了很多CPU的时间,适合少量数据的传送。
[

](https://blog.csdn.net/zhejfl/article/details/82555634)

DMA原理

DMA的出现就是为了解决批量数据的输入/输出问题。DMA是指外部设备不通过CPU而直接与系统内存交换数据的接口技术。这样数据的传送速度就取决于存储器和外设的工作速度。

通常系统总线是由CPU管理的,在DMA方式时,就希望CPU把这些总线让出来,即CPU连到这些总线上的线处于第三态(高阻状态),而由DMA控制器接管,控制传送的字节数,判断DMA是否结束,以及发出DMA结束信号。因此DMA控制器必须有以下功能:

1、能向CPU发出系统保持(HOLD)信号,提出总线接管请求;

2、当CPU发出允许接管信号后,负责对总线的控制,进入DMA方式;

3、能对存储器寻址及能修改地址指针,实现对内存的读写;

4、能决定本次DMA传送的字节数,判断DMA传送是否借宿。

5、发出DMA结束信号,使CPU恢复正常工作状态。

网络编程进阶学习笔记--高并发IO的底层原理_第4张图片

DMA传输将从一个地址空间复制到另外一个地址空间。当CPU初始化这个传输动作,传输动作本身是由DMA控制器来实行和完成。 典型例子—移动一个外部内存的区块到芯片内部更快的内存区。

对于实现DMA传输,它是由DMA控制器直接掌管总线(地址总线、数据总线和控制总线),因此,存在一个总线控制权转移问题

DMA传输开始前:    CPU------>DMA控制器

DMA传输结束后: DMA控制器------>CPU

一个完整的DMA传输过程必须经历DMA请求、DMA响应、DMA传输、DMA结束4个步骤。

DMA方式是一种完全由硬件进行组信息传送的控制方式,具有中断方式的优点,即在数据准备阶段,CPU与外设并行工作。
[

](https://blog.csdn.net/zhejfl/article/details/82555634)

DMA的传送过程

DMA的数据传送分为预处理、数据传送和后处理3个阶段。

(1)预处理

由CPU完成一些必要的准备工作。首先,CPU执行几条I/O指令,用以测试I/O设备状态,向DMA控制器的有关寄存器置初值,设置传送方向、启动该设备等。然后,CPU继续执行原来的程序,直到I/O设备准备好发送的数据(输入情况)或接受的数据(输出情况)时,I/O设备向DMA控制器发送DMA请求,再由DMA控制器向CPU发送总线请求(统称为DMA请求),用以传输数据。

(2)数据传送

DMA的数据传输可以以单字节(或字)为基本单位,对于以数据块为单位的传送(如银盘),DMA占用总线后的数据输入和输出操作都是通过循环来实现。需要特别之处的是,这一循环也是由DMA控制器(而不是通过CPU执行程序)实现的,即数据传送阶段是完全由DMA(硬件)来控制的。

(3)后处理

DMA控制器向CPU发送中断请求,CPU执行中断服务程序做DMA结束处理,包括检验送入主存的数据是否正确,测试传送过程中是否出错(错误则转入诊断程序)和决定是否继续使用DMA传送其他数据块等。

没引入DMA技术前的read过程

在没有 DMA 技术前,I/O 的过程是这样的:

  • CPU 发出对应的指令给磁盘控制器,然后返回;
  • 磁盘控制器收到指令后,于是就开始准备数据,会把数据放入到磁盘控制器的内部缓冲区中,然后产生一个中断
  • CPU 收到中断信号后,停下手头的工作,接着把磁盘控制器的缓冲区的数据一次一个字节地读进自己的寄存器,然后再把寄存器里的数据写入到内存,而在数据传输的期间 CPU 是无法执行其他任务的。

网络编程进阶学习笔记--高并发IO的底层原理_第5张图片可以看到,整个数据的传输过程,都要需要 CPU 亲自参与搬运数据的过程,而且这个过程,CPU 是不能做其他事情的。
简单的搬运几个字符数据那没问题,但是如果我们用千兆网卡或者硬盘传输大量数据的时候,都用 CPU 来搬运的话,肯定忙不过来。
计算机科学家们发现了事情的严重性后,于是就发明了 DMA 技术,也就是直接内存访问(Direct Memory Access 技术。
什么是 DMA 技术?简单理解就是,在进行 I/O 设备和内存的数据传输的时候,数据搬运的工作全部交给 DMA 控制器,而 CPU 不再参与任何与数据搬运相关的事情,这样 CPU 就可以去处理别的事务
那使用 DMA 控制器进行数据传输的过程究竟是什么样的呢?下面我们来具体看看。

网络编程进阶学习笔记--高并发IO的底层原理_第6张图片

具体过程:

  • 用户进程调用 read 方法,向操作系统发出 I/O 请求,请求读取数据到自己的内存缓冲区中,进程进入阻塞状态;
  • 操作系统收到请求后,进一步将 I/O 请求发送 DMA,然后让 CPU 执行其他任务;
  • DMA 进一步将 I/O 请求发送给磁盘;
  • 磁盘收到 DMA 的 I/O 请求,把数据从磁盘读取到磁盘控制器的缓冲区中,当磁盘控制器的缓冲区被读满后,向 DMA 发起中断信号,告知自己缓冲区已满;
  • DMA 收到磁盘的信号,将磁盘控制器缓冲区中的数据拷贝到内核缓冲区中,此时不占用 CPU,CPU 可以执行其他任务
  • 当 DMA 读取了足够多的数据,就会发送中断信号给 CPU;
  • CPU 收到 DMA 的信号,知道数据已经准备好,于是将数据从内核拷贝到用户空间,系统调用返回;

可以看到, 整个数据传输的过程,CPU 不再参与数据搬运的工作,而是全程由 DMA 完成,但是 CPU 在这个过程中也是必不可少的,因为传输什么数据,从哪里传输到哪里,都需要 CPU 来告诉 DMA 控制器。
早期 DMA 只存在在主板上,如今由于 I/O 设备越来越多,数据传输的需求也不尽相同,所以每个 I/O 设备里面都有自己的 DMA 控制器。

Socket中的read与write操作流程

下面我们来进入我们的正题,我们通过上面的基础知识应该对应用程序的read与write操作过程有了一定的认识, 下面我们来分析socket中的read与write操作的流程。

第一个阶段,应用程序等待数据通过网络中到达网卡,当所等待的分组到达时, 数据被操作系统复制到内核缓冲区中。这个工作由操作系统自动完成,用户程序 无感知。
⚫ 第二个阶段,内核将数据从内核缓冲区复制到应用的用户缓冲区。 再具体一点,如果是在Java客户端和服务器端之间完成一次socket请求和响应(包括 read和write)的数据交换,其完整的流程如下:
⚫ 客户端发送请求:Java客户端程序通过write系统调用,将数据复制到内核缓冲 区,Linux将内核缓冲区的请求数据通过客户端器的网卡发送出去。在服务端,这 份请求数据会从接收网卡中读取到服务端机器的内核缓冲区。
⚫ 服务端获取请求:Java服务端程序通过read系统调用,从Linux内核缓冲区读取数 据,再送入Java进程缓冲区。
⚫ 服务器端业务处理:Java服务器在自己的用户空间中,完成客户端的请求所对应 的业务处理。
⚫ 服务器端返回数据:Java服务器完成处理后,构建好的响应数据,将这些数据从 用户缓冲区写入内核缓冲区,这里用到的是write系统调用,操作系统会负责将内 核缓冲区的数据发送出去。
⚫ 发送给客户端:服务端Linux系统将内核缓冲区中的数据写入网卡,网卡通过底层 的通信协议,会将数据发送给目标客户端。

四种主要的IO模型

概述

1. 同步阻塞IO(Blocking IO)
首先,解释一下阻塞与非阻塞。阻塞IO,指的是需要内核IO操作彻底完成后,才返回
到用户空间执行用户程序的操作指令,阻塞一词所指的是用户程序(发起IO请求的进程或 者线程)的执行状态是阻塞的。可以说传统的IO模型都是阻塞IO模型,并且在Java中,默 认创建的socket都属于阻塞IO模型。
其次,解释一下同步与异步。简单理解,同步与异步可以看成是发起IO请求的两种方 式。同步IO是指用户空间(进程或者线程)是主动发起IO请求的一方,系统内核是被动接 受方。异步IO则反过来,系统内核主动发起IO请求的一方,用户空间是被动接受方。
所谓同步阻塞IO,指的是用户空间(或者线程)主动发起,需要等待内核IO操作彻底 完成后(即将网卡中的数据复制到内核缓冲区中之后),才返回到用户空间的IO操作,IO操作过程中,发起IO请求的用户进程(或者线 程)处于阻塞状态。

**2. 同步非阻塞NIO(Non-Blocking IO) **
非阻塞IO,指的是用户空间的程序不需要等待内核IO操作彻底完成,可以立即返回用 户空间去执行后续的指令,即发起IO请求的用户进程(或者线程)处于非阻塞的状态,与 此同时,内核会立即返回给用户一个IO的状态值。 阻塞和非阻塞的区别是什么呢? 阻塞是指用户进程(或者线程)一直在等待,而不能 干别的事情;非阻塞是指用户进程(或者线程)拿到内核返回的状态值就返回自己的空 间,可以去干别的事情。在Java中,非阻塞IO的socket套接字,要求被设置为NONBLOCK模式。
所谓同步非阻塞NIO,指的是用户进程主动发起,不需要等待内核IO操作彻底完成之 后,就能立即返回到用户空间的IO操作,IO操作过程中,发起IO请求的用户进程(或者线 程)处于非阻塞状态。

3. IO多路复用(IO Multiplexing)
为了提高性能,操作系统引入了一类新的系统调用,专门用于查询IO文件描述符的 (含socket连接)的就绪状态。在Linux系统中,新的系统调用为select/epoll系统调用。通过 该系统调用,一个用户进程(或者线程)可以监视多个文件描述符,一旦某个描述符就绪 (一般是内核缓冲区可读/可写),内核能够将文件描述符的就绪状态返回给用户进程(或 者线程),用户空间可以根据文件描述符的就绪状态,进行相应的IO系统调用。
IO多路复用(IO Multiplexing)属于经典的Reactor模式一种实现,有时也称为异步阻 塞IO,Java中的Selector属于这种模型。

**4. 异步IO(Asynchronous IO) **异步IO,指的是用户空间与内核空间的调用方式反过来。用户空间的线程变成被动接 受者,而内核空间成了主动调用者。在异步IO模型中,当用户线程收到通知时,数据已经 被内核读取完毕,并放在了用户缓冲区内,内核在IO完成后通知用户线程直接使用即可。 异步IO类似于Java中典型的回调模式,用户进程(或者线程)向内核空间注册了各种 IO事件的回调函数,由内核去主动调用。 接下来,对以上的四种常见的IO模型进行一下详细的介绍

同步阻塞

默认情况下,在Java应用程序进程中,所有对socket连接的进行的IO操作都是同步阻塞
IO(Blocking IO)。
在阻塞式IO模型中,Java应用程序从发起IO系统调用开始,一直到系统调用返回,在 这段时间内,发起IO请求的Java进程(或者线程)是阻塞的。直到返回成功后,应用进程 才能开始处理用户空间的缓存区数据。 同步阻塞IO的具体流程,如网络编程进阶学习笔记--高并发IO的底层原理_第7张图片

举个例子,在Java中发起一个socket的read读操作的系统调用,流程大致如下:
(1)从Java进行IO读后发起read系统调用开始,用户线程(或者线程)就进入阻塞状 态。
(2)当系统内核收到read系统调用,就开始准备数据。一开始,数据可能还没有到达 内核缓冲区(例如,还没有收到一个完整的socket数据包),这个时候内核就要等待。
(3)内核一直等到完整的数据到达,就会将数据从内核缓冲区复制到用户缓冲区(用 户空间的内存),然后内核返回结果(例如返回复制到用户缓冲区中的字节数)。
(4)直到内核返回后,用户线程才会解除阻塞的状态,重新运行起来。 阻塞IO的特点是:在内核进行IO执行的两个阶段,发起IO请求的用户进程(或者线程)被阻塞了。

**阻塞IO的优点是:**应用的程序开发非常简单;在阻塞等待数据期间,用户线程挂起, 用户线程基本不会占用CPU资源。
**阻塞IO的缺点是:**一般情况下,会为每个连接配备一个独立的线程,一个线程维护一 个连接的IO操作。在并发量小的情况下,这样做没有什么问题。但是,当在高并发的应用 场景下,需要大量的线程来维护大量的网络连接,内存、线程切换开销会非常巨大。在高 并发应用场景中,阻塞IO模型是性能很低的,基本上是不可用的

同步非阻塞IO

在Linux系统下,socket连接默认是阻塞模式,可以通过设置将socket变成为非阻塞的模 式(Non-Blocking)。在NIO模型中,应用程序一旦开始IO系统调用,会出现以下两种情 况:
(1)在内核缓冲区中没有数据的情况下,系统调用会立即返回,返回一个调用失败的 信息。
(2)在内核缓冲区中有数据的情况下,在数据的复制过程中系统调用是阻塞的,直到
完成数据从内核缓冲复制到用户缓冲。复制完成后,系统调用返回成功,用户进程(或者 线程)可以开始处理用户空间的缓存数据。
同步非阻塞IO的流程,如图所示。

网络编程进阶学习笔记--高并发IO的底层原理_第8张图片

举个例子。发起一个非阻塞socket的read读操作的系统调用,流程如下:
(1)在内核数据没有准备好的阶段,用户线程发起IO请求时,立即返回。所以,为 了读取到最终的数据,用户进程(或者线程)需要不断地发起IO系统调用。
(2)内核数据到达后,用户进程(或者线程)发起系统调用,用户进程(或者线程) 阻塞。内核开始复制数据,它会将数据从内核缓冲区复制到用户缓冲区,然后内核返回结 果(例如返回复制到的用户缓冲区的字节数)。
(3)用户进程(或者线程)读到数据后,才会解除阻塞状态,重新运行起来。也就是 说,用户空间需要经过多次的尝试,才能保证最终真正读到数据,而后继续执行。 同步非阻塞IO的特点:应用程序的线程需要不断地进行IO系统调用,轮询数据是否已 经准备好,如果没有准备好,就继续轮询,直到完成IO系统调用为止。 同
步非阻塞IO的优点:每次发起的IO系统调用,在内核等待数据过程中可以立即返 回。用户线程不会阻塞,实时性较好。
同步非阻塞IO的缺点:不断地轮询内核,这将占用大量的CPU时间,效率低下。
总体来说,在高并发应用场景中,同步非阻塞IO是性能很低的,也是基本不可用的, 一般Web服务器都不使用这种IO模型。在Java的实际开发中,也不会涉及这种IO模型。但 是此模型还是有价值的,其作用在于,其他IO模型中可以使用非阻塞IO模型作为基础,以 实现其高性能

IO多路复用模型

如何避免同步非阻塞IO模型中轮询等待的问题呢?这就是IO多路复用模型。 在IO多路复用模型中,引入了一种新的系统调用,查询IO的就绪状态。在Linux系统 中,对应的系统调用为select/epoll系统调用。通过该系统调用,一个进程可以监视多个文 件描述符(包括socket连接),一旦某个描述符就绪(一般是内核缓冲区可读/可写),内核能够将就绪的状态返回给应用程序。随后,应用程序根据就绪的状态,进行相应的IO系统
调用。

目前支持IO多路复用的系统调用,有select、epoll等等。select系统调用,几乎在所有 的操作系统上都有支持,具有良好的跨平台特性。epoll是在Linux 2.6内核中提出的,是 select系统调用的Linux增强版本。
在IO多路复用模型中通过select/epoll系统调用,单个应用程序的线程,可以不断地轮 询成百上千的socket连接的就绪状态,当某个或者某些socket网络连接有IO就绪状态,就返 回这些就绪的状态(或者说就绪事件)。
举个例子来说明IO多路复用模型的流程。发起一个多路复用IO的read读操作的系统调 用,流程如下:
(1)选择器注册。在这种模式中,首先,将需要read操作的目标文件描述符(socket 连接),提前注册到Linux的select/epoll选择器中,在Java中所对应的选择器类是Selector类。 然后,才可以开启整个IO多路复用模型的轮询流程。
(2)就绪状态的轮询。通过选择器的查询方法,查询所有的提前注册过的目标文件描 述符(socket连接)的IO就绪状态。通过查询的系统调用,内核会返回一个就绪的socket列 表。当任何一个注册过的socket中的数据准备好或者就绪了,就是内核缓冲区有数据了, 内核就将该socket加入到就绪的列表中,并且返回就绪事件。
(3)用户线程获得了就绪状态的列表后,根据其中的socket连接,发起read系统调 用,用户线程阻塞。内核开始复制数据,将数据从内核缓冲区复制到用户缓冲区。
(4)复制完成后,内核返回结果,用户线程才会解除阻塞的状态,用户线程读取到了 数据,继续执行。

在用户进程进行 IO 就绪事件的轮询时,需要调用了选择器的 select 查询方法,发起查
询的用户进程或者线程是阻塞的。当然,如果使用了查询方法的非阻塞的重载版本,发起查询的
用户进程或者线程也不会阻塞,重载版本会立即返回。

IO多路复用模型的read系统调用流程,如图所示。
网络编程进阶学习笔记--高并发IO的底层原理_第9张图片
IO多路复用模型的特点:IO多路复用模型的IO涉及两种系统调用,一种是IO操作的系 统调用,另一种是select/epoll就绪查询系统调用。IO多路复用模型建立在操作系统的基础 设施之上,即操作系统的内核必须能够提供多路分离的系统调用select/epoll。
和NIO模型相似,多路复用IO也需要轮询。负责select/epoll状态查询调用的线程,需要 不断地进行select/epoll轮询,查找出达到IO操作就绪的socket连接。
IO多路复用模型与同步非阻塞IO模型是有密切关系的,具体来说,注册在选择器上的 每一个可以查询的socket连接,一般都设置成为同步非阻塞模型。只是这一点对于用户程 序而言,是无感知的。
IO多路复用模型的优点:一个选择器查询线程,可以同时处理成千上万的网络连接, 所以,用户程序不必创建大量的线程,也不必维护这些线程,从而大大减小了系统的开 销。这是一个线程维护一个连接的阻塞IO模式相比,使用多路IO复用模型的最大优势。
通过JDK的源码可以看出,Java语言的NIO(New IO)组件,在Linux系统上,是使用 的是epoll系统调用实现的。所以,Java语言的NIO(New IO)组件所使用的,就是IO多路 复用模型。
IO多路复用模型的缺点:本质上,select/epoll系统调用是阻塞式的,属于同步IO。都 需要在读写事件就绪后,由系统调用本身负责进行读写,也就是说这个读写过程是阻塞 的。如何彻底地解除线程的阻塞,就必须使用异步IO模型。

异步IO模型

异步IO模型(Asynchronous IO,简称为AIO)。AIO的基本流程是:用户线程通过系统 调用,向内核注册某个IO操作。内核在整个IO操作(包括数据准备、数据复制)完成后, 通知用户程序,用户执行后续的业务操作。
在异步IO模型中,在整个内核的数据处理过程中,包括内核将数据从网络物理设备 (网卡)读取到内核缓冲区、将内核缓冲区的数据复制到用户缓冲区,用户程序都不需要 阻塞。

异步IO模型的流程

网络编程进阶学习笔记--高并发IO的底层原理_第10张图片

举个例子。发起一个异步IO的read读操作的系统调用,流程如下:
(1)当用户线程发起了read系统调用,立刻就可以开始去做其他的事,用户线程不阻塞。
(2)内核就开始了IO的第一个阶段:准备数据。等到数据准备好了,内核就会将数 据从内核缓冲区复制到用户缓冲区。
(3)内核会给用户线程发送一个信号(Signal),或者回调用户线程注册的回调方 法,告诉用户线程,read系统调用已经完成了,数据已经读入到了用户缓冲区。
(4)用户线程读取用户缓冲区的数据,完成后续的业务操作。 异步IO模型的特点:在内核等待数据和复制数据的两个阶段,用户线程都不是阻塞
的。用户线程需要接收内核的IO操作完成的事件,或者用户线程需要注册一个IO操作完成 的回调函数。正因为如此,异步IO有的时候也被称为信号驱动IO。
异步IO异步模型的缺点:应用程序仅需要进行事件的注册与接收,其余的工作都留给 了操作系统,也就是说,需要底层内核提供支持。
理论上来说,异步IO是真正的异步输入输出,它的吞吐量高于IO多路复用模型的吞吐 量。就目前而言,Windows系统下通过IOCP实现了真正的异步IO。而在Linux系统下,异步 IO模型在2.6版本才引入,JDK的对其的支持目前并不完善,因此异步IO在性能上没有明显 的优势。
大多数的高并发服务器端的程序,一般都是基于Linux系统的。因而,目前这类高并发 网络应用程序的开发,大多采用IO多路复用模型。大名鼎鼎的Netty框架,使用的就是IO多 路复用模型,而不是异步IO模型

通过合理配置来支持百万级连接

本章所聚焦的主题,是高并发IO的底层原理。前面已经深入浅出地介绍了高并发IO的 模型。但是,即使采用了最先进的模型,如果不进行合理的操作系统配置,也没有办法支 撑百万级的网络连接并发。在生产环境中,大家都使用Linux系统,所以,后续文字如果没有特别说明,所指的操作系统都是Linux系统。

这里所涉及的配置,就是Linux操作系统中文件句柄数的限制。在生产环境Linux系统 中,基本上都需要解除文件句柄数的限制。原因是,Linux的系统默认值为1024,也就是 说,一个进程最多可以接受1024个socket连接。这是远远不够的。
本书的原则是:从基础讲起。

文件句柄,也叫文件描述符。在Linux系统中,文件可分为:普通文件、目录文件、链 接文件和设备文件。文件描述符(File Descriptor)是内核为了高效管理已被打开的文件所 创建的索引,它是一个非负整数(通常是小整数),用于指代被打开的文件。所有的IO系 统调用,包括socket的读写调用,都是通过文件描述符完成的。

在Linux下,通过调用ulimit命令,可以看到一个进程能够打开的最大文件句柄数量,
这个命令的具体使用方法是:

ulimit -n 

ulimit 命令是用来显示和修改当前用户进程一些基础限制的命令,-n选项用于引用或 设置当前的文件句柄数量的限制值,Linux的系统默认值为1024。

理论上1024个文件描述符,对绝大多数应用(例如Apache、桌面应用程序)来说已经 足够了。但是,是对于一些用户基数很大的高并发应用,则是远远不够的。一个高并发的 应用,面临的并发连接数往往是十万级、百万级、千万级、甚至像腾讯QQ一样的上亿级。

文件句柄数不够,会导致什么后果呢?当单个进程打开的文件句柄数量超过了系统配 置的上限值时,就会发出“Socket/File:Can’t open so many files”的错误提示。

所以,对于高并发、高负载的应用,就必须要调整这个系统参数,以适应处理并发处 理大量连接的应用场景。可以通过ulimit来设置这两个参数。方法如下:

ulimit -n 1000000 

在上面的命令中,n的设置值越大,可以打开的文件句柄数量就越大。建议以root用户 来执行此命令。

使用ulimit命令有一个缺陷,该命令仅仅只能修改当前用户环境的一些基础限制,仅在 当前用户环境有效。也即是说,在当前的终端工具连接当前shell期间,修改是有效的;一 旦断开用户会话,或者说用户退出Linux后,它的数值就又变回系统默认的1024了。并且, 系统重启后,句柄数量又会恢复为默认值。

ulimit命令只能用于临时修改,如果想永久地把最大文件描述符数量值保存下来,可以 编辑/etc/rc.local开机启动文件,在文件中添加如下内容:

ulimit -SHn 1000000 

以上示例增加-S和-H两个命令选项。选项-S表示软性极限值,-H表示硬性极限值。硬 性极限是实际的限制,就是最大可以是100万,不能再多了。软性极限值则是系统发出警告 Warning)的极限值,超过这个极限值,内核会发出警告。
普通用户通过ulimit命令,可将软极限更改到硬极限的最大设置值。如果要更改硬极 限,必须拥有root用户权限。
终极解除Linux系统的最大文件打开数量的限制,可以通过编辑Linux的极限配置文件 /etc/security/limits.conf来解决,修改此文件,加入如下内容:

soft nofile 1000000 
hard nofile 1000000 

soft nofile表示软性极限,hard nofile表示硬性极限。

举个实际例子,在使用和安装目前非常流行的分布式搜索引擎——ElasticSearch时,基 本上就必须去修改这个文件,用于增加最大的文件描述符的极限值。当然,在生产环境运 行Netty时,也需要修改/etc/security/limits.conf文件,增加文件描述符数量的限制。

参考文章

一次read()系统调用在内核中的处理过程:https://blog.csdn.net/a675311/article/details/49077727

搞懂Linux零拷贝,DMA:https://blog.csdn.net/Rong_Toa/article/details/108825666

DMA之理解:https://blog.csdn.net/zhejfl/article/details/82555634

你可能感兴趣的:(Netty,网络编程进阶学习笔记,java)