笃行
首页
个人 & 心法
互联网/硬件后台
游戏基础架构
UE 引擎
游戏业务
AI / 大模型
数据结构与算法
机器学习数学
通用基础
GitHub
首页
个人 & 心法
互联网/硬件后台
游戏基础架构
UE 引擎
游戏业务
AI / 大模型
数据结构与算法
机器学习数学
通用基础
GitHub
  • 接入与通信

    • 游戏基础架构与工具
    • 接入网关(长连接接入层)
    • 消息总线(共享内存 IPC)
    • 帧同步(Lockstep)
    • 全栈极限低延迟(物理层→应用层)
    • KCP 与 QUIC
  • 网络与服务网格

    • CNI 与 K8s 网络插件
    • 容器运行时:Docker 与 containerd
    • Istio 与 Cilium 服务网格
    • 服务网格:中心化 vs 去中心化
    • eBPF 原理与在网络/可观测/安全的落地
    • 自研 Mesh 服务网格 × K8s 部署
    • 自研 Mesh × K8s · 数据面深水区
    • Tco 协程框架 · 运行时深水区
    • K8s 异构 & 网络插件
  • 有状态服务

    • 分布式游戏存储(分布式 KV)
    • 有状态服务的数据迁移
    • 有状态服务的数据恢复与容灾
    • 有状态服务的 K8s 编排层治理(防漂移 + 节点崩溃恢复)
    • 内存配置热刷新与更新机制
  • 算法与协程

    • 一致性哈希四算法实现(Ring Hash / Ketama / Maglev / Jump Hash)
    • 蓄水池抽样(Reservoir Sampling)与加权扩展
    • C++ 协程:有栈 vs 无栈 vs C++20 原生(函数染色与 libco)
    • Raft 与 Gossip:CP 强一致共识 vs AP 最终一致传播
  • 流量与承载

    • 游戏秒杀场景承载
    • 令牌桶与漏桶
    • 限流与熔断
    • 限流:互联网 vs 游戏
    • 灰度发布 · Canary / BlueGreen / A-B / Shadow
    • 服务器日常系统开发与维护 SOP
    • 极限发压工具设计(多长连接 + 发压任务)
    • 性能分析与优化方法论(六条排查线)
  • 编译与排查

    • 编译优化与 LLVM/Clang/GCC
    • Sanitizer 工具链与内存泄漏定位

Tco 协程框架 · 运行时深水区

从 tbus 收包扭转 · QueueMap 三态排队 · tco 协程调度 · 框架容错四板斧
上游专题 self-mesh-k8s-deepdive.md 讲清了"包从 A 机业务进程 → tbus SHM → msc → tmesh → 跨机 TCP → 远端 tmesh → msc → tbus SHM → B 机业务进程"这条 8 段搬运链;本文档接续第 ⑦ 段之后——业务进程从 tbus 通道 peek 出包这一刻起,Tco 协程框架如何把一条字节流扭转成一个玩家协程、如何按玩家排队、协程调度器为什么本质是一套 tco、以及框架侧的容错四板斧。

一句话结论

Tco = 一个永久 TbusDriver 协程 peek 收包 + 按 head.sharding_key 排队的 QueueMap + 每请求一个 TcoThread 的 tco 调度器 + 四板斧容错(NormalExclude Reload / SafeExit 缓存 / NoBlockService 越切保护 / 上游超时自动丢包)。

本文档不覆盖:tbus 共享内存环形队列内部(点分位地址 / tbus_peek_msg 底层实现 / 生产消费判空重试)—— 那些落在 self-mesh-k8s-deepdive.md#主干四-tbus-点分位分发 里;本文只关心业务进程从 tbus 通道 peek 出 TBusPkg 之后的字节流生命周期。

场景问题

主专题把"包怎么被搬到本机业务进程门口"讲透了,Tco 协程框架的深水区面试题却常常在此断档:

  1. 业务进程从 tbus 拿到消息之后到底怎么扭转? —— 面试常听到"tbus 收到包 → 起协程处理"这种一步跳的糊弄答案,但中间到底谁在 poll、什么时候 TcoYield、为什么"透传逻辑"和"生玩家协程"是两条路,全部说不出来。
  2. 玩家排队的 key 是怎么落地的?为什么排队用 sharding_key、不用 player_id? —— 只说"按玩家排队"是不够的:queue_id 与 queue_key 两级到底哪一级由业务决定、UnLock 之后 next 请求怎么被再次调度、队列超时怎么回错,都是深水区反问的入口。
  3. 框架为什么被叫做 tco / 协程框架,恢复启动的代价是什么? —— TcoDo / TcoWait / TcoYield / TcoTrigger 这四个原语加起来就是"腾讯系 T-Coroutine"(tco)的具象;深水追问的是"栈放在共享内存怎么恢复"、"金丝雀为什么要关"、"NormalExclude 和 Normal 为什么互斥"这一堆源码级细节。
  4. 越切、雪崩、Reload 卡死这些工程灾难,框架给了什么保护? —— 这是"看过源码 vs 没看过源码"的分水岭:NoBlockService、SafeExit、自动丢包、安全 Reload 四板斧,每一板都有它的历史事故背景。

四题合起来的答案就是 Tco 协程框架的运行时深水区:收包扭转 · QueueMap 排队 · tco 调度 · 容错四板斧。

实现方案

读前须知 · 五个术语速通

正文里 TbusDriver / EventParam / Service / TcoThread / QueueMap 五个词会反复出现,先花 30 秒补齐心智模型:

  • TbusDriver · Tco 框架唯一一个 poll tbus 通道的永久协程,进程启动时 TcoDo("TbusDriver", ThreadEntry_TBusDriver)(common/tco/context/context.cpp:129)就位。它是所有 Service 协程的分派中枢。
  • EventParam (缩写 Ep) · 协程级数据载体,一个请求包在框架内的全部信息(server_pkg / src_addr / queue_key / bind_event_id / locked_queue_key_set / 自定义上下文 map)都挂在它上面。协程结束时析构 → RAII 反锁排队 key。
  • Service · 注册在 cmd → callable 映射上的可调用对象(OnInitService(mgr){ mgr.RegisterService(name, cmd, callable); }),每个请求生成一个独立协程去执行它。
  • TcoThread · 协程本体,即"tco"里的 T-Coroutine——含五种类型(Normal / Main / Daemon / Permanent / NormalExclude)、六种状态(Initial / Runnable / BeforeRun / Running / Sleep / Suspend)。栈放在共享内存里,进程 core 后可恢复。
  • QueueMap · 两级排队字典 unordered_map<queue_id, unordered_map<queue_key, Queue>>;每个 Queue 有一个"正在处理"标志位 + 一个混合队列(Ep 元素 = 排队的请求 / Event 元素 = 主动 Lock 等待唤醒的协程)。

