操作系统之网络系统

发布于:2024-12-18 ⋅ 阅读:(85) ⋅ 点赞:(0)

Linux系统如何收发网络系统

网络模型

为了使得多种设备能通过⽹络相互通信,和为了解决各种不同设备在⽹络互联中的兼容性问题,国际标标准化组织制定了开放式系统互联通信参考模型( pen System Interconnection Reference Model ),也就是OSI ⽹络模型,该模型主要有 7 层,分别是应⽤层、表示层、会话层、传输层、⽹络层、数据链路层以及物理层。
每⼀层负责的职能都不同,如下:
  • 应⽤层,负责给应⽤程序提供统⼀的接⼝;
  • 表示层,负责把数据转换成兼容另⼀个系统能识别的格式;
  • 会话层,负责建⽴、管理和终⽌表示层实体之间的通信会话;
  • 传输层,负责端到端的数据传输;
  • ⽹络层,负责数据的路由、转发、分⽚;
  • 数据链路层,负责数据的封帧和差错检测,以及 MAC 寻址;
  • 物理层,负责在物理⽹络中传输数据帧;
由于 OSI 模型实在太复杂,提出的也只是概念理论上的分层,并没有提供具体的实现⽅案。事实上,我们⽐较常⻅,也⽐较实⽤的是四层模型,即 TCP/IP ⽹络模型, Linux 系统正是按照这套⽹络模型来实现⽹络协议栈的。
TCP/IP ⽹络模型共有 4 层,分别是应⽤层、传输层、⽹络层和⽹络接⼝层,每⼀层负责的职能如下:
  • 应⽤层,负责向⽤户提供⼀组应⽤程序,⽐如 HTTPDNSFTP ;
  • 传输层,负责端到端的通信,⽐如 TCPUDP 等;
  • ⽹络层,负责⽹络包的封装、分⽚、路由、转发,⽐如 IPICMP 等;
  • ⽹络接⼝层,负责⽹络包在物理⽹络中的传输,⽐如⽹络包的封帧、 MAC 寻址、差错检测,以及通过⽹卡传输⽹络帧等;
TCP/IP ⽹络模型相⽐ OSI ⽹络模型简化了不少,也更加易记,它们之间的关系如下图:
不过,我们常说的七层和四层负载均衡,是⽤ OSI ⽹络模型来描述的,七层对应的是应⽤层,四层对应的是传输层。

Linux网络协议栈

我们可以把⾃⼰的身体⽐作应⽤层中的数据,打底⾐服⽐作传输层中的 TCP 头,外套⽐作⽹络层中 IP头,帽⼦和鞋⼦分别⽐作⽹络接⼝层的帧头和帧尾。
在冬天这个季节,当我们要从家⾥出去玩的时候,⾃然要先穿个打底⾐服,再套上保暖外套,最后穿上帽⼦和鞋⼦才出⻔,这个过程就好像我们把 TCP 协议通信的⽹络包发出去的时候,会把应⽤层的数据按照⽹络协议栈层层封装和处理。
你从下⾯这张图可以看到,应⽤层数据在每⼀层的封装格式。
其中:
  • 传输层,给应⽤数据前⾯增加了 TCP 头;
  • ⽹络层,给 TCP 数据包前⾯增加了 IP 头;
  • ⽹络接⼝层,给 IP 数据包前后分别增加了帧头和帧尾;
这些新增和头部和尾部,都有各⾃的作⽤,也都是按照特定的协议格式填充,这每⼀层都增加了各⾃的协议头,那⾃然⽹络包的⼤⼩就增⼤了,但物理链路并不能传输任意⼤⼩的数据包,所以在以太⽹中,规定了最⼤传输单元(MTU )是 1500 字节,也就是规定了单次传输的最⼤ IP 包⼤⼩。
当⽹络包超过 MTU 的⼤⼩,就会在⽹络层分⽚,以确保分⽚后的 IP 包不会超过 MTU ⼤⼩,如果 MTU 越⼩,需要的分包就越多,那么⽹络吞吐能⼒就越差,相反的,如果 MTU 越⼤,需要的分包就越⼩,那么⽹络吞吐能⼒就越好。
知道了 TCP/IP ⽹络模型,以及⽹络包的封装原理后,那么 Linux ⽹络协议栈的样⼦,你想必猜到了⼤概,它其实就类似于 TCP/IP 的四层结构:
从上图的的⽹络协议栈,你可以看到:
  • 应⽤程序需要通过系统调⽤,来跟 Socket 层进⾏数据交互;
  • Socket 层的下⾯就是传输层、⽹络层和⽹络接⼝层;
  • 最下⾯的⼀层,则是⽹卡驱动程序和硬件⽹卡设备;

Linux接收网络包的流程

⽹卡是计算机⾥的⼀个硬件,专⻔负责接收和发送⽹络包,当⽹卡接收到⼀个⽹络包后,会通过 DMA 技术,将⽹络包放⼊到 Ring Buffer ,这个是⼀个环形缓冲区,接着就会告诉操作系统这个网络包已经到达。
那接收到⽹络包后,应该怎么告诉操作系统这个⽹络包已经到达了呢?
最简单的⼀种⽅式就是触发中断,也就是每当⽹卡收到⼀个⽹络包,就触发⼀个中断告诉操作系统。
但是,这存在⼀个问题,在⾼性能⽹络场景下,⽹络包的数量会⾮常多,那么就会触发⾮常多的中断,要知道当 CPU 收到了中断,就会停下⼿⾥的事情,⽽去处理这些⽹络包,处理完毕后,才会回去继续其他事情,那么频繁地触发中断,则会导致 CPU ⼀直没玩没了的处理中断,⽽导致其他任务可能⽆法继续前进,从⽽影响系统的整体效率。
所以为了解决频繁中断带来的性能开销, Linux 内核在 2.6 版本中引⼊了 NAPI 机制 ,它是混合「中断和轮询」的⽅式来接收⽹络包,它的核⼼概念就是 不采用中断的⽅式读取数据 ,⽽是⾸先采⽤中断唤醒数据接收的服务程序,然后 poll 的⽅法来轮询数据。
⽐如,当有⽹络包到达时,⽹卡发起硬件中断,于是会执⾏⽹卡硬件中断处理函数, 中断处理函数处理完 需要「暂时屏蔽中断」,然后唤醒「软中断」来轮询处理数据,直到没有新数据时才恢复中断,这样⼀次 中断处理多个⽹络包 ,于是就可以降低⽹卡中断带来的性能开销。
那软中断是怎么处理⽹络包的呢?它会从 Ring Buffer 中拷⻉数据到内核 struct sk_buff 缓冲区中,从⽽可以作为⼀个⽹络包交给⽹络协议栈进⾏逐层处理。
⾸先,会先进⼊到⽹络接⼝层,在这⼀层会检查报⽂的合法性,如果不合法则丢弃,合法则会找出该⽹络包的上层协议的类型,⽐如是 IPv4 ,还是 IPv6 ,接着再去掉帧头和帧尾,然后交给⽹络层。
到了⽹络层,则取出 IP 包,判断⽹络包下⼀步的⾛向,⽐如是交给上层处理还是转发出去。当确认这个⽹络包要发送给本机后,就会从 IP 头⾥看看上⼀层协议的类型是 TCP 还是 UDP ,接着去掉 IP 头,然后交给传输层。
传输层取出 TCP 头或 UDP 头,根据四元组「源 IP 、源端⼝、⽬的 IP 、⽬的端⼝」 作为标识,找出对应的 Socket ,并把数据拷⻉到 Socket 的接收缓冲区。
 
最后,应⽤层程序调⽤ Socket 接⼝,从内核的 Socket 接收缓冲区读取新到来的数据到应⽤层。
⾄此,⼀个⽹络包的接收过程就已经结束了,你也可以从下图左边部分看到⽹络包接收的流程,右边部分刚好反过来,它是⽹络包发送的流程。

Linux发送网络包的流程

如上图的有半部分,发送⽹络包的流程正好和接收流程相反。
⾸先,应⽤程序会调⽤ Socket 发送数据包的接⼝,由于这个是系统调⽤,所以会从⽤户态陷⼊到内核态中的 Socket 层, Socket 层会将应⽤层数据拷⻉到 Socket 发送缓冲区中。
接下来,⽹络协议栈从 Socket 发送缓冲区中取出数据包,并按照 TCP/IP 协议栈从上到下逐层处理。
如果使⽤的是 TCP 传输协议发送数据,那么会在传输层增加 TCP 包头,然后交给⽹络层,⽹络层会给数据包增加 IP 包,然后通过查询路由表确认下⼀跳的 IP ,并按照 MTU ⼤⼩进⾏分⽚。
分⽚后的⽹络包,就会被送到⽹络接⼝层,在这⾥会通过 ARP 协议获得下⼀跳的 MAC 地址,然后增加帧头和帧尾,放到发包队列中。
这⼀些准备好后,会触发软中断告诉⽹卡驱动程序,这⾥有新的⽹络包需要发送,最后驱动程序通过DMA,从发包队列中读取⽹络包,将其放⼊到硬件⽹卡的队列中,随后物理⽹卡再将它发送出去。

总结

