Tunnelto 源码解析 #8:多路复用机制:StreamId、ActiveStreams 与并发请求生命周期

前几篇文章中,我们已经打通了 tunnelto 的核心链路:

复制代码
浏览器
  ↓
tunnelto_server
  ↓
WebSocket 控制通道
  ↓
tunnelto 客户端
  ↓
localhost:8000

也分析了服务端如何通过 Host Header 找到对应客户端,以及客户端如何把远端请求写入本地 TCP 连接。

这一篇继续深入一个关键问题:

复制代码
一条 WebSocket 控制通道,如何同时处理多个远端请求?

比如浏览器访问一个页面时,通常不会只发一个请求,而是会连续甚至并发请求:

复制代码
GET /index.html
GET /style.css
GET /app.js
GET /favicon.ico
GET /api/user

这些请求都访问同一个公网域名,也都会走同一个 tunnelto 客户端连接。

那么问题来了:

复制代码
服务端怎么区分这些请求?
客户端怎么知道某段数据应该写入哪个本地 TCP 连接?
本地服务返回响应后,服务端又怎么知道应该写回哪个浏览器 socket?

答案就是本篇的主角:

复制代码
StreamId + ActiveStreams

一、为什么需要多路复用?

假设用户执行:

复制代码
tunnelto --port 8000

客户端会和服务端建立一条 WebSocket 控制通道。

这条通道是长期存在的。

如果只有一个请求,那事情很简单:

复制代码
浏览器请求 -> 服务端 -> WebSocket -> 客户端 -> localhost:8000

但真实场景里,一个页面可能同时有多个请求。

如果每个请求都重新建立一条客户端到服务端的 WebSocket,开销会很大,也不符合 tunnelto 的设计。

所以 tunnelto 采用了更轻量的方式:

复制代码
一条 WebSocket 控制通道
承载多个逻辑 stream
每个 stream 对应一个远端 TCP 连接
每个 stream 用 StreamId 区分

这就是多路复用。

可以把它想象成一条高速公路:

复制代码
WebSocket = 高速公路
StreamId  = 每辆车的车牌号
Data 包   = 每辆车上的货物

所有数据都走同一条路,但每个数据包都有自己的身份标识。


二、StreamId:每个逻辑连接的唯一标识

tunnelto_lib 中,StreamId 是一个 8 字节数组:

复制代码
pub struct StreamId([u8; 8]);

它通过随机字节生成。

每当服务端接收到一个新的远端 TCP 连接,就会创建一个新的 StreamId

例如:

复制代码
浏览器连接 A -> StreamId A
浏览器连接 B -> StreamId B
浏览器连接 C -> StreamId C

之后,无论是请求数据还是响应数据,只要属于同一个连接,就都会携带同一个 StreamId

例如:

复制代码
Data(StreamId A, 请求 A 的字节)
Data(StreamId B, 请求 B 的字节)
Data(StreamId A, 响应 A 的字节)
Data(StreamId C, 请求 C 的字节)

这样即使多个请求的数据交错传输,客户端和服务端也能知道每段数据属于哪个连接。

没有 StreamId,一条 WebSocket 里混着多个请求的数据,就会完全乱掉。


三、ControlPacket 如何承载 StreamId

tunnelto 的控制通道消息统一用 ControlPacket 表示。

核心类型包括:

复制代码
ControlPacket::Init(StreamId)
ControlPacket::Data(StreamId, Vec<u8>)
ControlPacket::Refused(StreamId)
ControlPacket::End(StreamId)
ControlPacket::Ping(...)

可以看到,除了 Ping,其他和 stream 相关的控制包都带有 StreamId

它们分别表示:

复制代码
Init      某个 stream 开始
Data      某个 stream 上有数据
Refused   某个 stream 被拒绝
End       某个 stream 结束

所以一个请求的生命周期可以抽象为:

复制代码
Init(stream_id)
  ↓
Data(stream_id, request_bytes)
  ↓
Data(stream_id, response_bytes)
  ↓
End(stream_id)

如果本地服务连接失败,则变成:

复制代码
Init(stream_id)
  ↓
Data(stream_id, request_bytes)
  ↓
Refused(stream_id)

这就是 tunnelto 多路复用协议的基本形态。


四、服务端侧的 ActiveStream

服务端上有一个结构叫 ActiveStream

它可以简化理解为:

