我们在设计高并发、高吞吐的系统时,总会碰到几个绕不开的名词:多路复用、零拷贝、顺序写、内存映射、数据压缩。它们不是零散的技巧,而是操作系统为我们提供的基础能力。本文会用“是什么 → 怎么发展来的 → 解决什么问题 → 典型应用场景”这条主线,把每个概念说透,遇到陌生术语也会就地解释,让你可以平顺地建立完整认知。


一、前置知识:必须理解的两个概念

在深入技术之前,我们需要先弄清两个反复出现的概念:文件描述符用户态/内核态

  • 文件描述符(fd)
    Linux 世界里,“一切皆文件”。磁盘上的普通文本、网络 Socket、管道,甚至硬件设备,都用文件描述符来表示。它是一个简单的整数,就像餐厅的桌号,当你打开一个文件或建立一个网络连接,内核会返回一个编号(比如 5),后续对这个连接的所有读写操作都通过“桌号 5”来指代。多路复用里的“监控”,就是盯着这些编号对应的对象有没有新动静。
    在这里插入图片描述
  • 用户态 vs 内核态
    现代 CPU 有权限分级,操作系统运行在内核态,可以访问所有硬件和内存;普通应用程序运行在用户态,受到诸多限制。读写磁盘、发送网络包等操作,必须通过系统调用暂时切换到内核态,让内核替我们完成。每次调用都伴随一次“出入境”式的上下文切换,而数据在用户内存和内核内存之间搬来搬去,就产生了昂贵的拷贝开销。很多优化技术本质上就是在减少这类切换和拷贝。
    在这里插入图片描述

有了这两个铺垫,我们就可以正式开始。


二、多路复用:一个服务员如何照看上百桌客人

1. 它是什么?

I/O 多路复用是一种让单个线程同时监听大量文件描述符(连接)的机制。哪个连接就绪(可读/可写),内核就通知程序去处理,从而实现单线程管理海量并发。

2. 怎么发展来的?

早期网络编程采用 阻塞 I/O 模型:线程调用 read(),如果没有数据到来,整个线程会被挂起,直到数据到达才恢复。一套“一个连接一个线程”的模式简单直接,但在高并发场景下,线程数迅速膨胀,频繁切换带来的 CPU 和内存开销会拖垮系统。

人们开始尝试非阻塞轮询:把连接设为非阻塞,程序循环逐个检查每个连接是否有数据。这解决了阻塞的问题,但大多数连接处于空闲状态,循环空转白白浪费 CPU,效率极低。

于是操作系统提供了多路复用接口——由内核代替应用去“轮询”,只要告诉内核“我对哪些 fd 感兴趣”,当任意一个 fd 就绪时,内核才通知程序来处理,这样就把无意义的空转去掉了。

3. 解决什么问题?

  • C10K 问题(单机同时处理上万个客户端连接)。
  • 避免为每个连接创建一个线程/进程,节省大量内存和上下文切换开销。
  • 将连接管理和业务处理解耦,让单机并发能力产生质变。

4. 三大具体实现:select、poll 与 epoll

接口 机制 缺点
select 位图fd_set)记录监听的 fd。位图就是一个二进制位串,第 N 位代表 fd=N 是否被关注。函数每次调用都需要将整张位图从用户态拷贝到内核,内核轮询全部 fd 后返回,用户还得遍历所有 fd 找出就绪的。位图大小受 FD_SETSIZE(通常 1024)限制。 fd 数量有上限;每次调用时间复杂度 O(n)。
poll 改用链表传入 fd 数组,消除了数量上限。但依旧需要全部拷贝和遍历,连接数增大后性能线性下降。 没有上限,但本质还是全量轮询。
epoll (Linux 2.6+) 核心思想是事件驱动epoll_create 创建 epoll 对象;epoll_ctl 将 fd 注册到内核,内核用红黑树维护这些 fd;epoll_wait 只返回已就绪的 fd 列表,无需遍历全部。 仅 Linux 支持;Windows 有 IOCP,macOS 有 kqueue。

