6、I / O 系统

1、零拷贝

1.1、DMA 技术

在没有 DMA 技术前,I / O 的过程是这样的(整个数据的传输过程,需要 CPU 亲自搬运数据,而且这个过程,CPU 是不能做其他事情的)

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

image

DMA 技术,也就是直接内存访问(Direct Memory Access) 技术
在进行 I / O 设备和内存的数据传输的时候,数据搬运的工作全部交给 DMA 控制器,而 CPU 不再参与任何与数据搬运相关的事情,这样 CPU 就可以去处理别的事务
CPU 不再参与「将数据从磁盘控制器缓冲区搬运到内核空间」的工作,这部分工作全程由 DMA 完成
但是 CPU 在这个过程中也是必不可少的,因为传输什么数据,从哪里传输到哪里,都需要 CPU 来告诉 DMA 控制器
早期 DMA 只存在在主板上,如今由于 I / O 设备越来越多,数据传输的需求也不尽相同,所以每个 I / O 设备里面都有自己的 DMA 控制器

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

image

1.2、传统的文件传输

如果服务端要提供文件传输的功能,我们能想到的最简单的方式是:将磁盘上的文件读取出来,然后通过网络协议发送给客户端
传统 I / O 的工作方式是,数据读取和写入是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的 I / O 接口从磁盘读取或写入

read(file, tmp_buf, len);
write(socket, tmp_buf, len);

代码很简单,虽然就两行代码,但是这里面发生了不少的事情

  • 共发生了 4 次用户态与内核态的上下文切换,因为发生了两次系统调用,一次是 read() ,一次是 write()
    每次系统调用都得先从用户态切换到内核态,等内核完成任务后,再从内核态切换回用户态
    上下文切换到成本并不小,一次切换需要耗时几十纳秒到几微秒,虽然时间看上去很短,但是在高并发的场景下,这类时间容易被累积和放大,从而影响系统的性能
  • 还发生了 4 次数据拷贝,其中两次是 DMA 的拷贝,另外两次则是通过 CPU 拷贝的
    第一次拷贝:把磁盘上的数据拷贝到操作系统内核的缓冲区里,这个拷贝的过程是通过 DMA 搬运的
    第二次拷贝:把内核缓冲区的数据拷贝到用户的缓冲区里,于是我们应用程序就可以使用这部分数据了,这个拷贝到过程是由 CPU 完成的
    第三次拷贝:把刚才拷贝到用户的缓冲区里的数据,再拷贝到内核的 socket 的缓冲区里,这个过程依然还是由 CPU 搬运的
    第四次拷贝:把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程又是由 DMA 搬运的

我们只是搬运一份数据,结果却搬运了 4 次,过多的数据拷贝无疑会消耗 CPU 资源,大大降低了系统性能
要想提高文件传输的性能,就需要减少「用户态与内核态的上下文切换」和「内存拷贝」的次数
image

1.3、如何优化文件传输的性能

如何减少「用户态与内核态的上下文切换」的次数

读取磁盘数据的时候,之所以要发生上下文切换,这是因为用户空间没有权限操作磁盘或网卡
内核的权限最高,这些操作设备的过程都需要交由操作系统内核来完成,所以一般要通过内核去完成某些任务的时候,就需要使用操作系统提供的系统调用函数
而一次系统调用必然会发生 2 次上下文切换:首先从用户态切换到内核态,当内核执行完任务后,再切换回用户态交由进程代码执行
所以要想减少上下文切换到次数,就要减少系统调用的次数

如何减少「数据拷贝」的次数

在前面我们知道了,传统的文件传输方式会历经 4 次数据拷贝
这里面「从内核的读缓冲区拷贝到用户的缓冲区里,再从用户的缓冲区里拷贝到 socket 的缓冲区里」,这个过程是没有必要的
因为文件传输的应用场景中,在用户空间我们并不会对数据「再加工」,所以数据实际上可以不用搬运到用户空间,因此用户的缓冲区是没有必要存在的

1.4、如何实现零拷贝

零拷贝技术实现的方式通常有 2 种:mmap + write、sendfile

mmap + write

read() 系统调用的过程中会把内核缓冲区的数据拷贝到用户的缓冲区里,于是为了减少这一步开销,我们可以用 mmap() 替换 read() 系统调用函数

buf = mmap(file, len);
write(sockfd, buf, len);

mmap() 系统调用函数会直接把内核缓冲区里的数据「映射」到用户空间,操作系统内核与用户空间就不需要再进行任何的数据拷贝操作

  • 应用进程调用了 mmap() 后,DMA 会把磁盘的数据拷贝到内核的缓冲区里,接着应用进程跟操作系统内核「共享」这个缓冲区
  • 应用进程再调用 write(),操作系统直接将内核缓冲区的数据拷贝到 socket 缓冲区中,这一切都发生在内核态,由 CPU 来搬运数据
  • 把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程是由 DMA 搬运的

