外观
高并发服务器:为什么应用层要有缓冲区
序言
一个高并发服务器,应用层收发缓冲区的性能至关重要。很多人往往会忽略它,对它的实现也不太了解。
内核明明已经有了缓冲区,为什么应用层还需要接收缓冲区、发送缓冲区?由谁解析缓冲区中的数据包、又该怎么实现?
读完这篇,你对应用层收发缓冲区将有一个全面的认识。

一、从网络收到数据后,谁来解析消息?
一个高并发服务端,运行时有哪几个角色?
一个典型的高并发服务端程序,通常都会使用网络库。很少直接在一个进程里面写网络功能。这种程序不具备扩展性,如果其他进程也要支持网络功能,那网络部分又得重新写一遍。
因此,对于高并发服务器而言,通常都会将网络功能独立出来,以网络库提供。每个进程只需要加载网络库,就能获取网络能力。
高并发服务器,通常由两部分组成。
一部分是通用网络库,以静态库或动态库的形式提供,比如 C++ 的 muduo、libevent。它是别人写好、编译好的,你的程序链接它。
另一部分是使用这个库写出来的业务程序:可能是 Web 服务器,可能是邮件服务器,也可能是游戏网关。同一个网络库可以开发出不同的业务,变成完全不同的服务端,靠的就是这部分代码。
服务器运行起来后,通常有四个角色。
- 网络库:管 epoll、连接的建立与关闭、收发缓冲区、什么时候该通知你。它的世界里只有连接、字节和事件,不认识任何协议;
- 网络 I/O 线程:也叫 Reactor 线程,是网络库跑起来后真正在执行事件循环的那个线程。它反复做三件事:等事件、读写 socket、调用提前设置好的回调函数;
- codec:编解码器,把收到的字节流解包,解析为一条条消息;或者把字节流打包,组成一个消息发送出去。它知道具体的消息格式,知道如何分割一条条消息。
- 业务线程:线程池里的线程,也常被叫做 work 线程。它拿到完整消息后执行业务逻辑,查库、计算、组装响应。

从 socket 接收字节流后,先经过网络库的收发缓冲区,再被 codec 解包为一条完整的消息,最后将这条消息交给业务线程处理。
| 角色 | 由谁提供 | 跑在哪个线程 | 认识什么 |
|---|---|---|---|
| 网络库核心 | 第三方库 | I/O 线程 | 连接、字节、事件 |
| codec | 网络库使用方 | I/O 线程 | 消息之间的分界 |
| 业务处理器 | 使用方 | 业务线程 | 消息中字段含义、业务规则 |
这里出现了 codec, 先看看 codec 到底在干什么活。
收到字节流,如何解包成完整的消息?
codec 干的活就是一个循环,每一轮都在问同样三个问题。
- 够不够包头:应用层接收缓冲区里的可读数据是否够一个包头的大小。如果还凑不满一个包头,直接返回,等下一次事件;
- 够不够整个消息:包头凑齐了,从里面解出包体长度,但可读数据还不够包头加包体那么多,仍然返回等待;
- 切出来继续下一轮:够了,解析完这条消息交给上层,回到第一步,直到剩余数据不足一条消息为止。

从图中可以看到,一次事件读进来的数据被循环解析,解析出完整消息,缓冲区里剩下数据不足以解析出一个完整消息时,退出解析,等收到数据后再解析。
这里会发生一次拷贝:把这一条消息的内容从应用层接收缓冲区投递给业务线程。这次拷贝不能省。
一条消息从应用层接收缓冲区投递给业务线程,为什么需要拷贝?
应用层接收缓冲区是 I/O 线程独占的,后续接收到的数据还会存到这里、还可能触发扩容,如果让业务线程直接持有它的内部指针,数据随时会被踩坏。拷完之后业务线程拿到的是一份独立数据,可以直接处理,不需要再拷第二次。
注意: 我说的是应用层接收缓冲区到业务线程,跨线程通信了,需要拷贝一份消息对象。因为应用层接收缓冲区位于网络库,网络库由 I/O 线程运行; I/O 线程和业务线程属于两个不同的消息,会通过队列通信,因此会多一次拷贝。

从网络库中的接收缓冲区,把数据返回到调用者,这里是不需要拷贝的。因为网络库和调用者,都位于 I/O 线程,不需要加锁。调用者可以直接持有网络库中接收缓冲区指针。调用者解析完成一条消息,调用者自己会更新下一次待解析的位置。
看清楚 codec 干的活之后,codec 运行在哪个线程就清楚了: codec 运行在 I/O 线程,而不是业务线程
解包运行在 I/O 线程还是业务线程?
解包跑在 I/O 线程,只把解析好的完整消息传递给业务线程池。

