ubsocket
ubsocket 的源码位于 openEuler 的 ubs-comm 仓库。最值得说的一点是:当通信从内核 TCP 转移到用户态 UB 传输之后,原本由内核维护的连接状态、资源回收和事件通知,需要由通信库重新组织,并且让上层框架仍然能够正确运行。
这篇文章从我的相关提交出发,解释期间遇到的问题、它们为什么会出现,以及如果要重新设计时 kbsocket 应该怎样减少避免。
我对项目的理解
ubsocket 面向 socket 风格的应用提供 UB 通信能力,brpc 是其中一个重要的集成场景。在这个仓库中,ubsocket 位于 src/ubsocket,其使用的 UMQ 位于 src/hcom/umq。
上层使用对外的 socket、epoll 和 buffer 接口,中间维护连接与收发状态,下层通过 UMQ 使用 UB 传输资源。UMQ 内部再与 URMA 等底层能力对接。
graph TD
A["应用 / bRPC"] --> B["UBSocket API 与 IOBuf 接管"]
B --> C["连接状态、收发、事件通知"]
C --> D["UMQ:队列、buffer、共享资源"]
D --> E["URMA / UB 设备"]
C --> F["TCP:建链协商、连接状态、回退"]
整个项目主要有以下几部分:
- 如何建立连接 双方通过 TCP 进行能力协商、交换 UB 信息、创建 UB 资源。
- 如何推进数据传输 发送提交、硬件执行、完成队列产生 CQE、软件处理 CQE.
post成功不等于应用可以立即释放所有相关内存,也不等于对端应用已经消费数据。 - 如何向应用报告进展 UB 上的完成事件需要转换成上层可理解的可读、可写或失败通知。返回
EAGAIN之后,必须还有一个能够推动重试的来源。 - 谁持有资源,何时归还 普通收发、共享队列、IOBuf 接管和单边 READ 的生命周期不同。CPU 不再使用某个对象,不代表硬件已经不再访问它。
这些责任彼此相连,也是下面几类问题反复出现的原因。
建链
建链之 UB 握手
这里需要区分 TCP 建链与 UB 握手。TCP 提供控制通道,UB 握手在这个通道上进行能力协商、交换资源信息。在本文重点讨论的 jetty 池和共享接收路径中,每条连接有自己的逻辑 UMQ,多条连接可以共享底层接收资源。bind_info 是双方交换的 UMQ 绑定信息,其中包含本地 jetty 的描述 rjetty;对端将收到的 rjetty 导入为本进程中的 target,之后发送时通过这个 target 指定目标。交换的是描述信息,target 则是 import 后在本地得到的对象。
// client
rjetty = urma_get_rjetty();
send_rjetty_to_server(rjetty);
// server
recv_rjetty_from_client(rjetty);
tjetty = urma_import_jetty(rjetty);
下面先看经典流程,再看优化之后建链成功时的流程图。图中的箭头表示逻辑消息,不对应固定数量的 TCP 报文。
最经典的路径:先协商,再交换资源,最后确认结果
下图展示的是最经典的 UB 建链流程图,省略了 TCP 本身的建链,以及 UB 建链失败后的换路重试。
sequenceDiagram
participant C as Client / Connector
participant S as Server / Acceptor
C->>S: 1. NegotiateReq:库版本、能力、传输模式等
S->>C: 2. NegotiateRsp:协商结果、EID、拓扑信息等
Note over C,S: 各自创建本地 UMQ,取得 bind_info
C->>S: 3. CpMsg:客户端 bind_info
S->>C: 4. CpMsg:服务端 bind_info
Note over C,S: CpMsg 双方均先发后收,两个方向可以交叉
Note over C,S: 各自 umq_bind,并完成接收资源准备
C->>S: 5. ack_ret:客户端本轮结果
S->>C: 6. ack_ret:服务端结果及降级判定信息
Note over C,S: 双方成功,进入 UB 通信
这里的 ACK 是 ubsocket 控制协议中的结果通知,与 TCP 报文头的 ACK 标志没有直接关系。它确认的也不只是 umq_bind 返回值,还包括本轮必要的接收队列预填充等准备工作。下文用 READY 表示本端准备成功;除了 import 完成,接收 buffer、软件接收映射等也必须就绪,才能声明可以接收对端数据。
当前优化后的路径
在支持 jetty 池的路径上,双方将 bind_info 都携带到发给对端的首个 TCP 协商包中,并且并行 umq_bind 可以省去独立的 CpMsg 交换。当前这些优化全部生效时,首轮成功流程如下:
- Connector 提前创建本地 UMQ。 在发送协商请求之前,调用
umq_bind_info_get()获取本端bind_info,随NegotiateReq一起发送给 Acceptor。 - Acceptor 在协商应答中返回自己的绑定信息。 收到请求后,确定传输参数,创建本地 UMQ 并取得
bind_info,随NegotiateRsp返回。启用并行 bind 时,先发送应答,再执行本端 bind。 - 双方并行完成绑定与接收准备。 此时双方都已拿到对端的
bind_info,可以各自调用umq_bind(),并完成必要的接收队列预填充等工作。 - 双方交换准备结果。 各自先发送本端结果,再接收对端结果;双方均成功后结束握手,进入 UB 通信。
sequenceDiagram
participant C as Client / Connector
participant S as Server / Acceptor
C->>C: 创建 UMQ,获取 bind_info
C->>S: 1. NegotiateReq + 客户端 bind_info
S->>S: 确定参数,创建 UMQ,获取 bind_info
S->>C: 2. NegotiateRsp + 服务端 bind_info + 结果延后标志
par 客户端准备
C->>C: umq_bind,完成接收准备
C->>S: 3. 客户端结果
and 服务端准备
S->>S: umq_bind,完成接收准备
S->>C: 4. 服务端结果
end
Note over C,S: 第 3、4 条可以交叉,均成功后各自结束握手
这条路径将 bind_info 交换合并进了协商过程,同时让两端 umq_bind 重叠执行。
若服务端在发送协商应答前就完成 umq_bind 和接收准备,也可以把结果放进应答包内,最后只由客户端发送确认,形成三条逻辑消息;不过两端 umq_bind 就会串行执行,消息更少未必耗时更短。
在 RM_CTP 路径中,umq_bind 只会调用 urma_import_jetty,不需要 RC 模式的 urma_bind_jetty。可以认为在正常情况下 urma_import_jetty 平均只有 10us 的耗时。以约 20us 的同机房 TCP RTT 作比较,urma_import_jetty 与 TCP 小包传输耗时差不多。但是我感觉 urma_import_jetty 在高并发下性能会变得更差
怎么才能做得更好一点
我之前倾向于把 UB 握手也整理成三条消息,过于看重消息数量,认为 urma_import_jetty 耗时不会太大。而且本来以为可以使用 urma_import_jetty_async 接口来达到极致建链性能,但现场检查实际 udma context 的 ops 表后,发现当前 raw UDMA provider 没有注册异步 import 及配套 notifier 回调。卒…
现在更合适的做法是:尽早交换 jetty 描述符,两端独立准备,完成后立即发送各自的结果。 本地描述符来自已经创建的 jetty,可以通过 urma_get_rjetty 取得,目前 ubsocket 也是这样做的。
拿一个简化的模型看一下区别:从服务端收到请求开始计时,控制通道单程延迟为 d,两端 import 分别耗时 Ls、Lc,忽略其他准备成本,客户端完成双向就绪确认的时间为:
方案1: 服务端 import 完成才回复:Ls + d + Lc
方案2: 先回复描述符、双方并行 import、结果交叉发送:max(Ls, Lc) + d
例如假设两端 import 都为 10us、d=10us,这段时间分别是 30us 和 20us。
在共享接收路径下,多条逻辑 UMQ 交换的可能是同一个主队列 Jetty 的描述,背后复用共享 JFR。因此,同一对进程建立多条连接时,可以考虑复用导入后的 target,并把相同目标的并发导入合并成一次。
目前分析下来,在当前基础上还可以再做以下几点优化:
- 复用导入结果,减少真正的 import。 为可共享的远端 rjetty 缓存 target;相同 rjetty 缓存未命中的并发合并成一次导入,其余连接等待结果。缓存至少区分本地 context、远端 endpoint generation. 本地 jetty 池、底层 CTP 复用和 target 缓存是三件事,前两者不自动免除 import 调用。
- 如果服务端命中仍然有效的 target 缓存,而且接收资源和软件接收映射都已就绪,就可以在返回本地描述符时一并携带 READY,无需再单独发送结果消息。在前面的简化模型中,服务端缓存命中意味着
Ls≈0,三条消息与结果交叉发送的四条消息都约为d + Lc,剩余等待由客户端 import 决定(客户端本身也有缓存)。缓存 target 减少了底层urma_import_jetty工作及其争用;合并 READY 则可以减少控制消息和处理开销,虽然在这个的耗时上可能与 4 条消息的流程差不多,不过仍旧可以尝试一下。
- 如果服务端命中仍然有效的 target 缓存,而且接收资源和软件接收映射都已就绪,就可以在返回本地描述符时一并携带 READY,无需再单独发送结果消息。在前面的简化模型中,服务端缓存命中意味着
- 专用的建链线程。 当前 ubsocket 已有异步 accept 处理路径,进一步可以统一 Acceptor 和 Connector 的建链执行机制。双方先发送本地描述符,再在各自的建链线程中就地执行同步 import,就能实现两端并行,不必为了并行而将每次 import 再转交另一个线程。
此外另有一个必须要提及,控制消息走 TCP,业务数据走 UB,两条通道之间没有统一的到达顺序。 先发 TCP READY 再发 UB 首包,不保证服务端先处理 READY;接收映射应提前发布,早到的首包可以有界暂存,满足会话确认条件后再交付。
当前 ubsocket 已有类似问题的修复:
aa6049a5将 fd 到 socket 的注册提前到协商之前,避免服务端尚在等客户端 ACK 时,共享 JFR 收到首包却查不到 socket。
这一点与下一章节提及的 epoll 相关内容相关。
EAGAIN 之后:怎样保证连接还能继续推进
原生 brpc 什么时候关注 EPOLLOUT
brpc 的普通 TCP 对于已经注册读回调的 RPC 连接,ResetFileDescriptor() 通过 AddConsumer() 注册 EPOLLIN | EPOLLET,平时不关注 EPOLLOUT。发送时直接尝试写,只有写不动时才临时等待可写。
具体来说,Socket::StartWrite() 通常先在调用线程尝试写一次。如果没有写完,就创建一个新的 bthread 进行 Socket::KeepWrite()。KeepWrite() 会先调用 DoWrite() 继续发送;正数返回值表示有进展,即使只是部分写入,也不立即等待可写。对于没有按致命错误退出、且本次 nw <= 0 的情况,才调用 WaitEpollOut();普通 TCP 最典型的就是 writev() 返回 -1、errno == EAGAIN。
WaitEpollOut() 的调用链是:
Socket::KeepWrite()
-> Socket::WaitEpollOut(fd, pollin, deadline)
-> _io_event.RegisterEvent(fd, pollin)
-> EventDispatcher::RegisterEvent(...)
-> epoll_ctl(...)
-> butex_wait(...)
-> _io_event.UnregisterEvent(fd, pollin)
-> EventDispatcher::UnregisterEvent(...)
-> epoll_ctl(...)
-> 再次尝试 DoWrite()
这里 pollin bool 值来自 _on_edge_triggered_events != NULL,表示这条连接还需要保留读监听。对于这样的连接,注册可写实际执行的是 EPOLL_CTL_MOD,把关注集合改为 EPOLLIN | EPOLLOUT | EPOLLET.
sequenceDiagram
participant W as KeepWrite bthread
participant E as EventDispatcher
participant K as 内核 epoll / TCP
W->>K: DoWrite / writev
K-->>W: -1,errno=EAGAIN
W->>W: 保存 butex 当前值 expected
W->>E: RegisterEvent(fd, true)
E->>K: EPOLL_CTL_MOD:IN + OUT + ET
W->>W: butex_wait(expected),挂起当前 bthread
K-->>E: epoll_wait 返回 EPOLLOUT
E->>W: OnOutputEvent:butex 加一并唤醒
W->>E: UnregisterEvent(fd, true)
E->>K: EPOLL_CTL_MOD:仅保留 IN + ET
W->>K: 再次 DoWrite / writev
EventDispatcher::Run() 收到 EPOLLOUT 后,通过 output_cb 调用 Socket::OnOutputEvent(),增加 _epollout_butex 的值并唤醒等待者,真正的发送仍由恢复的 KeepWrite() 完成。WaitEpollOut() 会在注册事件前保存 butex 值。如果事件先于 butex_wait() 到达,回调已经改变这个值,等待操作就不会再按旧值睡下去。普通 bthread 在这里挂起时,worker 可以继续执行其他 bthread。
这次等待还设置了 50ms 的 deadline。超时后 KeepWrite() 会继续检查待发送请求、推进发送,而不是仅凭这次等待超时就判定连接失败;代码这样做也为了定期检查积压请求和拥塞状态。因此,丢失一次可写通知可能表现为等待到超时后的重试和长尾,并不必然永久卡住。
WaitEpollOut() 从 butex_wait() 返回后就调用 UnregisterEvent(),之后才回到发送循环;它不会等数据全部写完再取消。 被事件唤醒和等待超时都会走这一步。
另外,如果一个新的 Socket 刚准备写,这时它会进行非阻塞 TCP 建链,connect() 通常返回 EINPROGRESS,brpc 用携带 EpollOutRequest 的临时 Socket 注册 pollin=false 的 EPOLLOUT 等待事件。当可写或者超时后进入 HandleEpollOutRequest(),它会先调用 UnregisterEvent(fd, false) (内部调用 EPOLL_CTL_DEL 删除临时注册),再执行连接回调。成功事件仍要通过 CheckConnected() 检查 SO_ERROR。连接确认成功后,实际通信 Socket 再注册自己的读监听。
下面画出 connect() 返回 EINPROGRESS 后的成功路径。临时 Socket 和实际通信 Socket 使用的是同一个 fd,删除临时 epoll 注册并不关闭 fd。图中的 IN、OUT、ET 分别表示 EPOLLIN、EPOLLOUT、EPOLLET,省略 EPOLLRDHUP 等其他标志。
sequenceDiagram
participant S as 实际通信 Socket
participant T as 临时 Socket / EpollOutRequest
participant E as EventDispatcher
participant K as 内核 TCP / epoll
S->>K: 非阻塞 connect(fd)
K-->>S: EINPROGRESS
S->>T: 创建临时 Socket,保存连接回调
T->>E: RegisterEvent(fd, false)
E->>K: EPOLL_CTL_ADD:OUT + ET,关联临时 Socket
K-->>E: epoll_wait 返回 EPOLLOUT
E->>T: OnOutputEvent
T->>T: HandleEpollOutRequest:取得本次处理权
T->>E: UnregisterEvent(fd, false)
E->>K: EPOLL_CTL_DEL:移除临时注册
T->>S: 调用连接回调,进入 CheckConnectedAndKeepWrite
S->>K: CheckConnected:getsockopt(SO_ERROR)
K-->>S: SO_ERROR = 0
S->>S: ResetFileDescriptor(fd)
S->>E: AddConsumer(fd)
E->>K: EPOLL_CTL_ADD:IN + ET,关联实际通信 Socket
S->>S: 进入正常收发
Note over T,S: 超时同样先 DEL,再回调失败;不会进入成功后的 ADD IN 路径
ubsocket 做了什么
原生 TCP 还有一层保证:通过 EPOLL_CTL_MOD 添加可写关注时,内核会重新检查当前 IO 就绪状态。因此,即使发送失败之后、注册可写之前 TCP 已经恢复可写,只要注册时仍然可写,也可以得到通知,不必等下一次状态变化。这个行为在 epoll(7) 中有说明。如果是第一次使用 EPOLL_CTL_ADD 来监听 EPOLLOUT 事件,同样也会进行这个检查。
从连接确认成功开始,一个普通 TCP 通信 Socket 的注册变化如下。图中只画正常发送和 EAGAIN,省略致命错误处理;等待结束后先撤销 OUT,再重试发送。
sequenceDiagram
participant B as bRPC 发送逻辑
participant E as EventDispatcher
participant K as 内核 TCP / epoll
B->>E: 连接确认成功,AddConsumer(fd)
E->>K: EPOLL_CTL_ADD:IN + ET
loop 有待发送数据
B->>K: DoWrite / writev
alt 写入有进展
K-->>B: 返回已写入字节数
Note over B: 未写完则继续发送,本批写完则处理后续业务
else EAGAIN
K-->>B: -1,errno=EAGAIN
B->>B: 保存 butex 当前值 expected
B->>E: RegisterEvent(fd, true)
E->>K: EPOLL_CTL_MOD:IN + OUT + ET
K->>K: 重新检查 TCP 当前就绪状态
alt 当前已可写,或在 deadline 前恢复可写
K-->>E: epoll_wait 返回 OUT
E->>B: OnOutputEvent:butex 加一并唤醒
B->>B: butex_wait 返回,提前到达的通知也不会丢失
else 等待达到 50ms deadline
B->>B: butex_wait 超时返回
end
B->>E: UnregisterEvent(fd, true)
E->>K: EPOLL_CTL_MOD:IN + ET
Note over B: 撤销 OUT 后再重试发送
end
end
B->>E: 连接最终回收,RemoveConsumer(fd)
E->>K: EPOLL_CTL_DEL
B->>K: close(fd)
但是换成 UB 数据面之后,TCP fd 可写与 UB 发送资源可用是两回事。如果直接把控制通道的 TCP EPOLLOUT 当成 UB 可写通知,就可能反复出现 “被唤醒 → UB 发送仍然 EAGAIN → 再次注册 → 又因 TCP 可写被唤醒” 的循环。
假设直接透传 brpc 请求的关注集合,并且 UB 发送资源一直不足、TCP fd 却持续可写,就会形成下面的循环。每轮重新执行 MOD 都会检查 TCP 状态,因此使用 ET 也不能避免这种无效唤醒。
sequenceDiagram
participant B as bRPC 发送逻辑
participant U as 直接透传的 UB 适配层
participant K as 内核 TCP / epoll
B->>U: 连接确认成功,ADD:IN + ET
U->>K: EPOLL_CTL_ADD:IN + ET
loop UB 资源不足,但 TCP fd 持续可写
B->>U: 尝试发送 UB 数据
U-->>B: EAGAIN
B->>B: 保存 butex 当前值 expected
B->>U: 注册可写,MOD:IN + OUT + ET
U->>K: 直接透传 EPOLL_CTL_MOD:IN + OUT + ET
K->>K: 重新检查 TCP fd,可写则加入就绪队列
K-->>U: epoll_wait 返回 OUT
U-->>B: 分发 OUT,触发 OnOutputEvent
B->>B: butex_wait 返回
Note over B,U: UB 资源并未恢复,这次唤醒无法推进发送
B->>U: 撤销 OUT,MOD:IN + ET
U->>K: EPOLL_CTL_MOD:IN + ET
Note over B: 再次尝试 UB 发送,重复上述过程
end
所以,ubsocket 的 AsyncEventPoll::EpollCtlMod() 会保存上层请求的关注集合,同时去掉实际 TCP fd 的 EPOLLOUT 监听;UB 的可写事件由资源恢复路径调用 NotifyWritable() 产生。如果恢复发生时 brpc 还没有关注 EPOLLOUT,就先记住这个状态,等后续 EpollCtlMod() 注册可写时补发通知。
修复后,上层关注集合与实际 TCP fd 的关注集合分开维护。下面仍从已建立的连接开始,图中的 OUT 通知由 ubsocket 投递给 brpc,不来自 TCP fd 的可写状态。
sequenceDiagram
participant B as bRPC 发送逻辑
participant U as UBSocket
participant R as UB 资源恢复路径
participant K as 内核 TCP / epoll
B->>U: 连接确认成功,ADD:IN + ET
U->>K: EPOLL_CTL_ADD:IN + ET
loop 有待发送数据
B->>U: 尝试发送 UB 数据
alt 写入有进展
U-->>B: 返回已写入字节数
Note over B: 未写完则继续发送,本批写完则处理后续业务
else EAGAIN
U-->>B: UB 发送资源不足
opt 资源恢复早于注册 OUT
R->>U: NotifyWritable()
U->>U: 上层尚未关注 OUT,记录 ready
end
B->>B: 保存 butex 当前值 expected
B->>U: 请求 MOD:IN + OUT + ET
U->>K: EPOLL_CTL_MOD:IN + ET
U->>U: 保存上层关注集合:IN + OUT + ET
alt 已有 ready 待补发
U->>U: 消费 ready,调用 NotifyWritable()
U-->>B: 投递软件 OUT,触发 OnOutputEvent
B->>B: butex_wait 返回,无须等待下一次资源恢复
else 尚无 ready 待补发
B->>B: butex_wait 等待通知
alt deadline 前资源恢复
R->>U: 资源回收或流控额度恢复,NotifyWritable()
U-->>B: 投递软件 OUT,触发 OnOutputEvent
B->>B: butex_wait 返回
else 等待达到 50ms deadline
B->>B: butex_wait 超时返回
end
end
B->>U: 撤销 OUT,请求 MOD:IN + ET
U->>K: EPOLL_CTL_MOD:IN + ET
U->>U: 更新上层关注集合:IN + ET
Note over B: 撤销 OUT 后再重试发送
end
end
B->>U: 连接最终回收,移除 epoll 注册
U->>K: EPOLL_CTL_DEL
B->>U: 关闭 fd
U->>K: close(fd)
如果资源恢复早于上层注册 OUT,NotifyWritable() 会先记录 ready,之后 MOD 时补发;如果恢复发生在等待期间,则直接通知。实际投递可以走已注册的直接分发回调,也可以通过用户态事件队列和内部 eventfd 唤醒等待者,两种方式都不需要监听 TCP 的 OUT。
NotifyWritable 为什么仍然会丢 EPOLLOUT 通知
一个有问题的时序是这样的:发送线程的 umq_post 因资源不足失败;在它处理失败之前,完成线程已经回收资源并设置可写;随后发送线程无条件写入 writable_ready = false,覆盖了刚到达的通知。
sequenceDiagram
participant S as 发送线程
participant P as 完成处理线程
participant A as 上层框架
S->>S: umq_post 遇到资源不足
P->>P: 回收资源,设置 ready=true
S->>S: 处理失败,覆盖 ready=false
S-->>A: EAGAIN
A->>A: 注册 EPOLLOUT,等待通知
Note over P,A: 通知丢失,本轮可能等到超时后才重试
这些读写即使都是原子的,也只能保证单次访问不被拆开,不能保证一个较旧的失败结果不会覆盖较新的资源恢复事件。
修复把状态扩展成“版本号 + ready 位”。发送之前保存快照;可写通知到达时增加版本;失败路径只有在版本仍未变化时,才能通过 CAS 清除 ready。若 CAS 失败,就保留期间到达的通知,供后续 EpollCtlMod 补发事件。
这里版本号想要表达:“从我开始尝试发送到现在,底层 jetty 是否已经收到可写通知了?” 这是单个 bool 无法表达的信息。
从 ubsocket 到 kbsocket
做这些工作之前,我更多关注一次发送要经过多少层调用、一次握手需要交换多少条消息。现在我会先问:哪些工作必须串行完成,哪些可以重叠;一次操作暂时无法继续时,谁负责让它重新推进;状态发生变化后,谁负责保存并通知上层。
建链优化让我意识到,消息数量并不能直接代表首包时延。ubsocket 当前提前交换 bind_info、两端并行准备的方案,已经缩短了这条路径。继续做 kbsocket 时,我希望保留这个思路,进一步减少重复 import,并在接收资源和连接映射真正就绪后再声明 READY。是否合并结果消息、是否引入额外线程,都应该用首包时延和高并发下的排队成本来判断。
EAGAIN 的问题则让我重新理解了 socket 兼容需要承担的责任。内核在 ADD、MOD 时检查当前就绪状态;换成 UB 数据面后,通信库也需要处理“资源先恢复、应用后注册”的情况。对 kbsocket 来说,返回 EAGAIN 时就应明确后续由哪条资源回收或流控路径推动重试,让可写状态的记录、关注事件的注册和通知投递能够衔接起来。可写通知只表示值得再次尝试,重试时仍然需要处理 EAGAIN。
这也影响了 kbsocket 的并发设计:计划由固定的 owner 线程统一推进所属物理队列的收发和完成处理,让资源回收、额度更新与连接状态变化有明确的归属;其他线程通过提交队列与它交互。跨线程提交仍有开销,应用注册事件与 owner 发布状态之间也仍需处理竞态,但明确所有权可以减少需要同时修改同一份状态的参与者。核心传输层负责维护这些状态,socket 兼容层再将它们转换成上层理解的可读、可写和失败事件。
接下来在 kbsocket 中,我想沿着这些边界逐步实现和验证:从建链到首包,检查准备工作的依赖和耗时;从发送失败到资源恢复,检查连接能否继续推进;从提交 WR 到完成或故障清理,检查资源是否能安全归还。这些是后续工作的设计和验证方向,性能收益还需要通过实际测量来确认。