Skip to content

9.1 什么是零拷贝?


DMA 技术

直接内存访问(Direct Memory Access

读取数据流程如下

  • 1、用户进程需要数据,调用read(),进程阻塞,用户态切换到内核态
  • 2、CPU发出指令给磁盘控制器,磁盘将数据放入磁盘控制器缓冲区,之后通知进行下一步搬运
    • 如果有 DMA,这个流程会增加
      • DMA发起IO请求
      • 通知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 中,我们可以用如下配置,来根据文件的大小来使用不同的方式:

plain
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 个虚拟节点

正在精进