Rust in Everything
基于 rustls 0.23.46

发送路径

主线位置:记录主线的发送侧,也是主线的起点。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)。

📝NOTE
冷知识: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 倍。

💬 关联讨论

发起讨论
加载中...