从图中可以看到,解包运行在 I/O 线程时,状态和连接绑在同一个线程里,投递出去的是彼此独立的完整消息;
如果 I/O 线程读完数据什么都不做,直接把一整块原始字节流交给业务线程,让业务线程自己去解包,会有什么问题:
- 线程间通信开销会加大。 半包、粘包状态要跟着跑到业务线程上。I/O 线程只要收到了数据,就要投递给业务线程。即使数据还不足以组成一个消息,也要投递,多了线程间通信的开销。
- 组包问题根本没消失。 I/O 线程反正还是要从 socket 读数据,这么做只是把"解包后再投递"的逻辑挪了个地方,复杂度一点没少,还多出了跨线程同步开销。

如果由业务线程解包,则连接绑定就不在一个线程,状态被拆散。
执行位置定了,接下来的问题:这段 codec 代码,该由网络库来实现,还是由调用者来写。
codec 解包该由网络库实现,还是由使用它的程序实现?
codec 运行在 I/O 线程上,网络库也是运行在 I/O 线程的,那把 codec 直接写进网络库里,让库读完数据顺手解析消息再回调业务,岂不是最省事?
听上去很方便,问题却是致命的。
这个库从此只能收发一种消息。 库里写死的是"定长包头加变长包体"这套规则,那它就只能服务这一种协议。拿它写 HTTP 服务器不行,规则完全不一样。拿它写 SMTP 服务器也不行。
想支持就得改库的源码,往里加一堆协议分支。改一次加一个协议,库越来越肥,而且每个使用者都被迫编译进了自己根本用不到的协议解析代码。
问题在于:一个通用网络库不应该认识任何协议。 它的世界里只有连接、字节流和事件。一旦它认识了 HTTP,它就不再是网络库,而是 HTTP 库了。
所以正确的分工是这样的:
解包的执行位置在 I/O 线程,解包的代码由使用网络库的调用者编写。库负责在合适的时机、在 I/O 线程上,调用一段由使用方提供的解包代码。
网络库要做的不是实现 codec,而是留一个回调,只要收到数据就触发回调,让调用者在回调里执行 codec 解析包的操作。

从图中可以看到,网络库本身一行都没变,回调中插进去不同的 codec,那它实现的就是 Web、邮件、游戏网关等不同服务器。
两种做法的差别可以直接对照:
| 维度 | codec 写进库里 | codec 由使用方注入 |
|---|---|---|
| 支持多协议 | 每加一种就得改库 | 换一个 codec 即可 |
| 库的体积 | 越堆越肥,用不上的也编进来 | 核心保持精简 |
| 协议升级 | 要动库的源码,牵连所有使用者 | 只改自己的 codec |
| 库的定位 | 退化成某协议的专用库 | 真正通用的网络库 |
判断一个网络库设计得干不干净,有个很简单的办法:翻它的核心源码,看能不能找到 HTTP、SMTP 这类协议名。找不到,说明它只管字节流;找得到,说明它已经和某个协议绑死了。
既然 codec 由使用网络库的调用方实现。那网络库只要收到数据,就要触发回调,让调用方的 codec 开始介入解包。即使网络库只收到 1 字节,也要回调,有可能调用方的 codec 就差这 1 字节的数据,就能完整解析一个消息。
网络库只要收到数据就回调,会不会太频繁了,影响性能?
网络库和使用者,都位于同一个 I/O 线程,又不是跨线程。网络库收到消息后,只要回调调用方提供的接口,都在同一个线程,也就是一个函数调用的开销。
解包、打包都在 I/O 线程,那 I/O 线程需要知道业务逻辑吗?
既然解析包这个操作是由 I/O 线程执行的,那网络 I/O 线程不就需要知道业务的消息格式了吗?
不会,我们从三个层面说明,每一层认识的东西都不同。
| 层次 | 由谁提供 | 运行在哪 | 认识什么 | 不认识什么 |
|---|---|---|---|---|
| 网络库核心 | 第三方库 | I/O 线程 | 连接、字节、事件 | 任何协议 |
| codec | 使用方 | I/O 线程 | 消息头 | 消息包体 |
| 业务逻辑 | 使用方 | 业务线程 | 字段含义、业务规则 | 字节是怎么来的 |
网络库是通用的,不关心任何协议,只关注字节流,可以实现不同的服务器。
codec 根本不在网络库中,是使用方自己实现的,只是运行在 I/O 线程。
codec 不需要知道业务逻辑,codec 认识的只有消息格式规则,只需要知道消息头的格式,不需要知道消息 body 的格式。知道消息头,也就知道怎么界定一条消息的边界,从而解决半包粘包问题。
codec 是每种服务器特有的,每种网络 I/O 线程执行 codec 对网络库的数据进行解包,实现不同的服务器。
一句话总结:I/O 线程必须懂"怎么解析包为完整消息",需要知道消息头,不关心消息 body, 消息 body 是业务线程关心的。不必懂"消息里业务字段的含义"
这是使用 TCP 字节流的代价。如果换成 UDP 或 QUIC 这类自带消息边界的传输层,就没有这层问题。
网络库从套接字读数据,还是由上层读?
codec 的位置和归属都定了,还剩最后一个分工问题:read 这个动作本身,该由网络库来做,还是交给上层?两条路都有成熟实现。
模型一,库先读进缓冲区,再通知上层。 事件循环内部就执行 read,把字节放进连接自带的应用层接收缓冲区,然后调用上层注册的接收回调,上层拿到的是已经读进来的数据,不用再自己去调一次底层 read。