记不住也没关系——每个术语在下文第一次出现时会再点一次名,但不会再展开解释。

主干一 · 从 tbus 收包到 Service 协程的扭转链路

入门梯子 · 3 段 → 5 段 → 7 段

一句话直觉:从业务进程 peek 到 tbus 通道有包,到玩家 Service 协程真正跑起来,本质上只做一件事——把字节流翻译成协程。粗颗粒看只有 3 段:

peek 出字节 → 挂到 EventParam → 起 Service 协程

问题在于"挂到 EventParam"和"起 Service 协程"之间还夹着排队和协程池准入两个隐形关卡。往下拆两次就能看清:

  • 3 段(直觉):peek → EventParam → Service。忽略一切细节,只关心"字节流变协程"这一件事。
  • 5 段(加分派与准入):peek → OnRequest 前置 → 协程池准入 / QueueMap 排队 → CreateService → Service 协程运行。这里第 2 段和第 3 段才是深水区的活。
  • 7 段(拆双源和回包):peek from tbus || 从 QueueMap 兜出 → OnRequest → 协程池准入 → 排队关卡 → CreateService → 玩家协程运行 → (回包路径)Respond → TriggerService 唤醒等待协程。7 段之所以是 7 而不是 6:TbusDriver 每一圈 loop 前会先尝试 TryUnblock——把之前排队被 UnLock 释放的请求当作"和 tbus 新包平级"的输入源重新起一次 Service。这条排队回流线是 Tco 排队机制的地基。

三步揭盲之后再看下面这张 7 段图,每一段都有归属,不再是"看着复杂的连线"。

图中 ② 和 ⑥ 组成了排队闭环:请求进不来时挂进 QueueMap;协程结束 UnLock 时把 queue_key 加进 m_ready_to_shift_queue_key_set,让 TbusDriver 下一圈通过 DischargeEp 兜出、再走一遍分派。这套闭环用 TbusDriver 主循环做"通用调度器",不额外起 poll 协程——单核单线程能吃满协程池的秘诀。

每段的搬运机制

#段机制关键代码
①tbus → driverTBus::ReceivePkg 走 tbus_peek_msg(tbus.cpp:49)——不立即消费,包内存仍在共享内存里;TBusPkg 析构时才调 tbus_delete_msg(tbus.h:26-33)。这就是"peek vs pop"的分界:任何一层拒收(bad size / parse fail / not tgw)都能重新入队。common/tco/context/tbus.h:26-33 · tbus.cpp:44-72
②queue → driverTryUnblock → QueueMap::DischargeEp,从 m_ready_to_shift_queue_key_set 里取出可放行的 key,把队首 Ep 弹给 TbusDriver 主循环,标记 pkg_from = EPKG_FROM_QUEUE(context.cpp:1540)。common/tco/context/context.cpp:1540-1543 · queue.cpp:221-265
③driver → OnRequest每包过 TcoContext::OnRequest(ep_uptr)(context.cpp:1649),失败则整包丢弃(框架回 NEED_RESPOND_BEFORE_END)。common/tco/context/context.cpp:1647-1651
④driver → TriggerService回包走另一条路:TriggerEvent(event_id, ep) 唤醒之前 RequestAndWait 挂起的协程(context.cpp:1682);找不到 event 就统计 rsp_ignore_count 直接扔。common/tco/context/context.cpp:1667-1683 · 1946-1963
⑤OnRequest 三段式上游超时截断 → relay_header 回写 → 模块与业务 OnRequest 回调(后段展开)。common/tco/context/context.cpp:392-510
⑥排队关卡TryBlock(ep) = !QueueMap::TryLock(ep)——如果 key 已在处理,ep 移到该队列末尾,返回 true 阻挡 CreateService。common/tco/context/context.cpp:1911-1917
⑦CreateService协程池耗尽提前拒收;否则 TcoGetEventMgr()->CreateService(cmd, ep) 分配一个 TcoThread,调用注册的 callable,开跑。common/tco/context/context.cpp:1924-1944 · base/eventmgr.h:366

饿死保护 · 一个"看似多余"的 TcoYield

TbusDriver 是永久协程,它跑得越猛别的协程就越难得到 CPU。框架给了两道保护:

// context.cpp:1517-1520
if (recv_count_before_yield >= GetServerContext()->GetTBusDriverMaxPkgCountBeforeYield()) {
    TcoYield(true);            // 忙 yield:把 CPU 让给其他 Runnable 协程
    recv_count_before_yield = 0;
}
  • 忙 yield:连续收 N 包都成功之后,主动让出一次;is_busy_yield=true 走的是"我还有活但我先让让"的分支,不进 Sleep。
  • 闲 yield:本圈没收到任何包(!ep_uptr && !has_receive_pkg)时走 TcoYield()(context.cpp:1616-1631),并统计本次让出时长到 tbus_yield_ms_max_cur_sec / max_total——这是排查"TbusDriver 被卡住"的核心指标,卡顿曲线一抬头就说明有协程忘了 yield。

没有饿死保护的话,一个高 QPS 玩家可能把整个协程池刷满、其他玩家的心跳/tick 全部饿死;有了它,最坏情况也只是 QPS 上限被压到 MaxPkgCountBeforeYield × 100Hz 左右。

OnRequest 三段式 · 拦下"必然会失败的请求"

OnRequest(context.cpp:392)是分派前的最后一道 gate,三件事同时发生:

① 上游超时截断(context.cpp:411-434)

包头里带着 upstream_timeout_timestamp_ms —— 这是发起方在打 RPC 时告诉下游"我最多等到这个时刻"。框架在 buffer 2 秒(防机器时间误差)之后仍未处理的、且 cmd 在 cmd_set_can_ignore_if_timeout 集合内的请求,直接就地回错:

timeout_timestamp_real_real_time_ms = ep_uptr->GetHead().upstream_timeout_timestamp_ms() + 2000;
if (GetRealRealTimeInMs(GetNowTimeInMs()) < timeout_timestamp_real_real_time_ms)
    break;   // 还没过期,正常处理
