Skip to content

修复事件轮询器死在自己线程上导致的读已释放内存 - #300

Closed
alexliyu7352 wants to merge 4 commits into
ZLMediaKit:masterfrom
alexliyu7352:fix/poller-use-after-free
Closed

alexliyu7352 wants to merge 4 commits into
ZLMediaKit:masterfrom
alexliyu7352:fix/poller-use-after-free

Conversation

@alexliyu7352

@alexliyu7352 alexliyu7352 commented Sep 22, 2026 •

Copy link
Copy Markdown
Member

依赖:本 PR 基于 Windows 死锁修复那一个分支,请先合并它。在它合并之前,下方 diff 会连带显示它的改动;合并后本 PR 的 diff 会自动收敛为只剩本文所述内容。

问题

池子析构时,任务队列里残留的任务(例如 Socket 的析构)会在轮询线程上执行,并在那里释放掉 poller 的最后一个引用。对象于是死在自己的线程里,任务返回到 runLoop 的循环条件时,读到的已经是释放过的内存。哪个线程拿到最后一个引用是随机的,所以表现为偶发。

AddressSanitizer 实测(Linux):

ERROR: AddressSanitizer: heap-use-after-free
READ of size 1 ... thread T3 (event poller 0)
    #0 toolkit::EventPoller::runLoop(bool, bool)   src/Poller/EventPoller.cpp:382
freed by thread T3 (event poller 0) here:
    #6 toolkit::Socket::~Socket()                  src/Network/Socket.cpp:79
   #10 toolkit::EventPoller::onPipeEvent(bool)     src/Poller/EventPoller.cpp:334

在 macOS 流水线上,同一用例裸跑 30 次会段错误 3 次(master 同为 3 次)。

修法

池子析构时先停掉轮询线程,再释放 _threads 中的引用。

轮询线程在 onPipeEvent 中捕获到 ExitException 之后,就地把任务列队反复取到空为止(任务执行过程中可能又投递新任务,所以要取到空),然后才退出循环。此时池子尚未释放引用,因此这些残留任务即便释放掉自己持有的 poller 引用,计数也不会归零,对象不会死在自己的轮询线程里;等 join 结束、池子再释放引用时,析构自然落在池子这一侧。

任务的执行线程不发生变化——残留任务始终由该 poller 自己的轮询线程执行完毕。这一点很重要:调用方普遍假定某对象只在其所属轮询线程上被访问,并据此省掉了加锁。

EventPoller 析构中因此不再执行任务列队。

为什么放在 TaskExecutorGetterImp

_threads 归该基类所有。EventPollerPool 与 WorkThreadPool 都经 addPoller() 把 EventPoller 放进来,需要的是同一份收尾。

若只修 EventPollerPool,WorkThreadPool 不会被覆盖——用同样形状的探针实测,20 次中 20 次报同一处 use-after-free(线程 work poller 0);放到基类后两者均零报错。

验证

项目 修复前 修复后
EventPollerPool,ASAN 40 次 40 次 heap-use-after-free 0
WorkThreadPool,ASAN 20 次 20 次 heap-use-after-free 0
残留任务的执行线程 — 4/4 仍在轮询线程(未发生变化)
跨 poller 投递 — 3/3 仍异步执行于对方轮询线程
三平台 CI — 全绿,Windows 8/8

对下游的影响

消费者是 EventPollerPool 与 WorkThreadPool,两者都受益。ZLMediaKit 未自建 TaskExecutorGetterImp 子类(仅 server/WebApi.cpp 以其为形参),不受基类析构变更影响。

已用 ZLMediaKit 做过端到端验证(ASAN 全量编译,空载与有负载各一轮,SIGINT 优雅退出):修复前后均为零 ASAN 报错、退出码 0、16/16 poller 在主线程析构,逐项一致。ZLMediaKit 退出时有显式清理阶段,会先把 session/socket 收干净才轮到池子析构,因此它既不触发本缺陷,也不受本修复影响。

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。
池子析构时,任务队列里残留的任务(例如Socket析构)会在轮询线程上执行,并在那里释放掉
poller的最后一个引用。对象于是死在自己的线程里,任务返回到runLoop的循环条件时就读到了
已释放的内存。哪个线程拿到最后一个引用是随机的,因而表现为偶发。

Linux上以AddressSanitizer实测:master 40次中40次报heap-use-after-free,栈为
EventPoller::runLoop(EventPoller.cpp:382)读取已被Socket::~Socket(Socket.cpp:79)释放的this;
修复后40次零报错。macOS上同一用例裸跑30次段错误3次,master同样是3次。