这种做法,有点像 Proactor 异步网络模型,但不全是。Windows 的 IOCP、陈硕的 muduo 网络库是这种类型典型代表。
好处是网络库的使用者,不需要知道网络收发的细节,直接拿到的是原始数据。
代价是网络库要先缓存一部分再通知,从网络库到使用者之间,收到的消息,可能会多一层拷贝;
模型二,库只通知可读,由上层自己读。 库只负责"这个 fd 可读了,然后调你注册的接收处理回调,真正把数据从 socket 读进应用层接收缓冲区的动作由网络库的使用方执行。

也有不少开源软件是这么实现:Nginx 在读事件触发后,在接收回调中,由 HTTP 等模块调用 read 读数据;Redis 也是一样的处理函数。
好处是每个模块可以定制自己的读取策略,扩展性好。如果在网络库中实现,只能实现一种通用的读取策略。
代价是网络库的使用者,需要知道网络收发的具体细节,再往上才是业务层。
两种模型的差别就在于 read 这一步落在网络库内还是网络库的使用者,以及回调拿到的是"一段数据"还是"一个可读信号"。
两者的取舍可以这样对照:
| 维度 | 模型一:库来读 | 模型二:上层读 |
|---|---|---|
| 拷贝次数 | 多一层库内缓冲 | 直接读进目标缓冲区 |
| 协议扩展性 | 读取策略固化 | 每个模块自定,更灵活 |
| 上手成本 | 低,上层只管处理数据 | 高,要自己管读取和边界 |
| 典型代表 | muduo | Nginx、Redis |
两种模型都有人在用,如何选择呢?
如果网络库的使用者只关心完整报文、不想碰网络 I/O 细节,那可以选择网络库来执行 read 读数据操作。
要在网络库上支持多种协议、例如使用者要同时支持 TCP、WebSocket 等。追求极致控制力,可以选择在网络库的使用者这一侧来执行 read 读数据操作。
不管 read 由谁来调,codec 都应该由使用方实现。
模型一只是把"读"这个动作收进了库里,并没有把"怎么解析包为完整消息"也收进去。
模型二,本身做2件事件,一个是网络数据的收发,另一个是实现一个 codec
解包、打包过程,对于高并发服务器,是非常重要的一部分。 在把解包过程铺垫清楚了,我们就来看下,为什么应用层需要有接收缓冲区?
二、为什么必须要有应用层接收缓冲区?
初学网络编程时,我们对于 TCP 字节流往往存在一些疑惑。
内核已经有收发缓冲区,为什么应用层还要有收发缓冲区?
这是很多人的第一个疑问:内核明明给每个 socket 都配了收发缓冲区,为什么还要在应用层实现收发缓冲区?
两层缓冲区的目标完全不同,谁也替代不了谁。 内核缓冲区负责"字节可靠、有序地送达对端",应用层缓冲区负责"字节什么时候凑成一条消息、什么时候能被写出去"。
内核缓冲区服务的是 TCP 协议本身。 内核接收缓冲区的剩余空间会被填进 TCP 报文的窗口字段做流量控制,内核发送缓冲区里的数据要留着做超时重传,什么时候能发、能发多少由拥塞控制说了算。
内核缓冲区,只认识字节流,不认识应用层的协议格式。
内核不会替你保存读了一半的消息。 应用层调用 read 读取数据后,内核立刻从内核接收缓冲区里删除已经读取的数据。如果应用层不缓存,那永远丢了。
调大 SO_RCVBUF 能解决半包吗? 不能。调大只是让内核能多缓存一些字节,这个参数改变的是缓存容量,不是消息边界。只要一次 read 读取的不是整条消息,半包依然会出现。

网卡收包后,要经过内核接收缓冲区、应用层接收缓冲区、协议解析层,最后才到业务处理。分层处理,职责分离,中间任何一层处理有问题,字节流都无法到达业务层。
TCP 没有消息边界,半包和粘包是怎么来的?
TCP 只保证字节的顺序和完整,而不保证 read 就能读取到完整消息。
TCP 是字节流,它不认识消息边界。一次 read 拿到的可能是半条、一条、也可能是一条半。
用一个具体协议来看会更清楚。假设我们的协议是定长包头加变长包体,包头里写着包体有多长。客户端连着发了两条消息,服务端这边调一次 read,可能读到什么?至少有三种结果。
- 连包头都没读全:包头还缺一截,包体有多长都不知道,这一轮什么都做不了;
- 读到一条半:第一条完整,第二条只来了个开头,剩下的还在网络上;
- 两条粘在一起:一次读到两个完整帧,出现了粘包,要处理两条消息。