复制代码
ActiveStream {
    id: StreamId,
    client: ConnectedClient,
    tx: UnboundedSender<StreamMessage>,
}

它表示:

复制代码
一个正在进行中的远端 TCP 连接

其中:

复制代码
id      当前远端连接的 StreamId
client  这个请求对应的 tunnel 客户端
tx      服务端写回远端 socket 的消息通道

也就是说,服务端收到浏览器请求后,并不是直接把 socket 丢给客户端,而是先为这个 socket 创建一个 ActiveStream

然后用这个 ActiveStream 把三件事绑定起来:

复制代码
远端浏览器 socket
目标 tunnel 客户端
当前请求的 StreamId

可以理解成:

复制代码
ActiveStream = remote socket 在服务端内部的"档案"

五、服务端 ACTIVE_STREAMS:StreamId 到远端连接的映射

服务端创建 ActiveStream 后,会把它放入全局的 ACTIVE_STREAMS 表。

这张表可以理解成:

复制代码
StreamId -> ActiveStream

为什么需要这张表?

因为客户端返回响应数据时,只会告诉服务端:

复制代码
Data(stream_id, response_bytes)

服务端需要根据 stream_id 找到原来的远端浏览器连接,然后把响应写回去。

如果没有 ACTIVE_STREAMS,服务端就无法知道:

复制代码
这段响应应该写回哪个浏览器?

所以服务端侧的 ACTIVE_STREAMS 解决的是:

复制代码
客户端响应数据 -> 找回远端 socket

例如:

复制代码
stream_A -> browser socket A
stream_B -> browser socket B
stream_C -> browser socket C

客户端返回 stream_B 的响应时,服务端就写回 browser socket B。


六、服务端创建 stream 的过程

当公网请求进入服务端后,remote.rs 会先通过 Host 找到对应的 ConnectedClient

找到客户端后,服务端执行类似这样的流程:

复制代码
ActiveStream::new(client)
  ↓
生成新的 StreamId
  ↓
创建一个 queue_rx
  ↓
split 远端 socket
  ↓
ACTIVE_STREAMS.insert(stream_id, active_stream)
  ↓
启动 process_tcp_stream()
  ↓
启动 tunnel_to_stream()

这一步非常关键。

一个远端 TCP 连接进入后,服务端会拆出两个方向:

复制代码
process_tcp_stream():
  浏览器 -> 客户端

tunnel_to_stream():
  客户端 -> 浏览器

第一个任务负责读取浏览器请求,并发送给客户端。

第二个任务负责等待客户端返回响应,并写回浏览器。

二者通过同一个 StreamId 对齐。


七、process_tcp_stream():远端请求进入客户端

process_tcp_stream() 是服务端读取远端 socket 的任务。

它启动后,首先发送:

复制代码
ControlPacket::Init(stream_id)

这表示:

复制代码
一个新的远端连接开始了。

然后它循环读取浏览器 socket 中的数据。

每次读到一段字节,就封装成:

复制代码
ControlPacket::Data(stream_id, data)

再通过 ConnectedClient.tx 发给对应的 tunnelto 客户端。

也就是说,浏览器请求最终会变成:

复制代码
Data(stream_id, request_bytes)

并沿着 WebSocket 控制通道发送给客户端。

如果浏览器 socket 结束,服务端会发送:

复制代码
ControlPacket::End(stream_id)

通知客户端这个远端连接已经结束。


八、客户端侧的 ACTIVE_STREAMS

客户端也有一个 ACTIVE_STREAMS

但它和服务端的意义不同。

客户端侧的映射可以理解成:

复制代码
StreamId -> 本地 localhost 连接的发送通道

也就是说:

复制代码
服务端 ACTIVE_STREAMS:
  StreamId -> 远端浏览器 socket

客户端 ACTIVE_STREAMS:
  StreamId -> 本地 localhost socket

这两个表通过同一个 StreamId 对齐。

完整关系是:

复制代码
浏览器 socket
    ↕
服务端 ActiveStream
    ↕
StreamId
    ↕
客户端 ActiveStream 记录
    ↕
本地 localhost socket

这就是 tunnelto 的 stream 映射模型。


九、客户端收到 Data 后如何路由

客户端 WebSocket 读取循环收到服务端消息后,会调用:

复制代码
process_control_flow_message()

如果解析出的控制包是:

复制代码
ControlPacket::Data(stream_id, data)

客户端会先检查:

