前言
第一次看到 smux 这类库时,很多人都会有一个疑问:
既然已经有一条 TCP 连接了,为什么还要在上面再做一层多路复用?
原因很简单。
现实里的很多场景,并不是只想在一条连接上收发一段连续字节流,而是想在一条底层可靠连接之上,同时承载多条彼此独立的逻辑流。
比如:
- 一个客户端和服务端之间已经建立了一条长连接;
- 但这条连接上可能同时有多个请求在飞;
- 每个请求都希望像独立连接一样,各自读写、各自关闭、互不干扰。
如果不做复用,常见做法就是每个逻辑请求都单独建一条 TCP 连接。
这样当然能解决问题,但连接数、握手成本、内核资源和调度开销都会上来。
smux 做的事情,本质上就是:把一条可靠有序的底层连接,再切成很多条看起来像独立 net.Conn 的逻辑流。
它自己不负责可靠传输,也不负责乱序重排。
这些工作它默认交给下层,比如 TCP 或 KCP;而它自己专注做两件事:
- 如何在一条连接上区分多条逻辑流;
- 如何让这些逻辑流公平、可控、尽量低开销地共享这条连接。
smux 解决的是什么问题
可以先把 smux 理解成一个轻量级的应用层复用器。
它的输入是一条已经存在的 io.ReadWriteCloser,比如一条 TCP 连接;
它的输出则是一组逻辑上的 Stream。每个 Stream 都可以单独读、单独写、单独关闭,对上层代码来说,使用感受很像一条独立连接。
也就是说,smux 解决的不是“怎么把包可靠送到对端”,而是:
- 怎么在一条连接里打开很多个逻辑子通道;
- 怎么告诉对端“这是哪个子通道的数据”;
- 怎么避免某一个流把整条连接独占;
- 怎么控制整体内存占用;
- 怎么在读慢、写快的情况下把背压传上去。
这就是它的核心定位:连接复用层,而不是传输层本身。
整体架构
从源码结构看,smux 的核心非常集中,主要就是 5 个文件:
session.go:管理整条复用连接;stream.go:管理单条逻辑流;frame.go:定义线上帧格式;shaper.go:做写入调度和整形;alloc.go:做帧内存复用。
如果只用一句话概括这几个组件的关系,可以这么看:
也就是说,Session 是总控,Stream 是业务看到的逻辑连接,而真正在线上传输的最小单元其实是 Frame。
Session 和 Stream 是怎么分工的
Session 是总控层
Session 可以理解成一条被复用过的会话。
它包住底层那条真实连接,并负责几件核心事情:
- 创建和接受新的逻辑流;
- 维护当前所有活跃
Stream; - 启动收包循环和发包循环;
- 管理共享接收缓冲;
- 处理 keepalive、超时、错误传播和关闭。
从使用方式上看,客户端和服务端都会先创建一个 Session,然后:
- 主动方通过
OpenStream()打开一个新流; - 被动方通过
AcceptStream()接受一个新流。
所以 Session 更像一个“多路复用连接管理器”。
Stream 是逻辑连接层
Stream 则是 smux 暴露给上层的主要对象。
它实现的是 net.Conn 风格接口,所以对业务代码来说,读写方式很自然:
Read()从当前逻辑流读取数据;Write()向当前逻辑流写数据;Close()关闭当前逻辑流,而不是整条底层连接。
这一点很重要。
因为 smux 的目标并不是让上层直接操作 frame,而是让上层觉得自己拿到的就是一条普通连接,只不过这条连接是“逻辑上的”。
协议格式为什么这么小
smux 的线上协议非常克制。
一个 frame 的头部只有 8 个字节:
| |
这 8 个字节分别解决 4 个问题:
VERSION:当前协议版本;CMD:这帧是开流、关流、传数据,还是窗口更新;LENGTH:负载有多长;STREAMID:这帧属于哪条逻辑流。
这个设计很朴素,但也很有效。
因为 smux 的目标不是做一个特别复杂的通用协议栈,而是尽量用最小头部实现多路复用。
一个 Stream 是怎么建立起来的
通过 SYN 开流
当调用 OpenStream() 时,smux 会先分配一个新的 StreamID,然后发送一个 cmdSYN 控制帧,告诉对端:我要开一条新的逻辑流。
StreamID 也有一套很简单的规则:
- 客户端使用奇数流 ID;
- 服务端使用偶数流 ID。
这样做的好处是不用额外协商,就能避免两边同时开流时的 ID 冲突。
对端通过 Accept 接住
接收侧的 recvLoop 读到底层连接后,如果发现命令是 cmdSYN,就会创建对应的 stream 对象,并把它塞进 AcceptStream() 读取的队列里。
这样一来,上层调用 AcceptStream() 时,就能像接受新连接一样拿到一个新的逻辑流。
所以开流这件事的本质并不神秘,就是:
- 本端分配一个新的流 ID;
- 发一个
SYN帧; - 对端收到后创建对应
Stream; - 上层从 accept 队列里拿走这个
Stream。
数据是怎么在一条连接里复用的
写路径
上层调用 Stream.Write() 时,数据并不会直接一把写到底层连接。smux 会先把这段数据按 MaxFrameSize 拆成多个 cmdPSH 数据帧,每一帧都带上当前 StreamID,然后再交给 Session 的发送链路。
也就是说,逻辑上是一条流,在线上其实是一串带相同 StreamID 的 frame。
读路径
接收侧的 recvLoop 会不断从底层连接里读 frame 头,再根据 CMD 做分发:
cmdSYN:创建新流;cmdFIN:关闭某条流;cmdPSH:把数据塞进对应流的缓冲区;cmdNOP:保活;cmdUPD:更新窗口信息,仅 v2 使用。
当 cmdPSH 到来时,Session 会先找到对应的 Stream,再把 payload 推入这条流自己的缓冲区。
上层调用 Read() 时,读到的其实就是这里缓存的数据。
所以从结果上看,smux 做的就是一件事:
在线上把不同流的数据交错发送,在本地再按 StreamID 重新拆回各自的缓冲区。
为什么它能避免一条流把整条连接写爆
如果只是最简单地做 frame 分发,其实还不够。
因为多条流共享一条底层连接时,最容易出现的问题就是:
- 大流量流把带宽占满;
- 小流量控制消息迟迟发不出去;
- 某条慢流拖累其它流的延迟。
smux 在这里做了两层控制。
第一层:控制帧优先
源码里把写请求分成两类:
CLSCTRL:控制帧,比如SYN、FIN、NOP、UPD;CLSDATA:普通数据帧。
控制帧优先级更高。
这意味着像开流、关流、窗口更新这类“维持协议正常运作”的信号,不会轻易被大块数据淹没。
这点非常重要。
否则一旦控制信号发不及时,整个复用层的体验会迅速变差。
第二层:按 Stream 做公平调度
shaper.go 里的 shaperQueue 并不是简单一个全局 FIFO。
它会先按 StreamID 分桶,再结合优先级和轮转策略做调度。
你可以把它理解成:
- 同一条流里的请求,保持自己的顺序;
- 不同流之间,尽量轮着发;
- 控制消息优先于数据消息。
这样做的直接收益是,单个大流量流不容易长期饿死其它小流。
这也是 README 里提到的 fair queue traffic shaping 的核心含义。
流控是怎么做的
smux 的流控分成两个层次看会更清楚。
Session 级流控:共享接收桶
在 session.go 里,有一个 session 级别的 bucket。
它本质上是整条复用连接共享的接收配额,初始值来自 MaxReceiveBuffer。
当 recvLoop 收到新的数据帧并把它放进某条流的缓冲区时,会扣减这个 bucket。
当上层真的从某条 Stream 里把数据读走后,对应 token 才会被归还给 session。
这套机制的意义很实际:
- 限制整个
Session的总接收内存; - 防止很多流一起堆数据时内存失控;
- 让“上层消费速度”反过来影响“底层继续收数据的速度”。
换句话说,smux 不是无限往内存里吞数据,而是把整条连接的接收缓存当成一个共享池来统一控制。
Stream 级流控:v2 的滑动窗口
协议 v1 只有 session 级共享接收控制。
协议 v2 在此基础上又给每条流加了一层窗口控制,也就是 cmdUPD。
它的思路和经典滑动窗口很像:
- 写端记录自己已经写出了多少字节;
- 读端记录自己已经消费了多少字节;
- 当读端消费到一定阈值后,会发一个
cmdUPD告诉对端:- 我已经消费到哪里了;
- 我当前窗口还有多大。
写端收到 cmdUPD 之后,会更新 peerConsumed 和 peerWindow,再决定自己还能继续发多少数据。
所以 v2 的核心价值是:
把背压从 session 级,进一步细化到了每条 stream 级别。
这意味着某一条慢流不会那么容易无限积压自己的数据,也更容易把写阻塞准确地反馈给上层。
为什么说它把背压传回了上层
很多网络库的问题,不在于能不能发,而在于发不动时怎么处理。
smux 在 v2 里,一个流如果已经把对端窗口打满了,Write() 就不会继续无脑把数据塞到内部队列,而是会等待:
- 对端消费更多数据;
- 收到新的窗口更新;
- 或者超时、关闭。
这件事的意义很大。
因为它把“对端来不及读”的事实,变成了“本端写操作会被阻塞”。
这就是背压。
对一个复用库来说,能把背压正确传到上层,通常比单纯追求高吞吐更重要。
内存复用为什么是它的一个重点
如果一条底层连接上挂了很多 stream,而且每条 stream 都在收发 frame,那么 frame 的创建和回收会非常频繁。
这时候如果每次都新分配切片,就会带来明显的 GC 压力。
smux 在 alloc.go 里专门做了一个 slab 风格的分配器。
它的基本思路是:
- 针对
1B到64KB的大小准备多个sync.Pool; - 每个池子缓存 2 的幂次容量的
[]byte; - 分配时选择“刚好够用或略大一档”的池子;
- 用完后把切片放回池里复用。
这种做法的几个好处很直接:
- 避免频繁创建和回收小块内存;
- 降低 GC 压力;
- 控制碎片浪费;
- 让高并发场景下的内存曲线更平稳。
README 里提到的 session-wide receive buffer shared among streams 和这里其实是一脉相承的:
smux 不只关心协议对不对,也很关心大并发时内存会不会炸。
保活和关闭是怎么处理的
smux 还有两个很实用但容易被忽略的机制。
cmdNOP 做保活
Session 会定期发送 cmdNOP。
这个帧本身不承载业务数据,它的意义是让连接保持活跃,并帮助检测对端是否已经长时间无响应。
cmdFIN 做单流关闭
当某条流关闭时,不需要把整条连接关掉,只需要给对应 StreamID 发一个 cmdFIN。
对端收到后,会把这条逻辑流标记为结束,从而让读端返回 EOF。
这也是复用协议和普通裸连接最大的区别之一:
它需要支持“只关一条子流,不关整条物理连接”。
为什么它适合跑在 TCP 或 KCP 之上
smux 并不自己处理乱序、重传、丢包恢复。
它默认下层已经提供了这些能力。
所以它特别适合跑在这类传输层之上:
TCP:天然可靠、有序;KCP:虽然跑在 UDP 上,但上层已经补了可靠和有序能力。
这也是 README 里一直强调的一点:smux 依赖底层连接提供 reliability 和 ordering,自己不重复发明这部分轮子。
从分层设计上看,这样其实很干净:
- 下层负责“字节流可靠送达”;
smux负责“把一条字节流拆成多条逻辑流”。
一张图看懂它的工作方式
从这个视角看,smux 的核心价值可以压缩成一句话:
它把一条可靠连接,变成了很多条可独立读写的逻辑连接,同时尽量把调度、流控和内存成本控制住。
这个项目最值得学的地方
如果把 smux 当成一份源码来学习,我觉得最值得看的不是它“功能多不多”,而是它在几个点上的克制:
- 协议头很小,只保留最必要的信息;
Session和Stream职责分得很清楚;- v2 在不推翻整体结构的前提下补上了更细粒度流控;
- 写入调度不追求绝对复杂,而是优先解决公平性和控制帧优先;
- 内存复用和共享接收桶,让它更适合跑在高并发长连接场景里。
这类项目很适合用来学习一件事:
一个网络库要想真正能跑在生产环境里,除了功能正确,还必须考虑公平性、背压、内存和关闭语义。
总结
smux 的底层原理其实不复杂,但它把几个关键点组合得很完整:
- 用
Session管理整条复用连接; - 用
Stream暴露逻辑子连接; - 用 8 字节 frame 头区分不同流和不同命令;
- 用
SYN、FIN、PSH、NOP、UPD这几个命令完成协议交互; - 用共享接收桶和 v2 滑动窗口控制流量;
- 用优先级加轮转队列保证写入公平性;
- 用 slab 风格分配器降低 GC 和内存抖动。
如果你已经理解了这些点,再回头看 smux 源码,就不会只看到一堆并发控制和 channel,而是能更清楚地看出它背后的设计取舍:
在一条可靠连接上,用尽量小的协议成本,换来足够好用、足够稳的多路复用能力。