从图中可以看到,发送端发出的两条消息,接收端不一定两次接收就能收到完整的内容.
半包意味着"这次处理不了,要等下次",粘包意味着"这次要处理不止一条"。 这两个其实都指向同一个问题:需要有个地方把收到的字节流先缓存起来。
所以必须给每个连接配应用层接收缓冲区,将读到的字节先缓存到这里,等积累够一个完整消息头,才知道消息体有多长。再积累整个消息体才算是一条完整消息。
还没凑够一个完整的消息就等下一次事件触发,进行读取。凑齐了就解包,然后将解析出的完整消息交给业务层。
一次 read 读不完,剩下的字节该放哪?
read 的返回值有四种情况,但每一种都在告诉你,"数据的节奏你不好控制"。逼着你必须有应用层接收缓冲区。
| 返回值 | 含义 | 应对 |
|---|---|---|
| 等于请求长度 | 传进去的空间被填满,内核里可能还有数据 | 继续读 |
| 小于请求长度 | 这次只有这么多 | 存起来等下次再读 |
| -1 且 errno 为 EAGAIN | 内核接收缓冲区已读空 | 停止本轮读取 |
| 0 | 对端已关闭写端 | 走连接关闭流程 |
能使用临时栈空间,存放读取到的数据吗?
答案是不能:
原因在于:半条消息可能跨多个事件触发。 事件是一次次回调触发,两次回调之间函数栈早就清理了。那"上次读到哪、还差多少"这份状态,需要保留到下次回调触发时。就只能在每个客户端连接对象上,维护一个缓存,将数据保存到这个缓冲区中。
因此,任何一个高并发服务器,"每个连接都有一个应用层接收缓冲区",这不是什么性能优化,而是事件驱动框架成立的必要条件。
LT 和 ET 模式下,接收缓冲区的作用有区别吗?
两种模式都必须有应用层接收缓冲区,ET 模式下更是硬性要求。
LT 水平触发,看似可以偷懒。 只要内核接收缓冲区还有数据,epoll 就会一直通知你,所以这次没读完,下次还会被通知。
但 epoll 通知的是"内核里还有数据",不是"帮你记住上次解析到哪"。已经被你 read 走的那半条消息,epoll 是感知不到的,得你自己把数据存起来。
因此 ET 边缘触发方式下,必须一次性读完全部数据。 边缘触发只在状态发生变化时通知一次,所以必须一直循环读取,直到 read 返回 EAGAIN 为止,否则剩下的数据永远等不到,直到下一次事件触发。
一次读取操作,读取到一大段数据,这些字节只能先放到应用层接收缓冲区存储起来。
同样一批到达的数据,LT 下可能触发多次事件、每次读一点。ET 下只触发一次、必须一次读完。读得越多,对应用层接收缓冲区的依赖也越强。
顺带解释一个疑问:ET 边缘模式为什么必须配非阻塞 fd?因为要循环读,直到把所有数据读取完成。而如果使用阻塞套接字,在数据读空后不会返回 EAGAIN,而是直接阻塞在 read 系统调用上,导致线程无法处理其他连接。
小结
高并发服务器,必须要有应用层接收缓冲区,有两个理由:
- TCP 协议没有边界,会出现半包、粘包问题,应用层必须先缓存起来,才能解析出完整的一个消息;
read的返回值不可控,不是你想接收多少,就能返回满足你要求的数据;
接收这一端讲完了,很多人会觉得发送要简单些。数据准备好了直接写出去就行。但发送侧的问题还更多,我们来看下。
三、为什么必须要有应用层发送缓冲区?
一次完整的发送流程是怎样的?

业务处理完一个请求后,需要给客户端进行回包,生成一个很大的响应内容,调用 write 发送数据,完整流程是:
- 先走快路径:业务要发数据时,先看应用层发送缓冲区是不是空的。如果是空的,就直接发送数据,绝大多数情况一次就发送完了。此时是不需要往 epoll 中注册 EPOLLOUT 写事件。
- 没写完就入队:把剩余部分追加进应用层发送缓冲区,同时向 epoll 注册这个 fd 的 EPOLLOUT 事件;后续调用者每次发送数据时,网络库检查到发送缓冲区中还有待发送的数据,会把调用者此次发送的数据追加到发送缓冲区中,保证按序发送。
- 可写事件到来时继续写:EPOLLOUT 触发,从应用层发送缓冲区取数据继续
write,写出去多少就从缓冲区里删掉多少; - 发送缓冲区空后,立刻删除写事件:应用层发送缓冲区变空的那一刻,必须马上把 EPOLLOUT 写事件从 epoll 中删除。
数据要么走快路径一次写完,要么进入"网络库提供的发送缓冲区中,等待可写事件触发",可写事件触发时,将发送缓冲区中数据发送出去。
为什么一次不能全部发送完成呢?
初学网络编程时,我们可能会认为一次 write 一定能把数据全写出去。
内核发送缓冲区是有限的,内核发送缓冲区满了,write 就只收下一部分,剩下的写入会失败。
调用 write 想一次性发出去,返回值却比要发送的数据小很多。
这个返回值的意思是:内核发送缓冲区满了,只收下了前面一部分,后面剩的内核不要了。这其实就是 TCP 的流量控制与拥塞控制在发挥作用。
必须把没写完的剩余数据,从调用者中拷到这个连接自己的应用层发送缓冲区,等 socket 可写的时候再接着把剩余的数据发送出去。