复制代码
ACTIVE_STREAMS 里是否已经有这个 stream_id?

如果没有,说明这是这个远端请求第一次传来数据。

客户端会调用:

复制代码
local::setup_new_stream()

创建一个新的本地 TCP 连接:

复制代码
localhost:8000

并把这个连接的发送通道插入客户端 ACTIVE_STREAMS

复制代码
stream_id -> local_tx

之后,客户端再把当前这段 data 发送给这个本地连接。

如果后续又收到同一个 stream_id 的数据,就不再新建本地连接,而是直接通过已有的 local_tx 写入同一个本地 socket。

这就保证了同一个远端连接的数据始终进入同一个本地连接。


十、setup_new_stream():客户端建立本地连接

客户端的 setup_new_stream() 做了几件事。

第一,连接本地服务:

复制代码
TcpStream::connect(config.local_addr)

例如:

复制代码
localhost:8000

第二,如果启用了 --use-tls,则把本地 TCP 连接升级成 TLS 连接。

第三,把本地连接拆成读写两个方向:

复制代码
读取方向:process_local_tcp()
写入方向:forward_to_local_tcp()

第四,把当前 stream_id 插入客户端 ACTIVE_STREAMS

复制代码
ACTIVE_STREAMS.insert(stream_id, tx)

这意味着,从这一刻开始,客户端知道:

复制代码
以后收到这个 stream_id 的远端数据,都写入这个本地连接。

十一、forward_to_local_tcp():远端数据写入本地服务

客户端创建本地 stream 后,会启动:

复制代码
forward_to_local_tcp()

它的职责是:

复制代码
从当前 stream 的队列中读取数据
写入本地 TCP 连接

也就是:

复制代码
Data(stream_id, request_bytes)
  ↓
StreamMessage::Data(request_bytes)
  ↓
forward_to_local_tcp()
  ↓
localhost:8000

这样,外部浏览器发出的 HTTP 请求字节就进入了本地服务。

如果收到关闭消息,或者队列结束,它会关闭本地写方向。


十二、process_local_tcp():本地响应发回服务端

本地服务处理请求后,会返回响应字节。

客户端通过:

复制代码
process_local_tcp()

读取本地服务返回的数据。

每读到一段字节,就封装成:

复制代码
ControlPacket::Data(stream_id, response_bytes)

然后通过 WebSocket 发回服务端。

注意这里仍然携带同一个 StreamId

服务端收到后,才能知道这段响应属于哪个浏览器连接。

如果本地连接读到 0 字节,客户端会把这个 stream_idACTIVE_STREAMS 中移除。

这一步对应本地连接生命周期结束。


十三、tunnel_to_stream():服务端把响应写回浏览器

服务端侧的 tunnel_to_stream() 负责反方向:

复制代码
客户端返回数据 -> 浏览器 socket

当客户端返回:

复制代码
Data(stream_id, response_bytes)

服务端根据 stream_id 找到对应的 ActiveStream,把数据放进它的队列。

tunnel_to_stream() 从队列取出数据,然后写入远端 socket:

复制代码
browser socket.write_all(response_bytes)

这样,浏览器最终收到的就是本地服务的响应。

如果收到 TunnelRefused,服务端会写回 connection refused 响应。

如果收到 NoClientTunnel,服务端会写回 tunnel not found 响应。

如果队列结束,服务端会关闭 socket,并从服务端 ACTIVE_STREAMS 中移除这个 stream_id


十四、一个请求的完整生命周期

现在把一次请求完整串起来。

假设浏览器访问:

复制代码
https://abc123.tunnelto.dev/api/hello

服务端收到这个远端 TCP 连接。

1. 服务端创建 stream

复制代码
ActiveStream::new(client)
  ↓
生成 StreamId = stream_X
  ↓
ACTIVE_STREAMS.insert(stream_X, active_stream)

2. 服务端通知客户端

复制代码
ControlPacket::Init(stream_X)

3. 服务端读取浏览器请求

复制代码
GET /api/hello HTTP/1.1
Host: abc123.tunnelto.dev

封装成:

复制代码
ControlPacket::Data(stream_X, request_bytes)

发给客户端。

4. 客户端创建本地连接

客户端发现 stream_X 不存在,于是:

复制代码
TcpStream::connect(localhost:8000)
  ↓
ACTIVE_STREAMS.insert(stream_X, local_tx)

5. 客户端写入本地服务