// 已过期 → 提前截断
ep_uptr->GetHead().set_result(msg::ERROR_TCO_REQUEST_IGNORED_BECAUSE_TBUS_TIMEOUT);
ep_uptr->SetFlag(EEPFlag_NeedRespondBeforeEnd);
return false;

这是雪崩防护的第一道闸:上游都已经放弃等回包了,下游还继续算就是给自己雪上加霜——见后文"框架容错 · 上游超时自动丢包"。

② relay_header 回写(context.cpp:404-408)

透传场景(Lobby → 后端)里,SRouteProtocol.src_servbusip 其实是转发者的地址,真正的源在 PB body 的 relay_header_s.tbus_id_from。OnRequest 拿到之后把 src_addr_when_receive 改成原始上游、清空 relay_header_s——否则一路传下去每一跳都会误判"我在被透传"、层层设置 relay,最终回包回不到发起端。

③ 协程池准入与 NoService 拒收

  • 协程池耗尽(context.cpp:1654-1660):TcoGetThreadMgr()->GetFreeThreadCount() <= 0 时立刻回错 ERROR_TCO_REQUEST_IGNORED_BECAUSE_THREAD_POOL_EXHAUST。这一步在 CreateService 之前做,是为了让错误码能带回上游做链路追踪——如果放到 CreateService 里失败再回错,链路追踪拿不到具体原因。
  • NoService(context.cpp:488-497):TcoGetEventMgr()->IsServiceIDExists(cmd) == false && DefaultServiceID 也不存在时回错 NO_SERVICE,统计 req_ignore_count_by_no_service——用于抓"上游发错了命令字"这类偶发问题。

三件事都过了,才把 ep_uptr 交给 TryBlock 走排队关卡。

记忆口诀

双源 peek,三段前置,四码定生死——tbus 通道 + 排队回流两条源,OnRequest 三段前置(超时/relay/池),四种错误码(TBUS_TIMEOUT / QUEUE_TIMEOUT / QUEUE_LIMIT / THREAD_EXHAUST)判定请求命运。

追问 3 连

Q1 · tbus_peek_msg 和 tbus_pop_msg 的语义差异?为什么框架选 peek?

  • A:peek 只拿指针不移动读游标,包内存仍在 tbus 共享内存环形队列里;pop 才真正推进 tail。Tco 选 peek 是因为收到包之后仍可能被拒收(bad size / parse fail / not tgw 且业务 OnReceiveNotTgwPkg 返回 false / SafeExit 时命令字要缓存到重启后处理)——只有走完全部校验、TBusPkg 析构那一刻才通过 tbus_delete_msg 真正移动 tail,中间任何一步失败都可以"下次再看看"。

Q2 · TbusDriver 主循环里的 TcoYield(true) 和 TcoYield() 有什么区别?为什么两个都要有?

  • A:TcoYield(true) 是忙 yield——本协程还有活干、只是暂时让 CPU,不进 Sleep 队列;TcoYield() 是闲 yield——本圈啥都没干、进 Sleep 让别的协程跑,配合 daemon 协程的时钟醒来。两个都要:如果只有忙 yield,进程 idle 时会空转烧 CPU;如果只有闲 yield,高 QPS 时 TbusDriver 会把协程池挤爆。

Q3 · 从 tbus 兜出的包和从 QueueMap 兜出的包,在 OnRequest 处理上有什么差别?

  • A:EPKG_FROM_TBUS 会完整走一遍 OnRequest(上游超时/relay/OnRequest 业务回调);EPKG_FROM_QUEUE 跳过 OnRequest(context.cpp:1647-1651),因为这个包已经通过过一次前置了,重复过一遍不但浪费还可能出错(比如 relay_header 已经被清空、再改一次源地址会写错)。这也是为什么排队 key 必须在 OnRequest 里设置——一旦排队,UnLock 之后不再有机会再设。

主干二 · 排队 key 与 QueueMap 三态机

排队 key 从哪来 · sharding_key 的 2021-12-01 优化

排队要解决的本质问题:数据库同一行数据的并发写要串行化——上游把同一个玩家的两个请求几乎同时打过来,下游必须保证第一个"读 → 改 → 写"完成后第二个才开始,否则会出现"读到相同版本、写回时后者覆盖前者"的丢失更新。

业务侧唯一要做的事——在 OnRequest 里给 EventParam 打上一个 queue_key:

bool OnRequest(EventParam& ep) {
    ep.SetQueueKey(ep.GetHead().sharding_key());   // 新姿势,2021-12-01 之后
    return true;
}

对比 2021-12-01 之前的老姿势:

// 老姿势:得先解包才拿得到 player_id
msg::MissionUpdateReq req;
if (ep.ParseBodyTo(req))
    ep.SetQueueKey(req.player_id());

老姿势有两个问题:① 白白解一次包(PB 反序列化不便宜);② head.player_id 有时候是"透传发起者"而不是"目标玩家",字段语义不稳。2021-12-01 版本把 sharding_key 提到 SRouteHead 里(tbus.cpp:109 — head->qqnum = sharding_key),发起方在 RPC 声明里就设好,下游直接取即可,每次收包省一次 PB 解包(README §2021-12-01)。

两级 QueueKey · queue_id 与 queue_key 的分工

// common/tco/context/queue.h:17-58
struct QueueKey {
    uint64_t queue_id  = uint64_max;   // 队列集合 ID:默认 EQueueIDReserved_Default
    uint64_t queue_key = uint64_max;   // 具体排队键
};
  • 单 DB 表(比如玩家表):只用一级——SetQueueKey(sharding_key) 走单参数重载,queue_id = EQueueIDReserved_Default(uint64_max),实际只按 queue_key 排队。
  • 多 DB 表(比如玩家表 + 公会表):用两级——SetQueueKey(TABLE_ID, sharding_key),TABLE_ID 做 queue_id,同一玩家在不同表上的写操作互不阻塞(改玩家资料 vs 改玩家公会角色两个操作可以并发)。

为什么不合成一级 hash(table_id, sharding_key)? —— 因为运维需要按表维度看队列水位(GetMaxQueueCountPerKey(cmd, key) 允许按 cmd 或 queue_id 差异化限额)。合成一级之后无法从 hash 反推 table_id,一次告警你都不知道是哪张表出问题。这就是"看起来冗余的两级设计"背后的可运维性诉求。

QueueMap 三态机