通过使用 mmap() 来代替 read(), 可以减少一次数据拷贝的过程
但这还不是最理想的零拷贝,因为仍然需要通过 CPU 把内核缓冲区的数据拷贝到 socket 缓冲区里,而且仍然需要 4 次上下文切换,因为系统调用还是 2 次
image

sendfile

#include <sys/socket.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

在 Linux 内核版本 2.1 中,提供了一个专门发送文件的系统调用函数 sendfile()
它的前两个参数分别是目的端和源端的文件描述符,后面两个参数是源端的偏移量和复制数据的长度,返回值是实际复制数据的长度

  • 它可以替代前面的 read() 和 write() 这两个系统调用,这样就可以减少一次系统调用,也就减少了 2 次上下文切换的开销
  • 该系统调用,可以直接把内核缓冲区里的数据拷贝到 socket 缓冲区里,不再拷贝到用户态,这样就只有 2 次上下文切换和 3 次数据拷贝

image

真正的零拷贝技术

但是这还不是真正的零拷贝技术,如果网卡支持 SG-DMA 技术(The Scatter-Gather Direct Memory Access 和普通的 DMA 有所不同)
我们可以进一步减少通过 CPU 把内核缓冲区里的数据拷贝到 socket 缓冲区的过程
你可以在你的 Linux 系统通过下面这个命令,查看网卡是否支持 scatter-gather 特性

$ ethtool -k eth0 | grep scatter-gather
scatter-gather: on

于是从 Linux 内核 2.4 版本开始起,对于支持网卡支持 SG-DMA 技术的情况下,sendfile() 系统调用的过程发生了点变化,具体过程如下

  • 通过 DMA 将磁盘上的数据拷贝到内核缓冲区里
  • 缓冲区描述符和数据长度传到 socket 缓冲区,这样网卡的 SG-DMA 控制器就可以直接将内核缓存中的数据拷贝到网卡的缓冲区里
    此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中,这样就减少了一次数据拷贝

这个过程之中只进行了 2 次数据拷贝
image

这就是所谓的零拷贝(Zero-copy)技术,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的
零拷贝技术的文件传输方式相比传统文件传输的方式,减少了 2 次上下文切换和数据拷贝次数,只需要 2 次上下文切换和数据拷贝次数,就可以完成文件的传输
而且 2 次的数据拷贝过程都不需要通过 CPU,2 次都是由 DMA 来搬运,所以总体来看,零拷贝技术可以把文件传输的性能提高至少一倍以上

1.5、使用零拷贝技术的项目

Kafka 这个开源项目,就利用了「零拷贝」技术,从而大幅提升了 I / O 的吞吐率,这也是 Kafka 在处理海量数据为什么这么快的原因之一

@Overridepublic
long transferFrom(FileChannel fileChannel, long position, long count) throws IOException {
    return fileChannel.transferTo(position, count, socketChannel);
}

如果你追溯 Kafka 文件传输的代码,你会发现最终它调用了 Java NIO 库里的 transferTo() 方法
如果 Linux 系统支持 sendfile() 系统调用,那么 transferTo() 实际上最后就会使用到 sendfile() 系统调用函数

曾经有大佬专门写过程序测试过,在同样的硬件条件下,传统文件传输和零拷拷贝文件传输的性能差异
你可以看到下面这张测试数据图,使用了零拷贝能够缩短 65% 的时间,大幅度提升了机器传输数据的吞吐量
image

Nginx 也支持零拷贝技术,一般默认是开启零拷贝技术,这样有利于提高文件传输的效率,是否开启零拷贝技术的配置如下

http {
...
    sendfile on
...
}

sendfile 配置的具体意思

  • 设置为 on 表示,使用零拷贝技术来传输文件:sendfile,这样只需要 2 次上下文切换和 2 次数据拷贝
  • 设置为 off 表示,使用传统的文件传输技术:read + write,这时就需要 4 次上下文切换和 4 次数据拷贝

当然要使用 sendfile,Linux 内核版本必须要 2.1 以上的版本

2、PageCache

回顾前面说道文件传输过程,其中第一步都是先需要先把磁盘文件数据拷贝「内核缓冲区」里,这个「内核缓冲区」实际上是磁盘高速缓存(PageCache)

小文件传输时,使用了 PageCache 的零拷贝技术非常有用,但是大文件传输相反
在传输大文件(GB 级别的文件)的时候,PageCache 会不起作用,那就白白浪费 DMA 多做的一次数据拷贝,造成性能的降低
这是因为如果你有很多 GB 级别文件需要传输,每当用户访问这些大文件的时候,内核就会把它们载入 PageCache 中,于是 PageCache 空间很快被这些大文件占满

  • PageCache 由于长时间被大文件占据,其他「热点」的小文件可能就无法充分使用到 PageCache,于是这样磁盘读写的性能就会下降了
  • PageCache 中的大文件数据,由于没有享受到缓存带来的好处,但却耗费 DMA 多拷贝到 PageCache 一次

