9.1 什么是零拷贝?
DMA 技术
直接内存访问(Direct Memory Access)
读取数据流程如下
- 1、用户进程需要数据,调用read(),进程阻塞,用户态切换到内核态
- 2、CPU发出指令给磁盘控制器,磁盘将数据放入磁盘控制器缓冲区,之后通知进行下一步搬运
- 如果有 DMA,这个流程会增加
- DMA发起IO请求
- 通知DMA控制器
- 如果有 DMA,这个流程会增加
- 3、将数据拷贝到用户缓冲区,包括:1、将数据从磁盘控制缓冲区拷贝到内核缓冲区,2、将数据从内核缓冲区拷贝到用户缓冲区
- 有DMA,1的步骤将不需要CPU参加,其中1是耗时最长的IO部分
- 4、read()调用返回,内核态切换为用户态
写入数据流程调用write(),可以看成上面的反向
零拷贝
网络传输数据需要读写各一次,需要
- 两次 DMA 拷贝
- 两次 CPU 拷贝
- 四次内核态和用户态

其实网络传输,用户态不需要进行数据的加工的话,可以避免 2 的CPU拷贝
- 通过 mmap + write (不用read函数) 将用户缓存区映射到内核缓冲区
- 因为调用两次函数,所以有4次上下文切换
- 跳过用户缓冲区,所以3次拷贝
- 通过 sendfile ,CPU 直接将内核缓冲区的数据搬运到 socket 缓冲区
- 因为调用一次函数,所以有 2 次上下文切换
- 跳过用户缓冲区,所以 3 次拷贝
- 零拷贝,使用sendfile
- 通过SG-DMA拷贝直接将内核缓冲区数据拷贝到网卡,跳过socket缓冲区
- 因为调用一次函数,所以有 2 次上下文切换
- 跳过用户缓冲区、socket缓冲区,所以 2 次拷贝
- 且完全不需要CPU参与搬运
- 需要网卡支持 SG-DMA(The Scatter-Gather Direct Memory Access)技术
零拷贝技术可以把文件传输的性能提高至少一倍以上(总性能不止,因为 CPU 可用率很高 但是需要进程不对文件进行加工(如压缩)
- RabbitMQ需要再加工数据到死信队列,无法使用零拷贝
- Kafka 使用了零拷贝,所以速度快很多
- Nginx 也支持零拷贝,默认开启
PageCache 有什么作用?
我们都知道程序运行的时候,具有「局部性」。PageCache 的优点主要是两个:
- 缓存最近被访问的数据;
- 预读功能;
这两个做法,将大大提高读写磁盘的性能。
但是,在传输大文件(GB 级别的文件)的时候,PageCache 会不起作用,那就白白浪费 DMA 多做的一次数据拷贝,造成性能的降低,即使使用了 PageCache 的零拷贝也会损失性能
- PageCache 由于长时间被大文件占据,其他「热点」的小文件可能就无法充分使用到 PageCache,于是这样磁盘读写的性能就会下降了;
- PageCache 中的大文件数据,由于没有享受到缓存带来的好处,但却耗费 DMA 多拷贝到 PageCache 一次;
- PageCache是内核态的缓冲区。
大文件传输用什么方式实现?
它把读操作分为两部分:
- 前半部分,内核向磁盘发起读请求,但是可以不等待数据就位就可以返回,于是进程此时可以处理其他任务;
- 后半部分,当内核将磁盘中的数据拷贝到进程缓冲区后,进程将接收到内核的通知,再去处理数据;
异步 I/O 会绕开 PageCache。(也要使用缓冲区,但是不是PageCache,不会导致PageCache失效)
绕开 PageCache 的 I/O 叫直接 I/O,使用 PageCache 的 I/O 则叫缓存 I/O。通常,对于磁盘,异步 I/O 只支持直接 I/O。
于是,在高并发的场景下,针对大文件的传输的方式,应该使用「异步 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」,否则使用「零拷贝技术」。
最基本的 Socket 模型
服务端的 Socket 编程过程:
- 调用socket函数,创建特定网络和传输协议的Socket对象
- 协议:TCP/UDP
- 网络:IPv4/IPv6
- 调用bind函数,绑定端口号和IP地址(每个机器有多个端口,还可能有多个网卡即IP地址,内核通过两者绑定确定是哪个程序)
- 调用listen监听
- 调用accpet从内核获取客户端连接,这个会阻塞直到收到连接
- 收到连接后会构成一个新的socket连接(和监听的socket是两个不同的对象)
客户端的Socket编程过程
- 创建好 Socket
- 调用
connect()函数发起连接,该函数的参数要指明服务端的 IP 地址和端口号
连接开始就是使用TCP连接(或者UDP)
在 TCP 连接的过程中,服务器的内核实际上为每个 Socket 维护了两个队列:
- TCP 半连接队列:
- 未完成三次握手的连接,服务端处于
syn_rcvd的状态
- 未完成三次握手的连接,服务端处于
- TCP 全连接队列
- 完成三次握手的连接,服务端处于
established状态
- 完成三次握手的连接,服务端处于
- 每个Socket对象在Linux中是文件,文件里有两个队列,分别是发送队列和接收队列,这个两个队列里面保存的是一个个
struct sk_buff(各个层的数据包),用链表的组织形式串起来。
当 TCP 全连接队列不为空后,服务端的 accept() 函数,就会从内核中的 TCP 全连接队列里拿出一个已经完成连接的 Socket 返回应用程序,后续数据传输都用这个 Socket。
服务器可以承受的连接数主要会受两个方面的限制:
- 文件描述符,Socket 实际上是一个文件,也就会对应一个文件描述符。在 Linux 下,单个进程打开的文件描述符数是有限制的,没有经过修改的值一般都是 1024,不过我们可以通过 ulimit 增大文件描述符的数目;
- 系统内存,每个 TCP 连接在内核中都有对应的数据结构,意味着每个连接都是会占用一定内存的;
多连接的方案
- 多进程:主进程负责监听客户端连接,连接后 accept 返回一个 fork 的子进程,进程资源占用高,连接量不能太大
- 多线程:accept 后创建一个线程,在线程中和客户端通信,线程资源占用也不低
- IO 多路复用
IO 多路复用
概念:内核发现进程指定的一个或者多个 IO条件准备读取,它就通知该进程。IO多路复用适用如下场合:
- Nginx:每个新请求放入 epoll 中,内核监控到数据通知 Nginx 处理,避免一个连接一个线程
- Redis:单线程处理实际增删改查请求
- 使用一个进程维护多个 Sokcet,当有事件时将对应的连接给到进程处理
方案:以下方案适用于 Linux,其他系统有其他方案
- select:
- 1、将已连接的 Socket 放到一个文件描述符集合,将其拷贝到内核
- 2、内核遍历集合,如果有事件产生,将对应的 socket 标记为可读/写,将集合拷贝回用户态
- 3、用户态遍历找到可读/写的 socket 进行处理
- 需要 2 次遍历,2 次拷贝,时间复杂度 O(n)
- 用长度固定的 BitsMap,最大监听 1024 个 Socket
- poll:
- 使用动态数组,以链表形式组织文件描述符,突破个数限制,但是流程和 select 基本一致
- epoll:
- 内核中使用红黑树跟踪待检测文件描述符,高效增删
- 每次新增只需要传入一个新的 socket,不需要全量
- 内核维护一个链表记录就绪事件
- 当某个 socket 有时间发生,通过回调函数(核心,避免扫描)将其加入就绪事件列表
- 每次用户调用
epoll_wait()函数,只返回有事件发生的文件描述符,效率高- 注意这里的回调是内核的回调,对于用户来说还是通过轮询进行事件获取
- 没有 socket 监听上限(只要系统允许可以一直增加)
- 内核中使用红黑树跟踪待检测文件描述符,高效增删
触发方式
- 边缘触发
- 当 socket 描述符有可读事件发生时,服务端只会苏醒一次,需要循环将所有的描述符读取完,可能导致阻塞
- 水平触发
- 当 socket 描述符有可读事件发生时,服务端不断苏醒,直到读取完所有的数据
- select/poll 只有水平触发,因为需要完整复制
- epoll 默认是水平触发,因为每次事件可能比较少
Reactor
- IO 多路复用的具体实现,由两部分组成
- Reactor 负责监听和分发事件,事件类型包含连接事件、读写事件;
- 处理资源池负责处理事件
- Acceptor:连接处理对象
- Handler:业务逻辑处理对象,read -> 业务逻辑 -> send
- 可以有四种模式:单/多 Reactor <->单/多 进程/线程,不同的语言和平台不同
- Java 使用线程(Netty),C 语言进程和线程都有
- Redis 是单 Reactor 单进程,处理在内存,很快
- Netty 是多 Reactor 多线程,可以很好利用 CPU 多核
负载均衡?
服务器集群如何分配请求
- 轮询:
- 普通轮询,最简单,每个服务器处理一次
- 加权轮训,每个阶段设置不同的权重,适用于机器配置不同等情况
- 需要每个服务器数据相同,否则会出问题
- 比如分布式 KV(key-value) 缓存系统,每个key有固定的机器持有,其他机器没有,就没法直接通过这种做到
- 哈希:
- 通过关键字(如用户名)进行哈希计算并取模
- 如果3个机器,对3取模
- 但是如果节点数量发生变化,映射关系发生变化,需要数据迁移,成本较高
- 通过关键字(如用户名)进行哈希计算并取模
- 一致性哈希:
- 先进行哈希运算,之后对2^32(看成一个环) 进行取模运算,是一个固定的值
- 一致性哈希是指将「存储节点」和「数据」都映射到一个首尾相连的哈希环上
- 每次按照环往固定方向找,找到的第一个节点,就是存储该数据的节点
- 在一致哈希算法中,如果增加或者移除一个节点,仅影响该节点在哈希环上顺时针相邻的后继节点,其它数据也不会受到影响
- 但是一致性哈希算法并不保证节点能够在哈希环上分布均匀,这样就会带来一个问题,会有大量的请求集中在一个节点上。在这种节点分布不均匀的情况下,进行容灾与扩容时,哈希环上的相邻节点容易受到过大影响,容易发生雪崩式的连锁反应。
- 一致性哈希+虚拟节点
- 不再将真实节点映射到哈希环上,而是将虚拟节点映射到哈希环上,并将虚拟节点映射到实际节点,所以这里有「两层」映射关系。
- 层一:真实节点映射虚拟节点
- 层二:虚拟节点映射哈希环
- Nginx 的一致性哈希算法,每个权重为 1 的真实节点就含有 160 个虚拟节点
- 不再将真实节点映射到哈希环上,而是将虚拟节点映射到哈希环上,并将虚拟节点映射到实际节点,所以这里有「两层」映射关系。
