Zhongjun Qiu 元婴开发者

线程池死锁问题

我的 OJ 架构不算复杂:用户提交代码 -> 消息队列解耦 -> Judge模块接收消息 -> 分发给线程池执行。

为了提高效率,我设计了二级异步:

  1. 调度层:单线程从队列获取任务。
  2. 执行层:丢进 JudgingThreadPool 异步执行。
  3. 任务内部:编译代码、跑多个测试用例 为了快,这些子任务也用了多线程。

初始的AI给我的代码 省略一些细节处理 只保留了核心

(并且除了死锁外,AI的代码还没有处理compile过慢的情况,会导致错误识别成编译错误)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
private void pollAndDispatch() {
while (running.get()) {
try {
JudgingTask task = poll(JUDGING_QUEUE_KEY, POLL_TIMEOUT_SECONDS, TimeUnit.SECONDS);
if (task != null) {
// 分发到线程池异步执行
judgingExecutor.execute(() -> executeTask(task));
}
} catch (Exception e) {
}
}
}

private void executeTask(JudgingTask task) {
try {
// 1. 异步编译
CompletableFuture<CompileResult> compileFuture = CompletableFuture.supplyAsync(
() -> sandboxService.compile(task.getCodePath(), task.getLang()),
judgingExecutor
);

// 2. 获取测试用例并并发运行
List<TestCase> testCases = loadTestCases(task.getProblemId());
CountDownLatch latch = new CountDownLatch(testCases.size());
// 可能同时会有10~30个测试用例
for (TestCase tc : testCases) {
CompletableFuture.supplyAsync(
() -> sandboxService.runSingleTestCase(task, tc),
judgingExecutor // 同一个池子!
).whenComplete((res, ex) -> latch.countDown());
}

// 3. 阻塞等待:父任务在这里“挂起”等待子任务结果
latch.await(60, TimeUnit.SECONDS);

JudgeResult finalResult = aggregateResults(compileFuture.get(), runFutures);
updateSubmissionStatus(task.getSubmissionId(), finalResult);
} catch (Exception e) {
}
}

然后进行压测的时候,同时执行了100次提交,结果一直在WAIT 查看了线程池信息发现

1
2
3
ThreadPool Size: [64]
Active Threads: 64
Number of Tasks in Queue: 377

线程池里的线程都消耗完了 并且队列里有大量阻塞的任务 CPU却不转

很可能就是死锁 导致所有线程park了

jstack 导出了线程栈,发现线程全部卡在 CountDownLatch.await()

原因很简单:

  1. 有一部分executeTask先抢到了线程池里的线程
  2. 这些父任务又提交了运行测试用例的子任务到同一个线程池,但在提交过程中线程池爆了,导致子任务一直在队列里
  3. 这样就出现了死锁:子任务只能在队列里排队,而所有的父任务在等所有子任务运行完才能释放线程。

解决方法

将父子任务的线程池分离 这样即使子任务堆积也不会影响到父任务对应的线程池

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
private void executeTask(JudgingTask task) {
try {
// 第一步:编译(用沙箱专用池)
CompileResult compileResult = CompletableFuture.supplyAsync(
() -> sandboxService.compile(task.getCodePath(), task.getLang()),
sandboxExecutor
).get(10, TimeUnit.SECONDS); // 明确的超时控制 防止运行的时候没有可执行文件 造成误判

if (!compileResult.isSuccess()) {
updateSubmissionStatus(task.getSubmissionId(), JudgeResult.compileError());
return;
}

// 第二步:并发执行测试用例
List<TestCase> testCases = loadTestCases(task.getProblemId());
List<CompletableFuture<RunResult>> futures = testCases.stream()
.map(tc -> CompletableFuture.supplyAsync(
() -> sandboxService.runSingleTestCase(task, tc),
sandboxExecutor // 隔离后的池子 彻底解决死锁
))
.toList();

CompletableFuture.allOf(runFutures.toArray(new CompletableFuture[0]))
.exceptionally(ex -> { throw new CompletionException(ex); })
.join(); // 阻塞等待所有测试用例结果

JudgeResult finalResult = aggregateResults(compileResult, results);
updateSubmissionStatus(task.getSubmissionId(), finalResult);

} catch (Exception e) {
}
}

经验总结

  1. 线程池最好不要始终只用一个 尤其是任务之间有父子关系的情况 父任务提交子任务时很容易出现死锁; 并且出问题后也不好定位到底是哪个任务导致的死锁。
  2. 线程池中谨慎使用wait和join这些操作 其实上面如果加个超时等待还能好一点,避免把队列撑爆 OOM

最后对 KIMI-K2.5 有点失望啊 除了上面这个问题

下面启动一个前端项目跑了20分钟,就光启动这个项目没有其他操作; 因为我的项目是前后端一块的,前端放在src/目录下,npm run dev要cd src里进行; 但kimi-2.5一直察觉不到,并且有时候判断出不在目录下后,过了几条命令又开始抽风了

alt text
 REWARD AUTHOR
 Comments
Comment plugin failed to load
Loading comment plugin