关键状态位:

  • m_is_processing · 当前 key 是否有协程在处理;TryLock 快路径的判定条件是 !is_processing && list.empty()。
  • m_queue_list · 每 key 一个 list,元素是 Ele{ep, eid} 二选一——ep 是 tbus 收到但被挡下的请求包,eid 是业务主动 QueueMap::Lock 挂起协程时挂进来的等待事件。
  • m_ready_to_shift_queue_key_set(QueueMap 级) · UnLock 之后若队首还是 Ep,key 加入此 set;TbusDriver 主循环第一件事就是 DischargeEp 把这些 key 兜出来重新调度(queue.cpp:207-209)。

TryLock 快慢路径 + 进队限额

// queue.cpp:19-76 — TryLock(ep)
if (!TryLock(ep->m_queue_key)) {            // 快路径失败:正在处理或队列非空
    // 全局队列总数限额(保 GetMaxQueueCount > 0 时生效)
    if (max_queue_count > 0 && queue_count + 1 > max_queue_count) {
        ep->GetHead().set_result(msg::ERROR_TCO_REQUEST_IGNORE_BECAUSE_QUEUE_COUNT_LIMIT);
        req_ignore_count_by_queue_limit.Add();
        return false;
    }
    // 单 key 队列长度限额
    if (max_queue_count_per_key > 0 && queue_list.size() + 1 > max_queue_count_per_key) {
        ep->GetHead().set_result(msg::ERROR_TCO_REQUEST_IGNORED_BECAUSE_QUEUE_COUNT_PER_KEY);
        req_ignore_count_by_per_key_queue_limit.Add();
        // 记录最后一次触发的 sharding_key + cmd,用于告警时精准定位
        return false;
    }
    queue_ptr->m_queue_list.emplace_back(std::move(ep));   // 入队
    return false;
}
return true;    // 快路径成功

两级限额都有默认关闭(> 0 才生效)。原因是限额本身是防雪崩的兜底、不是常态——一旦 PER_KEY_LIMIT 被打爆意味着某个玩家的处理耗时严重超标,运维需要立刻拉出 last_sharding_key_for_alert_queue_key_limit + last_cmd_for_alert_queue_key_limit 两个统计定位到具体 (玩家, 命令字)。这不是"限流"的概念,是"熔断兜底"。

UnLock 与 DischargeEp 兜底

UnLock 的核心是唤醒 Event、放行 Ep两条不同路径:

// queue.cpp:191-219 — UnLock 主循环
queue_ptr->m_is_processing = false;
service_ep_ptr->m_locked_queue_key_set.erase(key);    // 反锁:从当前协程的 locked set 摘掉

// 如果下一个是 Event(业务主动 Lock 挂起的协程),主动唤醒,不需要走 tbus 主循环
while (!queue_list.empty() && queue_list.front().IsEvent()) {
    auto eid = queue_list.front().eid;
    queue_list.pop_front();
    if (TcoIsEventWaiting(eid)) {
        queue_ptr->m_is_processing = true;    // 提前抢占,避免中间被其他协程截胡
        TcoTrigger(eid, QueueLockEp{});
        break;
    }
}

// 队首是 Ep 时不直接唤醒——交给 TbusDriver 主循环去 DischargeEp
if (!queue_ptr->m_is_processing && !queue_list.empty() && queue_list.front().IsEp())
    m_ready_to_shift_queue_key_set.insert(key);

为什么 Event 直接 TcoTrigger、Ep 却要走 TbusDriver 兜底?

  • Event 挂进来时业务协程已经存在(只是被挂起),TcoTrigger 直接唤醒即可,路径最短。
  • Ep 是"还没起协程的请求包",需要通过 CreateService 起新协程;这一步等价于"一次新的 tbus 收包",所以复用 TbusDriver 的分派链路——避免两处 CreateService 逻辑,源码维护一处即可。

RAII 反锁:协程结束时 TcoContext::OnServiceFinished(service_ep)(context.cpp:513-515)遍历 m_locked_queue_key_set 逐一 UnLock。所以即使玩家协程 core 掉、EventParam 走析构,排队 key 也一定会被释放——不会永久卡住。这是 tco 恢复启动能"跳过 core 协程继续跑"的前置条件。

队列超时丢包

// queue.cpp:232-243 — DischargeEp 出队时检查
bool is_timeout = queue_list.front().ep->IsQueueTimeout();
if (!is_timeout)
    out_ep = std::move(queue_list.front().ep);
else {
    scope_service.GetServiceEp()->GetHead().set_result(msg::ERROR_TCO_REQUEST_IGNORED_BECAUSE_QUEUE_TIMEOUT);
    scope_service.GetServiceEp()->SetFlag(EEPFlag_NeedRespondBeforeEnd);
    req_ignore_count_queue_timeout_count.Add();
}

超时基准是 upstream_timeout_timestamp_ms + 2s buffer(context.cpp:422-423 在 OnRequest 里设进 m_queue_timeout_timestamp_real_real_ms)。注意:只有在从队列里拿出的时刻才判超时,而不是包躺在队列里就周期性检查——省掉了一个巡检协程,代价是超时包在队列里"占位"到出队才被扔。这个 trade-off 的假设是"排队时间不会太长",一旦被打破就要靠 MAX_QUEUE_COUNT 限额兜底。

记忆口诀

两级键,快慢路,两条唤醒线——(queue_id, queue_key) 两级键;TryLock 快慢路径二分;UnLock 时 Event 直接 TcoTrigger、Ep 交给 TbusDriver DischargeEp 兜底。

追问 3 连

Q1 · queue_id 为什么要独立于 queue_key?合成一个 hash 不行吗?

  • A:queue_id 一般填 TABLE_ID,把不同表的写操作分成独立队列集合——同一玩家的"改资料"与"改公会角色"可以并发跑,而不是被 player_id 一刀切串行。合成 hash 不行的原因是运维需要按 table 维度做告警和限额(GetMaxQueueCountPerKey(cmd, key) 允许按 queue_id 差异化配置)。合成之后从 hash 反推不出来 table_id,一次告警你都不知道该找哪张表的 owner。

Q2 · UnLock 时如果队首是 Ep,为什么不直接 CreateService,非要挂到 m_ready_to_shift_queue_key_set 让 TbusDriver 兜出来?

  • A:一是源码路径唯一化——CreateService 只在 TbusDriver 主循环里被调用,UnLock 走同一条路便于加协程池准入 / OnRequest 前置等 gate,重复实现两套容易漂移;二是避免协程栈嵌套——UnLock 发生在业务协程结束的析构链上,如果这里立刻 CreateService 就等于"在一个协程的清理阶段里直接切进另一个协程",栈布局会很难看,反倒不如让 UnLock 只做"标记 ready"、把真正的调度动作还给 TbusDriver 单线程调度器。