电脑与电脑之间通常都是通话⽹卡、交换机、路由器等⽹络设备连接到⼀起,那由于⽹络设备的异构性,国际标准化组织定义了⼀个七层的 OSI ⽹络模型,但是这个模型由于⽐较复杂,实际应⽤中并没有采⽤,⽽是采⽤了更为简化的 TCP/IP 模型, Linux ⽹络协议栈就是按照了该模型来实现的。
TCP/IP 模型主要分为应⽤层、传输层、⽹络层、⽹络接⼝层四层,每⼀层负责的职责都不同,这也是Linux ⽹络协议栈主要构成部分。
当应⽤程序通过 Socket 接⼝发送数据包,数据包会被⽹络协议栈从上到下进⾏逐层处理后,才会被送到⽹卡队列中,随后由⽹卡将⽹络包发送出去。
⽽在接收⽹络包时,同样也要先经过⽹络协议栈从下到上的逐层处理,最后才会被送到应⽤程序。

零拷贝

磁盘可以说是计算机系统最慢的硬件之⼀,读写速度相差内存 10 倍以上,所以针对优化磁盘的技术⾮常的多,⽐如零拷⻉、直接 I/O 、异步 I/O 等等,这些优化的⽬的就是为了提⾼系统的吞吐量,另外操作系统内核中的磁盘⾼速缓存区,可以有效的减少磁盘的访问次数。
​​​​​​​
这次,我们就以「⽂件传输」作为切⼊点,来分析 I/O ⼯作⽅式,以及如何优化传输⽂件的性能。

为什么要有DMA技术?

在没有 DMA 技术前, I/O 的过程是这样的:
  • CPU 发出对应的指令给磁盘控制器,然后返回;
  • 磁盘控制器收到指令后,于是就开始准备数据,会把数据放⼊到磁盘控制器的内部缓冲区中,然后产⽣⼀个中断
  • CPU 收到中断信号后,停下⼿头的⼯作,接着把磁盘控制器的缓冲区的数据⼀次⼀个字节地读进⾃⼰的寄存器,然后再把寄存器⾥的数据写⼊到内存,⽽在数据传输的期间 CPU 是⽆法执⾏其他任务的。