从图中可以看到,write 的返回值把这份数据切成了两段:前一段已经进了内核,后一段必须放到网络库里面的发送缓冲区中,待 epoll 通知可写时,把剩余的数据发送出去。
为什么发送缓冲区空后,需要删除 EPOLLOUT 写事件?
LT 水平触发方式,套接字可写就会一直触发 EPOLLOUT,应用层发送缓冲区已经空了还残留这个写事件,则可写事件将会频繁触发,epoll_wait 一直被唤醒,导致 sys 系统调用消耗非常高。
之前我在线上运营 CDN 服务器时,就发现过这种问题,CPU sys 系统调用占比消耗高达 40%,加重 CPU 高负载
ET 边缘触发下,只会触发一次 EPOLLOUT 可写事件,CPU 消耗基本没影响,但也多了一次 epoll_wait 系统调用的开销。
如果应用层发送缓冲区非空却忘了注册 EPOLLOUT 会怎样?
一次 write 没有全部发送完成,剩余数据会保存到网络库提供的发送缓冲区中。如果此时忘记了注册 EPOLLOUT 写事件,那些数据就永远躺在缓冲区里发不出去,表现为客户端一直收不到响应,而服务端日志里一切正常。
为什么不能原地循环重试直到写完
一次发送没完成,很多人的第一反应是:没写完那就 while 循环接着写,写完为止。这种疯狂发包是错误的做法。
它会卡死整个 I/O 线程。 只要对端不读数据,内核发送缓冲区就一直是满的,write 会一直返回 EAGAIN,这个循环就一直转,成为了一个死循环,拖垮同一个 Reactor 上的所有连接。
它给了慢读攻击一个完美的入口。 如果对端是个恶意客户端,故意每隔几秒才读几十字节,这个循环能卡到天荒地老。一个连接就能打垮一个 I/O 线程,几十个连接就能打垮整台服务器。
由此推出全文的一条核心原则:
I/O 线程绝对不能阻塞。所以"写不完"这件事只能靠发送缓冲区 + 事件驱动来兜
调用者如果真的循环不停的写,该怎么处理?
发送缓冲区通常是无界的,业务产生数据的速度只要长期高于对端消费速度,它就会一直涨。
如果发送端不管 write 的返回值,一直的循环写,会导致应用层自己维护的发送缓冲区内存暴涨。
举个例子,10 万个连接每个积压 1 MB,加起来就是 100 GB。服务器不会等到网络出问题,会先被系统 OOM 杀掉。
解法是高低水位加背压。 给应用层发送缓冲区设两条线,比如高水位 64 MB、低水位 16 MB。缓冲区总量超过高水位时,写数据时,如果超过高水位,网络库则触发一个高水位回调,通知调用者"别再产生数据了",业务可以暂停发送。
等缓冲区降到低水位以下,网络库再触发另一个低水位回调,业务层就可以继续发送剩余数据。

从图中可以看到,正常连接的应用层发送缓冲区一直在低位波动,慢读连接则会持续爬升,直到触发高水位回调被叫停。
这也解释了成熟框架的连接对象上为什么总有一个高水位回调接口。它不是锦上添花的高级特性,是防 OOM 的最后一道闸。
小结
应用层发送缓冲区承担三个职责:
- 保存
write没写完的剩余数据,保证数据按序发送; - 与 EPOLLOUT 可写事件配合,调用者直接调用 write 就返回,不必阻塞等待;
- 通过高低水位做背压,防止内存被慢连接拖爆。
到这里,接收进来数据可以存放到接收缓冲区中,发送出去的数据可以存放到发送缓冲区中。
那高并发场景下,如何设计一个缓冲区呢?
主流实现,有两种方案。接下来我们就来看下,实现应用层收发缓冲区的第一种方案:
四、应用层收发缓冲区实现: 连续内存 + 读写双索引
应用层接收缓冲区、发送缓冲区,通常有两种方案。
- 连续内存 + 读写双索引。也就是本节要介绍的
- 链式缓冲区。下一节介绍
我们先来看连续内存 + 读写双索引的实现方案:
这是最容易理解、也最适合自己动手实现的方案,muduo 的 Buffer 是典型代表。
内存是怎么划分的?

底层就是一块可增长的连续内存,被两个下标切成三段。
- 预留区:从内存起点到读下标,是为了在缓冲区头部插入数据,而无需移动后面的数据;
- 可读区:从读下标到写下标,是已经收到但还没被解析走的数据;
- 可写区:从写下标到内存末尾,是还能接收新数据的空间。
初始状态是这样的:整块内存开一个不大的初始容量,头上留出一小段预留区,读下标和写下标都停在预留区末尾,此时可读区为空,剩下的全是可写区。
读写下标,把整块内存分成"已读走的","待解析的","还能写的"三段,所有操作都只是在移动这两个下标。
预留区不是浪费。 它是留给发送使用的:例如在一段数据前面加包头时,直接把读下标往前移动,在腾挪出来的那部分空间里写入包头就行,不用把整个 body 往后搬。类似于头插法。网络协议里,"先写 body 再根据 body 的大小写长度"是很常见的需求,这是预留区的使用场景。
写入数据是如何处理的?