Q3 · 为什么排队 key 必须在 OnRequest 里设?在 Service 协程内部再 SetQueueKey 行不行?

  • A:不行。TryBlock 是 OnRequest 完成之后紧接着执行的(context.cpp:505-507)——一旦 OnRequest 返回 true 并且 GetQueueKey().NeedQueue() 为 false,TryBlock 就直接跳过、Service 协程会立刻起来跑。到 Service 内部再 SetQueueKey 已经无人再读——那个字段只在 TryBlock 那一刻起作用。这也是为什么 EPKG_FROM_QUEUE 的包会跳过 OnRequest:排队 key 在第一次入队前已经设好,不能被覆盖。

主干三 · tco 协程调度器(TcoThread + TcoEventManager + TcoFramework)

四原语调用图

Tco 底座对业务只暴露 4 个原语,全在 common/tco/base/interface.h:

四原语的落地:TcoDo 从协程池抢一个 TcoThread、绑定 entry function、扔进 Runnable 队列(base/eventmgr.h:CreateThread);TcoWait 走 EventManager::WaitEvent 把当前协程挂到 event 的 waiters 上、状态置 Suspend;TcoYield 直接 TcoSelf()->Yield(is_busy);TcoTrigger 把 event 的 waiters 全部拉回 Runnable。所有切协程动作最终都汇聚到 TcoThread::SwitchContext(base/thread.h:201)——一个 mov 系列的汇编切栈。

协程五型 + 六态

五型(base/thread.h:106-155)—— 决定协程的生命周期与调度优先级:

Type语义恢复策略典型例子
Normal单次任务型协程不恢复——core 时正在跑的 Normal 直接释放Service 协程(每请求一个)
Main进程 main 函数所在协程不做特殊恢复,重跑初始化然后交权int main()
Daemon后台维护协程恢复心跳、Sleep 超时检查、栈缩水
Permanent永远不退出的循环型协程恢复;如果 core 就从头重跑TbusDriver / Tick / HeartBeats
NormalExclude独占型协程恢复Reload 协程

NormalExclude 与 Normal 互斥是安全 Reload 的地基(详见主干四)——只要有 NormalExclude 存在或即将开始,所有新 Normal 协程都不投入运行;等所有 Normal 跑完 NormalExclude 才起。这保证了 Reload 的独占执行、不与业务协程并发。

六态(base/thread.h:157-164):Initial → Runnable → BeforeRun → Running → (Sleep | Suspend),其中 Sleep 由 Daemon 协程唤醒(时间到)、Suspend 由 TcoTrigger 唤醒(事件到)。

栈与金丝雀 · 为什么栈布局是 tco 的核心资产

// common/tco/base/thread.h:95-103
struct StackInfo {
    size_t stk_size;         // 栈的可用大小
    size_t vaddr_size;       // 申请的 buff 总大小(栈 + 金丝雀 + 对齐余量)
    uintptr_t vaddr;         // 栈基地址(放在共享内存里)
    uintptr_t esp;           // 保存的 esp 寄存器
    uintptr_t stk_bottom;    // 栈最低地址
    uintptr_t stk_top;       // 栈最高地址
    uintptr_t canary;        // 金丝雀位置,值固定 STACK_CANARY_VALUE = 0xAFAFAFAFAFAFAFAF
};

关键设计:

  • 栈放在共享内存——进程 core 之后新进程重新 attach 同一片 SHM,栈内容原封不动。这是 Tco"恢复启动"的物理基础。
  • 金丝雀值 0xAFAFAFAFAFAFAFAF 是刻意重复的字节模式(十六进制看很显眼),栈溢出触发时 gdb 一眼看穿;同时框架自己会周期性巡检金丝雀值,被覆盖立刻 LOG_ERROR + core dump。
  • ShrinkStackMem(base/thread.h:204)—— 协程结束或长时间闲置时把用不到的栈页 madvise(MADV_DONTNEED) 归还 OS,物理内存回收但虚拟地址保留,恢复时不用重新映射。

为什么必须框架自管栈,OS 给的不行? —— OS 分配的栈是每个 pthread 私有的、进程重启后没了;框架自管栈才能"绑定到 SHM + 金丝雀 + 缩水"三件事同时做。而这三件事加起来就是 tco 之所以叫"框架"的核心——不是"协程库",是"能恢复启动 + 能压缩 core + 能安全 Reload 的完整运行时"。

恢复启动的三编译约定

栈+堆放在 SHM 只解决了"数据能恢复",但代码地址不能变——否则栈上保存的函数返回地址、函数指针全部指错了。为此有三条硬约定(README §恢复启动):

  1. 禁 -fpie -pie:位置无关可执行文件每次启动加载地址不同,栈上保存的返回地址失效。
  2. 开 -fno-stack-protector:关闭编译器插入的金丝雀检查——内核在函数进入时把 SP 前放一个内核随机数、退栈时对比,重启后内核随机数变了会误判"栈溢出"直接 abort。框架自己实现了金丝雀(值固定),跳过编译器的那套。
  3. 静态库优先:动态库每次加载地址不同,其内部全局变量地址也变;只要业务代码不保存 .so 里全局变量的地址,只调用它的函数,就安全——但风险面偏大,不如全静态链接。

core 从 3.6G → 156M · MADV_DONTDUMP 的三层缩水

Tco 一开始就把栈 + 堆 + tbus 通道全放进共享内存,导致 core dump 默认要写 3.6G 到磁盘——线上一台机器 core 会把邻居的写盘 IO 卡到 19s(相当于拒绝服务 19s,README §2021-10-19)。方案:从 Linux 3.4 开始的 madvise(MADV_DONTDUMP) 支持"这块内存不进 core"。

三层缩水策略:

  1. 栈:只保留最后一个协程(即导致 core 的那个)的栈,其他协程的栈全部 DONTDUMP——单栈 128K,比 1G 的全栈区小 8000 倍。
  2. 堆:shared_pool / shared_array 类自己知道哪些 slot 在用;只导出 in-use 部分。业务侧也可以配置 SetEnableDump(false) 完全屏蔽堆。
  3. tbus 通道 + Tco 内部管理内存:在 TBus::Bind 里直接 DoNotDumpMemorySection(tbus_queue1_start, tbus_queue1_end)(tbus.cpp:325-333)——这两块内存对 gdb 调试完全无价值。

