Repository navigation
修复事件轮询器在自己线程上被析构导致的读已释放内存 - #301
Merged
xia-chu merged 3 commits intoSep 24, 2026
Merged
Conversation
Windows在进程退出时会先强行终止其余线程,轮询线程于是死在wepoll的epoll_wait里。 该函数进入时给句柄加引用(wepoll.c:563)、返回时才释放(wepoll.c:572),线程既然被杀, 这个引用就永远不会释放。随后EventPoller析构调用的epoll_close在 reflock_unref_and_destroy(wepoll.c:1413)上等待引用归零,于是永久阻塞:流水线上表现为 用例断言全过、结果已打印,进程却被120秒超时判为失败。 流水线诊断佐证:退出期_pipe.write返回-1、错误码10093(WSANOTINITIALISED,Winsock已被 卸载),join却立即返回(线程早已不在),卡点精确落在close_event一行。 现于析构时判断轮询线程是否正常走出了循环,未正常退出则把句柄留给系统回收——进程即将 结束,这是安全的。判定条件比'进程退出'更宽(运行期管道写失败也会落入,那就是真泄漏), 以及onPipeEvent仍可能因被杀线程持有_mtx_task而阻塞,两点都已写进代码注释。 顺带给_exit_flag补上初值:它此前未初始化,而注释本就是'标记loop线程是否退出',循环尚未 开始时理应为true。构造函数中即已创建句柄,取true可使从未运行过轮询的EventPoller照常 关闭句柄。 死锁修复后,test_ringBuffer与test_udpSocketBufferConfig一并收录进Windows门禁, 原先'待修复后再并入'的注释随之改写。 非Windows平台不受影响:跳过逻辑整段位于#if defined(_WIN32)内,其余平台can_close为 常量true,走的仍是原来的close_event。
EventPoller的最后一个shared_ptr引用可能恰好在它自己的轮询线程上释放(典型:Socket的析构
任务在该线程执行,而它持有最后一个引用)。对象随即析构,而runLoop仍在栈上,返回到循环条件
时读到的已是释放过的内存。哪个线程拿到最后一个引用是随机的,因而表现为偶发:Linux上ASAN
实测master 40/40报heap-use-after-free,macOS流水线裸跑30次段错误3次。
修法只针对这一个成因:给addPoller()创建的shared_ptr挂删除器EventPoller::destroy,发现
最后一个引用是在本对象自己的轮询线程上释放时,不在此处销毁,只置退出标志并写一字节管道
让循环结束,由线程函数在runLoop返回之后再销毁;在其它线程释放则照旧直接销毁。轮询线程
仍活到最后一个引用释放,与master的生命周期模型完全一致;shutdown()、onPipeEvent()、
async_l()、~TaskExecutorGetterImp()均未改动。
此前两个方案(在池子析构时主动shutdown轮询线程,再以drain/closed状态处理残留任务)均已
废弃:它们造出master没有的"线程已停、对象仍活"状态,先后引入跨线程sync()挂死、任务被
丢弃、队列排不空、_loop_thread竞争等回归,经独立审查否定。
验证(全部实测,Linux):
ASAN test_udpSocketBufferConfig x150 master 145次UAF -> 本分支 0
probe_uaf / p1_uaf x10 master 10次UAF -> 0
从poller自己线程释放池子 x5 master 5次UAF -> 0 (此前被归为既有,一并修掉)
前两轮独立审查的21个探针+本轮26个: 零UAF、零挂死,行为与master逐项一致
最后引用在延时任务里释放 x20: 修前线程停在epoll_wait永久残留,修后20/20正常回收
运行期释放池子: 轮询线程于自身完整跑完析构,LSan 0/10
门禁 8/8 (RelWithDebInfo与ASAN各一套)
消费者清单:
TaskExecutorGetterImp::addPoller是EventPoller唯一的创建点,EventPollerPool、
WorkThreadPool以及下游ZLMediaKit api/source/mk_thread.cpp的WorkThreadPoolForC
(运行期new/delete)全部经它创建,一并覆盖。
EventPoller新增一个bool成员,类布局改变,以动态库方式使用本库的下游需重新编译。
已知边界(均已写入代码注释):
- 进程退出期若恰在此刻于自己线程析构,exit()不等待该脱离线程,析构可能被截断,那条析构
日志可能丢失或模块名字段为乱码;退出码不受影响(1400余次实测),运行期不存在。
- 在池内自己的轮询线程上释放池子时,该线程会活到当前批次结束才销毁;master同序列直接UAF。
- fork子进程exit时pthread_join段错误、静态析构期在poller任务里再取池子: master既有。
Closed
守住上一个提交修的缺陷。用例让延时任务的闭包持有poller的最后一个引用,运行期释放池子后 检查三点:两秒内销毁了;销毁发生在轮询线程自己身上(经释放前记下的线程id比对);释放引用的 当场对象仍活着(经成员逆序析构的Guard观察析构日志是否已出现,主线程等Guard写完才读结论)。 对照(同一文件):master 5/5退出码3(当场销毁、未推迟);去掉唤醒的库3/3退出码1(两秒内未销毁); 本分支20/20通过。负载压测:正常/满负载/单核/单核+64 busy loop各100次零非零,ASan 200次无报告。 已在Linux/macOS/Windows流水线各跑通。
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
问题
EventPoller 的最后一个 shared_ptr 引用可能恰好在它自己的轮询线程上释放——典型情况是 Socket 的析构任务在该线程执行,而它持有最后一个引用。对象随即析构,但 runLoop 还在栈上,返回到循环条件时读到的已经是释放过的内存。哪个线程拿到最后一个引用是随机的,所以表现为偶发:Linux 上 ASAN 实测 master 40/40 报 heap-use-after-free,macOS 流水线裸跑 30 次段错误 3 次。
修法
只针对这一个成因。
addPoller()创建的 shared_ptr 挂一个删除器EventPoller::destroy:发现最后一个引用是在本对象自己的轮询线程上释放的,就不在这里销毁,只置退出标志、写一字节管道让循环结束,由线程函数在 runLoop 返回之后再delete this;在别的线程释放则照旧直接销毁。轮询线程仍然活到最后一个引用释放,和 master 的生命周期模型完全一样。
shutdown()、onPipeEvent()、async_l()、~TaskExecutorGetterImp()都没动。60 行。为什么不走 #300 那条路
#300 在池子析构时主动 shutdown 轮询线程,再用 drain/closed 状态处理残留任务。这造出一个 master 里没有的状态——线程停了、对象还活着、别的线程还在往里投——之后每一版都在给这个状态打补丁,三轮独立审查先后抓到跨线程 sync() 永久等待、任务被丢弃、队列排不空、
delete _loop_thread与其它线程读它竞争。不引入新状态,这些问题就都不存在。验证(Linux,全部实测)
test_udpSocketBufferConfig×150对下游
EventPoller 只有
TaskExecutorGetterImp::addPoller一个创建点,EventPollerPool、WorkThreadPool 和 ZLMediaKitapi/source/mk_thread.cpp里运行期 new/delete 的 WorkThreadPoolForC 全经它创建,一并覆盖。ZLMediaKit 端到端(ASAN 全量编译,子模块指向本分支):MediaServer 空载 ×3 与有负载 ×5 → SIGINT,Sanitizer 0、退出码 0、16 个 poller 全在主线程析构,和 master 一样——它退出时先清 session/socket 再析构池子,根本不走推迟路径。C API
mk_thread_pool_release主线程调 ×5、在池内自己线程的任务里调 ×5,全部 0 Sanitizer、进程正常继续;后者在 master 上是直接 UAF。升级注意:EventPoller 新增一个 bool 成员,类布局变了。以子模块源码方式编译的下游跟着重编即可;动态链接旧二进制配新 .so 会崩。
回归用例
新增门禁用例
tests/test_pollerDeferredDelete,守的就是这个缺陷:让延时任务的闭包持有 poller 的最后一个引用,运行期释放池子后检查三点——两秒内销毁了;销毁发生在轮询线程自己身上(经释放前记下的线程 id 比对);释放引用的当场对象仍活着(经成员逆序析构的 Guard 观察析构日志是否已出现)。对照(同一文件):master 5/5 退出码 3(当场销毁、未推迟);去掉唤醒的库 3/3 退出码 1(两秒内未销毁);本分支 20/20 通过。负载压测:正常 / 满负载 / 单核 / 单核+64 busy loop 各 100 次零非零,ASan 200 次无报告。三平台流水线各跑通。
已知边界(都写在代码注释里)
Windows 路径本机无法运行,合并前请让 Windows 流水线跑一次。