从 socket 读到数据后,写下标从预留区末尾一路挪到这批数据的末尾,读下标不动。
这两个下标之间的区域就是等着被解析的可读区,后面剩下的仍然是可写区。
可读区随之变长,可写区随之缩短。
写入只修改写下标一个位置,已有数据完全没被触碰。这是这个方案里最廉价的操作。
读取数据是如何处理的?

codec 解析器解析完一个完整的消息,上层读走这条消息后,读下标就从预留区末尾推进到这一消息的末尾。此时缓冲区前面出现了一段空洞,正好是那一消息的大小,而可读区缩小了,只包含剩下还没解析的数据。
只把读下标往前移,不搬动任何数据。这是整个方案性能的基础——读操作是常数时间的。
读走的数据并没有被清除,成了暂时不能用的空洞。
空洞会一直累积吗?
不会,因为有一条关键规则兜底:当读下标等于写下标时,说明数据被读空了,此时把两个下标一起复位到初始位置,整块内存重新变成全空。

复位是一次纯下标操作,不涉及任何内存移动。对于请求应答型的服务,绝大多数连接在每轮处理结束时都会读空一次,空洞就在无感知的情况下被回收掉了。
空间不够时,是先腾挪还是直接扩容?
可写区够用:直接从写下标处写入,什么都不用做。
那缓冲区空间不够时,如果再写入数据,此时无法写入数据了。
有两种处理方式: 对原有的缓冲区进行腾挪,也就是将未解析的数据,移动到缓冲区开始位置,留出更多的可写入空间。或者直接扩容,分配一个新的空间。
可写区不够,但预留区空洞加可写区够用:说明不需要扩容,把可读区的数据整体前移到起始位置,空洞被挤到后面并入可写区,两个下标同步调整;
加起来还不够:这时才真正扩容,按需增长到能装下为止。

腾挪只是把可读区那一小段数据往前搬,扩容则要重新分配一块更大的内存并整体拷贝过去,扩容比腾挪的性能会更差些。
为什么必须先试腾挪? 一个长期活跃的连接会不断读走数据,前面的空洞会越积越大。
如果发现可写区不够就直接扩容,缓冲区会在实际数据量根本没增长的情况下持续膨胀,手上待处理的数据始终只有小部分,内存却一路涨到了几 MB,涨上去的全是前面那些回收不掉的空洞。
先腾挪后扩容这一条,是这个方案能长期稳定运行的前提。
怎么用一次系统调用尽可能读更多数据?

在非阻塞网络编程中,一方面希望减少系统调用,一次读的数据越多越划算,那么应该准备一个大一点的缓冲区。
另一方面,我们希望减少应用层缓冲区内存占用。应用层接收缓冲区很小,但内核接收缓冲区数据非常多,此时read 读取的数据很少,则需要多次系统调用。
如何使用一次系统调用,尽可能多读一些数据?
这里还有一个两难:可写区分配得小,一次读不完,就要多次系统调用,才能写满接收缓冲区;分配得大,几十万个连接的内存全浪费在空闲缓冲区上。
经典解法是 readv 配两块 iovec:第一块指向缓冲区的可写区(不包括前面已经读完的空洞),第二块指向一段临时空间,例如 32 KB,这段临时空间放在栈上,用完即弃。
效果分两种情况:
- 内核数据量小于可写区:一次系统调用读完,临时空间完全没用上,缓冲区也不需要扩容;
- 内核数据量超出可写区:多出来的部分进临时栈空间。然后再根据整个缓冲区剩余空间大小,决定是腾挪,还是扩容。

如果剩余空间能够容纳临时栈空间的数据,则进行腾挪。如果不行,则进行扩容,再把临时栈空间的数据追加到新缓冲区末尾。

从图中可以看到,无论数据多少,读取都只用一次系统调用完成,既不需要事先用 ioctl 探测长度,也不需要为了应付大包而给每个连接预留大缓冲区。
这个技巧的真正价值在内存。 有了栈上临时空间兜底,每个连接的常驻缓冲区可以开得很小(比如 1 KB),大包场景照样能一次读完。连接数越多,这个设计省下的内存越可观。
小结
- 优点:结构简单,解析时可以直接把可读区当成一段连续内存做指针操作,写起来最省心,调试也最直观;
- 缺点:腾挪和扩容都要整体搬数据,消息体越大代价越明显;发送时想在头部拼包头,虽然有预留区兜着,但预留区一旦用完还是要搬。
高并发服务器,连续的单缓冲区用得也很多,muduo 网络库目前就是单缓冲区实现。对付一般的服务器,单缓冲区实现足以应对。
如果需要更高的缓存性能,那就需要避免数据搬迁。
那有没有办法改善呢?有,代价是放弃"内存连续"这个前提。
五、应用层收发缓冲区实现:链式分段缓存
为什么要把一整块拆成一条链?
在连续内存方案里,腾挪要 memmove,扩容要重新分配并且全量拷贝,两个都是搬数据,非常耗性能。单个连接看不出什么,高并发场景下,是很容易出现的。极端场景十万个连接持续搬运,此时 CPU 大部分消耗在拷贝与分配上,利用率非常高。
链式方案的思路:不再要求内存连续。而是写满一块就从内存池分配一块新的,串成链表。读空一块就从链表中摘除,放回到内存池。不管是读还是写操作,永远不搬数据。
连续内存要把老数据整体拷贝到新内存里,链式只是在链尾多挂一个节点,老数据不需要移动。
代价也很直接:数据不连续了,解析就不能再简单地做指针操作,读取操作会比较复杂,需要考虑读取的数据是否跨越多个块。这是后面两小节要解决的问题。
一条链上的写入和读取过程

