tinyCoroLab 实现复盘:唤醒、停机与协程帧所有权

这篇笔记面向正在做 tinyCoroLab、已了解 C++20 协程语法的同学,也供未来的我回查。它记录我实现时犯的错,重点保留 LLM 先于我发现的问题。每节从失败现象追到当时代码和事件顺序,再归纳可复用的规则。记录主要来自 2026-10-15 至 10-23 的 Codex 会话,源文件在这段时间多次修改;历史错误不等于当前 HEAD 仍有同一错误。有测试、诊断报告的结论与仅由源码推演的风险会分别说明。原始会话见末尾参考文献。

运行图:一个协程会经过哪些地方

scheduler:分发根任务,协调多个 context 停机
  └─ context:工作线程 + engine + 已登记的等待计数
       └─ engine:可运行句柄队列 + io_uring + eventfd
            ├─ 取 handle → resume() → 计算或准备 I/O
            └─ 提交 SQE → 收 CQE → 回调安排协程恢复

同一个协程会在“可运行”“挂起等待 I/O”“挂起等待同步条件”等状态间移动。这里至少有三种不同的工作量:可运行句柄、已准备但未提交的 SQE、已提交但未完成的 I/O。把它们都叫“任务”,会使唤醒和停机判断失去边界。源码:engine、源码:context。

Lab 1:task、parent、detach() 管的是不同的事

task<T> 保存指向协程帧的句柄;未 detach 的 task 析构会销毁帧。父协程 co_await 子 task 时,子 promise 的 parent 被设为父句柄;子协程进入 final_suspend 后返回 parent,让父协程接着运行。这个字段只规定续接关系,并不转移子帧的所有权。源码:task。

detach() 标记帧由外部执行路径负责清理,并把当前 task 内的句柄置空。因此,先 detach() 再调用 task.handle() 会得到空句柄;仅复制句柄,也不会复制或接管协程帧。后面 condition_variable 的空句柄和 channel 的 use-after-free,分别来自这两个误解。[S8][S9]

项目里的关键语句很短,足以说明两者的边界:

// task::detach(),摘自当前 task.hpp
promise.is_detached = true;
m_coroutine = nullptr;
 
// FinalSuspendAwaiter::await_suspend()
return promise.parent ? promise.parent : std::noop_coroutine();

我现在分析每个句柄时先写清楚三件事:**它指向哪一次协程调用产生的帧;谁持有这块帧;恢复它的代码在 resume() 返回后是否还会访问它。**类型相同不代表帧相同:两次 ch.send() 都返回 task<bool>,却是两个独立的协程帧。

Lab 2a:LastSubmit 没有 I/O,为何仍要求 poll_submit() 醒来

submit_task、add_io_submit 与 poll_submit 的语义

LastSubmitTaskToEngine 让线程先进入 poll_submit(),另一线程约 100 ms 后提交一个纯计算任务。我最早在“无运行中 I/O”时直接返回;改为“等一个 CQE”后仍卡住,因为这个测试根本不会产生 CQE。LLM 通过测试指出:这里等待的是引擎有可处理的工作,而不只是 I/O 完成。测试:LastSubmitTaskToEngine[S1]

submit_task(handle) → 句柄进入可运行队列
exec_one_task()     → 恢复协程;协程这时才可能准备 SQE
add_io_submit()     → 记录一项已准备、待提交的 SQE
poll_submit()       → 提交 SQE;唤醒后重新检查工作和 CQE
CQE 回调             → 将挂起协程重新放入可运行队列

所以 submit_task() 既不是“计算任务专用”,也不提交 I/O;它只要求句柄现在可以运行。add_io_submit() 是 awaiter 准备好 SQE 后的记账。poll_submit() 也不负责一直等 register_wait 归零:等待计数属于 context 的生命周期判断。源码:engine[S1][S6]

当前 engine.cpp 的入口仍清楚体现“入队”的含义;这里截取的是现行代码中的操作顺序,不是最早失败版本:

m_task_queue.push(handle);
m_upxy.write_eventfd(1);

eventfd 的读数不是 CQE 数

项目将 io_uring 完成通知注册到 eventfd,同时任务提交也会写它。一次 eventfd 读取可能对应新任务、CQE,或合并的多次通知;它只是“醒来检查”的门铃。我的一版实现把一次通知解释为全部运行中 I/O 已完成,清零 m_n_rio 或断言本次 CQE 数等于运行中 I/O 数。只有实际取到并处理的 CQE才能用来扣减该计数。这个错误由 LLM 在读源码时指出。源码:uring_proxy[S1]