最终 core 从 3.6G → 156M,gdb 调试仍完好。这是 tco 相对 pthread 的第二个价值:运行时可以自主决定 core 内容,而 pthread 完全依赖内核转储。

记忆口诀

四原语一套栈,五型六态两互斥——TcoDo/Wait/Yield/Trigger 四原语共享 SHM 栈 + 金丝雀;五种协程类型 + 六种状态;NormalExclude 与 Normal 互斥是安全 Reload 的地基。

沉淀结论 · 为什么这套底座叫 tco

TcoThread + TcoEventManager + TcoFramework 三件套就是腾讯系 T-Coroutine(tco)协程框架的具象——名字里的 Tco 是这个仓库对外的品牌名,但源码里没有 tco_ 前缀符号。之所以在面试口径里也叫 tco,是因为它满足 T-系框架的三条特征:

  1. 协程栈 + 堆全部框架自管(放在 SHM),不用 OS 的 pthread 栈;
  2. 恢复启动——进程 core 之后新进程 attach SHM 继续跑,跳过 core 协程;
  3. 能被 tapp 框架吸纳作为业务运行时——TcoContext::Init<XxxSvrContext>(init_param) 里的 init_param.meta_name / meta_lib_ptr 就是 tapp 的 XML 配置钩子。

回过头看主干一提到的 TcoDo("TbusDriver", ...)——那一行代码之所以能同时完成"分配协程 + 挂 permanent 类型 + 加入调度队列"三件事,就是因为 tco 底座把这三件事收敛到了一个 API 里。这才是"tco 协程框架"的口径落地。

追问 3 连

Q1 · 为什么 TbusDriver 协程里不能起 RPC?

  • A:TbusDriver 是唯一一个 poll tbus 通道的协程——如果它调 RPC 被切出,等回包时协程挂起,回包从 tbus 出来时没人去 peek,就会永远超时;等超时唤醒 TbusDriver 时它已经不知道自己错过了多少包。框架用 IsServiceCanBeBlock=false 标记(context.cpp:1513)阻止在 TbusDriver 协程内切出,越切会直接失败并上报 GSM RPCInNoBlockService。逃逸姿势是 TcoDo([]{ TcoScopeService<EventParam> s; RPC::XXX; }) 起新协程去发(README §2024-07-17)。

Q2 · NormalExclude 和 Normal 为什么必须互斥?举一个不互斥就出问题的例子。

  • A:Reload 协程被声明为 NormalExclude,它会 res.Clear() 然后 res.Init() 重新分配资源——期间旧的资源指针失效。如果同时有 Normal 协程握着 AwardResPtr* ptr = GetContext().res.GetAwardResPtr() 调 RPC 挂起,等回包唤醒时用 ptr 就是野指针访问,core。互斥的语义是"等所有 Normal 协程跑完 Reload 才起、Reload 期间新 Normal 不投入运行",代价是短时阻塞(一个最长 RTT),换取"业务代码不用手动应对 Reload 时序"的简洁性。

Q3 · 为什么编译时必须关闭金丝雀(-fno-stack-protector)?框架自己那个金丝雀是补什么?

  • A:编译器的金丝雀是"进入函数时把内核随机数放到 SP 前、退栈前对比"——重启后内核随机数变了,即使栈内容一字不差也会误判"栈溢出"直接 abort,拿不到调用栈。框架的金丝雀是固定值 0xAFAFAFAFAFAFAFAF,只用于"业务代码有没有写溢出"这一件事——恢复启动时值仍然一致所以不会误判,同时溢出触发时因值特殊 gdb 一眼看穿。两个金丝雀解决的是不同的问题,编译器的那个恢复场景下必须关。

主干四 · 框架容错四板斧

Tco 在生产里踩过的坑最终收敛成四道容错闸门,每一道都对应一次真实事故:

板斧一 · 安全 Reload(NormalExclude)

事故背景:普通协程框架 Reload 直接 res.Clear() + res.Init()——如果此刻有业务协程握着旧指针挂起,Reload 完之后指针指向的地址已经被回收/重用,业务协程一唤醒继续用就是野指针 core。

方案(README §OnReload · context.cpp:229-232):

// Reload 协程被声明为 NormalExclude
if (!TcoIsValidThreadID(TcoDo("Reload", DoReload, EThreadType::ThreadType_NormalExclude)))
    ...

代价:等所有正在跑的 Normal 协程执行完才起 Reload,Reload 期间新请求被挂起——通常阻塞时长 = 最长一个 RTT(几十 ms 到几百 ms);DB 出问题时可能拖到秒级,此时"上游超时"和"Reload 阻塞"叠加,需要主干四板斧其它三招兜底。

备选方案 A(README §OnReload):多份资源——把资源做成 vector<Res>,Reload 时只操作新槽位,旧槽位保留供旧协程使用;协程结束时才释放旧槽位。推荐用于对 Reload 时延敏感的进程(如 Lobby)。

板斧二 · SafeExit 缓存队列

事故背景:进程被 kill 时如果 tbus 通道里还有未处理的包,重启后会丢失——上游看到"请求发了没回包"就重发,形成雪崩。

方案:SafeExit 检测到退出信号后把 tbus 通道里剩余的包先复制到共享内存缓存队列,重启后(ReceivePkg tbus.cpp:34-40)优先从缓存队列 PopTBusPkg:

if (TcoContext::IsSafeExitEnabled()) {
    if (!tbus_receive_helper.IsQueueDone()) {
        ASSERT(tbus_receive_helper.PopTBusPkg(out_tbus_pkg));
        return true;   // 优先兜底缓存
    }
}
// 之后再走正常的 tbus_peek_msg

关键约束:缓存队列有最大条数(SafeExitMaxPendingCount 默认 1000);超过就不再往里塞、退出还是会丢包。这是"安全退出"和"不允许无限阻塞退出"之间的折中。

板斧三 · NoBlockService 越切保护

事故背景(README §2024-07-17):Lobby 大量透传逻辑直接跑在 TbusDriver 协程里(每个透传包不起新协程节省资源)。业务同学在透传路径里深处的一个函数里加了 RPC,RPC::RequestAndWait 让 TbusDriver 挂起等回包——回包到达 tbus 通道没人 peek,永远超时,进程整体拒绝服务。

方案:TbusDriver 起手就打上 SetIsServiceCanBeBlock(false)(context.cpp:1512-1513);框架在 RPC 底层判断当前协程 !IsServiceCanBeBlock 时直接失败(util.cpp:773)并上报 GSM RPCInNoBlockService。业务如果确实需要在透传逻辑里发 RPC,正确姿势是起新协程逃逸:

TcoDo([]{
    TcoScopeService<EventParam> scope_service;
    RPC::XXXX;    // 在这里调用你的 RPC
});

判断当前是否可切出:RPC::GetServiceEp()->IsServiceCanBeBlock()——用于那些"既可能在 Service 也可能在 TbusDriver 里被调用"的公共函数,让它自适应决定是否需要 TcoDo 包裹。

板斧四 · 上游超时自动丢包

事故背景(README §2025-05-01):下游负载过高导致处理超时,上游放弃等回包;下游继续把请求跑完再回包,回包也被上游丢,纯浪费下游 CPU 加剧雪崩。

方案:

  • 业务侧:注册 cmd 时声明"可在上游超时后丢弃"(业务需要评估"上游超时后我还有必要处理吗"——比如玩家操作类的可以丢,扣款成功通知类的不能丢)。
  • 框架侧:包头带 upstream_timeout_timestamp_ms(发起方 RPC 时自动填的),框架在两个时机检查(context.cpp:411-434 OnRequest 里 + queue.cpp:232 DischargeEp 出队时):now > timeout + 2s buffer 且 cmd 在 cmd_set_can_ignore_if_timeout 集合内 → 直接丢,回错码 TBUS_TIMEOUT / QUEUE_TIMEOUT。
  • 2s buffer 是防机器时间误差(上下游机器 NTP 偏差在秒级是常见的)。

Notify 类型没有天然超时概念——框架允许业务 override GetDefaultUpstreamTimeoutMs(cmd) 显式声明,默认 10s。

附板 · EpCustomContextBase 协程级"全局变量"

(README §2026-03-19,附板不算板斧但常被追问)

问题:RPC 调用链嵌套很深时——operator()(ep) → func1 → func2 → ... → func8——深层函数需要 req.some_field,要么一路加参数(接口污染),要么在深层重复拉一次 DB(RTT 浪费)。

方案:EpCustomContextBase 挂在 EventParam 上:

// tco/context/ep.h — EventParam 内部
std::map<EEpCustomContextType, std::unique_ptr<EpCustomContextBase>> m_custom_context_map;
  • 生命周期:首次 ep.FindOrAddCustomContext<T>(type) 时构造,协程退出时随 EventParam 析构。
  • 无需传参:协程内任意位置 RPC::GetServiceEp() 拿到 ep,再拿到挂载的上下文。
  • 类型安全:每种上下文 = EEpCustomContextType 里一个唯一枚举值;类型和枚举不匹配时 ASSERT 崩溃。
  • 零开销:没业务用某个类型,那个 slot 根本不创建。

理解成"协程内的 thread-local 存储"最直观——只是它绑定的粒度是协程(EventParam)而不是 OS 线程。

记忆口诀

独占重载、缓存续命、切出拒切、超时早死——NormalExclude 独占跑 Reload、SafeExit 缓存队列续命、NoBlockService 拒绝在 TbusDriver 里切出、上游超时的请求提前死。

追问 3 连

Q1 · SafeExit 缓存队列怎么保证不丢包?极端场景下会失效吗?

  • A:SafeExit 在进程收到 term 信号时先停止 tbus 收包,然后把已经 peek 出但还没跑完的 EP 序列化写进 SHM 缓存队列——重启后新进程从缓存队列 pop 优先处理。极端场景失效:① 缓存队列有最大条数(SafeExitMaxPendingCount 默认 1000),超过就不塞;② 进程是被 kill -9 强杀(没有 term 信号处理时机);③ SHM 直接被清(比如 clean 启动 / 二进制 MD5 变了)。这三种情况下 SafeExit 都兜不住,必须依赖上游 RPC 重试或幂等设计。

Q2 · NormalExclude 和 Reload 是什么关系?

  • A:Reload 协程被 TcoDo("Reload", DoReload, EThreadType::ThreadType_NormalExclude) 起为 NormalExclude 类型(context.cpp:231);NormalExclude 与 Normal 互斥的语义在调度器里落地——只要 NormalExclude 在或即将运行,所有新 Normal 协程都不投入运行;等 Normal 全部跑完 NormalExclude 才起。这样 Reload 期间不会有业务协程握着旧资源指针切出去等 RPC 回来发现指针失效。代价:如果有一个 Normal 协程卡住不结束(比如 DB 挂了导致 RPC 一直不回),Reload 会跟着阻塞,进而阻塞所有新请求——这是主干四板斧其它三招(超时自动丢包、SafeExit)需要联动的原因。

Q3 · 为什么 MADV_DONTDUMP 是选择性用而不是全用?

  • A:全部 DONTDUMP 之后 gdb 打不开——bt 会说 <optimized out>、Cannot access memory at address。有效的做法是只 DONTDUMP 明显对调试无价值的内存:非活协程的栈(只留当前)、tbus 通道内存(内容是 wire format,gdb 看不出啥)、可选的堆(业务同意舍弃时)。核心调试内容——当前协程栈、寄存器、代码段、必要全局变量——仍要落 core。最终 3.6G → 156M 的效果就是这层选择的产物,而不是无脑关。

为什么这么做

这四大主干拼出来的答案,回过头看每一项都不是随便选的。

为什么"永久 TbusDriver + peek 语义"是唯一合理的收包扭转形态:单进程只有一个 tbus 收包点,避免多协程竞争 peek——竞争会引入锁;peek 允许"任何一层校验失败都能重新入队",是恢复启动能"跳过 core 协程继续处理它前面的包"的前提。如果换成 pop 语义,业务协程 core 时该包就永久丢失。

为什么"两级 QueueKey + Ep/Event 混合队列"能同时满足业务与运维:两级键让"多表并发写"和"按表告警"共存;Ep(未起协程的请求)和 Event(已挂起的协程等待锁)混合入队让"排队入口"和"业务主动锁"复用同一套 QueueMap 代码——不用维护两套排队数据结构。