3、大文件传输

我们先来看看最初的例子,当调用 read 方法读取文件时,进程实际上会阻塞在 read 方法调用,因为要等待磁盘数据的返回

  • 当调用 read 方法时会阻塞着,此时内核会向磁盘发起 I / O 请求,磁盘收到请求后便会寻址,当磁盘数据准备好后,就会向内核发起 I / O 中断,告知内核磁盘数据已经准备好
  • 内核收到 I / O 中断后,就将数据从磁盘控制器缓冲区拷贝到 PageCache 里
  • 内核再把 PageCache 中的数据拷贝到用户缓冲区,于是 read 调用就正常返回了

image

对于阻塞的问题,可以用异步 I / O 来解决,它把读操作分为两部分

  • 前半部分:内核向磁盘发起读请求,但是可以不等待数据就位就可以返回,于是进程此时可以处理其他任务
  • 后半部分:当内核将磁盘中的数据拷贝到进程缓冲区后,进程将接收到内核的通知,再去处理数据

image

我们可以发现,异步 I / O 并没有涉及到 PageCache,所以使用异步 I / O 就意味着要绕开 PageCache
绕开 PageCache 的 I / O 叫直接 I / O,使用 PageCache 的 I / O 则叫缓存 I / O,通常对于磁盘来说,异步 I / O 只支持直接 I / O

前面也提到,大文件的传输不应该使用 PageCache,因为可能由于 PageCache 被大文件占据,而导致「热点」小文件无法利用到 PageCache
于是在高并发的场景下,针对大文件的传输的方式,应该使用「异步 I / O + 直接 I / O」来替代零拷贝技术,直接 I / O 常见的两种应用场景

  • 应用程序已经实现了磁盘数据的缓存,那么可以不需要 PageCache 再次缓存,减少额外的性能损耗
    在 MySQL 数据库中,可以通过参数设置开启直接 I / O,默认是不开启
  • 传输大文件的时候,由于大文件难以命中 PageCache 缓存
    而且会占满 PageCache 导致「热点」文件无法充分利用缓存,从而增大了性能开销,因此这时应该使用直接 I / O

由于直接 I / O 绕过了 PageCache,就无法享受内核的这两点的优化

  • 内核的 I / O 调度算法会缓存尽可能多的 I / O 请求在 PageCache 中
    最后「合并」成一个更大的 I / O 请求再发给磁盘,这样做是为了减少磁盘的寻址操作
  • 内核也会「预读」后续的 I / O 请求放在 PageCache 中,一样是为了减少对磁盘的操作

于是传输大文件的时候,使用「异步 I / O + 直接 I / O」了,就可以无阻塞地读取文件了,所以传输文件的时候,我们要根据文件的大小来使用不同的方式

  • 传输小文件的时候,则使用「零拷贝技术」
  • 传输大文件的时候,使用「异步 I / O + 直接 I / O」

在 nginx 中,我们可以用如下配置,来根据文件的大小来使用不同的方式
当文件大小 > directio 值后,使用「异步 I / O + 直接 I / O」,否则使用「零拷贝技术」

location /video/ {
    sendfile on;
    aio on;
    directio 1024m;
}

4、五大 I / O 模型

Java IO
五大 I / O 模型
阻塞与非阻塞 vs 同步与异步
谈谈对不同 I / O 模型的理解
IO 多路复用是什么?如何设计一个高性能服务器?

  • 阻塞 I / O(blocking IO)
  • 非阻塞 I / O(nonblocking IO)
  • I / O 多路复用( IO multiplexing)
  • 信号驱动 I / O( signal driven IO)
  • 异步 I / O(asynchronous IO)

image
image
image
image
image

5、select / poll / epoll

套接字 Socket
socket 到底是什么

5.1、最基本的 Socket 模型

socket - 编程
TCP 半连接队列和全连接队列

服务器的程序要先跑起来,然后等待客户端的连接和数据,我们先来看看服务端的 Socket 编程过程是怎样的
服务端首先调用 socket() 函数,创建网络协议为 IPv4,以及传输协议为 TCP 的 Socket ,接着调用 bind() 函数,给这个 Socket 绑定一个 IP 地址和端口

  • 绑定端口的目的:当内核收到 TCP 报文,通过 TCP 头里面的端口号,来找到我们的应用程序,然后把数据传递给我们
  • 绑定 IP 地址的目的:一台机器是可以有多个网卡的,每个网卡都有对应的 IP 地址,当绑定一个网卡时,内核在收到该网卡上的包,才会发给我们