另一版 submit_task() 先写 eventfd,再把 handle 入队。等待线程可在两步之间醒来,看见队列仍空,导致 LastSubmit 偶发失败。应先发布任务、再通知,并让等待者醒后重查真实队列。MixTaskNopIO 则展示另一端:若非要等所有 I/O 完成,已可运行的任务和部分完成的 CQE 会被拖住。运行循环需要分轮处理,而不是清空一类工作后才看另一类。测试:MixTaskNopIO[S1]

阻塞式有界队列可能卡住唯一消费者

对照参考实现时,LLM 还指出一项未在会话中单独复现的风险:工作线程向自己的满任务队列做阻塞式 push(),会等消费者取走任务;唯一消费者又正是这个工作线程。队列操作本身线程安全,不代表这种调用位置不会自锁。检查有界队列时必须把“谁能消费”画进等待关系。[S6]

Lab 2b:context 空闲,不等于 scheduler 可以结束

本地队列暂空,却仍可能收到递归提交

我曾在 context 本地任务和 I/O 清空时退出并关闭 eventfd。SubmitToScheduler 的任务会在运行中继续提交下一任务;轮询分发可能把它投给已经退出的 context,write_eventfd() 随后报错。LLM 最初也建议“本地工作清空即可退出”,在看到这个故障后修正为:由 scheduler 管理的空闲 context 必须继续可接收任务,统一停机由全局协议决定。当时没有取得具体 errno,所以“投递到已关闭 context”是有源码支持的定位,而非 errno 已证实的结果。测试:SubmitToScheduler[S1]

register_wait 是“仍有挂起协程”的记账。队列暂空时,别的 context 可能正在运行、稍后派新任务;本地等待计数为 0 也不能单独证明全局工作结束。

各自加锁的 ready() 与 submit() 仍留下空窗

我观察到 scheduler 的 alive 掩码全是 false,engine 中却已有任务,便给 ready() 和 submit() 各自加锁。LLM 把一次提交拆成“标活跃”和“真正入队”,指出两步之间仍允许停止回调运行:[S3]

时间提交线程目标工作线程
1标记目标 context 为 alive,释放锁
2看见队列仍空
3报告空闲,可能让全局计数归零并发出 stop
4把任务放入队列

因此不能用加锁前的 ready() == false,在加锁后直接决定停机。提交方需要协调“状态更新+任务发布”;停机回调在同一同步保护下重新检查队列,才能给这两条路径确定先后顺序。会话中一次重新构建后 28 个 lab2b 用例、几轮 SchedulerSubmit 通过;有限运行不能证明竞态已穷尽。[S3]

jthread 可以先于 m_job 赋值完成而运行

Valgrind 的首个错误是地址 0x0 的 Invalid read of size 8,第一处项目代码在 context::notify_stop() 的 m_job->request_stop();随后才是 SIGSEGV。make_unique<jthread> 构造过程中,新线程可能已经进入 run() 和默认停止回调,而主线程要在构造返回后才把结果赋给 m_job。这是报告和源码共同支持的启动竞态解释。scheduler 还需考虑第一个 context 已运行完时,其他 context 的线程对象是否都已创建。源码:context[S2][S3]

这次报告末尾的 possibly lost 出现在崩溃之后,不能优先当业务泄漏。诊断要从首个非法读写和地址开始,再回溯线程创建时间线。[S3]

reg_cnt == 0 放进等待谓词会制造假活跃

一版 engine::wait() 把 reg_cnt == 0 当成唤醒条件。真正空闲时它通常恰好为 0:线程报告空闲,wait() 立即返回,m_cont_cb() 无条件把它标回活跃,下一轮又报告空闲。多个 context 交错时,scheduler 可能始终看不到全部同时空闲。火焰图里大量时间落在 scheduler::init_impl 中定义的 stop callback lambda 和锁上,实际是工作线程反复调用回调,不是初始化函数一直执行。[S4][S5][S6]

旧版谓词的关键结构可以缩成 ready() || !empty_io() || reg_cnt == 0 || stop_requested()。当所有工作都空、reg_cnt 又为 0 时,条件已经成立,wait() 没有睡眠的机会。“能报告空闲”与“应从睡眠醒来”是两个不同判断。