为什么 tco 必须把栈放在共享内存:不这么做就不可能有恢复启动。栈+堆+ServerContext 全部落地到 SHM,加上编译三约定固定代码地址,才能"进程 core 后新进程 attach SHM 从被打断的 yield 点继续跑"——这就是 tco 相对普通协程库的护城河,也是它敢在游戏后台生产里承担核心业务的原因。

为什么容错要做成四板斧而不是一板:每一板对应一类事故形态——Reload 时机不当会 core(NormalExclude 解决)、正常退出丢包会引发上游重试雪崩(SafeExit 解决)、透传逻辑越切引发 TbusDriver 卡死(NoBlockService 解决)、上游放弃后下游继续算加剧雪崩(自动丢包解决)。这些事故形态是解耦的,用一板通用方案要么覆盖不全、要么代价过高——用四板刚好。

为什么别的选择不行

为什么不用"每包起协程 + pop_msg 语义"?

  • 每包起协程需要每包都进 Runnable 队列 → 切一次栈,即便"这个包马上就要被丢弃"也白付一次切栈开销。TbusDriver 单协程 peek 让"分派决策"和"实际起协程"分开——大部分被拒收的包(超时 / no service / 队列限额爆)都在 TbusDriver 协程里完成、根本不起新协程。
  • pop 之后包已经离开 tbus 内存,业务侧要么内存拷贝一份(浪费),要么承担"处理中途 core 就永远丢失"的风险。peek 的存在让"tbus 通道 = 一次性检查失败后可退回的暂存区",成本极低。

为什么不用"整个进程共享一把大锁"代替 QueueMap?

  • 大锁把"跨玩家并发"也串行化——一个玩家的慢请求会拖住所有其他玩家,QPS 立刻雪崩到个位数。QueueMap 的两级键让"跨玩家跨表并发"最大化保留,只在"同 key 冲突"时才排队,是"最小化串行域"的落地。
  • 大锁没有 UnLock 时的"下一位是 Event 还是 Ep"这套双路径,业务主动 Lock 挂起协程和请求包入队会共用同一个 mutex——mutex 不能带负载,退化成信号量或者引入外部数据结构,代码更复杂。

为什么不用 pthread + 系统栈代替 tco 协程?

  • pthread 栈 pthread 私有,进程 core 之后新进程起来时 pthread 全没了,谈不上"恢复启动"。
  • pthread 上下文切换要陷入内核(约 1μs 起步),高 QPS 下切换开销吃满一整核。tco 全用户态切换(一个汇编 mov 系列,几十 ns),才能撑起单核 10 万 QPS 级别的服务。
  • pthread 的栈默认 8MB 保留、无法自主 madvise 缩水;协程池要开到几千个的话虚拟地址空间会撑爆。

为什么容错不用"服务网格 sidecar 全部拦下来"?

  • Sidecar 只能看到 wire format,看不到"这个 cmd 是否允许超时后丢弃"这种业务语义;也看不到 Tco 内部的 QueueMap 状态,无法判断"这个包已经在下游进程队列里等 3 秒了"。
  • Sidecar 层做超时拦截会重复"上游超时自动丢包"这道闸,但它没法感知下游内部的排队与 Reload 时序——最终结果是两层都做、都覆盖不全,反而增加了运维复杂度。

沉淀结论

Tco 协程框架的运行时深水区可以浓缩成一句话:一根永久 TbusDriver 协程 peek 出所有输入 · 一张两级 QueueMap 按 sharding_key 排队 · 一套 tco 底座把每请求变成一个共享内存栈协程 · 四板斧容错兜底 Reload / 退出 / 越切 / 超时。

面试深水区的四题依次对应本文档四大主干:

  1. "tbus 收到消息后怎么扭转?" → 主干一的 7 段路径与双源 peek。
  2. "玩家排队 key 怎么落地?" → 主干二的两级 QueueKey 三态机与 UnLock 双路径。
  3. "这框架为什么叫 tco 协程框架?" → 主干三的四原语 + 五型六态 + 恢复启动三约定。
  4. "越切/雪崩/Reload 怎么防?" → 主干四的容错四板斧 + EpCustomContextBase 附板。

每主干末尾的记忆口诀是快速召回索引:

  • 主干一:双源 peek,三段前置,四码定生死
  • 主干二:两级键,快慢路,两条唤醒线
  • 主干三:四原语一套栈,五型六态两互斥
  • 主干四:独占重载、缓存续命、切出拒切、超时早死

四条口诀合起来记 5 秒,展开讲能撑 30 分钟深水区追问。

记忆口诀

双源三段 · 两级三态 · 四元五型 · 四板独占——TbusDriver 双源 peek + OnRequest 三段前置,QueueMap 两级键 + 三态机,TcoDo/Wait/Yield/Trigger 四原语 + TcoThread 五型,四板斧容错以 NormalExclude 独占为首。

内容来源 / Sources

一手代码引用(基于 ~/t/NZM HEAD 快照,行号可能随源码演进偏移,函数名与文件名保持稳定):

  • ~/t/NZM/common/tco/README.md · 1547 行使用手册,是 Tco 框架的顶层设计文档
  • ~/t/NZM/common/tco/context/context.h / context.cpp · TcoContext 主类、ThreadEntry_TBusDriver(context.cpp:1502)、OnRequest(context.cpp:392)、CreateService / TriggerService(context.cpp:1924, 1946)
  • ~/t/NZM/common/tco/context/tbus.h / tbus.cpp · TBus::ReceivePkg(tbus.cpp:44)、TBusPkg 析构删包(tbus.h:26-33)、Bind 中的 DoNotDumpMemorySection(tbus.cpp:325-333)
  • ~/t/NZM/common/tco/context/queue.h / queue.cpp · QueueKey 结构、QueueMap::TryLock / UnLock / DischargeEp 三态机
  • ~/t/NZM/common/tco/context/ep.h · EventParam 结构、EpCustomContextBase 挂载
  • ~/t/NZM/common/tco/base/thread.h · TcoThread 五型六态、StackInfo 与金丝雀
  • ~/t/NZM/common/tco/base/interface.h · TcoDo / TcoWait / TcoYield / TcoTrigger 四原语门面
  • ~/t/NZM/common/tco/context/util.cpp:773 · NoBlockService 越切拦截点

上游专题(不在本文档范围内的部分):

  • self-mesh-k8s-deepdive.md · tbus SHM 环形队列、msc、tmesh、点分位分发 4 主干
最近更新: 2026/9/10 11:38
Prev
自研 Mesh × K8s · 数据面深水区
Next
K8s 异构 & 网络插件