绑定完 IP 地址和端口后,就可以调用 listen() 函数进行监听
此时对应 TCP 状态图中的 listen,如果我们要判定服务器中一个网络程序有没有启动,可以通过 netstat 命令查看对应的端口号是否有被监听
服务端进入了监听状态后,通过调用 accept() 函数,来从内核获取客户端的连接,如果没有客户端连接,则会阻塞等待客户端连接的到来

客户端在创建好 Socket 后,调用 connect() 函数发起连接,该函数的参数要指明服务端的 IP 地址和端口号,然后万众期待的 TCP 三次握手就开始了
在 TCP 连接的过程中,服务器的内核实际上为每个 Socket 维护了两个队列

  • 一个是「还没完全建立」连接的队列,称为 TCP 半连接队列,这个队列都是没有完成三次握手的连接,此时服务端处于 syn_rcvd 的状态
  • 一个是「已经建立」连接的队列,称为 TCP 全连接队列,这个队列都是完成了三次握手的连接,此时服务端处于 established 状态

当 TCP 全连接队列不为空后,服务端的 accept() 函数,就会从内核中的 TCP 全连接队列里拿出一个已经完成连接的 Socket 返回应用程序,后续数据传输都用这个 Socket
注意:监听的 Socket 和真正用来传数据的 Socket 是两个

  • 一个叫作监听 Socket,用于接收新的连接(本质是一个文件,可以用来 TCP 三次握手以建立连接)
  • 一个叫作已连接 Socket,用于网络传输数据(本质是一个文件,可以发送和接收数据,还可以 TCP 四次挥手断开连接)

连接建立后,客户端和服务端就开始相互传输数据了,双方都可以通过 read() 和 write() 函数来读写数据
image

image

image

image

进程在 recv 阻塞期间,计算机收到了对端传送的数据(步骤 ⓵),数据经由网卡传送到内存(步骤 ⓶)
然后网卡通过中断信号通知 cpu 有数据到达,cpu 执行中断程序(步骤 ⓷)
此处的中断程序主要有两项功能,先将网络数据写入到对应 socket 的接收缓冲区里面(步骤 ⓸),再唤醒进程 A(步骤 ⓹),重新将进程 A 放入工作队列中

操作系统如何知道网络数据对应于哪个 socket:因为一个 socket 对应着一个端口号,而网络数据包中包含了 ip 和端口的信息
内核可以通过端口号找到对应的 socket,为了提高处理速度,操作系统会维护端口号到 socket 的索引结构,以快速读取
image
image

5.2、如何服务更多的用户

前面提到的 TCP Socket 调用流程是最简单、最基本的,它基本只能一对一通信
因为使用的是同步阻塞的方式,当服务端在还没处理完一个客户端的网络 I / O 时,或者读写操作发生阻塞时,其他客户端是无法与服务端连接的
可如果我们服务器只能服务一个客户,那这样就太浪费资源了,于是我们要改进这个网络 I / O 模型,以支持更多的客户端

在改进网络 I / O 模型前,我先来提一个问题,你知道服务器单机理论最大能连接多少个客户端
相信你知道 TCP 连接是由四元组唯一确认的,这个四元组就是:本机 IP、本机端口,对端 IP、对端端口
服务器作为服务方,通常会在本地固定监听一个端口,等待客户端的连接,因此服务器的本地 IP 和端口是固定的
于是对于服务端 TCP 连接的四元组只有对端 IP 和端口是会变化的,所以最大 TCP 连接数 = 客户端 IP 数 × 客户端端口数
对于 IPv4,客户端的 IP 数最多为 2 的 32 次方,客户端的端口数最多为 2 的 16 次方,也就是服务端单机最大 TCP 连接数约为 2 的 48 次方
这个理论值相当 "丰满",但是服务器肯定承载不了那么大的连接数,主要会受两个方面的限制

  • 文件描述符:Socket 实际上是一个文件,也就会对应一个文件描述符
    在 Linux 下,单个进程打开的文件描述符数是有限制的,没有经过修改的值一般都是 1024,不过我们可以通过 ulimit 增大文件描述符的数目
  • 系统内存:每个 TCP 连接在内核中都有对应的数据结构,意味着每个连接都是会占用一定内存的

那如果服务器的内存只有 2 GB,网卡是千兆的,能支持并发 1 万请求吗
并发 1 万请求,也就是经典的 C10K 问题 ,C 是 Client 单词首字母缩写,C10K 就是单机同时处理 1 万个请求的问题
从硬件资源角度看,对于 2GB 内存千兆网卡的服务器,如果每个请求处理占用不到 200KB 的内存和 100Kbit 的网络带宽就可以满足并发 1 万个请求
不过要想真正实现 C10K 的服务器,要考虑的地方在于服务器的网络 I / O 模型,效率低的模型,会加重系统开销,从而会离 C10K 的目标越来越远

5.3、多进程模型

基于最原始的阻塞网络 I / O,如果服务器要支持多个客户端,其中比较传统的方式,就是使用多进程模型,也就是为每个客户端分配一个进程来处理请求
服务器的主进程负责监听客户的连接,一旦与客户端连接完成,accept() 函数就会返回一个「已连接 Socket」
这时就通过 fork() 函数创建一个子进程,实际上就把父进程所有相关的东西都复制一份,包括文件描述符、内存地址空间、程序计数器、执行的代码等
这两个进程刚复制完的时候,几乎一模一样,不过会根据返回值来区分是父进程还是子进程,如果返回值是 0,则是子进程;如果返回值是其他的整数,就是父进程

正因为子进程会复制父进程的文件描述符,于是就可以直接使用「已连接 Socket 」和客户端通信了

  • 子进程不需要关心「监听 Socket」,只需要关心「已连接 Socket」
  • 父进程则相反,将客户服务交给子进程来处理,因此父进程不需要关心「已连接 Socket」,只需要关心「监听 Socket」

下面这张图描述了从连接请求到连接建立,父进程创建生子进程为客户服务
image
另外,当「子进程」退出时,实际上内核里还会保留该进程的一些信息,也是会占用内存的
如果不做好 "回收" 工作,就会变成僵尸进程,随着僵尸进程越多,会慢慢耗尽我们的系统资源
因此父进程要 "善后" 好自己的孩子:有两种方式可以在子进程退出后回收资源,分别是调用 wait() 和 waitpid() 函数

这种用多个进程来应付多个客户端的方式,在应对 100 个客户端还是可行的
但是当客户端数量高达一万时,肯定扛不住的,因为每产生一个进程,必会占据一定的系统资源,而且进程间上下文切换的 "包袱" 是很重的,性能会大打折扣
进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源

5.4、多线程模型

线程是运行在进程中的一个 "逻辑流",单进程中可以运行多个线程
同进程里的线程可以共享进程的部分资源,比如:文件描述符列表、进程空间、代码、全局数据、堆、共享库等
这些共享些资源在上下文切换时不需要切换,而只需要切换线程的私有数据、寄存器等不共享的数据,因此同一个进程下的线程上下文切换的开销要比进程小得多

当服务器与客户端 TCP 完成连接后,通过 pthread_create() 函数创建线程
然后将「已连接 Socket」的文件描述符传递给线程函数,接着在线程里和客户端进行通信,从而达到并发处理的目的
如果每来一个连接就创建一个线程,线程运行完后,还得操作系统还得销毁线程,虽说线程切换的上写文开销不大,但是如果频繁创建和销毁线程,系统开销也是不小的

我们可以使用线程池的方式来避免线程的频繁创建和销毁,所谓的线程池,就是提前创建若干个线程
这样当由新连接建立时,将这个已连接的 Socket 放入到一个队列里,然后线程池里的线程负责从队列中取出「已连接 Socket」进行处理
image
需要注意的是,这个队列是全局的,每个线程都会操作,为了避免多线程竞争,线程在操作这个队列前要加锁
上面基于进程或者线程模型的,其实还是有问题的:新到来一个 TCP 连接,就需要分配一个进程或者线程
如果要达到 C10K,意味着要一台机器维护 1 万个连接,相当于要维护 1 万个进程 / 线程,操作系统就算死扛也是扛不住的

5.5、I / O 多路复用

你管这破玩意叫 IO 多路复用

既然为每个请求分配一个进程 / 线程的方式不合适,那有没有可能只使用一个进程来维护多个 Socket 呢,答案是有的,那就是 I / O 多路复用技术
image
一个进程虽然任一时刻只能处理一个请求,但是处理每个请求的事件时,耗时控制在 1 毫秒以内,这样 1 秒内就可以处理上千个请求
把时间拉长来看,多个请求复用了一个进程,这就是多路复用,这种思想很类似一个 CPU 并发多个进程,所以也叫做时分多路复用

我们熟悉的 select / poll / epoll 内核提供给用户态的多路复用系统调用,进程可以通过一个系统调用函数从内核中获取多个事件
在获取事件时,先把所有连接(文件描述符)传给内核,再由内核返回产生了事件的连接,然后在用户态中再处理这些连接对应的请求即可

5.6、select / poll

select 实现多路复用的方式是,将已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生
检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记为可读或可写,接着再把整个文件描述符集合拷贝回用户态里
然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理

所以对于 select 这种方式,需要进行 2 次「遍历」文件描述符集合,一次是在内核态里,一个次是在用户态里
而且还会发生 2 次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中

select 使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的
在 Linux 系统中,由内核中的 FD_SETSIZE 限制,默认最大值为 1024,只能监听 0 ~ 1023 的文件描述符
poll 不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制

但是 poll 和 select 并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合
因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n)
而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长

示例

假如能够预先传入一个 socket 列表,如果列表中的 socket 都没有数据,挂起进程,直到有一个 socket 收到数据,唤醒进程,这种方法很直接,也是 select 的设计思想

int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...);
listen(s, ...);

int fds[] = 存放需要监听的 socket;

while(1) {
    int n = select(..., fds, ...);
    for(int i = 0; i < fds.count; i++) {
        if(FD_ISSET(fds[i], ...)) {
            // fds[i] 的数据处理
        }
    }
}
  • 假如程序 A 同时监视 sock1、sock2、sock3 三个 socket,那么在调用 select 之后,操作系统把进程 A 分别加入这三个 socket 的等待队列中
    当任何一个 socket 收到数据后,中断程序将唤起接收到数据的 socket 进程
  • sock2 接收到了数据,中断程序唤起进程 A,所谓唤起进程,就是将进程从所有的等待队列中移除,加入到工作队列里面
  • 经由这些步骤,当进程 A 被唤醒后,它知道至少有一个 socket 接收了数据,程序只需遍历一遍 socket 列表,就可以得到就绪的 socket

image

5.7、epoll

epoll 详解

select 低效的原因之一是将 "维护监控队列" 和 "阻塞进程" 两个步骤合二为一
如图所示,每次调用 select 都需要这两步操作,然而大多数应用场景中,需要监视的 socket 相对固定,并不需要每次都修改
epoll 将这两个操作分开,先用 epoll_ctl 维护等待队列,再调用 epoll_wait 阻塞进程

  • 先用 epoll_create 创建一个 epoll 对象 epfd
  • 再通过 epoll_ctl 将需要监视的 socket 添加到 epfd 中
  • 最后调用 epoll_wait 等待数据

select 低效的另一个原因在于程序不知道哪些 socket 收到数据,只能一个个遍历
如果内核维护一个 "就绪列表",引用收到数据的 socket,就能避免遍历,当进程被唤醒后,只要获取 rdlist 的内容,就能够知道哪些 socket 收到数据
image

int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...);
listen(s, ...);

int epfd = epoll_create(...);
epoll_ctl(epfd, ...); // 将所有需要监听的 socket 添加到 epfd 中

while(1) {
    int n = epoll_wait(...);
    for(接收到数据的 socket) {
        // 处理
    }
}

image

更多

  • epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里
    红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn),而 select / poll 内核里没有类似 epoll 红黑树这种保存所有待检测的 socket 的数据结构
    所以 select / poll 每次操作时都传入整个 socket 集合给内核,而 epoll 因为在内核维护了红黑树,可以保存所有待检测的 socket
    所以只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配
  • epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中
    当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select / poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率

image

5.8、总结

select 的应用场景

  • 可移植性:每个支持网络和非阻塞套接字的平台都会支持 select
  • select 的超时时间理论上可以精确到纳秒级别,而 poll 和 epoll 的精度只有毫秒级

poll 的应用场景

  • 你需要在不止 Linux 一个平台上运行,而且不希望使用 epoll 的封装库,例如 libevent(epoll 是 Linux 平台上特有的)
  • 同一时刻你的应用程序监听的套接字少于 1000(这种情况下使用 epoll 不会得到任何益处)
  • 你的应用程序没有被设计成:在改变事件时,而其它线程正在等待事件

epoll 的应用场景

  • 你的程序通过多个线程来处理大量的网络连接,如果你的程序只是单线程的那么将会失去 epoll 的很多优点,并且很有可能不会比 poll 更好
  • 你需要监听的套接字数量非常大(至少 1000);如果监听的套接字数量很少则使用 epoll 不会有任何性能上的优势甚至可能还不如 poll
  • 你的网络连接相对来说都是长连接;就像上面提到的 epoll 处理短连接的性能还不如 poll 因为 epoll 需要额外的系统调用来添加描述符到集合中

6、Reactor 和 Proactor

6.1、演进

Reactor 模式也叫 Dispatcher 模式,我觉得这个名字更贴合该模式的含义,即 I / O 多路复用监听事件,收到事件后,根据事件类型分配(Dispatch)给某个进程 / 线程
Reactor 模式主要由 Reactor 和处理资源池这两个核心部分组成,它俩负责的事情如下

  • Reactor 负责监听和分发事件,事件类型包含连接事件、读写事件
  • 处理资源池负责处理事件,如 read -> 业务逻辑 -> send

Reactor 模式是灵活多变的,可以应对不同的业务场景,灵活在于

  • Reactor 的数量可以只有一个,也可以有多个
  • 处理资源池可以是单个进程 / 线程,也可以是多个进程 / 线程

将上面的两个因素排列组设一下,理论上就可以有 4 种方案选择
其中「多 Reactor 单进程 / 线程」实现方案相比「单 Reactor 单进程 / 线程」方案,不仅复杂而且也没有性能优势,因此实际中并没有应用
剩下的 3 个方案都是比较经典的,且都有应用在实际的项目中

  • 单 Reactor 单进程 / 线程
  • 单 Reactor 多进程 / 线程
  • 多 Reactor 单进程 / 线程(不用)
  • 多 Reactor 多进程 / 线程

方案具体使用进程还是线程,要看使用的编程语言以及平台有关

  • Java 语言一般使用线程,比如 Netty
  • C 语言使用进程和线程都可以,例如 Nginx 使用的是进程,Memcache 使用的是线程

6.2、单 Reactor 单进程 / 线程

一般来说,C 语言实现的是「单 Reactor 单进程」的方案,因为 C 语编写完的程序,运行后就是一个独立的进程,不需要在进程中再创建线程
而 Java 语言实现的是「单 Reactor 单线程」的方案,因为 Java 程序是跑在 Java 虚拟机这个进程上面的,虚拟机中有很多线程,我们写的 Java 程序只是其中的一个线程而已
image

可以看到进程里有 Reactor、Acceptor、Handler 这三个对象

  • Reactor 对象的作用是监听和分发事件
  • Acceptor 对象的作用是获取连接
  • Handler 对象的作用是处理业务

对象里的 select、accept、read、send 是系统调用函数,dispatch 和 「业务处理」是需要完成的操作,其中 dispatch 是分发事件操作

接下来介绍下「单 Reactor 单进程」这个方案

  • Reactor 对象通过 select(IO 多路复用接口) 监听事件,收到事件后通过 dispatch 进行分发,具体分发给 Acceptor 对象还是 Handler 对象,还要看收到的事件类型
  • 如果是连接建立的事件,则交由 Acceptor 对象进行处理,Acceptor 对象会通过 accept 方法获取连接,并创建一个 Handler 对象来处理后续的响应事件
  • 如果不是连接建立事件, 则交由当前连接对应的 Handler 对象来进行响应
  • Handler 对象通过 read -> 业务处理 -> send 的流程来完成完整的业务流程

单 Reactor 单进程的方案因为全部工作都在同一个进程内完成,所以实现起来比较简单,不需要考虑进程间通信,也不用担心多进程竞争,但是这种方案存在 2 个缺点

  • 因为只有一个进程,无法充分利用多核 CPU 的性能
  • Handler 对象在业务处理时,整个进程是无法处理其他连接的事件的,如果业务处理耗时比较长,那么就造成响应的延迟
    所以单 Reactor 单进程的方案不适用计算机密集型的场景,只适用于业务处理非常快速的场景

Redis 是由 C 语言实现的,在 Redis 6.0 版本之前采用的正是「单 Reactor 单进程」的方案
因为 Redis 业务处理主要是在内存中完成,操作的速度是很快的,性能瓶颈不在 CPU 上,所以 Redis 对于命令的处理是单进程的方案

6.3、单 Reactor 多进程 / 线程

如果要克服「单 Reactor 单线程 / 进程」方案的缺点,那么就需要引入多线程 / 多进程,这样就产生了单 Reactor 多线程 / 多进程的方案
image

详细说一下这个方案

  • Reactor 对象通过 select(IO 多路复用接口) 监听事件,收到事件后通过 dispatch 进行分发,具体分发给 Acceptor 对象还是 Handler 对象,还要看收到的事件类型
  • 如果是连接建立的事件,则交由 Acceptor 对象进行处理,Acceptor 对象会通过 accept 方法 获取连接,并创建一个 Handler 对象来处理后续的响应事件
  • 如果不是连接建立事件,则交由当前连接对应的 Handler 对象来进行响应

上面的三个步骤和单 Reactor 单线程方案是一样的,接下来的步骤就开始不一样了

  • Handler 对象不再负责业务处理,只负责数据的接收和发送,Handler 对象通过 read 读取到数据后,会将数据发给子线程里的 Processor 对象进行业务处理
  • 子线程里的 Processor 对象就进行业务处理,处理完后,将结果发给主线程中的 Handler 对象,接着由 Handler 通过 send 方法将响应结果发送给 client

单 Reator 多线程的方案优势在于能够充分利用多核 CPU 的能,那既然引入多线程,那么自然就带来了多线程竞争资源的问题
例如子线程完成业务处理后,要把结果传递给主线程的 Handler 进行发送,这里涉及共享数据的竞争
要避免多线程由于竞争共享资源而导致数据错乱的问题,就需要在操作共享资源前加上互斥锁
以保证任意时间里只有一个线程在操作共享资源,待该线程操作完释放互斥锁后,其他线程才有机会操作共享数据

聊完单 Reactor 多线程的方案,接着来看看单 Reactor 多进程的方案
事实上,单 Reactor 多进程相比单 Reactor 多线程实现起来很麻烦,主要因为要考虑子进程 <-> 父进程的双向通信,并且父进程还得知道子进程要将数据发送给哪个客户端
而多线程间可以共享数据,虽然要额外考虑并发问题,但是这远比进程间通信的复杂度低得多,因此实际应用中也看不到单 Reactor 多进程的模式
另外「单 Reactor」的模式还有个问题:因为一个 Reactor 对象承担所有事件的监听和响应,而且只在主线程中运行,在面对瞬间高并发的场景时,容易成为性能的瓶颈的地方