链式缓冲区,使用链表实现,每个节点是一块定长内存(比如 4 KB),每块自己带读写下标。链表头是最老的数据,链表尾是当前正在写的块。
链上的块分三种状态:已经读完等着回收的、正在被读的、正在被写的。缓冲区中的数据由这三部分组成。
写入:先填链表尾块的剩余空间,填满了就从内存池取一块新的挂到链尾,继续写。写入永远不涉及已有数据的移动。
写入操作,永远操作的是链表末尾的缓冲区。末尾节点空间还够,直接写入;不够,从内存池中申请一个节点,挂入到链表末尾
写完成后,块内的 write 下标会跟着改变,指向可写的位置
读取:从链表头开始取数据,头块被读空就把它从链上摘下来还给内存池,头指针指向下一块。
读取操作永远只发生在链表头节点。当头节点中缓冲区数据满足调用者读取的长度,则直接返回头节点的数据。读完成后,块内的 read 下标会跟着改变,指向最新可读位置
当头节点缓冲区里的数据全部被读完后,将这个节点回收到内存池中。
由此可以看出,链的两端是一边生长,一边收缩,中间的块自始至终不会被改写和读取,保证了数据的时序。
有一处比连续内存麻烦:当前可读多少字节不再是两个下标相减那么简单,要么遍历整条链表进行累加,要么额外维护一个计数器,并在每次读写时同步更新。实践中为了性能,都会选择后者。
一条消息横跨两个块,怎么解析?
这是链式方案最难处理的问题,就是读取的数据跨越了两个块。甚至包头都可能被存放到两个块上。前半截在这一块的末尾,后半截在下一块的开头。codec 解析器不能再把可读区当成一段连续内存来强转结构体了。
主流做法是:
逻辑连续视图:给 codec 解析器提供一个跨越块的,按字节读取的接口,网络库内部维护"当前在第几块、块内偏移多少",读到块尾自动跳到下一块。
codec 解析器眼里数据依然是连续的,代价是每读取一个字节都多一层边界判断,判断是否跨越块了,解析热路径会慢下来。
需要注意的是 codec 解析器根本就不关心,也不需要知道网络库的收发缓冲区是如何实现的。用的是单缓冲区还是链式缓冲区,都是网络库实现的,而非 codec 实现。
网络库链式缓冲区,需要做什么?

网络库对于收发缓冲区,需要给上层调用者提供好接口。例如:
- 获取链式缓冲区,数据的总长度(各个块的总和)。
- 从网络库读取指定长度的数据。从网络库到调用者,会涉及一次拷贝。
- 读取指定长度数据后,需要更新当前可读的位置,下一次就从这个位置开始读取数据。相当于调用者消费完成了这些数据。因此网络库也需要给调用者提供一个更新当前可读位置接口
从网络库读取指定长度的数据,从网络库到调用者,通常会涉及一次拷贝,因为:
调用者 codec 不知道网络库具体的形式。网络库使用链式缓冲区时,一旦要读取的数据跨越多个块时,是没法给调用者返回一个连续的数据的,这部分必然会涉及拷贝,将跨越多个块的数据合在一起返回给调用者。
如果获取指定长度的数据在一个块内,则可以直接给调用者返回数据在块内的位置,这部分是不需要拷贝的。

那调用者,例如 codec 如何使用网络库接口呢?
- 调用者需要读取一定长度数据时,只需要调用网络库提供的读取指定长度数据接口,就能获取到数据。
- 调用者读取完数据后,调用网络库更新当前可读位置接口,就可以设置下一次可以从接收缓冲区哪个位置读取数据。
发送时怎么做到零拷贝拼接?
跨块解析是链式方案付出的代价,那它换来了什么?发送侧的零拷贝就是最大的一笔回报。
考虑一个最常见的需求:把包头和包体拼成一条完整消息发出去。连续内存方案要么用预留区(预留区不够就得搬数据),要么申请一块能装下两者的新内存做一次拷贝。
链式方案不需要拼。把包头当成一个节点、包体当成另一个节点,直接串成链交给 writev,两段数据谁都不用动。 内核会使用分散聚合技术,将 iovec 数组中分散的数据聚合起来,按序读取,进行一次系统调用操作,将这些数据发送出去。

