发送路径
主线位置:记录主线的发送侧,也是主线的起点。
writer().write_all(b"...")交进来的明文在这里被分片、交给记录层加密、排进发送缓冲,直到调用方write_tls把字节取走。握手还没完成时,明文先放在另一块缓冲里等着。接收侧见去帧与缓冲。
flowchart TB
w["writer write"] --> sp["send_plaintext"]
sp --> may{"握手完成"}
may -->|否| pt["sendable_plaintext 拷贝"]
may -->|是| lim["apply_limit"]
pt -->|start_traffic| lim
lim --> frag["MessageFragmenter"]
frag --> enc["encrypt_outgoing"]
enc --> q["sendable_tls 一条一个 Vec"]
q --> wt["write_tls 至多 64 条"]
代码:msgs/fragmenter.rs(232 行,含测试)、vecbuf.rs(377 行),把它们串起来的是 common_state.rs 和 conn.rs。实验程序是 exp-rustls/rec-lab 的 outbound、smallwrites、allocs、bench,都用 TLS 1.3;除吞吐对比外用 ring 的 TLS13_AES_128_GCM_SHA256。
从 writer() 到 sendable_tls
writer() 返回的 Writer 实现 io::Write,write 和 write_vectored 都进 send_plaintext(它为换密钥循环,见记录层),再到 CommonState 的 send_plain:
if !self.may_send_application_data {
// If we haven't completed handshaking, buffer
// plaintext to send once we do.
let len = match limit {
Limit::Yes => sendable_plaintext.append_limited_copy(payload),
Limit::No => sendable_plaintext.append(payload.to_vec()),
};
return len;
}
self.send_plain_non_buffering(payload, limit, true)
握手完成后走 send_appdata_encrypt:先按缓冲上限截短(apply_limit),再分片,每一片 encrypt_outgoing 得到一个 OutboundOpaqueMessage,queue_tls_message 把它 encode() 成一个完整记录的 Vec<u8> 追加进 sendable_tls。加密器分配的那块缓冲前面已经留好 5 字节的头,encode 只是填上头、把 Vec 交出去,没有再拷贝。所以发送侧每条记录正好一次分配、一次拷贝(明文拷进密文缓冲,在原处加密)。去帧与缓冲里的分配计数显示,一次写 65536 字节是 4 次分配、每次 16406 字节,write_tls 本身不分配。
握手消息走另一条路但终点相同:状态机把一组飞行里的几条握手消息编码进同一个 HandshakeFlight 缓冲,send_msg 再整体分片、加密、排队。这就是为什么一次握手的抓包里服务端的 EncryptedExtensions、Certificate、CertificateVerify、Finished 只占一条记录。
分片:MessageFragmenter
MessageFragmenter 只有一个字段 max_frag,默认 MAX_FRAGMENT_LEN = 16384(2^14,TLS 允许的最大明文)。fragment_payload 返回一个迭代器,Chunker 每次从 OutboundChunks 前面 split_at(max_frag)。OutboundChunks 要么是一个切片,要么是「一组切片 + 起止游标」(write_vectored 的多个 IoSlice),分片只是移动游标,明文直到加密那一刻才被拷贝。迭代器实现 ExactSizeIterator,记录层据此提前知道这次写入要占几个序列号。
ClientConfig / ServerConfig 的 max_fragment_size 改的就是 max_frag,经过 set_max_fragment_size:
pub fn set_max_fragment_size(&mut self, max_fragment_size: Option<usize>) -> Result<(), Error> {
self.max_frag = match max_fragment_size {
Some(sz @ 32..=MAX_FRAGMENT_SIZE) => sz - PACKET_OVERHEAD,
None => MAX_FRAGMENT_LEN,
_ => return Err(Error::BadMaxFragmentSize),
};
Ok(())
}
合法范围 32 到 16389,减去的 PACKET_OVERHEAD 是 5 字节记录头。配置项的文档说它是「单条 TLS 记录里明文输入的最大长度」,代码却从中减去了头的 5 字节,而 AEAD 的开销(TLS 1.3 的内层类型 1 字节和 16 字节标签)又完全没算:设成 1000,每条记录装 995 字节明文,线上 1017 字节,两种理解都对不上。实测一次写 100000 字节:
== fragmentation of one 100000-byte write after the handshake
max_fragment_size None: write returned 100000, 100154 bytes, 7 records [16406x6, 1718]
max_fragment_size Some(1000): write returned 100000, 102222 bytes, 101 records [1017x100, 522]
max_fragment_size Some(32): write returned 100000, 181488 bytes, 3704 records [49x3703, 41]
max_fragment_size Some(31): Some(BadMaxFragmentSize)
默认是 6 条 16406 字节(16384 + 1 + 16 + 5)加一条尾巴。Some(32) 每条只有 27 字节明文,线上膨胀到 1.8 倍。Some(31) 在 ClientConnection::new 时就报错。这只是 rustls 自己发送时的上限,不和对端协商:rustls 不实现 RFC 6066 的 max_fragment_length 扩展,也不实现 RFC 8449 的 record_size_limit,接收方向始终接受 16 KiB 的记录。
握手完成前的缓冲
握手完成前写入的明文进 ConnectionCommon 的 sendable_plaintext,一个 ChunkVecBuffer;append_limited_copy 把每次 write 的数据拷成一个新 Vec 追加进去。等状态机调 start_outgoing_traffic,flush_plaintext 一块一块 pop 出来加密:
while let Some(buf) = sendable_plaintext.pop() {
self.send_plain_non_buffering(buf.as_slice().into(), Limit::No, false);
}
客户端在收到服务端 Finished、发出自己的 Finished 时开始发送,所以缓冲的数据和 Finished 在同一组飞行里出去。服务端默认要等客户端的 Finished;ServerConfig::send_half_rtt_data 打开且不要求客户端证书时,服务端在发出自己的 Finished 后就开始发(0.5-RTT 数据)。实测,握手开始前写 100000 字节:
== writes before the handshake are buffered (default limit)
handshaking=true write #1 -> 65536, write #2 -> 0, write_all -> Err(Error { kind: WriteZero, message: "failed to write whole buffer" })
write_all error: kind WriteZero, Display "failed to write whole buffer"
client flight 1: 1 records [212]
server flight 1: 3 records [127, 6, 513]
client flight 2: 6 records [6, 58, 16406x4]
server received 65536 plaintext bytes
只收下 64 KiB(下一节)。客户端第二组飞行是 ChangeCipherSpec(6)、Finished(58)和 4 条满载的应用数据记录。
注意 pop 是一块一块的:缓冲里的每一块单独分片,块之间不合并。smallwrites 在握手前连续 write(b"x"):
during the handshake: 65536 one-byte writes accepted, then write returned 0
client flight 2: 65538 records, 1507392 bytes; 65536 of them are 23-byte records carrying one byte each
65536 次 1 字节的写入变成了 65536 条记录、1.5 MB 的线上数据,是明文的 23 倍。握手后直接写也是一次 write 至少一条记录,rustls 不做 Nagle 式的合并;小块写入应当先在应用层攒起来(BufWriter 之类),再交给 writer()。
ChunkVecBuffer 与 write_tls
sendable_plaintext、sendable_tls、接收侧的 received_plaintext 都是 ChunkVecBuffer:
pub(crate) struct ChunkVecBuffer {
/// How many bytes have been consumed in the first chunk.
///
/// Invariant: zero if `chunks.is_empty()`
/// Invariant: 0 <= `prefix_used` < `chunks[0].len()`
prefix_used: usize,
chunks: VecDeque<Vec<u8>>,
/// The total upper limit (in bytes) of this object.
limit: Option<usize>,
}
追加就是把 Vec 推到队尾,不拷贝;读出时只有第一块可能被读一半,用 prefix_used 记下读到哪里,读完整块才 pop_front(consume)。len() 每次把所有块的长度加一遍,块数通常很少。
write_tls 调 sendable_tls 的 write_to:把最多 64 块装进一个 [IoSlice; 64],调一次 write_vectored,写了多少就 consume 多少。在 sendable_tls 里一块就是一条记录,所以一次 write_tls 最多交出 64 条记录(约 1 MiB),而且取决于 io::Write 实现:没有覆盖 write_vectored 的类型用标准库的默认实现,只写第一个非空切片,也就是一条记录。实测关掉缓冲上限、写 2 MiB(128 条记录)后:
== write_tls batching (limit None, 2 MiB written)
Vec<u8>: one write_tls wrote 1049984 bytes = 64 records [16406x64]
writer without write_vectored: one write_tls wrote 16406 bytes = 1 records [16406]
total write_tls calls: 3
套接字的 write_vectored 是 writev,一次系统调用交出多条记录;包装套接字的自定义 Write 如果忘了转发 write_vectored,每条记录就是一次系统调用。write_to 还检查 write_vectored 的返回值不超过给它的字节数,否则当作不可恢复的错误(源码注释指向 issue #2316)。
prefix_used 是 0.23.15 才有的之前 consume 读了半块时,直接对那一块 buf.drain(..used),再把它推回队首。drain 要把剩下的字节整体前移,读一条 16 KiB 记录的明文时,如果调用方每次只读几十字节,每读一次就搬一次剩余部分,总代价和块长的平方成正比;write_tls 被套接字部分写入时同理。916494a0(「vecbuf: add bookkeeping to avoid drain() expense」,首次进入 0.23.15)加了 prefix_used,只记下偏移;pop 在把整块交出去时才 drain 掉前缀,注释写的是「(uncommon)」。后来 a5d8a6b1 和 784b8733(都在 0.23.22)又为这个新的不变量加了两道防线:consume 超过长度时的 debug_assert,和上面那个 write_vectored 返回值检查。
set_buffer_limit
set_buffer_limit 同时设置 sendable_plaintext 和 sendable_tls 两个缓冲的上限,默认都是 DEFAULT_BUFFER_LIMIT = 64 KiB,None 表示不限。上限只约束 writer():握手消息和告警不受限制。判断用的是 apply_limit:
pub(crate) fn apply_limit(&self, len: usize) -> usize {
if let Some(limit) = self.limit {
let space = limit.saturating_sub(self.len());
cmp::min(len, space)
} else {
len
}
}
这里有两点和直觉不同:
- 握手后比较的是密文,截短的是明文。
send_appdata_encrypt拿sendable_tls里已经排队的密文长度算剩余空间,再用它截短这次写入的明文。源码注释承认这样会「out by whatever the cipher+record overhead is」。所以sendable_tls为空时一次write能收下整整 65536 字节明文,排队的密文是 65624 字节,超过了上限;反过来,1 字节的写入每次在sendable_tls里占 23 字节,smallwrites实测握手后只能连续写 2850 次(tls_bytes_to_write到 65550),而握手前(比较的是明文)能写 65536 次。 - 满了返回
Ok(0)。Writer::write不阻塞、不报错,返回 0。io::Write::write_all把Ok(0)当作写不进去,报ErrorKind::WriteZero、「failed to write whole buffer」。
== writes after the handshake (default limit 64 KiB)
write #1 -> 65536, tls_bytes_to_write 65624, write #2 -> 0
after 1 write_tls call(s) of 65624 bytes: write #3 -> 65536
set_buffer_limit(Some(1)): write -> 1, 1 records [23]
after the handshake: 2850 one-byte writes accepted, tls_bytes_to_write 65550, then write returned 0
正确的用法是写一点、write_tls 一点。rustls 自带的 Stream 在每次 write 前先 complete_prior_io 把排队的密文写到套接字,写完再 complete_io;tokio-rustls 0.26 的 poll_write 每交给 writer() 一次就把 sendable_tls 冲到底层 IO,再继续写剩下的部分。自己驱动 sans-IO 循环时,对 writer() 用 write_all 一次塞进超过 64 KiB 的数据,就会撞上 WriteZero;要么分批并在中间调 write_tls,要么 set_buffer_limit(None) 并自己负责内存。Some(1) 是另一个极端:每次只收 1 字节,一条 23 字节的记录。
不想要这层缓冲的程序可以用 unbuffered API(conn/unbuffered.rs):它的 write_plaintext 先算出所需空间,不够就返回 InsufficientSizeError,够就直接把记录加密写进调用方给的切片,sendable_tls 只用来暂存握手消息。调用循环的写法见 sans-IO。
吞吐:ring 与 aws-lc-rs
bench:握手后关掉缓冲上限,客户端以 64 KiB 为单位写 32 MiB、write_tls 进一个预先分配的 Vec(「send」计时),服务端 read_tls + process_new_packets + reader().read 读完(「receive」计时)。记录大小用 max_fragment_size = 记录明文长度 + 5 控制。每个组合跑 7 次取最快,单位是每秒百万字节明文。机器上同时有其他写手的任务,uptime 的 1 分钟负载在 6 到 8 之间(12 核);同样的程序跑第二遍,各项差别大多在 5% 以内、最大 11%,下面是第一遍:
32 MiB per run, 64 KiB writes, min of 7 runs, MB/s = 10^6 plaintext bytes per second
provider suite record send receive
ring TLS13_AES_128_GCM_SHA256 256 1902 1141
aws-lc-rs TLS13_AES_128_GCM_SHA256 256 1739 1170
ring TLS13_AES_128_GCM_SHA256 1024 3701 2692
aws-lc-rs TLS13_AES_128_GCM_SHA256 1024 3820 2936
ring TLS13_AES_128_GCM_SHA256 4096 4638 3974
aws-lc-rs TLS13_AES_128_GCM_SHA256 4096 5237 4751
ring TLS13_AES_128_GCM_SHA256 16384 5541 4843
aws-lc-rs TLS13_AES_128_GCM_SHA256 16384 6700 6075
ring TLS13_AES_256_GCM_SHA384 256 1796 1162
aws-lc-rs TLS13_AES_256_GCM_SHA384 256 1687 1133
ring TLS13_AES_256_GCM_SHA384 1024 3390 2681
aws-lc-rs TLS13_AES_256_GCM_SHA384 1024 3449 2675
ring TLS13_AES_256_GCM_SHA384 4096 4162 3930
aws-lc-rs TLS13_AES_256_GCM_SHA384 4096 4606 4208
ring TLS13_AES_256_GCM_SHA384 16384 5075 4752
aws-lc-rs TLS13_AES_256_GCM_SHA384 16384 5881 5374
ring TLS13_CHACHA20_POLY1305 256 597 529
aws-lc-rs TLS13_CHACHA20_POLY1305 256 577 513
ring TLS13_CHACHA20_POLY1305 1024 1133 913
aws-lc-rs TLS13_CHACHA20_POLY1305 1024 1134 926
ring TLS13_CHACHA20_POLY1305 4096 1375 1321
aws-lc-rs TLS13_CHACHA20_POLY1305 4096 1361 1361
ring TLS13_CHACHA20_POLY1305 16384 1544 1494
aws-lc-rs TLS13_CHACHA20_POLY1305 16384 1551 1492
- 记录越大越快。 AES-128-GCM 从 256 字节到 16 KiB,发送快 3 倍、接收快 4 倍多:每条记录固定有一次分配、一次头编码、一次 AEAD 调用的初始化,小记录摊不开。默认 16 KiB 分片是吞吐最好的选择;把
max_fragment_size调小换来的是更低的首字节延迟。 - 接收比发送慢。 接收多一次拷贝(明文进
received_plaintext再读出来),还要切记录、检查头。 - AES-GCM 上 aws-lc-rs 更快,记录越大差得越多。 16 KiB 时 AES-128-GCM 发送快约 21%、接收快约 25%;256 字节时两者相当,ring 的发送还略快。
- ChaCha20 两家一样,只有 AES-GCM 的四分之一左右。 M2 有 AES 和多项式乘法指令,AES-GCM 由硬件加速;ChaCha20 没有对应的专用指令。
这些是单核、内存里、不含网络和系统调用的上限:一次 16 KiB 记录的加密加解密约 6 微秒,远低于一次网络往返。两个 provider 怎么接进 rustls、各自的套件列表见 CryptoProvider 和套件与密钥交换。
小结
writer()的数据在握手完成前拷进sendable_plaintext,之后经MessageFragmenter分片、逐片加密,每条记录一个Vec进sendable_tls;发送侧每条记录一次分配、一次拷贝。- 默认分片 16384 字节明文;
max_fragment_size的合法范围是 32 到 16389,实际明文上限是配置值减 5,AEAD 开销另算,和文档的说法对不上;rustls 不和对端协商记录大小。 - 握手前缓冲的每次写入各自成为至少一条记录,65536 次 1 字节写入变成 65536 条记录;rustls 不合并小写入。
write_tls一次最多交出 64 条记录,依赖write_vectored;没实现它的Write每次只写一条。set_buffer_limit默认 64 KiB,握手后按排队的密文长度截短明文,满了write返回Ok(0),write_all报WriteZero。- 内存中 16 KiB 记录:AES-128-GCM 发送 ring 约 5.5 GB/s、aws-lc-rs 约 6.7 GB/s;ChaCha20 两家都约 1.5 GB/s;256 字节记录慢 3 到 4 倍。