反方向也要检查:若某计数从正数降为 0,却没有使负责停机判断的线程醒来,它可能真睡住。会话后来澄清 event 等待者原则上应回原 context 恢复并在那里注销,因此“注销一定跨 context”不是普遍事实。核心不变量是:状态变化后,判断方必须有机会再次运行,并在醒来后重查状态。[S4][S6]

还有两处容易被计数日志掩盖的独立问题。早期 register_wait() 在 context 锁下写 m_register_cnt,run() 却无同步地读它;锁若未覆盖读写双方,数据竞争不会因“写操作上了锁”而消失。另一版 stop_impl() 重置所有 m_ctxs_live[i],却没有同步调整 m_n_ctx_live;若是在停机阶段看见掩码与总数不一致,先检查这条代码路径,而不是立即推断有任务丢失。[S5][S6]

io_uring 初始化失败的资源链

我把频繁的 uring_proxy init uring failed 猜作物理内存限制,因为 sudo 似乎能缓解。LLM 查到当时每 ring 的队列长度为 10240,默认同时建 16 个 context,普通用户 memlock 硬上限为 8 MiB;scheduler 与 context::start() 还各初始化一次 ring,首次创建的 ring 被覆盖而未关闭。独立探针用相同参数连续创建 ring,第 5 次返回 -ENOMEM。因此,更有证据的解释是 ring 大小、数量、重复初始化和锁定内存记账,而非单凭 sudo 推断物理内存不足。源码:配置、源码:scheduler[S3]

该故障也要求日志打印 liburing 返回码。bench 每轮重新初始化 scheduler 时,要检查旧资源是否真正清理。另有一版 scheduler 停止回调按引用捕获循环变量 i,属于独立的失效引用风险,不能拿它解释 -ENOMEM。[S3][S9]

停止请求不会自动唤醒所有阻塞点

stop_token 只是状态。工作线程若阻塞在条件变量或 eventfd_read(),还需要对应的通知使它返回并检查停止条件。早期 notify_stop() 只调用 request_stop();scheduler 另行 wake() 也只覆盖相应等待路径。单独运行 context、等待 eventfd 的路径都要检查。另一个设计风险是 while (!empty_io()) poll_submit() 会被长期 I/O 困住,而持续产生任务的 while (ready()) 可能拖延 I/O;应分轮重查两类工作。以上由源码与参考实现对照提出,历史记录没有逐条复现测试。[S6]

Lab 3:区分真实 I/O、退出错误和测试脚本等待

socket 对端退出,不会完成 stdin 的读取

stdin_client 同时运行 client() 等 socket、echo() 等标准输入,两项 I/O 可分属两个 context。对端退出只使 socket 读取完成;未输入的 stdin 读取仍挂起,因此不能让另一个 context 因“网络协程结束”就停机。要自动结束,应明确取消 stdin I/O、处理取消完成并安全清理协程;只改一个 bool 不够。还要注意 stdin 返回 0(EOF)时,示例中的无限循环应退出。源码:stdin_client[S5]

我曾说“两个协程都卡在 poll_submit()”。更准确地说:**协程在 co_await 处挂起;阻塞在 poll_submit() 的是工作线程。**两个说法涉及不同层次,混淆后很难解释为何对端关闭不唤醒 stdin 侧。[S5]

bind 失败后的 self-join 可能只是后续症状

lab3 先出现 server bind error,随后出现 Resource deadlock avoided。后者很可能是工作线程中的错误退出触发 jthread 析构并尝试 join 自己;但当时没有记录 bind() 的 errno,不能断言首错一定是端口占用或 TIME_WAIT。应该先在失败点打印 errno,再调查退出路径,而不是从最后一个异常倒推首因。源码:tcp[S5]

test-lab3 不输出,不直接证明 C++ 死锁

脚本分两轮压测、每轮设为 30 秒,还对压测程序 stderr 做阻塞式 readline();程序尚在运行且不输出时,脚本本身可以停在那里。server 在 accept 等连接也属正常。会话中绕开脚本、用两个终端分别跑 server 和压测能够完成。因此排查要把 server、压测程序、Python 脚本拆开观察。源码:lab3_test.py[S5]

Lab 4:event 的状态、句柄和 context 必须一起登记

await_ready() 的 false 不是对未来状态的预约

等待方 await_ready() 看见 event 未 set;另一线程马上 set(),看见等待队列为空并结束;等待方随后在 await_suspend() 入队,便可能永久睡下去。LLM 对照参考实现指出这条丢唤醒时间线。登记点需要在同一同步协议下重新检查 set 状态;若已经 set,就不挂起。源码:event[S4][S6]

