持着 Mutex 调 par_iter,偶尔卡死
主线位置:一次 join 的「等 B」那一步。等待时执行的任务从哪来,见 WorkerThread 主循环;完整实验在 join 与 join_context。
sequenceDiagram participant W as worker W participant O as 另一个 worker W->>W: 外层元素 i:lock() W->>W: 内层 par_iter:join,推 B O->>W: 偷走 B W->>W: 跑完 A,本地空了,wait_until(B) W->>W: find_work 拿到外层元素 j(本地 deque 或注入队列) W->>W: 元素 j:lock() 同一把锁 Note over W: 锁在 W 自己的外层栈帧里,永远不会释放
现象
外层是并行迭代(或任何 rayon 任务),每个元素里拿一把 std::sync::Mutex,持锁期间调用一个内部也用 rayon 的函数。程序大多数时候正常,偶尔整个卡住,CPU 占用为零;采样看到某个 worker 停在 Mutex::lock 上,而持锁者是同一个线程栈上更早的一帧。
复现
实验 E7(exp-rayon/lock-reentry/)。先是不依赖时序的版本:2 线程池,worker W 持锁后 join(A, B),A 自旋到 B 被另一个 worker 偷走并睡住它;池外线程此时 pool.spawn(Y),W 跑完 A 后在 wait_until 里从注入队列取到 Y:
| 模式 | Y 做什么 | 结果 |
|---|---|---|
determ-try | try_lock() | 20/20:Y 跑在 W 上,返回 WouldBlock |
determ-lock | lock() | 5/5:看门狗 10 秒超时,进程卡死 |
determ-free(反向) | W 在 join 前先放锁 | 3/3:Y 仍跑在 W 上,try_lock 成功 |
再是普通写法:外层 200 个元素 par_iter().for_each,每个元素里调一次 2000 个元素的内层 par_iter().map(..).sum(),12 线程,10 秒看门狗(1 / 5 分钟负载 2.31 / 2.79 的一轮):
| 模式 | 结果 |
|---|---|
detect:不加锁,统计「一个外层元素在另一个外层元素的栈帧上开始执行」 | 61、55、47 次(三次) |
hold-some:每 8 个外层元素有 1 个持锁调内层 | 10 次里 7 次卡死 |
hold-all:每个外层元素都持锁 | 10 次里 2 次卡死 |
release-first:先算内层再加锁 | 10/10 正常结束 |
所有卡死的运行之前都打印了 same-thread relock(加锁前检测到本线程外层已持锁),正常结束的运行一次也没有。detect 的计数在 RAYON_NUM_THREADS=1 时是 0(WorkerThread 主循环,那里另跑了 3 次:27、20、87)。
从哪来
join 等 B 时调 wait_until_cold:先弹本地 deque,再 find_work(偷别人、看注入队列),拿到什么执行什么,在当前线程、当前栈帧之上执行。外层并行迭代的切分把外层后续元素的 B 压在当前线程的 deque 里,所以内层等待时第一件事往往就是弹出外层的下一个元素。std::sync::Mutex 不可重入,同一线程第二次 lock() 的行为文档只说「可能死锁或 panic」,本机上是卡死。
死锁要两个条件同时成立:内层有任务被别的线程偷走(否则 W 自己弹回 B,不进 wait_until),以及 W 在等待时拿到一个要同一把锁的任务。所以是概率性的;hold-all 也会卡死,只是概率低:别的线程大多阻塞在锁上,没空来偷,但总有线程处在两个元素之间的空档。
怎么改
- 先算后锁:把并行部分移到锁外,
let r = inner(); let mut g = m.lock(); g.update(r);(release-first,10/10)。 - 不共享可变状态:每个任务各自累加,用
fold+reduce或collect归并,最后一次性合并。 - 非要在锁里做的工作,就别在里面用 rayon:锁内调串行版本。
- 同类问题不止
Mutex:持有RefCell的可变借用、依赖 thread_local 里的「当前上下文」(thread_local 在池里读不到、或者被别的任务改掉),以及install跨池等待(install 等待时,本池的其他任务插进来执行),都是「等待期间同一线程执行了别的任务」。yield_now也一样(yield_now 与 yield_local)。
小结
- rayon 的等待不是阻塞,而是在当前栈上执行别的任务;持锁期间进入 rayon,被执行的任务可能再加同一把锁。
- E7:确定性复现
try_lock20/20WouldBlock、lock()5/5 卡死;普通写法 10 次里 7 次(每 8 个持锁 1 个)、2 次(全部持锁)卡死;先算后锁 10/10 正常。 - 改法:锁外并行、锁内串行,或者干脆不共享可变状态。