复制代码
request_bytes -> localhost:8000

6. 本地服务返回响应

复制代码
HTTP/1.1 200 OK
Content-Type: application/json

{"message":"hello"}

客户端读取响应,并发送:

复制代码
ControlPacket::Data(stream_X, response_bytes)

7. 服务端写回浏览器

服务端根据 stream_X 找到远端 socket:

复制代码
stream_X -> browser socket

然后写回响应字节。

8. 请求结束并清理

当远端或本地连接结束时,双方分别清理:

复制代码
服务端 ACTIVE_STREAMS.remove(stream_X)
客户端 ACTIVE_STREAMS.remove(stream_X)

这个 stream 的生命周期就结束了。


十五、多个并发请求如何同时运行

假设浏览器同时请求:

复制代码
/index.html
/style.css
/app.js

服务端会创建三个 stream:

复制代码
/index.html -> stream_A
/style.css  -> stream_B
/app.js     -> stream_C

然后 WebSocket 控制通道里可能出现这样的数据顺序:

复制代码
Init(stream_A)
Data(stream_A, ...)
Init(stream_B)
Data(stream_B, ...)
Data(stream_A, ...)
Init(stream_C)
Data(stream_C, ...)
Data(stream_B, ...)
End(stream_A)
Data(stream_C, ...)
End(stream_B)
End(stream_C)

这些消息可以交错出现。

但因为每个包都带 StreamId,客户端可以正确分发:

复制代码
stream_A 的数据 -> local socket A
stream_B 的数据 -> local socket B
stream_C 的数据 -> local socket C

本地响应返回时也一样:

复制代码
stream_B 的响应 -> browser socket B
stream_A 的响应 -> browser socket A
stream_C 的响应 -> browser socket C

这就是多路复用的核心价值。


十六、End:stream 生命周期的结束信号

End(stream_id) 表示某个逻辑连接结束。

在服务端读取远端 socket 时,如果读到 0 字节,就会向客户端发送:

复制代码
ControlPacket::End(stream_id)

客户端收到后,会找到对应本地 stream,延迟一小段时间后发送关闭消息,并把这个 stream_id 从客户端 ACTIVE_STREAMS 中移除。

这里有一个值得注意的点:End 只结束某个 stream,不结束整个 WebSocket。

也就是说:

复制代码
End(stream_A) 不影响 stream_B
End(stream_B) 不影响 WebSocket 控制通道

这正是多路复用系统必须具备的能力。


十七、Refused:stream 级别的失败

如果客户端收到 Data(stream_id, data) 后,发现本地服务无法连接,例如:

复制代码
localhost:8000 没有服务
端口写错了
本地 TLS 握手失败

客户端会发送:

复制代码
ControlPacket::Refused(stream_id)

这不是整个 tunnel 失败,而是某一个 stream 失败。

也就是说:

复制代码
stream_X 连接本地失败
不代表 WebSocket 控制通道断开
也不代表其他 stream 不能继续工作

这也是 StreamId 的价值之一:错误可以被限制在单个请求生命周期内。


十八、两侧 ACTIVE_STREAMS 的镜像关系

到这里,可以总结出一个非常重要的模型:

复制代码
服务端 ACTIVE_STREAMS:
  StreamId -> ActiveStream -> browser socket

客户端 ACTIVE_STREAMS:
  StreamId -> local stream tx -> localhost socket

中间通过 WebSocket 和 ControlPacket 连接:

复制代码
browser socket
   ↕
server ACTIVE_STREAMS[stream_id]
   ↕
ControlPacket::Data(stream_id, bytes)
   ↕
client ACTIVE_STREAMS[stream_id]
   ↕
localhost socket

这就是 tunnelto 多路复用的核心。

它不是复杂的 HTTP 代理模型,而是一个非常清晰的 stream 映射模型。


十九、为什么不直接一个请求一个 WebSocket?

从实现上看,一个请求一个 WebSocket 似乎也能工作。

但那样会带来几个问题。

第一,连接成本高。

每个请求都要建立一条客户端到服务端的新连接,握手成本很高。

第二,客户端管理复杂。

浏览器加载一个页面就可能产生十几个请求,客户端需要频繁建立和关闭控制连接。

第三,无法保持稳定 tunnel。

内网穿透工具更适合维持一条长期控制通道,把多个请求复用在上面。

所以 tunnelto 的设计是:

复制代码
控制通道长期存在
请求连接短期存在
请求用 StreamId 标识
生命周期用 Init / Data / End 管理

这是一种非常自然的设计。


二十、从源码看这个设计的优点

1. 简单

核心结构只有两个:

复制代码
StreamId
ActiveStream

核心消息只有几个:

复制代码
Init
Data
Refused
End

读源码时很容易建立整体模型。

2. 清晰

服务端管理远端 socket,客户端管理本地 socket。

两边职责分明。

3. 支持并发

多个请求可以同时走同一条 WebSocket,不会互相串数据。

4. 错误隔离

某个 stream 失败,不影响整个 tunnel。

5. 资源可清理

End、队列关闭、本地读取结束都会触发 stream 清理。

这避免了长时间运行时无限积累无效连接。


二十一、这套机制的局限

当然,这套设计也有一些局限。

1. StreamId 是随机生成

随机 8 字节足够轻量,但没有额外语义。

调试时需要依赖日志中的 stream_... 字符串来追踪。

2. 缺少更细粒度的错误原因

Refused(stream_id) 只能说明某个 stream 被拒绝,但不能表达详细原因。

例如:

复制代码
端口未监听
TLS 握手失败
本地服务超时
权限问题

在协议层都被简化成 refused。

3. HTTP 级别信息不参与路由

tunnelto 主要按 Host 和 stream 转发字节,不在协议层理解 HTTP 方法、路径和状态码。

这让协议简单,但也意味着高级流量治理能力需要额外实现。

4. 清理时机依赖两端协作

stream 的完整清理依赖远端 socket、本地 socket、控制包和 channel 状态协同。

如果某一侧异常退出,另一侧需要靠错误处理、队列关闭或连接断开来收尾。


二十二、这一篇的核心结论

tunnelto 的多路复用机制可以总结成一句话:

复制代码
服务端为每个远端 TCP 连接创建一个 StreamId 和 ActiveStream,
客户端用同一个 StreamId 创建对应的本地 TCP 连接,
双方通过 ControlPacket::Init / Data / Refused / End 在同一条 WebSocket 控制通道中管理多个并发请求的生命周期。

更简洁地说:

复制代码
一条 WebSocket
多个 StreamId
两侧 ActiveStreams
每个 StreamId 绑定一对远端 socket 和本地 socket

理解了这个模型,再看 tunnelto 的数据转发代码就会非常清晰。

你会发现它并不是"神秘地把外网请求穿透到本地",而是在做一件很工程化的事:

复制代码
用 StreamId 把远端连接和本地连接一一对应起来,
再用 WebSocket 作为中间传输通道搬运字节。

二十三、下一篇预告

下一篇我们继续分析控制服务器:

Tunnelto 源码解析 #9:控制服务器设计:Warp、WebSocket、Ping/Pong 与连接保活

下一篇会重点研究:

复制代码
control_server.rs
/wormhole WebSocket 路由
handle_new_connection()
ConnectedClient 登记
tunnel_client()
process_client_messages()
PING_INTERVAL
ReconnectToken
匿名 tunnel 的保活与重连

也就是客户端连接服务端之后,服务端如何维持这条长期控制通道,并在客户端断开时及时清理连接。

相关推荐
数智化管理手记4 小时前
标准作业越推越虚?重塑认知、规避误区,破解精益落地形式主义
大数据·网络·精益工程
国科安芯6 小时前
ASP7A84AS——航天级低噪声高PSRR线性稳压器
网络·单片机·嵌入式硬件·架构·安全性测试
以太浮标6 小时前
华为eNSP模拟器综合实验之- 路由黑洞场景解析及实验
运维·网络·网络协议·网络安全·华为·智能路由器·信息与通信
MetrixAeroCore7 小时前
Metrix 国际物联网卡资费及套餐 — 全球流量池·按量付费·无隐形费
网络
志栋智能7 小时前
超自动化巡检:在混合云时代更显其必要性
大数据·运维·网络·人工智能·自动化
小二·8 小时前
Python 异步编程深度解析:Async/Await 实战
网络·python·github
Yang96118 小时前
宽温大功率输出,LDMN-GM7 助力矿区雷达性能验收工作
网络·能源
网安小白的进阶之路9 小时前
B模块 安全通信网络 第二门课IPv6与WLAN 03
网络·安全
dong__csdn9 小时前
websocket实现简单的单聊、群聊demo
网络·websocket·网络协议