链式缓存方案中,"拼接包头和包体消息",只是对链表指针的操作,无需任何的拷贝,而连续内存方案下是需要真实的内存拷贝的。
静态大文件可以用 sendfile 技术,直接将数据从 pagecache 页缓存发到网卡,连应用层缓冲区都不用经过,一次用户态拷贝都没有,真正做到零拷贝。
块从哪来、到哪去?
链式方案的块如果每次都 new、每次都 delete,那省下的搬运开销会被分配开销吃掉。所以它总是和另外两件事绑在一起。
内存池:块从池里取、用完还回去,一般按尺寸分类。例如 4 KB 和 16 KB 两类。小消息用小块不浪费,大消息用大块少挂几个节点。使用内存池后,高频的分配释放变成了链表摘挂,碎片和系统调用都省了。
引用计数:同一份内容可以挂在多个连接的发送链上。广播场景最能体现价值。一条消息要发给 1 万个连接,内存里只存一份,每个连接的发送链上挂的都是同一个块的引用,谁发完了就把计数减一,归零才真正回收。

从图中可以看到,1 万个连接的发送链最终指向的是同一块内存,而不是 1 万份拷贝。这在推送、直播弹幕这类场景下,是数量级的内存差距。
这两条加起来,才是链式方案在超大连接数下内存表现优于连续内存方案的根本原因。
小结
- 优点:无扩容搬运;发送侧天然零拷贝;内存池和引用计数让大连接数下的内存开销可控;
- 缺点:解析要处理跨块,实现复杂度比连续内存高一个台阶;可读字节数、随机访问这类在连续内存下唾手可得的操作,在这里都变贵了。
相对连续缓冲方案,链上缓冲区在性能上有了很大的提升。高并发服务器使用这个方案的非常多,例如 Nginx。
两种方案该怎么选
两种方案讲完了,横向对照一下。
| 维度 | 连续内存加双下标 | 链式分段 |
|---|---|---|
| 扩容成本 | 腾挪或全量拷贝 | 挂新块,无搬运 |
| 解析复杂度 | 低,可直接指针操作 | 高,要处理跨块 |
| 发送零拷贝 | 受预留区限制 | 天然支持 |
| 内存占用 | 随峰值增长,可收缩 | 内存池复用,可共享 |
| 典型代表 | muduo Buffer | Nginx |
选型建议很直白:
- 自己动手实现,或者以学习为目的,选连续内存加双下标。逻辑最清晰,两个下标就能讲明白全部状态,muduo 的实现是最好的参考;
- 通用高性能网络框架,连接数大、消息体大小差异大,选链式分段。跨块解析的复杂度是一次性投入,换来的无搬运和零拷贝是长期收益;
六、总结
到此,我们把应用层收发缓冲区的内容讲完了。整体上做一个总结:
- codec 解包以及打包,运行在 I/O 线程,但 codec 的代码由网络库的使用者实现。网络库只提供回调,不认识任何协议,所以同一个库换个 codec 就能从 Web 服务器变成邮件服务器;
- 应用层接收缓冲区不可省:因为 TCP 没有消息边界、
read的返回值不可控、半包状态必须跨越事件存储; - 应用层发送缓冲区不可省:因为 I/O 线程绝对不能阻塞,写不完只能缓冲加 EPOLLOUT 等待下一次写事件的触发,还要靠高低水位做背压防 OOM;
- 应用层收发缓存有两种方案:连续内存双下标,实现最简单,性能也不差,应对一般的服务端程序足够了;链式分段虽然复杂一些,跨块解析的复杂度是一次性投入,换来的无搬运和零拷贝是长期收益,最适合线上生产环境。
还有三个也是比较容易搞混的,有必要再整理下:
- 内核缓冲区不等于应用层缓冲区,前者管可靠送达,后者管消息边界;
- codec 和网络库都运行在 I/O 线程上,但不代表 codec 它要由网络库实现。真实是由网络库的使用者实现;
- EPOLLOUT 可写事件使用完后必须移除,否则 CPU 会被空的可读事件频繁触发。
写在最后
应用层收发缓冲区看着不起眼,很多人在看网络库源码时,可能会忽略它,不就是一个缓存吗?
然而缓存的性能却对于高并发服务器而言至关重要。高并发服务器,要考虑的地方非常多,其中很大一部分是围绕缓存展开的。有了应用层收发缓冲区,半包以及粘包不再是问题、I/O 线程发送数据时才不会阻塞、慢连接才不会把内存打满。
muduo、Nginx、libevent 这些网络框架实现各不一样,但本质上是在解决同一个问题:接收到的字节流如何存储、如何发送、如何在内存可控的前提下扛住几十万客户端并发连接。
当你对应用层收发缓冲区了解后,再去读它们的源码,就不会觉得缓冲区的实现是可有可无的。"缓冲区是给高并发服务器添砖加瓦"。
我尽可能站在读者角度,回答缓存在高并发服务器的方方面面,但每个人理解不一样,还是会有疑惑的地方。有任何疑问,我们评论区里交流。