epoll 还有两种触发模式:

  • 水平触发(LT):只要 fd 的缓冲区还有剩余数据没读完,每次 epoll_wait 都会通知。编程简单,类似传统阻塞 I/O 的行为。
  • 边缘触发(ET):只在状态从未就绪变为就绪时通知一次。程序必须把数据一次读完(通常使用非阻塞 I/O 循环读取),否则剩余数据不会再次触发。对程序要求高,但可以减少不必要的系统调用。

5. 当前主流场景

  • Nginx、Redis、Netty 等高性能服务器,使用 epoll/kqueue 实现单机数十万并发连接。
  • 即时通讯、游戏服务器、API 网关等需要维护大量长连接的场景。

在这里插入图片描述

三、零拷贝:别让 CPU 变成搬运工

1. 它是什么?

零拷贝 是指在数据从磁盘到网络的过程中,尽量减少甚至消除 CPU 参与的数据复制,让数据尽量在原位流转,从而释放 CPU 资源、降低内存带宽消耗。

2. 为什么需要零拷贝?——传统四次拷贝之痛

假设要把一个文件发送给客户端,传统路径如下:

  1. 应用调用 read()上下文切换到内核态,DMA 将磁盘数据拷贝到内核读缓冲区。
  2. CPU 将数据从内核读缓冲区拷贝到用户空间缓冲区,然后返回用户态。
  3. 应用调用 write()再次切换到内核态,CPU 将数据从用户空间缓冲区拷贝到内核 Socket 发送缓冲区
  4. DMA 将 Socket 缓冲区的数据拷贝到网卡进行发送。

整个过程:

  • 4 次数据拷贝(2 次 DMA 拷贝 + 2 次 CPU 拷贝)
  • 4 次上下文切换(用户态 ↔ 内核态)

内核缓冲区的设计本是为了减少慢速磁盘 I/O,但在“读磁盘+发网络”的组合下,额外多了两次 CPU 拷贝和频繁的上下文切换,数据完全在内存中绕了一圈,CPU 成了纯粹的搬运工。

3. 零拷贝的实现路线

(1)mmap + write
  • 做法:用 mmap() 把文件直接映射到进程的虚拟地址空间,程序读数据就像访问内存,省去一次向用户空间的 CPU 拷贝。
  • 效果:路径缩短为 “磁盘 → 内核读缓冲区 → Socket 发送缓冲区 → 网卡”,共 3 次拷贝,3 次上下文切换
  • 局限性:还不是真正的零拷贝。mmap 映射时有开销,且如果映射期间文件被截断,程序访问可能收到 SIGBUS 信号。
(2)sendfile (Linux 2.1+)
  • 做法sendfile() 系统调用直接指定输入文件描述符和输出 Socket 描述符,内核内部完成数据传输。
  • 效果:数据从内核读缓冲区直接复制到 Socket 缓冲区,再到网卡,不经过用户空间。共 2 次 CPU 拷贝,2 次上下文切换
  • 进一步优化:Linux 2.4 起,若网卡支持 SG-DMA(分散-聚集 DMA),sendfile 可以只传递描述符到 Socket 缓冲区,DMA 直接以“散列数据聚合”的方式从内核读缓冲区将数据搬运到网卡,此时的 CPU 拷贝次数降为 0(只剩下 DMA 拷贝),真正实现零拷贝。

4. 解决什么问题?

  • 避免 CPU 大量参与数据复制,降低 CPU 占用率,让 CPU 能处理更多业务。
  • 减少内存带宽占用,提升整体系统吞吐量。
  • 极大降低上下文切换次数,减轻调度开销。