改为逐个处理:停掉第i个轮询线程后,立即在本线程执行掉它队列中剩余的任务,再释放其引用,
然后才轮到第i+1个。处理第i个时其后的轮询线程仍在运行,投递给它们的工作因此照旧异步执行,
与修复前的时序一致——若改成先把所有线程一次停掉,这类任务会退化为在本线程同步执行,
实测探针在该写法下4次全部落到调用者线程,而逐个处理时4次全部仍在对方轮询线程。
另外,执行残留任务之前必须先处理掉epoll句柄,否则任务里的delEvent会在句柄仍有效时调进
epoll_ctl,Windows上该调用要取wepoll句柄树的锁而永久阻塞;这段已抽成可重复调用的
closeEventFd(),停机路径与析构路径共用同一顺序。

收尾逻辑放在TaskExecutorGetterImp而非某个具体池子,因为_threads归该基类所有。
消费者清单:
  - EventPollerPool  (src/Poller/EventPoller.h)
  - WorkThreadPool   (src/Thread/WorkThreadPool.h)
  两者都经addPoller()把EventPoller放入_threads,需要同一份收尾。若只修EventPollerPool,
  WorkThreadPool不会被覆盖:以同样形状的探针实测,20次中20次报同一处use-after-free
  (线程work poller 0),放到基类后两者均零报错。
  下游ZLMediaKit未自建TaskExecutorGetterImp子类(仅server/WebApi.cpp以其为形参),
  不受基类析构变更影响。
onPipeEvent(true);
}

EventPoller::~EventPoller() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

为什么析构中不直接调用shutdownAndFlush? 都是相同的代码

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已按您的意见处理,而且顺着第二条意见,这个函数整个删掉了。

改法见下一条回复:现在轮询线程会自己把任务列队取空,析构中不再需要执行任务,于是
shutdownAndFlush() 没有了存在的必要,析构里只剩 shutdown() + closeEventFd() + 日志。

Comment thread src/Poller/EventPoller.cpp Outdated
//The polling thread has stopped, so whatever is left in the queue runs on the caller thread.
//Such tasks (the destruction of a Socket for instance) usually hold a reference to this
//object, and letting the polling thread run them would make the object die on its own thread
onPipeEvent(true);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

另外这个析构中执行onPipeEvent不太安全 因为线程发生变化了 有优化空间 建议一并修改下吧。
执行shutdown时,会往任务列队中压入一个抛ExitException异常的任务 在onPipeEvent函数中捕获这个异常后,再清空执行所有任务吧

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

您说得对,这一点我之前没处理好,已按您给的思路改。

现在 onPipeEvent 捕获到 ExitException 之后,会就地把任务列队反复取到空为止(任务执行
过程中可能又投递新任务,所以要取到空),然后才退出循环;EventPoller 析构中不再执行任务。

这样也仍然能修掉原来的 use-after-free:shutdown() 是池子在尚未释放 _threads 中引用时
调用的,所以残留任务(例如 Socket 析构)即便释放掉自己持有的 poller 引用,计数也不会归零,
对象不会死在自己的轮询线程里;等 join 结束、池子再释放引用时,析构自然落在池子这一侧。

比我原来的写法好在:任务的执行线程不再发生变化。原写法下这些任务会落到析构所在的线程上,
而调用方普遍假定某对象只在其所属轮询线程上被访问、并据此省掉了加锁。

实测对比(各 4 次):

残留任务的执行线程
原写法 4/4 落在析构线程
现写法 4/4 仍在轮询线程

其余验证:跨 poller 投递 3/3 仍异步执行于对方轮询线程;use-after-free 在 EventPollerPool
40 次、WorkThreadPool 20 次下 ASAN 均零报错(master 基线 40/20);门禁 8/8。

按评审意见调整。此前的写法是在池子析构时停掉轮询线程、再由析构所在的线程把残留任务执行掉,
这会让任务的执行线程发生变化——而调用方普遍假定某对象只在其所属的轮询线程上被访问,
并据此省掉了加锁。

现改为:轮询线程在onPipeEvent中捕获到ExitException后,就地把任务列队反复取到空为止,
然后才退出循环。此时池子尚未释放_threads中的引用,因此这些任务(例如Socket析构)即便
释放掉自己持有的poller引用,计数也不会归零,对象不会死在自己的轮询线程里;等join结束、
池子再释放引用时,析构自然落在池子这一侧。

由此:
1. EventPoller析构中不再调用onPipeEvent,队列已由轮询线程清空;
2. shutdownAndFlush随之删除——它与析构中的代码完全重复,评审已指出这一点;
3. 池子析构只需逐个shutdown后释放引用。

实测:
  残留任务的执行线程   原方案4/4落在析构线程, 现方案4/4仍在轮询线程;
  跨poller投递         3/3仍异步执行于对方轮询线程;
  use-after-free       EventPollerPool 40次、WorkThreadPool 20次, ASAN均零报错(master基线40/20);
  门禁                 8/8。
@alexliyu7352

Copy link
Copy Markdown
Member Author

按你说的改完之后,我自己又测了一轮,发现这版有个坑,得先说一下。