为了⽅便你理解,我画了⼀副图:
可以看到,整个数据的传输过程,都要需要 CPU 亲⾃参与搬运数据的过程,⽽且这个过程, CPU 是不能做其他事情的。
简单地搬运⼏个字符数据那没问题,但是如果我们⽤千兆⽹卡或者硬盘传输⼤量数据的时候,都⽤ CPU 来搬运的话,肯定忙不过来。
计算机科学家们发现了事情的严重性后,于是就发明了 DMA 技术,也就是 直接内存访问( Direct
Memory Access 技术。
什么是 DMA 技术?简单理解就是, 在进行  I/O 设备和内存的数据传输的时候,数据搬运的工作全部交给 DMA 控制器,而 CPU 不再参与任何与数据搬运相关的事情,这样 CPU 就可以去处理别的事务
那使⽤ DMA 控制器进⾏数据传输的过程究竟是什么样的呢?下⾯我们来具体看看。
具体过程:
  • ⽤户进程调⽤ read ⽅法,向操作系统发出 I/O 请求,请求读取数据到⾃⼰的内存缓冲区中,进程进⼊阻塞状态;
  • 操作系统收到请求后,进⼀步将 I/O 请求发送 DMA,然后让 CPU 执⾏其他任务;
  • DMA 进⼀步将 I/O 请求发送给磁盘;
  • 磁盘收到 DMA I/O 请求,把数据从磁盘读取到磁盘控制器的缓冲区中,当磁盘控制器的缓冲区被读满后,向 DMA 发起中断信号,告知⾃⼰缓冲区已满;
  • DMA 收到磁盘的信号,将磁盘控制器缓冲区中的数据拷贝到内核缓冲区中,此时不占用 CPUCPU可以执行其他任务
  • DMA 读取了⾜够多的数据,就会发送中断信号给 CPU
  • CPU 收到 DMA 的信号,知道数据已经准备好,于是将数据从内核拷⻉到⽤户空间,系统调⽤返回;
可以看到, 整个数据传输的过程, CPU 不再参与数据搬运的⼯作,⽽是全程由 DMA 完成,但是 CPU 在这个过程中也是必不可少的,因为传输什么数据,从哪⾥传输到哪⾥,都需要 CPU 来告诉 DMA 控制器。
早期 DMA 只存在在主板上,如今由于 I/O 设备越来越多,数据传输的需求也不尽相同,所以每个 I/O 设备⾥⾯都有⾃⼰的 DMA 控制器。

传统的文件传输有多糟糕?

如果服务端要提供⽂件传输的功能,我们能想到的最简单的⽅式是:将磁盘上的⽂件读取出来,然后通过⽹络协议发送给客户端。
传统 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 资源,⼤⼤降低了系统性能。
这种简单⼜传统的⽂件传输⽅式,存在冗余的上⽂切换和数据拷⻉,在⾼并发系统⾥是⾮常糟糕的,多了很多不必要的开销,会严重影响系统性能。
所以, 要想提⾼⽂件传输的性能,就需要减少「⽤户态与内核态的上下⽂切换」和「内存拷⻉」的次数

如何优化文件传输的性能?

先来看看,如何减少「⽤户态与内核态的上下⽂切换」的次数呢?
读取磁盘数据的时候,之所以要发⽣上下⽂切换,这是因为⽤户空间没有权限操作磁盘或⽹卡,内核的权限最⾼,这些操作设备的过程都需要交由操作系统内核来完成,所以⼀般要通过内核去完成某些任务的时候,就需要使⽤操作系统提供的系统调⽤函数。
⼀次系统调⽤必然会发⽣ 2 次上下⽂切换:⾸先从⽤户态切换到内核态,当内核执⾏完任务后,再切换回⽤户态交由进程代码执⾏。
所以, 要想减少上下文切换到次数,就要减少系统调用的次数
再来看看,如何减少「数据拷⻉」的次数?
在前⾯我们知道了,传统的⽂件传输⽅式会历经 4 次数据拷⻉,⽽且这⾥⾯,「从内核的读缓冲区拷⻉到⽤户的缓冲区⾥,再从⽤户的缓冲区⾥拷⻉到 socket 的缓冲区⾥」,这个过程是没有必要的。
因为⽂件传输的应⽤场景中,在⽤户空间我们并不会对数据「再加⼯」,所以数据实际上可以不⽤搬运到 ⽤户空间,因此 用户的缓冲区是没有必要存在的

如何实现零拷贝

零拷⻉技术实现的⽅式通常有 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 次。

sendfile

Linux 内核版本 2.1 中,提供了⼀个专⻔发送⽂件的系统调⽤函数 sendfile() ,函数形式如下:
#include <sys/socket.h>

ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
它的前两个参数分别是⽬的端和源端的⽂件描述符,后⾯两个参数是源端的偏移量和复制数据的⻓度,返回值是实际复制数据的⻓度。
⾸先,它可以替代前⾯的 read() write() 这两个系统调⽤,这样就可以减少⼀次系统调⽤,也就减少了 2 次上下⽂切换的开销。
其次,该系统调⽤,可以直接把内核缓冲区⾥的数据拷⻉到 socket 缓冲区⾥,不再拷⻉到⽤户态,这样就只有 2 次上下⽂切换,和 3 次数据拷⻉。如下图:
但是这还不是真正的零拷⻉技术,如果⽹卡⽀持 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 次数据拷⻉,如下图:
这就是所谓的 零拷⻉( Zero-copy )技术,因为我们没有在内存层⾯去拷⻉数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进⾏传输的。
零拷⻉技术的⽂件传输⽅式相⽐传统⽂件传输的⽅式,减少了 2 次上下⽂切换和数据拷⻉次数, 只需要 2 次上下文切换和数据拷⻉次数,就可以完成⽂件的传输,而且 2 次的数据拷⻉过程,都不需要通过 CPU 2 次都是由 DMA 来搬运。
所以,总体来看, 零拷贝技术可以把⽂件传输的性能提高至少⼀倍以上

使使零拷贝技术的项目

事实上, Kafka 这个开源项⽬,就利⽤了「零拷⻉」技术,从⽽⼤幅提升了 I/O 的吞吐率,这也是 Kafka在处理海量数据为什么这么快的原因之⼀。
如果你追溯 Kafka ⽂件传输的代码,你会发现,最终它调⽤了 Java NIO 库⾥的 transferTo ⽅法:
@Overridepublic
long transferFrom(FileChannel fileChannel, long position, long count) throws
IOException {
 return fileChannel.transferTo(position, count, socketChannel);
}
如果 Linux 系统⽀持 sendfile() 系统调⽤,那么 transferTo() 实际上最后就会使⽤到 sendfile() 系统调⽤函数。
曾经有⼤佬专⻔写过程序测试过,在同样的硬件条件下,传统⽂件传输和零拷拷⻉⽂件传输的性能差异, 你可以看到下⾯这张测试数据图,使⽤了零拷⻉能够缩短 65% 的时间,⼤幅度提升了机器传输数据的吞吐量。
另外, Nginx 也⽀持零拷⻉技术,⼀般默认是开启零拷⻉技术,这样有利于提⾼⽂件传输的效率,是否开启零拷⻉技术的配置如下:
http {
...
sendfile on
...
}
sendfile 配置的具体意思 :
  • 设置为 on 表示,使⽤零拷⻉技术来传输⽂件:sendfile ,这样只需要 2 次上下⽂切换,和 2 次数据拷⻉。
  • 设置为 off 表示,使⽤传统的⽂件传输技术:read + write,这时就需要 4 次上下⽂切换,和 4 次数据拷⻉。
当然,要使⽤ sendfile Linux 内核版本必须要 2.1 以上的版本。

PageCache有什么作用?

回顾前⾯说道⽂件传输过程,其中第⼀步都是先需要先把磁盘⽂件数据拷⻉「内核缓冲区」⾥,这个「内核缓冲区」实际上是 磁盘高速缓存( PageCache
由于零拷⻉使⽤了 PageCache 技术,可以使得零拷⻉进⼀步提升了性能,我们接下来看看 PageCache是如何做到这⼀点的。
读写磁盘相⽐读写内存的速度慢太多了,所以我们应该想办法把「读写磁盘」替换成「读写内存」。于是,我们会通过 DMA 把磁盘⾥的数据搬运到内存⾥,这样就可以⽤读内存替换读磁盘。
但是,内存空间远⽐磁盘要⼩,内存注定只能拷⻉磁盘⾥的⼀⼩部分数据。
那问题来了,选择哪些磁盘数据拷⻉到内存呢?
我们都知道程序运⾏的时候,具有「局部性」,所以通常,刚被访问的数据在短时间内再次被访问的概率很⾼,于是我们可以⽤ PageCache 来缓存最近被访问的数据 ,当空间不⾜时淘汰最久未被访问的缓存。
所以,读磁盘数据的时候,优先在 PageCache 找,如果数据存在则可以直接返回;如果没有,则从磁盘中读取,然后缓存 PageCache 中。
还有⼀点,读取磁盘数据的时候,需要找到数据所在的位置,但是对于机械磁盘来说,就是通过磁头旋转到数据所在的扇区,再开始「顺序」读取数据,但是旋转磁头这个物理动作是⾮常耗时的,为了降低它的影响, PageCache 使⽤了「预读功能」
⽐如,假设 read ⽅法每次只会读 32 KB 的字节,虽然 read 刚开始只会读 0 32 KB 的字节,但内核会把其后⾯的 32 64 KB 也读取到 PageCache ,这样后⾯读取 32 64 KB 的成本就很低,如果在 32 64KB 淘汰出 PageCache 前,进程读取到它了,收益就⾮常⼤。
所以, PageCache 的优点主要是两个:
  • 缓存最近被访问的数据;
  • 预读功能;
这两个做法,将⼤⼤提⾼读写磁盘的性能。
但是,在传输大件( GB 级别的⽂件)的时候, PageCache 会不起作⽤,那就⽩⽩浪费 DMA 多做的⼀ 次数据拷贝,造成性能的降低,即使使⽤了 PageCache 的零拷贝也会损失性能
这是因为如果你有很多 GB 级别⽂件需要传输,每当⽤户访问这些⼤⽂件的时候,内核就会把它们载⼊ PageCache 中,于是 PageCache 空间很快被这些⼤⽂件占满。
另外,由于⽂件太⼤,可能某些部分的⽂件数据被再次访问的概率⽐较低,这样就会带来 2 个问题:
  • PageCache 由于⻓时间被⼤⽂件占据,其他「热点」的⼩⽂件可能就⽆法充分使⽤到PageCache,于是这样磁盘读写的性能就会下降了;
  • PageCache 中的⼤⽂件数据,由于没有享受到缓存带来的好处,但却耗费 DMA 多拷⻉到 PageCache ⼀次;
所以,针对⼤⽂件的传输,不应该使⽤ PageCache ,也就是说不应该使⽤零拷⻉技术,因为可能由于PageCache 被⼤⽂件占据,⽽导致「热点」⼩⽂件⽆法利⽤到 PageCache ,这样在⾼并发的环境下,会带来严重的性能问题。

大文件传输用什么方式实现?

那针对⼤⽂件的传输,我们应该使⽤什么⽅式呢?
我们先来看看最初的例⼦,当调⽤ read ⽅法读取⽂件时,进程实际上会阻塞在 read ⽅法调⽤,因为要等待磁盘数据的返回,如下图:
具体过程:
  • 当调⽤ read ⽅法时,会阻塞着,此时内核会向磁盘发起 I/O 请求,磁盘收到请求后,便会寻址,当磁盘数据准备好后,就会向内核发起 I/O 中断,告知内核磁盘数据已经准备好;
  • 内核收到 I/O 中断后,就将数据从磁盘控制器缓冲区拷⻉到 PageCache ⾥;
  • 最后,内核再把 PageCache 中的数据拷⻉到⽤户缓冲区,于是 read 调⽤就正常返回了。
对于阻塞的问题,可以⽤异步 I/O 来解决,它⼯作⽅式如下图:
它把读操作分为两部分:
  • 前半部分,内核向磁盘发起读请求,但是可以不等待数据就位就可以返回,于是进程此时可以处理其他任务;
  • 后半部分,当内核将磁盘中的数据拷⻉到进程缓冲区后,进程将接收到内核的通知,再去处理数据;
⽽且,我们可以发现,异步 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 中,我们可以⽤如下配置,来根据⽂件的⼤⼩来使⽤不同的⽅式:
location /video/ {
   sendfile on;
   aio on;
   directio 1024m;
}
当⽂件⼤⼩⼤于 directio 值后,使⽤「异步 I/O + 直接 I/O 」,否则使⽤「零拷⻉技术」。

总结

早期 I/O 操作,内存与磁盘的数据传输的⼯作都是由 CPU 完成的,⽽此时 CPU 不能执⾏其他任务,会特别浪费 CPU 资源。
于是,为了解决这⼀问题, DMA 技术就出现了,每个 I/O 设备都有⾃⼰的 DMA 控制器,通过这个 DMA控制器,CPU 只需要告诉 DMA 控制器,我们要传输什么数据,从哪⾥来,到哪⾥去,就可以放⼼离开了。后续的实际数据传输⼯作,都会由 DMA 控制器来完成,CPU 不需要参与数据传输的⼯作。
传统 IO 的⼯作⽅式,从硬盘读取数据,然后再通过⽹卡向外发送,我们需要进⾏ 4 上下⽂切换,和 4 次数据拷⻉,其中 2 次数据拷⻉发⽣在内存⾥的缓冲区和对应的硬件设备之间,这个是由 DMA 完成,另外2 次则发⽣在内核态和⽤户态之间,这个数据搬移⼯作是由 CPU 完成的。
为了提⾼⽂件传输的性能,于是就出现了零拷⻉技术,它通过⼀次系统调⽤( sendfile ⽅法)合并了磁盘读取与⽹络发送两个操作,降低了上下⽂切换次数。另外,拷⻉数据都是发⽣在内核中的,天然就降低了数据拷⻉的次数。
Kafka Nginx 都有实现零拷⻉技术,这将⼤⼤提⾼⽂件传输的性能。
零拷⻉技术是基于 PageCache 的, PageCache 会缓存最近访问的数据,提升了访问缓存数据的性能,同时,为了解决机械硬盘寻址慢的问题,它还协助 I/O 调度算法实现了 IO 合并与预读,这也是顺序读⽐随机读性能好的原因。这些优势,进⼀步提升了零拷⻉的性能。
需要注意的是,零拷⻉技术是不允许进程对⽂件内容作进⼀步的加⼯的,⽐如压缩数据再发送。
另外,当传输⼤⽂件时,不能使⽤零拷⻉,因为可能由于 PageCache 被⼤⽂件占据,⽽导致「热点」⼩⽂件⽆法利⽤到 PageCache ,并且⼤⽂件的缓存命中率不⾼,这时就需要使⽤「异步 IO + 直接 IO 」的⽅式。
Nginx ⾥,可以通过配置,设定⼀个⽂件⼤⼩阈值,针对⼤⽂件使⽤异步 IO 和直接 IO ,⽽对⼩⽂件使⽤零拷⻉

I/O多路复用: select/poll/epoll

这次,我们以最简单 socket ⽹络模型,⼀步⼀步的过度到 I/O 多路复⽤。
但我不会具体细节说到每个系统调⽤的参数,这⽅⾯书上肯定⽐我说的详细。

最基本的Socket模型

要想客户端和服务器能在⽹络中通信,那必须得使⽤ Socket 编程,它是进程间通信⾥⽐较特别的⽅式,特别之处在于它是可以跨主机间通信。
Socket 的中⽂名叫作插⼝,咋⼀看还挺迷惑的。事实上,双⽅要进⾏⽹络通信前,各⾃得创建⼀个Socket,这相当于客户端和服务器都开了⼀个 ⼝⼦ ,双⽅读取和发送数据的时候,都通过这个 ⼝⼦ 。 这样⼀看,是不是觉得很像弄了⼀根⽹线,⼀头插在客户端,⼀头插在服务端,然后进⾏通信。
创建 Socket 的时候,可以指定⽹络层使⽤的是 IPv4 还是 IPv6 ,传输层使⽤的是 TCP 还是 UDP
UDP Socket 编程相对简单些,这⾥我们只介绍基于 TCP Socket 编程。
服务器的程序要先跑起来,然后等待客户端的连接和数据,我们先来看看服务端的 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
  • ⼀个叫作已连接 Socket
连接建⽴后,客户端和服务端就开始相互传输数据了,双⽅都可以通过 read() write() 函数来读写数据。
⾄此, TCP 协议的 Socket 程序的调⽤过程就结束了,整个过程如下图:

看到这,不知道你有没有觉得读写 Socket 的⽅式,好像读写⽂件⼀样。
是的,基于 Linux ⼀切皆⽂件的理念,在内核中 Socket 也是以「⽂件」的形式存在的,也是有对应的⽂件
描述符。
PS : 下⾯会说到内核⾥的数据结构,不感兴趣的可以跳过这⼀部分,不会对后续的内容有影响。
⽂件描述符的作⽤是什么?每⼀个进程都有⼀个数据结构 task_struct ,该结构体⾥有⼀个指向「⽂件描述符数组」的成员指针。该数组⾥列出这个进程打开的所有⽂件的⽂件描述符。数组的下标是⽂件描述符,是⼀个整数,⽽数组的内容是⼀个指针,指向内核中所有打开的⽂件的列表,也就是说内核可以通过⽂件描述符找到对应打开的⽂件
然后每个⽂件都有⼀个 inode Socket ⽂件的 inode 指向了内核中的 Socket 结构,在这个结构体⾥有两个队列,分别是 发送队列 接收队列 ,这个两个队列⾥⾯保存的是⼀个个 struct sk_buff ,⽤链表的组织形式串起来。
sk_buff 可以表示各个层的数据包,在应⽤层数据包叫 data ,在 TCP 层我们称为 segment ,在 IP 层我们叫 packet ,在数据链路层称为 frame
你可能会好奇,为什么全部数据包只⽤⼀个结构体来描述呢?协议栈采⽤的是分层结构,上层向下层传递数据时需要增加包头,下层向上层数据时⼜需要去掉包头,如果每⼀层都⽤⼀个结构体,那在层之间传递数据的时候,就要发⽣多次拷⻉,这将⼤⼤降低 CPU 效率。
于是,为了在层级之间传递数据时,不发⽣拷⻉,只⽤ sk_buff ⼀个结构体来描述所有的⽹络包,那它是如何做到的呢?是通过调整 sk_buff data 的指针,⽐如:
  • 当接收报⽂时,从⽹卡驱动开始,通过协议栈层层往上传送数据报,通过增加 skb->data 的值,来逐步剥离协议⾸部。
  • 当要发送报⽂时,创建 sk_buff 结构体,数据缓存区的头部预留⾜够的空间,⽤来填充各层⾸部,在经过各下层协议时,通过减少 skb->data 的值来增加协议⾸部。
你可以从下⾯这张图看到,当发送报⽂时, data 指针的移动过程。

如何服务更多的用户

前⾯提到的 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 的⽬标越来越远。

多进程模型

基于最原始的阻塞⽹络 I/O , 如果服务器要⽀持多个客户端,其中⽐较传统的⽅式,就是使⽤ 多进程模 ,也就是为每个客户端分配⼀个进程来处理请求。
服务器的主进程负责监听客户的连接,⼀旦与客户端连接完成, accept() 函数就会返回⼀个「已连接Socket」,这时就通过 fork() 函数创建⼀个⼦进程,实际上就把⽗进程所有相关的东⻄都 复制 ⼀份,包括⽂件描述符、内存地址空间、程序计数器、执⾏的代码等。
这两个进程刚复制完的时候,⼏乎⼀摸⼀样。不过,会根据 返回值 来区分是⽗进程还是⼦进程,如果返回值是 0 ,则是⼦进程;如果返回值是其他的整数,就是⽗进程。
正因为⼦进程会 复制父进程的文件描述符 ,于是就可以直接使⽤「已连接 Socket 」和客户端通信了, 可以发现,⼦进程不需要关⼼「监听 Socket 」,只需要关⼼「已连接 Socket 」;⽗进程则相反,将客户服务交给⼦进程来处理,因此⽗进程不需要关⼼「已连接 Socket 」,只需要关⼼「监听 Socket 」。
下⾯这张图描述了从连接请求到连接建⽴,⽗进程创建⽣⼦进程为客户服务。
另外,当「⼦进程」退出时,实际上内核⾥还会保留该进程的⼀些信息,也是会占⽤内存的,如果不做好“回收 ⼯作,就会变成 僵⼫进程 ,随着僵⼫进程越多,会慢慢耗尽我们的系统资源。
因此,⽗进程要 善后 好⾃⼰的孩⼦,怎么善后呢?那么有两种⽅式可以在⼦进程退出后回收资源,分别是调⽤ wait() waitpid() 函数。
这种⽤多个进程来应付多个客户端的⽅式,在应对 100 个客户端还是可⾏的,但是当客户端数量⾼达⼀万时,肯定扛不住的,因为每产⽣⼀个进程,必会占据⼀定的系统资源,⽽且进程间上下⽂切换的“ 包袱 是很重的,性能会⼤打折扣。
进程的上下⽂切换不仅包含了虚拟内存、栈、全局变量等⽤户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。

多线程模型

既然进程间上下⽂切换的 包袱 很重,那我们就搞个⽐较轻量级的模型来应对多⽤户的请求 —— 多线程模
线程是运⾏在进程中的⼀个 逻辑流 ,单进程中可以运⾏多个线程,同进程⾥的线程可以共享进程的部分资源的,⽐如⽂件描述符列表、进程空间、代码、全局数据、堆、共享库等,这些共享些资源在上下⽂切换时是不需要切换,⽽只需要切换线程的私有数据、寄存器等不共享的数据,因此同⼀个进程下的线程上下⽂切换的开销要⽐进程⼩得多。
当服务器与客户端 TCP 完成连接后,通过 pthread_create() 函数创建线程,然后将「已连接 Socket 」的⽂件描述符传递给线程函数,接着在线程⾥和客户端进⾏通信,从⽽达到并发处理的⽬的。
如果每来⼀个连接就创建⼀个线程,线程运⾏完后,还得操作系统还得销毁线程,虽说线程切换的上写⽂开销不⼤,但是如果频繁创建和销毁线程,系统开销也是不⼩的。
那么,我们可以使⽤ 线程池 的⽅式来避免线程的频繁创建和销毁,所谓的线程池,就是提前创建若⼲个线程,这样当由新连接建⽴时,将这个已连接的 Socket 放⼊到⼀个队列⾥,然后线程池⾥的线程负责从队列中取出已连接 Socket 进程处理。
需要注意的是,这个队列是全局的,每个线程都会操作,为了避免多线程竞争,线程在操作这个队列前要加锁。
上⾯基于进程或者线程模型的,其实还是有问题的。新到来⼀个 TCP 连接,就需要分配⼀个进程或者线程,那么如果要达到 C10K ,意味着要⼀台机器维护 1 万个连接,相当于要维护 1 万个进程 / 线程,操作系统就算死扛也是扛不住的。

I/O多路复用

既然为每个请求分配⼀个进程 / 线程的⽅式不合适,那有没有可能只使⽤⼀个进程来维护多个 Socket 呢?
答案是有的,那就是 I/O 多路复⽤ 技术。
⼀个进程虽然任⼀时刻只能处理⼀个请求,但是处理每个请求的事件时,耗时控制在 1 毫秒以内,这样 1 秒内就可以处理上千个请求,把时间拉⻓来看,多个请求复⽤了⼀个进程,这就是多路复⽤,这种思想很类似⼀个 CPU 并发多个进程,所以也叫做时分多路复⽤。
我们熟悉的 select/poll/epoll 内核提供给⽤户态的多路复⽤系统调⽤, 进程可以通过⼀个系统调⽤函数从内 核中获取多个事件
select/poll/epoll 是如何获取⽹络事件的呢?在获取事件时,先把所有连接(⽂件描述符)传给内核,再由内核返回产⽣了事件的连接,然后在⽤户态中再处理这些连接对应的请求即可。
select/poll/epoll 这是三个多路复⽤接⼝,都能实现 C10K 吗?接下来,我们分别说说它们。

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) ,⽽且也需要在用户态与内核 态之间拷贝文件描述符集合 ,这种⽅式随着并发数上来,性能的损耗会呈指数级增⻓。

epoll

epoll 通过两个⽅⾯,很好解决了 select/poll 的问题。
第⼀点 epoll 在内核⾥使⽤ 红黑树来跟踪进程所有待检测的文件描述字 ,把需要监控的 socket 通过 epoll_ctl() 函数加⼊内核中的红⿊树⾥,红⿊树是个高效的数据结构,增删查⼀般时间复杂度是
O(logn) ,通过对这棵⿊红树进⾏操作,这样就不需要像 select/poll 每次操作时都传⼊整个 socket 集合,只需要传⼊⼀个待检测的 socket ,减少了内核和⽤户空间⼤量的数据拷贝和内存分配。
第⼆点 epoll 使⽤事件驱动的机制,内核⾥ 维护了⼀个链表来记录就绪事件 ,当某个 socket 有事件发⽣时,通过回调函数内核会将其加⼊到这个就绪事件列表中,当⽤户调⽤ epoll_wait() 函数时,只会返回有事件发⽣的⽂件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,⼤⼤提⾼了检测的效率。
从下图你可以看到 epoll 相关的接⼝作⽤:
epoll 的⽅式即使监听的 Socket 数量越多的时候,效率不会⼤幅度降低,能够同时监听的 Socket 的数⽬也⾮常的多了,上限就为系统定义的进程打开的最⼤⽂件描述符个数。因⽽, epoll 被称为解决 C10K 题的利器
插个题外话,网上⽂章不少说, epoll_wait 返回时,对于就绪的事件, epoll 使⽤的是共享内存的⽅式, 即⽤户态和内核态都指向了就绪链表,所以就避免了内存拷⻉消耗。
这是错的!看过 epoll 内核源码的都知道, 压根就没有使用共享内存这个玩意 。你可以从下⾯这份代码看到, epoll_wait 实现的内核代码中调⽤了 __put_user 函数,这个函数就是将数据从内核拷⻉到⽤户空间。
好了,这个题外话就说到这了,我们继续!
epoll ⽀持两种事件触发模式,分别是 边缘触发( edge-triggered ET 水平触发( level-triggered LT
这两个术语还挺抽象的,其实它们的区别还是很好理解的。
  • 使⽤边缘触发模式时,当被监控的 Socket 描述符上有可读事件发⽣时,服务器端只会从 epoll_wait中苏醒⼀次,即使进程没有调⽤ read 函数从内核读取数据,也依然只苏醒⼀次,因此我们程序要保证⼀次性将内核缓冲区的数据读取完
  • 使⽤⽔平触发模式时,当被监控的 Socket 上有可读事件发⽣时,服务器端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完才结束,⽬的是告诉我们有数据需要读取;
举个例⼦,你的快递被放到了⼀个快递箱⾥,如果快递箱只会通过短信通知你⼀次,即使你⼀直没有去取,它也不会再发送第⼆条短信提醒你,这个⽅式就是边缘触发;如果快递箱发现你的快递没有被取出, 它就会不停地发短信通知你,直到你取出了快递,它才消停,这个就是⽔平触发的⽅式。
这就是两者的区别,⽔平触发的意思是只要满⾜事件的条件,⽐如内核中有数据需要读,就⼀直不断地把这个事件传递给⽤户;⽽边缘触发的意思是只有第⼀次满⾜条件的时候才触发,之后就不会再传递同样的事件了。
如果使⽤⽔平触发模式,当内核通知⽂件描述符可读写时,接下来还可以继续去检测它的状态,看它是否依然可读或可写。所以在收到通知后,没必要⼀次执⾏尽可能多的读写操作。
如果使⽤边缘触发模式, I/O 事件发⽣时只会通知⼀次,⽽且我们不知道到底能读写多少数据,所以在收到通知后应尽可能地读写数据,以免错失读写的机会。因此,我们会 循环 从⽂件描述符读写数据,那么如果⽂件描述符是阻塞的,没有数据可读写时,进程会阻塞在读写函数那⾥,程序就没办法继续往下执⾏。所以, 边缘触发模式⼀般和非阻塞 I/O 搭配使⽤ ,程序会⼀直执⾏ I/O 操作,直到系统调⽤(如 read write )返回错误,错误类型为 EAGAIN EWOULDBLOCK
⼀般来说,边缘触发的效率⽐⽔平触发的效率要⾼,因为边缘触发可以减少 epoll_wait 的系统调⽤次数,系统调⽤也是有⼀定的开销的的,毕竟也存在上下⽂的切换。
select/poll 只有⽔平触发模式, epoll 默认的触发模式是⽔平触发,但是可以根据应⽤场景设置为边缘触发模式。
另外,使⽤ I/O 多路复⽤时,最好搭配⾮阻塞 I/O ⼀起使⽤, Linux ⼿册关于 select 的内容中有如下说明:
Under Linux, select() may report a socket file descriptor as "ready for reading", while nevertheless a
subsequent read blocks. This could for example happen when data has arrived but upon examination has
wrong checksum and is discarded. There may be other circumstances in which a file descriptor is
spuriously reported as ready. Thus it may be safer to use O_NONBLOCK on sockets that should not block.
我⾕歌翻译的结果:
Linux 下, select() 可能会将⼀个 socket ⽂件描述符报告为 " 准备读取 " ,⽽后续的读取块却没有。例如,当
数据已经到达,但经检查后发现有错误的校验和⽽被丢弃时,就会发⽣这种情况。也有可能在其他情况下,
⽂件描述符被错误地报告为就绪。因此,在不应该阻塞的 socket 上使⽤ O_NONBLOCK 可能更安全。
简单点理解,就是 多路复用  API 返回的事件并不⼀定可读写的 ,如果使⽤阻塞 I/O , 那么在调⽤
read/write 时则会发⽣程序阻塞,因此最好搭配⾮阻塞 I/O ,以便应对极少数的特殊情况。

总结

最基础的 TCP Socket 编程,它是阻塞 I/O 模型,基本上只能⼀对⼀通信,那为了服务更多的客户端, 我们需要改进⽹络 I/O 模型。
⽐较传统的⽅式是使⽤多进程 / 线程模型,每来⼀个客户端连接,就分配⼀个进程 / 线程,然后后续的读写都在对应的进程/ 线程,这种⽅式处理 100 个客户端没问题,但是当客户端增⼤到 10000 个时, 10000 个进程/ 线程的调度、上下⽂切换以及它们占⽤的内存,都会成为瓶颈。
为了解决上⾯这个问题,就出现了 I/O 的多路复⽤,可以只在⼀个进程⾥处理多个⽂件的 I/O Linux 下有三种提供 I/O 多路复⽤的 API ,分别是: select poll epoll
select poll 并没有本质区别,它们内部都是使⽤「线性结构」来存储进程关注的 Socket 集合。
在使⽤的时候,⾸先需要把关注的 Socket 集合通过 select/poll 系统调⽤从⽤户态拷⻉到内核态,然后由内核检测事件,当有⽹络事件产⽣时,内核需要遍历进程关注 Socket 集合,找到对应的 Socket ,并设置其状态为可读/ 可写,然后把整个 Socket 集合从内核态拷⻉到⽤户态,⽤户态还要继续遍历整个 Socket 集合找到可读/ 可写的 Socket ,然后对其处理。
很明显发现, select poll 的缺陷在于,当客户端越多,也就是 Socket 集合越⼤, Socket 集合的遍历和拷⻉会带来很⼤的开销,因此也很难应对 C10K
epoll 是解决 C10K 问题的利器,通过两个⽅⾯解决了 select/poll 的问题。
  • epoll 在内核⾥使⽤「红黑树」来关注进程所有待检测的 Socket,红⿊树是个⾼效的数据结构,增删查⼀般时间复杂度是 O(logn),通过对这棵⿊红树的管理,不需要像 select/poll 在每次操作时都传⼊整个 Socket 集合,减少了内核和⽤户空间⼤量的数据拷⻉和内存分配。
  • epoll 使⽤事件驱动的机制,内核⾥维护了⼀个「链表」来记录就绪事件,只将有事件发⽣的 Socket集合传递给应⽤程序,不需要像 select/poll 那样轮询扫描整个集合(包含有和⽆事件的 Socket ), ⼤⼤提⾼了检测的效率。
⽽且, epoll ⽀持边缘触发和⽔平触发的⽅式,⽽ select/poll 只⽀持⽔平触发,⼀般⽽⾔,边缘触发的⽅式会⽐⽔平触发的效率⾼。

高性能网络模式:Reactor Proactor

这次就来 图解 Reactor Proactor 这两个⾼性能⽹络模式。
别⼩看这两个东⻄,特别是 Reactor 模式,市⾯上常⻅的开源软件很多都采⽤了这个⽅案,⽐如 Redis 、 Nginx、 Netty 等等,所以学好这个模式设计的思想,不仅有助于我们理解很多开源软件,⽽且也能在⾯试时吹逼。

演进

如果要让服务器服务多个客户端,那么最直接的⽅式就是为每⼀条连接创建线程。
其实创建进程也是可以的,原理是⼀样的,进程和线程的区别在于线程⽐较轻量级些,线程的创建和线程间切换的成本要⼩些,为了描述简述,后⾯都以线程为例。
 
处理完业务逻辑后,随着连接关闭后线程也同样要销毁了,但是这样不停地创建和销毁线程,不仅会带来性能开销,也会造成浪费资源,⽽且如果要连接⼏万条连接,创建⼏万个线程去应对也是不现实的。
要这么解决这个问题呢?我们可以使用「资源复用」的⽅式。
也就是不⽤再为每个连接创建线程,⽽是创建⼀个「线程池」,将连接分配给线程,然后⼀个线程可以处理多个连接的业务。
不过,这样⼜引来⼀个新的问题,线程怎样才能⾼效地处理多个连接的业务?
当⼀个连接对应⼀个线程时,线程⼀般采⽤「 read -> 业务处理 -> send 」的处理流程,如果当前连接没有数据可读,那么线程会阻塞在 read 操作上( socket 默认情况是阻塞 I/O ),不过这种阻塞⽅式并不影响其他线程。
但是引⼊了线程池,那么⼀个线程要处理多个连接的业务,线程在处理某个连接的 read 操作时,如果遇到没有数据可读,就会发⽣阻塞,那么线程就没办法继续处理其他连接的业务。
要解决这⼀个问题,最简单的⽅式就是将 socket 改成⾮阻塞,然后线程不断地轮询调⽤ read 操作来判断是否有数据,这种⽅式虽然该能够解决阻塞的问题,但是解决的⽅式⽐较粗暴,因为轮询是要消耗 CPU的,⽽且随着⼀个 线程处理的连接越多,轮询的效率就会越低。
上⾯的问题在于,线程并不知道当前连接是否有数据可读,从⽽需要每次通过 read 去试探。
那有没有办法在只有当连接上有数据的时候,线程才去发起读请求呢?答案是有的,实现这⼀技术的就是I/O 多路复⽤
I/O 多路复⽤技术会⽤⼀个系统调用函数来监听我们所有关⼼的连接,也就说可以在⼀个监控线程
⾥⾯监控很多的连接。
我们熟悉的 select/poll/epoll 就是内核提供给⽤户态的多路复⽤系统调⽤,线程可以通过⼀个系统调⽤函数从内核中获取多个事件。
select/poll/epoll 是如何获取⽹络事件的呢?
在获取事件时,先把我们要关⼼的连接传给内核,再由内核检测:
  • 如果没有事件发⽣,线程只需阻塞在这个系统调⽤,⽽⽆需像前⾯的线程池⽅案那样轮训调⽤ read操作来判断是否有数据。
  • 如果有事件发⽣,内核会返回产⽣了事件的连接,线程就会从阻塞状态返回,然后在⽤户态中再处理这些连接对应的业务即可。
当下开源软件能做到⽹络⾼性能的原因就是 I/O 多路复⽤吗?
是的,基本是基于 I/O 多路复⽤,⽤过 I/O 多路复⽤接⼝写⽹络程序的同学,肯定知道是⾯向过程的⽅式写代码的,这样的开发的效率不⾼。
于是,⼤佬们基于⾯向对象的思想,对 I/O 多路复⽤作了⼀层封装,让使⽤者不⽤考虑底层⽹络 API 的细节,只需要关注应⽤代码的编写。
⼤佬们还为这种模式取了个让⼈第⼀时间难以理解的名字: Reactor 模式
Reactor 翻译过来的意思是「反应堆」,可能⼤家会联想到物理学⾥的核反应堆,实际上并不是的这个意思。
这⾥的反应指的是「 对事件反应 」,也就是 来了⼀个事件, Reactor 就有相对应的反应 / 响应
事实上, Reactor 模式也叫 Dispatcher 模式,我觉得这个名字更贴合该模式的含义,即 I/O 多路复用监 听事件,收到事件后,根据事件类型分配( Dispatch )给某个进程 / 线程
Reactor 模式主要由 Reactor 处理资源池这两个核⼼部分组成,它俩负责的事情如下:
  • Reactor 负责监听和分发事件,事件类型包含连接事件、读写事件;
  • 处理资源池负责处理事件,如 read -> 业务逻辑 -> send
Reactor 模式是灵活多变的,可以应对不同的业务场景,灵活在于:
  • Reactor 的数量可以只有⼀个,也可以有多个;
  • 处理资源池可以是单个进程 / 线程,也可以是多个进程 /线程;
将上⾯的两个因素排列组设⼀下,理论上就可以有 4 种⽅案选择:
  • Reactor 单进程 / 线程;
  • Reactor 多进程 / 线程;
  • Reactor 单进程 / 线程;
  • Reactor 多进程 / 线程;
其中,「多 Reactor 单进程 / 线程」实现⽅案相⽐「单 Reactor 单进程 / 线程」⽅案,不仅复杂⽽且也没有性能优势,因此实际中并没有应⽤。
剩下的 3 个⽅案都是⽐较经典的,且都有应⽤在实际的项⽬中:
  • Reactor 单进程 / 线程;
  • Reactor 多线程 / 进程;
  • Reactor 多进程 / 线程;
⽅案具体使⽤进程还是线程,要看使⽤的编程语言以及平台有关:
  • Java 语⾔⼀般使⽤线程,⽐如 Netty;
  • C 语⾔使⽤进程和线程都可以,例如 Nginx 使⽤的是进程,Memcache 使⽤的是线程。
接下来,分别介绍这三个经典的 Reactor ⽅案。

Reactor

单Reactor单进程/线程

⼀般来说, C 语⾔实现的是「 Reactor 单进程 」的⽅案,因为 C 语编写完的程序,运⾏后就是⼀个独⽴的进程,不需要在进程中再创建线程。
Java 语⾔实现的是「 Reactor 单线程 」的⽅案,因为 Java 程序是跑在 Java 虚拟机这个进程上⾯的,虚拟机中有很多线程,我们写的 Java 程序只是其中的⼀个线程⽽已。
我们来看看「 Reactor 单进程 」的⽅案示意图:

可以看到进程⾥有 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 语⾔实现的,它采⽤的正是「单 Reactor 单进程」的⽅案,因为 Redis 业务处理主要是在内存中完成,操作的速度是很快的,性能瓶颈不在 CPU 上,所以 Redis 对于命令的处理是单进程的⽅案。

单Reactor多线程/多进程

如果要克服「单 Reactor 单线程 / 进程」⽅案的缺点,那么就需要引⼊多线程 / 多进程,这样就产⽣了 Reactor 多线程 / 多进程 的⽅案。
闻其名不如看其图,先来看看「单 Reactor 多线程」⽅案的示意图如下:
详细说⼀下这个⽅案:
  • Reactor 对象通过 select IO 多路复⽤接⼝) 监听事件,收到事件后通过 dispatch 进⾏分发,具体分发给 Acceptor 对象还是 Handler 对象,还要看收到的事件类型
  • 如果是连接建⽴的事件,则交由 Acceptor 对象进⾏处理,Acceptor 对象会通过 accept ⽅法 获取连接,并创建⼀个 Handler 对象来处理后续的响应事件;
  • 如果不是连接建⽴事件, 则交由当前连接对应的 Handler 对象来进⾏响应;
上⾯的三个步骤和单 Reactor 单线程⽅案是⼀样的,接下来的步骤就开始不⼀样了:
Handler 对象不再负责业务处理,只负责数据的接收和发送, Handler 对象通过 read 读取到数据后, 会将数据发给⼦线程⾥的 Processor 对象进⾏业务处理;
⼦线程⾥的 Processor 对象就进⾏业务处理,处理完后,将结果发给主线程中的 Handler 对象,接着由 Handler 通过 send ⽅法将响应结果发送给 client
Reator 多线程的⽅案优势在于 能够充分利用多核 CPU 的功能 ,那既然引⼊多线程,那么⾃然就带来了多线程竞争资源的问题。
例如,⼦线程完成业务处理后,要把结果传递给主线程的 Reactor 进⾏发送,这⾥涉及共享数据的竞争。
要避免多线程由于竞争共享资源⽽导致数据错乱的问题,就需要在操作共享资源前加上互斥锁,以保证任意时间⾥只有⼀个线程在操作共享资源,待该线程操作完释放互斥锁后,其他线程才有机会操作共享数据。
聊完单 Reactor 多线程的⽅案,接着来看看单 Reactor 多进程的⽅案。
事实上,单 Reactor 多进程相⽐单 Reactor 多线程实现起来很麻烦,主要因为要考虑⼦进程 <-> ⽗进程的双向通信,并且⽗进程还得知道⼦进程要将数据发送给哪个客户端。
⽽多线程间可以共享数据,虽然要额外考虑并发问题,但是这远⽐进程间通信的复杂度低得多,因此实际应⽤中也看不到单 Reactor 多进程的模式。
另外,「单 Reactor 」的模式还有个问题, 因为⼀个 Reactor 对象承担所有事件的监听和响应,而且只在 主线程中运行,在面对瞬间高并发的场景时,容易成为性能的瓶颈的地⽅

多Reactor多进程/线程

要解决「单 Reactor 」的问题,就是将「单 Reactor 」实现成「多 Reactor 」,这样就产⽣了第
Reactor 多进程 / 线程 的⽅案。
⽼规矩,闻其名不如看其图。多 Reactor 多进程 / 线程⽅案的示意图如下(以线程为例):
⽅案详细说明如下:
  • 主线程中的 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 进⾏处理,不会再分配给其他⼦进程。

Proactor

前⾯提到的 Reactor 是⾮阻塞同步⽹络模式,⽽ Proactor 是异步网络模式
这⾥先给⼤家复习下阻塞、⾮阻塞、同步、异步 I/O 的概念。
先来看看 阻塞 I/O ,当⽤户程序执⾏ read ,线程会被阻塞,⼀直等到内核数据准备好,并把数据从内核缓冲区拷⻉到应⽤程序的缓冲区中,当拷⻉过程完成, read 才会返回。
注意, 阻塞等待的是「内核数据准备好」和「数据从内核态拷贝到⽤户态」这两个过程 。过程如下图:
知道了阻塞 I/O ,来看看 ⾮阻塞 I/O ,⾮阻塞的 read 请求在数据未准备好的情况下⽴即返回,可以继续往下执⾏,此时应⽤程序不断轮询内核,直到数据准备好,内核将数据拷⻉到应⽤程序缓冲区, read 调⽤才可以获取到结果。过程如下图:
注意, 这⾥最后⼀次 read 调⽤,获取数据的过程,是⼀个同步的过程,是需要等待的过程。这⾥的同步指 的是内核态的数据拷贝到⽤户程序的缓存区这个过程。
举个例⼦,如果 socket 设置了 O_NONBLOCK 标志,那么就表示使⽤的是⾮阻塞 I/O 的⽅式访问,⽽不做任何设置的话,默认是阻塞 I/O
因此,⽆论 read send 是阻塞 I/O ,还是⾮阻塞 I/O 都是同步调⽤。因为在 read 调⽤时,内核将数据从内核空间拷⻉到⽤户空间的过程都是需要等待的,也就是说这个过程是同步的,如果内核实现的拷⻉效率不⾼,read 调⽤就会在这个同步过程中等待⽐较⻓的时间。
⽽真正的 异步 I/O 是「内核数据准备好」和「数据从内核态拷⻉到⽤户态」这 两个过程都不用等待 当我们发起 aio_read (异步 I/O ) 之后,就⽴即返回,内核⾃动将数据从内核空间拷⻉到⽤户空间,这个拷⻉过程同样是异步的,内核⾃动完成的,和前⾯的同步操作不⼀样, 应⽤程序并不需要主动发起拷贝 动作 。过程如下图:
举个你去饭堂吃饭的例⼦,你好⽐应⽤程序,饭堂好⽐操作系统。
阻塞 I/O 好⽐,你去饭堂吃饭,但是饭堂的菜还没做好,然后你就⼀直在那⾥等啊等,等了好⻓⼀段时间终于等到饭堂阿姨把菜端了出来(数据准备的过程),但是你还得继续等阿姨把菜(内核空间)打到你的饭盒⾥(⽤户空间),经历完这两个过程,你才可以离开。
⾮阻塞 I/O 好⽐,你去了饭堂,问阿姨菜做好了没有,阿姨告诉你没,你就离开了,过⼏⼗分钟,你⼜来饭堂问阿姨,阿姨说做好了,于是阿姨帮你把菜打到你的饭盒⾥,这个过程你是得等待的。
异步 I/O 好⽐,你让饭堂阿姨将菜做好并把菜打到饭盒⾥后,把饭盒送到你⾯前,整个过程你都不需要任何等待。
很明显,异步 I/O ⽐同步 I/O 性能更好,因为异步 I/O 在「内核数据准备好」和「数据从内核空间拷⻉到⽤户空间」这两个过程都不⽤等待。
Proactor 正是采⽤了异步 I/O 技术,所以被称为异步⽹络模型
现在我们再来理解 Reactor Proactor 的区别,就⽐较清晰了。
  • Reactor 是非阻塞同步网络模式,感知的是就绪可读写事件。在每次感知到有事件发⽣(⽐如可读就绪事件)后,就需要应⽤进程主动调⽤ read ⽅法来完成数据的读取,也就是要应⽤进程主动将socket 接收缓存中的数据读到应⽤进程内存中,这个过程是同步的,读取完数据后应⽤进程才能处理数据。
  • Proactor 是异步网络模式, 感知的是已完成的读写事件。在发起异步读写请求时,需要传⼊数据缓冲区的地址(⽤来存放结果数据)等信息,这样系统内核才可以⾃动帮我们把数据的读写⼯作完成, 这⾥的读写⼯作全程由操作系统来做,并不需要像 Reactor 那样还需要应⽤进程主动发起 read/write来读写数据,操作系统完成读写⼯作后,就会通知应⽤进程直接处理数据。
因此, Reactor 可以理解为「来了事件操作系统通知应⽤进程,让应⽤进程来处理」 ,⽽ Proactor 可以 理解为「来了事件操作系统来处理,处理完再通知应⽤进程」 。这⾥的「事件」就是有新连接、有数据可读、有数据可写的这些 I/O 事件这⾥的「处理」包含从驱动读取到内核以及从内核读取到⽤户空间。
举个实际⽣活中的例⼦,Reactor 模式就是快递员在楼下,给你打电话告诉你快递到你家⼩区了,你需要⾃⼰下楼来拿快递。⽽在 Proactor 模式下,快递员直接将快递送到你家⻔⼝,然后通知你。
⽆论是 Reactor ,还是 Proactor ,都是⼀种基于「事件分发」的⽹络编程模式,区别在于 Reactor 模式是 基于「待完成」的 I/O 事件,⽽ Proactor 模式则是基于「已完成」的 I/O 事件
接下来,⼀起看看 Proactor 模式的示意图:
介绍⼀下 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 ⽅案。

总结

常⻅的 Reactor 实现⽅案有三种。
第⼀种⽅案单 Reactor 单进程 / 线程,不⽤考虑进程间通信以及数据同步的问题,因此实现起来⽐较简单,这种⽅案的缺陷在于⽆法充分利⽤多核 CPU ,⽽且处理业务逻辑的时间不能太⻓,否则会延迟响应, 所以不适⽤于计算机密集型的场景,适⽤于业务处理快速的场景,⽐如 Redis 采⽤的是单 Reactor 单进程的⽅案。
第⼆种⽅案单 Reactor 多线程,通过多线程的⽅式解决了⽅案⼀的缺陷,但它离⾼并发还差⼀点距离,差在只有⼀个 Reactor 对象来承担所有事件的监听和响应,⽽且只在主线程中运⾏,在⾯对瞬间⾼并发的场景时,容易成为性能的瓶颈的地⽅。
第三种⽅案多 Reactor 多进程 / 线程,通过多个 Reactor 来解决了⽅案⼆的缺陷,主 Reactor 只负责监听事件,响应事件的⼯作交给了从 Reactor Netty Memcache 都采⽤了「多 Reactor 多线程」的⽅案,Nginx 则采⽤了类似于 「多 Reactor 多进程」的⽅案。
Reactor 可以理解为「来了事件操作系统通知应⽤进程,让应⽤进程来处理」,⽽ Proactor 可以理解为「来了事件操作系统来处理,处理完再通知应⽤进程」。
因此,真正的⼤杀器还是 Proactor ,它是采⽤异步 I/O 实现的异步⽹络模型,感知的是已完成的读写事件,⽽不需要像 Reactor 感知到事件后,还需要调⽤ read 来从内核中获取数据。
不过,⽆论是 Reactor ,还是 Proactor ,都是⼀种基于「事件分发」的⽹络编程模式,区别在于 Reactor模式是基于「待完成」的 I/O 事件,⽽ Proactor 模式则是基于「已完成」的 I/O 事件。

什么是一致性哈希

腾讯WXG一面:一致性哈希,场景、解决的问题

如何分配请求?

大多数网站背后肯定不是只有一台服务器提供服务,因为单机的并发量和数据量都是有限的,所 以都会用多台服务器构成集群来对外提供服务。

但是问题来了,现在有那么多个节点(后面统称服务器为节点,因为少一个字),要如何分配客户端的请求呢?

其实这个问题就是「负载均衡问题」。解决负载均衡问题的算法很多,不同的负载均衡算法,对 应的就是不同的分配策略,适应的业务场景也不同。 最简单的方式,引入一个中间的负载均衡层,让它将外界的请求「轮流」的转发给内部的集群。 比如集群有 3 个节点,外界请求有 3 个,那么每个节点都会处理 1 个请求,达到了分配请求的目 的。

考虑到每个节点的硬件配置有所区别,我们可以引入权重值,将硬件配置更好的节点的权重值设 高,然后根据各个节点的权重值,按照一定比重分配在不同的节点上,让硬件配置更好的节点承 担更多的请求,这种算法叫做加权轮询

加权轮询算法使用场景是建立在每个节点存储的数据都是相同的前提。所以,每次读数据的请 求,访问任意一个节点都能得到结果。

但是,加权轮询算法是无法应对「分布式系统(数据分片的系统)」的,因为分布式系统中,每个节点存储的数据是不同的。

当我们想提高系统的容量,就会将数据水平切分到不同的节点来存储,也就是将数据分布到了不同的节点。比如一个分布式 KV(key-valu) 缓存系统,某个 key 应该到哪个或者哪些节点上获得,应该是确定的,不是说任意访问一个节点都可以得到缓存结果的。 因此,我们要想一个能应对分布式系统的负载均衡算法。

使用哈希算法有什么问题?

有的同学可能很快就想到了:哈希算法。因为对同一个关键字进行哈希计算,每次计算都是相同 的值,这样就可以将某个 key 确定到一个节点了,可以满足分布式系统的负载均衡需求。

哈希算法最简单的做法就是进行取模运算,比如分布式系统中有 3 个节点,基于 hash(key) % 3 公式对数据进行了映射。

如果客户端要获取指定 key 的数据,通过下面的公式可以定位节点:

hash(key) % 3

如果经过上面这个公式计算后得到的值是 0,就说明该 key 需要去第一个节点获取。 但是有一个很致命的问题,如果节点数量发生了变化,也就是在对系统做扩容或者缩容时,必须迁移改变了映射关系的数据,否则会出现查询不到数据的问题。

举个例子,假设我们有一个由 A、B、C 三个节点组成分布式 KV 缓存系统,基于计算公式 hash(key) % 3 将数据进行了映射,每个节点存储了不同的数据:

现在有 3 个查询 key 的请求,分别查询 key-01,key-02,key-03 的数据,这三个 key 分别经过 hash() 函数计算后的值为 hash( key-01) = 6、hash( key-02) = 7、hash(key-03) = 8,然后再对这 些值进行取模运算。

通过这样的哈希算法,每个 key 都可以定位到对应的节点。

hash(key) % 3 当 3 个节点不能满足业务需求了,这时我们增加了一个节点,节点的数量从 3 变化为 4,意味取模哈希函数中基数的变化,这样会导致大部分映射关系改变,如下图:

比如,之前的 hash(key-01) % 3 = 0,就变成了 hash(key-01) % 4 = 2,查询 key-01 数据时, 寻址到了节点 C,而 key-01 的数据是存储在节点 A 上的,不是在节点 C,所以会查询不到数据。

同样的道理,如果我们对分布式系统进行缩容,比如移除一个节点,也会因为取模哈希函数中基 数的变化,可能出现查询不到数据的问题。

要解决这个问题的办法,就需要我们进行迁移数据,比如节点的数量从 3 变化为 4 时,要基于新 的计算公式 hash(key) % 4 ,重新对数据和节点做映射。

假设总数据条数为 M,哈希算法在面对节点数量变化时,最坏情况下所有数据都需要迁移,所以 它的数据迁移规模是 O(M),这样数据的迁移成本太高了。 所以,我们应该要重新想一个新的算法,来避免分布式系统在扩容或者缩容时,发生过多的数据 迁移。

使用一致性哈希算法有什么问题?

一致性哈希算法就很好地解决了分布式系统在扩容或者缩容时,发生过多的数据迁移的问题。

一致哈希算法也用了取模运算,但与哈希算法不同的是,哈希算法是对节点的数量进行取模运算,而一致哈希算法是对 2^32 进行取模运算,是一个固定的值

我们可以把一致哈希算法是对 2^32 进行取模运算的结果值组织成一个圆环,就像钟表一样,钟表 的圆可以理解成由 60 个点组成的圆,而此处我们把这个圆想象成由 2^32 个点组成的圆,这个圆 环被称为哈希环,如下图:

一致性哈希要进行两步哈希:

  • 第一步:对存储节点进行哈希计算,也就是对存储节点做哈希映射,比如根据节点的 IP 地址进行哈希;
  • 第二步:当对数据进行存储或访问时,对数据进行哈希映射;

所以,一致性哈希是指将「存储节点」和「数据」都映射到一个首尾相连的哈希环上。

 问题来了,对「数据」进行哈希映射得到一个结果要怎么找到存储该数据的节点呢?

答案是,映射的结果值往顺时针的方向的找到第一个节点,就是存储该数据的节点。

举个例子,有 3 个节点经过哈希计算,映射到了如下图的位置

接着,对要查询的 key-01 进行哈希计算,确定此 key-01 映射在哈希环的位置,然后从这个位置 往顺时针的方向找到第一个节点,就是存储该 key-01 数据的节点。

比如,下图中的 key-01 映射的位置,往顺时针的方向找到第一个节点就是节点 A。

所以,当需要对指定 key 的值进行读写的时候,要通过下面 2 步进行寻址:

  • 首先,对 key 进行哈希计算,确定此 key 在环上的位置;
  • 然后,从这个位置沿着顺时针方向走,遇到的第一节点就是存储 key 的节点。

知道了一致哈希寻址的方式,我们来看看,如果增加一个节点或者减少一个节点会发生大量的数 据迁移吗?

假设节点数量从 3 增加到了 4,新的节点 D 经过哈希计算后映射到了下图中的位置

你可以看到,key-01、key-03 都不受影响,只有 key-02 需要被迁移节点 D。 假设节点数量从 3 减少到了 2,比如将节点 A 移除

你可以看到,key-02 和 key-03 不会受到影响,只有 key-01 需要被迁移节点 B。

因此,在一致哈希算法中,如果增加或者移除一个节点,仅影响该节点在哈希环上顺时针相邻的 后继节点,其它数据也不会受到影响

上面这些图中 3 个节点映射在哈希环还是比较分散的,所以看起来请求都会「均衡」到每个节 点。 但是一致性哈希算法并不保证节点能够在哈希环上分布均匀,这样就会带来一个问题,会有大量 的请求集中在一个节点上。

比如,下图中 3 个节点的映射位置都在哈希环的右半边:

这时候有一半以上的数据的寻址都会找节点 A,也就是访问请求主要集中的节点 A 上,这肯定不 行的呀,说好的负载均衡呢,这种情况一点都不均衡。

另外,在这种节点分布不均匀的情况下,进行容灾与扩容时,哈希环上的相邻节点容易受到过大 影响,容易发生雪崩式的连锁反应。

比如,上图中如果节点 A 被移除了,当节点 A 宕机后,根据一致性哈希算法的规则,其上数据应 该全部迁移到相邻的节点 B 上,这样,节点 B 的数据量、访问量都会迅速增加很多倍,一旦新增 的压力超过了节点 B 的处理能力上限,就会导致节点 B 崩溃,进而形成雪崩式的连锁反应。

所以,一致性哈希算法虽然减少了数据迁移量,但是存在节点分布不均匀的问题

如何通过虚拟节点提高均衡度

要想解决节点能在哈希环上分配不均匀的问题,就是要有大量的节点,节点数越多,哈希环上的 节点分布的就越均匀。

但问题是,实际中我们没有那么多节点。所以这个时候我们就加入虚拟节点,也就是对一个真实 节点做多个副本。

具体做法是,不再将真实节点映射到哈希环上,而是将虚拟节点映射到哈希环上,并将虚拟节点 映射到实际节点,所以这里有「两层」映射关系。

比如对每个节点分别设置 3 个虚拟节点:

  • 对节点 A 加上编号来作为虚拟节点:A-01、A-02、A-03
  • 对节点 B 加上编号来作为虚拟节点:B-01、B-02、B-03
  • 对节点 C 加上编号来作为虚拟节点:C-01、C-02、C-03

引入虚拟节点后,原本哈希环上只有 3 个节点的情况,就会变成有 9 个虚拟节点映射到哈希环 上,哈希环上的节点数量多了 3 倍。

你可以看到,节点数量多了后,节点在哈希环上的分布就相对均匀了。这时候,如果有访问请求 寻址到「A-01」这个虚拟节点,接着再通过「A-01」虚拟节点找到真实节点 A,这样请求就能访 问到真实节点 A 了。

上面为了方便你理解,每个真实节点仅包含 3 个虚拟节点,这样能起到的均衡效果其实很有限。 而在实际的工程中,虚拟节点的数量会大很多,比如 Nginx 的一致性哈希算法,每个权重为 1 的 真实节点就含有160 个虚拟节点。

另外,虚拟节点除了会提高节点的均衡度,还会提高系统的稳定性。当节点变化时,会有不同的 节点共同分担系统的变化,因此稳定性更高

比如,当某个节点被移除时,对应该节点的多个虚拟节点均会移除,而这些虚拟节点按顺时针方 向的下一个虚拟节点,可能会对应不同的真实节点,即这些不同的真实节点共同分担了节点变化 导致的压力。

而且,有了虚拟节点后,还可以为硬件配置更好的节点增加权重,比如对权重更高的节点增加更 多的虚拟机节点即可。

因此,带虚拟节点的一致性哈希方法不仅适合硬件配置不同的节点的场景,而且适合节点规模会 发生变化的场景。

总结

不同的负载均衡算法适用的业务场景也不同的。

轮询这类的策略只能适用与每个节点的数据都是相同的场景,访问任意节点都能请求到数据。

但是不适用分布式系统,因为分布式系统意味着数据水平切分到了不同的节点上,访问数据的时 候,一定要寻址存储该数据的节点。

哈希算法虽然能建立数据和节点的映射关系,但是每次在节点数量发生变化的时候,最坏情况下 所有数据都需要迁移,这样太麻烦了,所以不适用节点数量变化的场景。

为了减少迁移的数据量,就出现了一致性哈希算法。

一致性哈希是指将「存储节点」和「数据」都映射到一个首尾相连的哈希环上,如果增加或者移 除一个节点,仅影响该节点在哈希环上顺时针相邻的后继节点,其它数据也不会受到影响。

但是一致性哈希算法不能够均匀的分布节点,会出现大量请求都集中在一个节点的情况,在这种 情况下进行容灾与扩容时,容易出现雪崩的连锁反应。

为了解决一致性哈希算法不能够均匀的分布节点的问题,就需要引入虚拟节点,对一个真实节点 做多个副本。不再将真实节点映射到哈希环上,而是将虚拟节点映射到哈希环上,并将虚拟节点 映射到实际节点,所以这里有「两层」映射关系。

引入虚拟节点后,可以会提高节点的均衡度,还会提高系统的稳定性。所以,带虚拟节点的一致 性哈希方法不仅适合硬件配置不同的节点的场景,而且适合节点规模会发生变化的场景


网站公告

今日签到

点亮在社区的每一天
去签到