5. 当前主流场景

  • 文件服务器(如 Nginx 的 sendfile on 指令):静态文件传输性能大幅提升。
  • 消息中间件(如 Kafka):消费者直接从磁盘读取消息并通过网络发送,使用 sendfile 将数据从文件页缓存直接传送到 Socket,性能翻倍。
  • CDN 节点、流媒体服务器 等有大量“读盘+发网”操作的场景。
    在这里插入图片描述

四、顺序写:让磁头少跑冤枉路

1. 它是什么?

顺序写 是指数据在磁盘上按连续地址先后写入,对应物理存储介质上的连续扇区或块,磁头无需剧烈跳转。

2. 为什么会发生寻道浪费?

机械硬盘(HDD)内部是高速旋转的盘片和沿半径方向移动的磁头。读写数据分三步:

  • 寻道:磁头移动到数据所在磁道(径向前后移动),耗时3~15 毫秒
  • 旋转延迟:磁道上的目标扇区旋转到磁头下方,平均耗时 2~4 毫秒(取决于转速)。
  • 数据传输:实际读取或写入数据,速度很快。

随机写意味着每次写入都位于不同磁道,磁头必须反复做远距离机械运动,每秒能完成的随机写入次数(IOPS)通常仅百次左右,吞吐可能只有几 MB/s。
顺序写时,磁头几乎不用移动,数据可以连续地落盘,写吞吐轻松达到 100~200 MB/s,与连续读取几无差别。

3. 解决什么问题?

  • 将“慢速寻道”的影响降到最低,让磁盘吞吐贴近物理带宽上限。
  • 降低写入延迟,特别是对大量数据持续写入的场景。

4. 如何用顺序写优化应用?

  • 日志结构存储(LSM-Tree、RocksDB):将随机写转化为后台排序后的顺序写,同时通过 WAL(预写日志)保证持久性。
  • 消息系统(Kafka):分区文件只做追加写(append only),天然顺序写,单分区吞吐惊人。
  • 批量聚合与排序:将内存中的零星写入请求合并,按块地址排序后一次性顺序刷盘。
  • 空间预分配:用 fallocate 提前在文件系统占用连续物理块,避免产生碎片。

SSD 没有机械运动,随机写延迟远低于 HDD,但顺序写依然能减轻其内部垃圾回收和写放大的压力,提升写入效率与寿命。

5. 当前主流场景

  • 数据库预写日志(WAL)、LSM-Tree 引擎。
  • 消息队列数据文件(Kafka、RocketMQ)。
  • 大规模日志采集(Elasticsearch、ClickHouse)的写入优化。
    在这里插入图片描述

五、内存映射(mmap):像访问内存一样读写文件

1. 它是什么?

mmap 将一个文件(或设备)的一段区域直接映射到进程的虚拟地址空间。之后,程序对这段内存的任何读写操作,操作系统会自动翻译为对文件的读写,利用缺页中断页缓存来完成实际 I/O。

2. 怎么来的?

传统读写文件要调用 readwrite,每次都需要将数据在内核页缓存和用户缓冲区之间拷贝一次。对于需要频繁随机访问大文件的场景(如数据库索引文件),这会产生海量系统调用和拷贝。
mmap 允许进程直接操作内核页缓存中的页面,避开中间拷贝和系统调用,从而高效实现文件的内存化访问。

3. 工作流程细化

  1. 调用 mmap 后,内核只是在进程的虚拟地址空间里划出一块区域,并和文件的某个部分建立映射关系,此时并不读入任何数据
  2. 当进程首次读写这个地址,硬件触发缺页异常,内核根据映射关系将文件对应的页加载到物理内存(页缓存),然后重新执行刚才出错的指令。
  3. 后续对同一页的访问就是直接内存操作。发生修改时,该页被标记为“脏页”,由内核的后台 pdflush 线程定期回写到磁盘。