两个独立队列不能稳定配对一个等待者

一位等待者需要保存 handle 和它登记时的 context。历史实现分两个队列存它们;A、B 两线程并发入队时,两个队列的顺序可不同,set() 分别弹出可能得到 A 的 handle 和 B 的 context。两者应作为一条等待记录。即使后来加了“入队前复查 set 状态”,也不会自动消除配对风险。[S6]

当前 event.hpp 仍能看到这个结构(节选):

m_event.m_waiters.push(handle);
m_event.m_ctxs.push(&m_ctx);
m_ctx.register_wait();

这三句在一把 event 锁里顺序执行,也不意味着两个各自支持并发取出的队列会永远按相同顺序配对;需要让 handle 与 context 成为同一个等待节点。

更早的一版先让句柄进入可被唤醒的队列,之后才 register_wait()。另一线程可先恢复它,使 await_resume() 先 unregister_wait(),无符号计数便可能回绕成极大值;这与我观察到的异常 register_cnt 相符。LLM 还指出 set() 在 context 1 调用的 local_context() 就是 1,即使等待者原先在 0 登记。恢复时扣错 context 的计数,也会让账目失衡。登记计数、句柄与原 context 应在对外可见前形成同一记录。[S4][S6]

同一阶段还有一次“明明已提交任务,线程却没醒”:context::submit_task() 先入队再调用 engine::wake(),旧版 wake() 看到 ready() == true 便直接返回,恰好因队列已有任务而跳过通知。这是 LLM 阅读两个函数的组合顺序才指出的;单看任一函数似乎都合理。[S4]

固定容量与模板类型是另外两条边界

当时 event 等待队列容量只有 16384,而测试可能先提交约 100000 位等待者,再提交 setter。若所有工作线程堵在满队列的阻塞 push(),没有线程能运行 setter。此链由 LLM 根据容量和测试顺序推演,历史会话没有独立复现。[S4]

早期 event<T> 直接保存 T m_value 并赋值,要求 T 可默认构造、可赋值,不能覆盖仅可移动类型。这与丢唤醒不是同一故障,却说明通过 int 用例并不能证明模板接口满足约束。[S6]

协程 mutex 的重入与重复解锁

我问过 mutex 能否多次 unlock(),并把它与重入相提并论。重入是持锁时再次获取;重复解锁是已经释放后又解锁。现有测试只覆盖一次获取对应一次解锁;不能从当时待实现骨架的空 unlock() 推出“允许重复解锁”的契约。此处是语义难点,没有对应的已复现故障。[S10]

Lab 5a:when_all 的最后一个子任务可先于父句柄登记

when_all 的 awaiter 构造时就提交子任务,父协程稍后才进入 await_suspend() 写入自己的 handle。若最后一个子任务先完成,旧版 count_down() 只凭计数为 0 就提交尚未初始化的父句柄;engine 在 resume() 崩溃只是最终症状。LLM 找到了更早的坏句柄来源。源码:when_all[S7]

会话中旧版的关键判断可写成下面的节选;后来版本加入 m_wait,但还需初始化和同步它:

// 旧版 count_down() 的危险条件
if (m_count == 0)
    m_ctx.submit_task(m_handle); // m_handle 可能尚未由 await_suspend 赋值
时间子任务线程父协程线程
1awaiter 构造后已开始执行刚结束 awaiter 构造
2最后一个 count_down() 使计数归零,尝试提交 m_handle尚未进入 await_suspend()
3engine 可能取到无效句柄随后看见计数为 0,直接继续

构造函数里加锁,覆盖不到“构造结束→父句柄登记”的空窗。两个函数用同一把锁,也须在锁内共同决定谁负责恢复:完成先到,await_suspend() 返回 false;父协程已登记并确定挂起,最后一个 count_down() 才提交它。相邻的审查问题还包括:void 版当时遗漏相同判断;m_wait 未初始化;await_ready() 无锁读并发变化的计数;未登记等待却可能调用 unregister_wait()。锁不会把未初始化读取变成有效状态。[S7]

提交父句柄时还要考虑 awaiter 自身的寿命:父协程可能立刻恢复并销毁 awaiter,而最后一个子任务仍持有该 awaiter 的锁或正访问其成员。这个邻近竞态在会话中由 LLM 提出,属于需要通过锁释放与唤醒顺序进一步验证的设计风险,不能只靠“句柄已初始化”宣布安全。[S7]

