SCTP 流 · 多流复用与队头阻塞
一个 association 里跑多条独立的流,流内有序、流间互不阻塞——这是 SCTP 为「一条连接承载多路数据」量身设计的能力。这一章讲清流 id 的奇偶分配、按流的有序性、无序流、流数量协商、流重置,以及一个常被忽略的真相:普通 DATA 其实挡不住大消息造成的流间阻塞,要靠 I-DATA 才行。
SCTP 流 · 多流复用与队头阻塞
前面几章反复提到「流」:有序性是按流保证的、流间不阻塞、association 握手要协商流数量。这一章把「流」本身讲透——它是 SCTP 区别于 TCP 最根本的一处设计,也是数据通道能多路复用一条连接的全部原因。
流是什么
一个 association 内部,可以有多条流(stream)——彼此独立的子通道。
一个 SCTP association
┌──────────────────────────────────────────────────┐
│ 流 0 ▸▸▸▸▸▸▸▸▸▸▸▸ chat 消息(有序可靠) │
│ 流 1 ▸▸▸✕▸▸▸▸▸▸▸▸ 文件传输(丢包重传中) │
│ 流 2 ▸▸▸▸▸▸▸▸▸▸▸▸ 游戏状态(无序,照常流动) │
└──────────────────────────────────────────────────┘
流 1 在重传,流 0 与流 2 完全不受影响这就是 SCTP 和 TCP 的分水岭。 TCP 只有一条字节流,严格全局有序:中间缺了一段,后面所有数据都得在接收缓冲里等——哪怕后面那些数据属于逻辑上毫不相干的业务。这就是队头阻塞,也是「在一条 TCP 上多路复用」的死穴。
SCTP 把有序性的粒度从「整条连接」降到「每条流」,于是一条流的重传不再牵连其他流。
这个设计后来被 QUIC 完整继承。 QUIC 把「多流 + 流间独立」当作核心卖点,而 SCTP 早在 2000 年(RFC 2960)就这么做了。二者的动机也一样:HTTP/2 在一条 TCP 上多路复用,结果被队头阻塞反噬——这正是 QUIC 要换掉 TCP 的原因。SCTP 只是早了十几年遇到同一个问题。
流 id 怎么分配
每条流有一个 流 id(stream identifier,SID),16 位,收发共用同一个号。
问题是:两端都能主动开流,怎么保证不撞号?RFC 8832 的办法很简洁——按 DTLS 角色分奇偶:
| 角色 | 可用的流 id |
|---|---|
| DTLS client 侧 | 偶数(0, 2, 4, …) |
| DTLS server 侧 | 奇数(1, 3, 5, …) |
无需任何协商,两端各取各的号,天然不冲突。这和 ICE 里用 controlling / controlled 角色打破对称是同一种手法:先定一个不对称的角色,后续所有需要「谁说了算」的地方都能直接引用它。
有序性是按流的
上一层讲过,TSN 是整个 association 唯一递增的——它管的是「有没有丢」。而「按什么顺序交给应用」由另一个序号管:
- SSN(Stream Sequence Number) —— 每条流独立编号的消息序号。
- 接收方按 SSN 在流内重排后交付;流与流之间没有任何顺序约束。
所以 A 流的第 5 条消息在重传时,B 流的第 100 条消息照样可以立刻上交——因为它们的 SSN 空间根本不相干。
无序流则连 SSN 重排都跳过:消息一到就交付。数据通道把 ordered: false 映射成这种流,适合游戏状态同步这类「最新的才有价值」的数据。
流数量要在握手时协商
INIT / INIT_ACK 握手里带着两个数字:
- outbound streams —— 我打算开多少条出流。
- max inbound streams —— 我最多能接多少条入流。
双方取较小值作为实际可用的流数。默认值通常是 65535(16 位上限),实践中很少成为瓶颈——但如果对端给了个小值,你 createDataChannel 开到第 N 条就会失败。
流数量是握手时定死的,中途加不了。 SCTP 有一个 ADD_STREAMS 扩展(RFC 6525)可以动态增加,但并非所有实现都支持。所以真实系统一般在握手时就要一个足够大的数,而不是「按需扩容」。
流重置:关掉一条流
数据通道关闭时,对应的流要被回收——否则流 id 会一直被占着,长会话里迟早耗尽。
RFC 6525 的 RE-CONFIG chunk 干这件事:发一个 Outgoing SSN Reset Request,双方把该流的 SSN 归零、状态清空,流 id 就能被重新使用了。
这一步比听上去微妙:重置必须等该流上所有在途数据都确认完才能做,否则会把还没送达的消息连同状态一起清掉。dcSCTP 把这个时序处理在 net/dcsctp/socket/ 的 stream reset 逻辑里。
一个常被忽略的真相:大消息仍然会阻塞
到这里为止的说法——「多流所以无队头阻塞」——在有大消息时并不成立。
原因在于 SCTP 的分片规则:一条消息如果超过 MTU,会被切成多个 DATA chunk,而这些分片必须在该流上连续发送,中间不能插入其他流的数据。
用普通 DATA(RFC 9260):
流 0 发一个 10 MB 文件 ─────────────────────────────▶ 占满发送侧
流 1 想发一条 20 字节的聊天消息 ✕ 只能排队等于是「一条连接多路复用」的承诺,在传大文件时就破功了——流间阻塞变成了实际问题。
解法是 I-DATA(RFC 8260)。 它把 DATA chunk 换成 I-DATA,多带两个字段:
- MID(Message Identifier) —— 取代 SSN,标识这是该流的第几条消息。
- FSN(Fragment Sequence Number) —— 标识这是该消息的第几个分片。
有了这两个号,接收方就能把交错到达的分片正确重组,于是发送方可以在一条大消息的分片之间插入其他流的数据。真正的流间独立,到 I-DATA 才算兑现。
dcSCTP 实现了 I-DATA(net/dcsctp/packet/chunk/ 下有对应的 chunk 类型),并在握手时协商是否启用。net/dcsctp/tx/stream_scheduler 则负责「下一个包从哪条流取」——有了它,多条流之间才能按策略公平地轮转发送。
这也解释了为什么 SSN 和 MID 在文档里常被并列提及:它们是同一个角色在两代协议里的两个名字,普通 DATA 用 SSN,I-DATA 用 MID。
小结与下一章
SCTP 的流是它区别于 TCP 的根本设计:一个 association 内多条独立流,流 id 按 DTLS 角色分奇偶避免撞号,**有序性按流**保证(SSN 重排,无序流则直接交付),流数量在握手时取双方较小值且中途难加,关流靠 RE-CONFIG 重置回收 id。
但要注意那个反直觉的结论:只有多流还不够——普通 DATA 的分片必须连续发送,大消息照样会阻塞其他流;要靠 I-DATA(MID + FSN)允许分片交错,流间独立才真正成立。
流讲完了,还差最后一块:这些流怎么和数据通道对应起来、通道又怎么协商开启?下一章讲 DCEP,收尾第 8 部分。👉 数据通道建立 · DCEP。