onPipeEvent 里捕获到 ExitException 之后要把队列取空,但任务执行过程中如果又投递了新任务,这个循环就停不下来了。写个最简单的例子就能复现,任务里再 async 一个自己:

static EventPoller::Ptr g_p;
static void repost() {
    g_p->async(repost, false);
}
int main() {
    g_p = EventPollerPool::Instance().getFirstPoller();
    g_p->async([]() { std::this_thread::sleep_for(std::chrono::milliseconds(500)); }, false);
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    g_p->async([]() { repost(); }, false);   // 让它跟退出任务排在同一批
    return 0;
}

master 上进程正常退出,退出码 0,那个残留任务直接被丢掉、一次都没执行;改完之后进程退不出来,是我用 timeout 15 秒杀掉的,试了两次都一样。

原因是原来 _exit_flag 一置位 while 循环就退出了,剩下的任务不管;现在变成必须把队列跑空,碰上自我重投的任务就成死循环了。

想了两个办法:

  1. 给这个循环加个上界,比如最多转 N 轮,超了就把剩下的丢掉并打条警告日志;
  2. 只处理进入退出那一刻已经在队列里的那批,执行过程中新投递的不再管。

我倾向第一个,既能保住你说的"把任务执行完",又不至于卡死,代价是极端情况下还是会丢掉一点任务、但至少有日志。你看哪种合适?确定了我再改。

另外顺带测到一个边界情况,不算严重、但说一下:_threads 里靠前的 poller 先 shutdown,之后它的 isCurrentThread() 就恒为真了,所以靠后 poller 的残留任务如果往它投递,会直接在来源 poller 的线程上跑掉。master 上因为残留任务根本不执行,碰不到这个情况。

上一个提交让轮询线程在收到退出信号后把任务列队反复取到空为止,但没有给这个循环设上界。
若有任务在执行时再投递自己,列队永远取不空,进程也就永远退不出去。
实测:构造一个执行时再async一个自己的任务并排进退出批次,master退出码0(残留任务被直接
丢弃),上一个提交则卡死、被15秒超时杀掉,2/2。这比它要修的偶发use-after-free严重得多。

现给清空循环设上界kMaxDrainRounds(64轮)。正常退出场景的投递链条只有几层深,远达不到;
达到即视为异常,放弃剩余任务并打警告。上界只在退出路径上生效,正常运行期每次仍只处理
一批,无额外开销。

顺带清理:析构里的onPipeEvent(true)删除后,flush参数已无任何调用方,连同其分支一并删除;
常量按项目惯例移到文件作用域。

验证(全部实测):
  自我重投        3/3 退出码0,执行64次后停并告警(修复前2/2卡死)
  正常退出        3/3 残留任务仍在轮询线程执行,无告警
  上界边界        链条深度63/64全部执行无告警,65执行64个丢1个并告警
  跨poller投递    2/2 仍异步于对方轮询线程
  门禁            8/8
  ASAN            EventPollerPool 40次0,WorkThreadPool 20次0,自我重投+ASAN 10次0
@alexliyu7352

Copy link
Copy Markdown
Member Author

先按第一种改了,推上来了(09d379e):给清空循环加了 64 轮上界,超了就把剩下的丢掉、打条警告。正常退出的投递链条就几层,碰不到这个数;那个自我重投的例子现在能正常退出了,执行 64 次后停。

顺带把 onPipeEvent 的 flush 参数删了——析构里那个 onPipeEvent(true) 去掉之后,已经没人传 true 了。

你要是觉得第二种(只处理退出那一刻已在队列里的)更合适,我再改。

@alexliyu7352

Copy link
Copy Markdown
Member Author

@xia-chu 这个 PR 关了,方向错了,重新开了 #301。

错在第一版就把"池子析构时主动 shutdown 轮询线程"加进去了。这造出一个 master 里没有的状态:线程停了、对象还活着、别的线程还在往里投任务。后面按你建议改的 drain、我加的 64 轮上界、closed 标志,全是在给这个状态打补丁——找了三轮独立审查,每轮都抓到新的挂死或 UAF:跨线程 sync() 永久等待、队列排不空、shutdown 里 delete _loop_thread 和别的线程读它竞争。

你那两条意见本身没错,但都是在这个错的框架里提的。

#301 换了思路:不停线程,轮询线程照旧活到最后一个引用释放,和 master 一样。只在"最后一个引用恰好在 poller 自己线程上释放"这一个点拦一下——deleter 发现是这种情况就只置退出标志、写一字节管道,由线程函数在 runLoop 返回之后再 delete this。60 行,shutdown/onPipeEvent/async_l 一行没动。

你提的析构里那个 onPipeEvent(true),在 #301 里是 master 原样,我没碰。它管的是 join 之后才投进来的零星任务,那是既有行为。要不要改它我觉得是另一个 PR 的事,这次先把 UAF 收掉。

数据都在 #301 里。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants