并发 Worker 频繁崩溃 "can't start new thread" —— RLIMIT_NPROC 隐藏限制排查
技术栈:Linux、Docker、Python threading、RLIMIT_NPROC / ulimit / nproc、cgroup、并发线程 关键词:can't start new thread / unable to create new native thread / pthread_create EAGAIN / RLIMIT_NPROC / ulimit -u / docker ulimits nproc / 线程创建失败 / 线程数上限 / per-user process limit 日期:2026-09-05
一、问题背景
运维监控告警:一个"每来一个任务就 new 一个线程"的并发调度服务,运行到任务高峰时突然崩溃退出,日志末尾出现:
RuntimeError: can't start new thread
对应 Java 进程经典报错是 java.lang.OutOfMemoryError: unable to create new native thread——本质上都是操作系统无法再创建新线程,pthread_create() 返回了 EAGAIN。
奇怪的是:内存、CPU 都很充裕,为什么创建不了线程?
二、复现步骤
环境:Docker Compose 复现
docker-compose.yml 关键配置(触发问题的条件):
ulimits:
nproc: 300 # 压低 RLIMIT_NPROC
command: ["python3", "-u", "thread_spawner.py"]
复现命令:
docker compose up -d
docker compose logs app
输出:
[worker] unbounded 模式:target=20000
[worker] FAILED to create thread #0: can't start new thread
[worker] 线程创建失败,进程即将因并发模型缺陷而退出。
[worker] 本次共成功创建 0 个线程
关键现象:连第 0 个线程都创建失败!这说明不是简单的"线程数到了上限慢慢耗尽",而是从一开始就超出了进程/线程配额。
进一步排查:容器内 docker exec 执行任何需要 fork 的命令都报 Cannot fork 或 Resource temporarily unavailable。
单容器复现(更简洁)
docker run --rm --ulimit nproc=300 python:3.11-slim python3 -c "
import threading
threading.Thread(target=lambda: None).start()
print('thread created')
"
# RuntimeError: can't start new thread
三、根本原因
1. RLIMIT_NPROC 是 per-UID 限制,不是 per-process
RLIMIT_NPROC(对应 ulimit -u / max user processes)限制的是该 user namespace 中同一 UID 的所有进程 + 线程总数,而不是单个进程能创建的线程数。
2. Docker daemon 以 root 运行消耗了大量配额
在默认 Docker 环境(未启用 user namespace)下,容器内 UID 0(root)与宿主机 UID 0 共享同一个进程计数空间。
Docker daemon(dockerd + containerd + runc 等)以 root 运行,本身就创建了大量线程(在本环境中实测约 450+ 个)。
3. 容器 nproc=300 已经低于宿主机已用量
当容器设置 ulimits.nproc: 300 时,容器内 PID 1(root)的 RLIMIT_NPROC = 300。但由于宿主机 UID 0 已有约 450+ 个线程,300 远小于 450,容器内 PID 1 刚启动就超出了限制,任何 clone() 调用(创建线程/进程)都立即返回 EAGAIN。
探测方法(确定宿主机已用量)
# 用二分法测试不同 nproc 值
for n in 300 400 450 460 500; do
docker run --rm --ulimit nproc=$n python:3.11-slim \
python3 -c "import threading; threading.Thread(target=lambda: None).start(); print('nproc=$n OK')" 2>/dev/null || echo "nproc=$n FAIL"
done
输出显示:nproc=450 失败,nproc=460 成功 → 宿主机 UID 0 大约用了 450 个线程。
四、解决办法
方案1:环境层——去掉/调大 nproc 限制
修改 docker-compose.yml,删除或注释 ulimits.nproc 配置(继承 Docker 默认的 unlimited),或设置为足够大的值(如 nproc: 4096)。
# 删除以下配置
# ulimits:
# nproc: 300
方案2:应用层——改用有界并发模型(最佳实践)
不要"每任务一线程"无限创建,使用信号量或线程池限制最大并发:
import threading
sem = threading.Semaphore(100) # 最多同时 100 个并发
def worker():
try:
# 执行任务
pass
finally:
sem.release()
for task in tasks:
sem.acquire() # 超过上限时阻塞等待
t = threading.Thread(target=worker)
t.start()
对应到本题的 thread_spawner.py,已内置 --bounded 模式,在 docker-compose.yml 中设置:
command: ["python3", "-u", "thread_spawner.py", "--bounded"]
environment:
MAX_CONCURRENCY: "100"
五、验证
修复后重启服务:
docker compose up -d
docker compose logs -f app
输出:
[worker] bounded 模式:max_concurrency=100, target=20000
[worker] 已提交 1000 个任务,已完成 900 ...
...
[worker] 已提交 20000 个任务,已完成 19918 ...
[worker] 有界模式:20000 个任务全部完成,未触发线程创建失败
验证通过:20000 个任务全部成功处理,信号量将并发限制在 100 线程内,全程无 can't start new thread 错误。
六、经验与避坑
- 连第 0 个线程都创建失败 → 立即想到 RLIMIT_NPROC 已被同 UID 的宿主机进程占满(Docker daemon 消耗了大量线程)。这种情况不是"慢慢耗尽",而是从一开始就超出限制。
docker exec报Cannot fork→ 即使容器内/proc只显示极少数进程,也可能是 RLIMIT_NPROC 的 per-UID 限制被其他进程占满导致。- RLIMIT_NPROC 的计数范围:如果 Docker 未启用
--userns-remap(user namespace),容器内 UID 0 与宿主机 UID 0 共享计数空间。要用docker run --userns=host或检查 Docker daemon 的 userns-mode 配置。 - 排查命令清单:
cat /proc/self/limits— 查看 RLIMIT_NPROCulimit -u— 查看 max user processesdocker exec <ctr> ls /proc | grep -c "^[0-9]"— 看容器内进程数- 改变 nproc 值做二分法探测,确定宿主机同 UID 已用进程数
- 最佳实践:生产环境服务绝不应使用"每任务一线程"的无界并发模型。用
ThreadPoolExecutor、Semaphore或连接池等有界机制限制并发度,避免线程无上限增长最终打满任何系统限制(RLIMIT_NPROC、cgroup pids、栈内存)。 - Docker 默认 nproc 是 unlimited:容器内默认
RLIMIT_NPROC = -1(unlimited)。docker-compose.yml里主动设置ulimits.nproc时要格外小心——不要设得太低。
七、可复现命令汇总
# 复现问题(nproc=300)
docker run --rm --ulimit nproc=300 python:3.11-slim \
python3 -c "import threading; threading.Thread(target=lambda: None).start()"
# → RuntimeError: can't start new thread
# 确认正常(不设限制)
docker run --rm python:3.11-slim \
python3 -c "import threading; [threading.Thread(target=lambda: None).start() for _ in range(100)]; print('100 threads OK')"
# → 100 threads OK
# 完整场景复现
cd scenario/2026-09-05-0800
docker compose up -d
docker compose logs app