6.4、多 Reactor 多进程 / 线程

要解决「单 Reactor」的问题,就是将「单 Reactor」实现成「多 Reactor」,这样就产生了多 Reactor 多进程 / 线程的方案
image

方案详细说明如下

  • 主线程中的 MainReactor 对象通过 select 监控连接建立事件,收到事件后通过 Acceptor 对象中的 accept 获取连接,将新的连接分配给某个子线程
  • 子线程中的 SubReactor 对象将 MainReactor 对象分配的连接加入 select 继续进行监听,并创建一个 Handler 用于处理连接的响应事件
  • 如果有新的事件发生时,SubReactor 对象会调用当前连接对应的 Handler 对象来进行响应
    Handler 对象通过 read -> 业务处理 -> send 的流程来完成完整的业务流程

多 Reactor 多线程的方案虽然看起来复杂的,但是实际实现时比单 Reactor 多线程的方案要简单的多,原因如下

  • 主线程和子线程分工明确,主线程只负责接收新连接,子线程负责完成后续的业务处理
  • 主线程和子线程的交互很简单,主线程只需要把新连接传给子线程,子线程无须返回数据,直接就可以在子线程将处理结果发送给客户端

大名鼎鼎的两个开源软件 Netty 和 Memcache 都采用了「多 Reactor 多线程」的方案
采用了「多 Reactor 多进程」方案的开源软件是 Nginx,不过方案与标准的多 Reactor 多进程有些差异
具体差异表现在主进程中仅仅用来初始化 socket,并没有创建 mainReactor 来 accept 连接,而是由子进程的 Reactor 来 accept 连接
通过锁来控制一次只有一个子进程进行 accept(防止出现惊群现象),子进程 accept 新连接后就放到自己的 Reactor 进行处理,不会再分配给其他子进程

6.5、Proactor

前面提到的 Reactor 是非阻塞同步网络模式,而 Proactor 是异步网络模式(采用了异步 I / O 技术)
注意:阻塞等待的是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程

  • Reactor 是非阻塞同步网络模式,感知的是就绪可读写事件
    在每次感知到有事件发生(比如可读就绪事件)后,就需要应用进程主动调用 read 方法来完成数据的读取
    也就是要应用进程主动将 socket 接收缓存中的数据读到应用进程内存中,这个过程是同步的,读取完数据后应用进程才能处理数据
  • Proactor 是异步网络模式,感知的是已完成的读写事件
    在发起异步读写请求时,需要传入数据缓冲区的地址(用来存放结果数据)等信息,这样系统内核才可以自动帮我们把数据的读写工作完成
    这里的读写工作全程由操作系统来做,并不需要像 Reactor 那样还需要应用进程主动发起 read / write 来读写数据
    操作系统完成读写工作后,就会通知应用进程直接处理数据

因此 Reactor 可以理解为「来了事件操作系统通知应用进程,让应用进程来处理」
而 Proactor 可以理解为「来了事件操作系统来处理,处理完再通知应用进程」
这里的「事件」就是有新连接、有数据可读、有数据可写的这些 I / O 事件,这里的「处理」包含 "从驱动读取到内核" + "从内核读取到用户空间"

无论是 Reactor 还是 Proactor,都是一种基于「事件分发」的网络编程模式
区别在于 Reactor 模式是基于「待完成」的 I / O 事件,而 Proactor 模式则是基于「已完成」的 I / O 事件
image

介绍一下 Proactor 模式的工作流程

  • Proactor Initiator 负责创建 Proactor 和 Handler 对象,并将 Proactor 和 Handler 都通过 Asynchronous Operation Processor 注册到内核
  • Asynchronous Operation Processor 负责处理注册请求,并处理 I / O 操作
  • Asynchronous Operation Processor 完成 I / O 操作后通知 Proactor
  • Proactor 根据不同的事件类型回调不同的 Handler 进行业务处理
  • Handler 完成业务处理

可惜的是,在 Linux 下的异步 I /O 是不完善的
aio 系列函数是由 POSIX 定义的异步操作接口,不是真正的操作系统级别支持的,而是在用户空间模拟出来的异步,并且仅仅支持基于本地文件的 aio 异步操作
网络编程中的 socket 是不支持的,这也使得基于 Linux 的高性能网络程序都是使用 Reactor 方案

而 Windows 里实现了一套完整的支持 socket 的异步编程接口,这套接口就是 IOCP
是由操作系统级别实现的异步 I / O,真正意义上异步 I / O,因此在 Windows 里实现高性能网络程序可以使用效率更高的 Proactor 方案

posted @ 2023-09-01 12:23  lidongdongdong~  阅读(45)  评论(0编辑  收藏  举报