4. 解决什么问题?

  • 省去用户态缓冲区的一次拷贝,尤其对需要随机读写的大文件效率更高。
  • 让多进程共享同一文件的映射,实现高效的进程间通信,无需额外的拷贝。
  • 提供一种比 lseek + read/write 更简便的随机访问方式(指针直接操作)。

5. 注意事项

  • 地址空间压力:32 位系统只有 4 GB 虚拟地址,映射超大文件可能失败;64 位系统基本不受限。
  • 并发写保护mmap 不提供锁机制,多进程写入同一页需自行同步。
  • 异常处理:当写入过程中磁盘满或文件被截断,内核可能向进程发送 SIGBUS 信号,程序需要处理。
  • 不适合高频小记录随机写:每笔写入都会导致页缓存变脏,产生随机 I/O;此时顺序写 + write 可能更可控。

6. 当前主流场景

  • 数据库存储引擎(MongoDB、LevelDB):用 mmap 读写数据文件,利用操作系统页缓存替换策略。
  • 进程间共享内存:不同进程映射同一个文件,数据即时可见。
  • 大文件快速读取:如特征数据的在线加载。

在这里插入图片描述

六、数据压缩:用 CPU 时间换 I/O 带宽

1. 它是什么?

数据压缩 是利用算法对数据重新编码,减少其体积的技术。在计算机系统中,压缩不仅可以节省存储空间,更能变相提升 I/O 性能,因为最终读写和传输的字节数变少了。

2. 为什么需要压缩?

随着业务增长,磁盘容量、网络带宽都是瓶颈。在数据流动的多个环节嵌入压缩,可以:

  • 降低存储成本。
  • 减少网络传输的数据量,提升传输速度。
  • 增加内存中可缓存的数据量(压缩后一个内存页可容纳更多逻辑数据)。

3. 压缩在各层中的应用

  • 文件系统层(ZFS、Btrfs、NTFS 压缩):对上层透明,所有写入自动压缩。
  • 消息系统层(Kafka 消息压缩):生产者压缩批量消息,消费者解压,以 CPU 换吞吐。
  • 内存压缩(zram、zswap):将不活跃的内存页压缩后保持在 RAM 中,变相扩大可用内存。
  • 网络传输层(HTTP gzip、br 压缩):减少网页与 API 响应大小。

4. 常用算法权衡

算法 压缩率 速度 典型场景
LZ4 中等 极快 实时日志收集、缓存数据
Snappy 较低 很快 大数据框架(Hadoop、Cassandra)
Zstd 快(可调) 兼顾速度与压缩率的新一代选择
gzip 较慢 静态资源、归档、API 响应

5. 解决什么问题?

  • 用较小的 CPU 开销换取显著的 I/O 带宽和存储空间收益。
  • 在不改变业务逻辑的前提下,提升整体系统吞吐。

6. 当前主流场景

  • 消息中间件:Kafka 主题压缩。
  • 数据库:RocksDB 等支持 Block/Index 压缩。
  • Web 服务:HTTPS 中 gzip/br 压缩静态资源和接口。
  • 嵌入式/移动:ROM 文件系统压缩(squashfs)、内存压缩。
    在这里插入图片描述

总结:它们如何协同构建高性能系统

这些技术并非彼此孤立,在一个成熟的存储或消息系统中,它们经常以组合拳的形式出现:

  • 顺序写 保证 HDD/SSD 的写入性能,降低延迟;
  • 数据压缩 减少 I/O 和网络数据量,间接提高吞吐;
  • 内存映射 让随机读文件像访问内存一样自然,避免拷贝;
  • 零拷贝 消除 CPU 在数据搬运中的消耗,让文件服务和消息转发跑满网卡;
  • 多路复用 作为神经中枢,用一个线程驱动成千上万的连接,用最小的资源管理海量并发。

理解了这些基础机制,你就拥有了分析系统性能瓶颈、设计高性能中间件的底层知识框架。希望本文能帮助你真正把这些名词融入自己的技术体系,而非仅仅停留在“听说过”的阶段。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