Lab 5b:condition_variable 的 helper 与用户锁

detach() 后再取句柄,得到的是空句柄

一版 createHelper() 先 cv_task.detach(),再取 cv_task.handle();Lab 1 的 detach 会清空 task 内句柄,于是队列中注册的是空 handle,engine 恢复它会崩。LLM 直接从代码顺序找到该错误。正确分析顺序是先保存 helper 句柄,再 detach,最后交给等待队列。源码:task::detach[S8]

// 会话中旧版的关键顺序
cv_task.detach();
auto cv_handle = cv_task.handle(); // nullptr

测试已持有 mtx,挂起前却没有释放

测试先获取 mtx,再调用 cv.wait(mtx, predicate)。旧 awaiter 再 try_lock(),失败分支挂起前仍持有锁;helper 后来想获取同一锁,最小 notify_one 用例只打印 waiting 后超时。这是从测试调用约定即可发现的问题,不能只看 helper 的代码。测试:lab5b[S8]

另一版在谓词为假后先释放 mtx,再登记 helper。修改谓词并通知的协程可在空窗中运行,helper 随后才入队,错过通知。对该测试,修改谓词的一方持有同一 mtx,等待方应在保持必要同步关系时先完成 CV 登记,再释放用户锁。**通知方本身可以不持有 mtx;通知也不保存给未来等待者。**LLM 曾把“测试里通知者持锁”误说成普遍要求,后经追问修正,这里采用修正后的说法。[S8]

helper 若发现谓词仍为假,需要重新登记helper 自己的句柄,不能把原等待者直接塞回 CV 队列,否则下次通知会跳过谓词检查。历史中还出现 reg() 与 notify_all() 对分离的 handle/context 队列缺少同一同步边界的问题;它与 Lab 4 的“等待者是一条完整记录”属于同一个设计约束。[S8]

notify_all/4 运行 71 秒不等于死锁

该用例有 10000 个等待者,谓词按顺序逐个满足。每次 notify_all() 都让大量尚不满足条件的 helper 醒来、检查、重新登记,约有 5000 万次检查。调整锁位置后,Debug 用例约 71.5 秒并通过。应记录 id 或完成数是否持续前进;持续增长时先考虑复杂度,长期停在同一 id 才优先查丢通知。[S8]

Lab 5c:channel 同时要求数据、唤醒和帧所有权正确

ready consume, 0 waiting:在空等待队列上阻塞

首版 try_v() 看到缓冲计数为 0 就调用 wake_consumer();即使没有等待者,后者仍对空等待队列执行阻塞 pop(),日志停在 ready consume, 0 waiting。改为非阻塞取等待者后,同一日志不再指向该卡点。后续版本又出现 helper 拿到资源却不恢复原始 send/recv,以及关闭后空消费者重新挂起的问题。同一条日志必须和产生它的代码版本一起分析。[S9]

多生产者少收:先查谁过早关闭

ChannelProducerConsumer/4 少收数据时,LLM 发现当时 wait_group::add(count) 实际只做一次 m_cnt++。测试想等全部 producer 完成后才 close();计数却在第一个 producer 调用 done() 时归零,关闭截断了其他生产者。这个首错在 wait_group,而不是 channel 的数据队列。测试:lab5c[S9]

另一次少收有独立原因:关闭时缓冲区仍有数据,recv() 却先检查 stop 并返回空 optional。82/100、761/1000 这样的失败数与“未排空就结束”相符。应先取已有数据;队列空且关闭时才报告结束。两个少收现象外表相同,根因却分别是关闭过早和关闭后取数顺序。[S9]

关闭后的等待者也要有退出路径。旧版 close() 虽唤醒空缓冲的 consumer helper,helper 却只检查“是否能取数据”,失败便重新登记;此时不再有 producer,等待会永久持续。关闭状态与缓冲是否还有数据必须在同一临界区共同决定下一步。历史中还发现一版在无锁情况下读 m_stop、另一线程在 close() 写它,属于独立的数据竞争风险。[S9]

/5 卡住:每次成功接收都腾出位置

容量为 1024,producer A、B 都在等空位。旧代码只在“消费前刚好满”时唤醒一个 producer:第一次接收把占用 1024 降到 1023,唤醒 A;第二次降到 1022,却不再唤醒 B,尽管又腾出位置。B 可能永久留在队列,等待所有 producer 结束的协程也无法推进。LLM 根据 try_p() 条件指出该时序。每次成功取走数据,都应给一个等待 producer 重试机会;每次成功放入也应考虑等待 consumer。唤醒后仍须重查条件,因为其他协程可能抢先占用位置。[S9]

// 会话中旧版 try_p() 的唤醒条件
if (m_pv == capacity)
    wake_producer();
m_pv--;

该方向的一版改动在 Release 下让 /5 连续 20 次通过、BufferChannel 全组通过;我随后又改回代码要求只分析原因,所以不能把历史测试通过写成当前文件已修好。[S9]

ASan 的 UAF:被释放的是 send 帧

co_await ch.send(value) 至少涉及三个协程:外层 producer、ch.send() 创建的 task<bool> 所代表的 send 帧、等待空位时创建的 helper 帧。外层 producer 的临时 task<bool> 持有 send 帧。旧实现由 helper 调用 submit_to_context(send_handle),把 send 交给调度器。源码:channel、源码:task[S9]

调度器:send_handle.resume()
  → send 完成,final_suspend 恢复 producer
  → producer 离开 co_await ch.send(...),临时 task<bool> 析构
  → send 帧被销毁
  → resume() 返回调度器,调度器继续调用 send_handle.done()
  → 对已释放的 send 帧读取:heap-use-after-free

ASan 的 freed by 栈指向 task<bool>::~task(),非法访问发生在 engine 的 done()。我反复追问的“哪个 task<bool>”,答案是 producer 那句 co_await ch.send(...) 的临时对象;不是 helper,也不是 producer 帧。当时的修复方向是让独立 detached helper 成为调度任务,再经明确的 helper.promise().parent = send_handle 续接 send;detach() 本身不会设置 parent。一版 helper 的 parent 仍为空,结束时转向 noop,send/recv 又永久挂起。[S9]

engine.cpp 中的这两句解释了为何“resume() 已经成功返回”仍会报 UAF:

coro.resume();
if (coro.done()) clean(coro); // 要求 coro 在 resume 返回后仍有效

同类风险要按句柄所有者判断。cv.wait() 返回 awaiter,不像 ch.send() 额外创建子 task;现有 cond_var 测试中的等待者多为调度器持有的 detached 根任务,未必走到这条 UAF。若等待者是父协程持有的子 task,直接交给调度器恢复可能重现风险。反过来,直接用 helper.parent 恢复 detached 根任务却绕过其清理,也可能泄漏帧。submit_task() 与 parent 不能无条件互换。[S9]

重复 backtrace 不能单独证明栈溢出

一次崩溃停在 std::string 析构,栈中重复 channel_consumer → recv;LLM 先推测栈溢出,随后撤回,因为重复帧尚不能证明错误类型。那次 ASan 实际报的是上述 heap-use-after-free。另一次 /3 的 ASan 才明确报 stack-overflow;项目 CMake 也提醒 Debug 下大量连续 co_await 可能因 GCC 协程尾调用优化不足耗尽栈。两份报告不可合并为同一个根因。源码:CMakeLists[S9]

从这些故障提炼的排查顺序

  1. 先写事件时间线。 标出状态检查、登记 handle 与 context、计数、入队、通知、恢复和销毁;寻找双方都认可的顺序点。单个函数有锁,不等于跨函数决策原子。
  2. 给句柄标身份。 记录地址、对应帧、所有者、是否 detached、恢复后调度器还会做什么。读 ASan 时同时看非法访问栈和 freed by 栈。
  3. 从首个错误读报告。 Valgrind 的首个 Invalid read/write、地址和第一处项目代码,比末尾 SIGSEGV 或 possibly lost 更接近原因。bind() 打印 errno;io_uring 初始化打印 liburing 返回码。
  4. 分开看各层等待。 协程可挂在 co_await,工作线程可阻塞在 eventfd 或条件变量,测试脚本又可阻塞于 readline()。记录队列长度、待提交和运行中 I/O、等待计数、stop 状态,再判断是否死锁。
  5. 验证二进制对应哪版源码。 会话中曾以旧二进制的 dump 推断新代码仍坏;另一次 Debug 同时带 -g 和 -O3,行断点难命中。结论应附构建配置、测试过滤条件与报告时间。

参考文献

下列是实际核对的历史材料。S 编号将正文判断指向原会话;源码链接指向当前工作区,旧实现应以相应会话和提交为准。没有实质问答的会话未列入。

配套代码入口:task、engine、context、scheduler、event、when_all、condition_variable、channel。