1. 问题背景与核心痛点拆解
1.1 这个报错到底在说什么
Segmentation fault (core dumped)这行字,只要写过 C/C++ 或者碰过底层库的人看到都会头皮发麻。它意味着进程访问了不属于自己的内存区域,操作系统直接把进程干掉了,同时把当时的内存状态 dump 到 core 文件里。在 PyTorch 的多进程数据加载场景下,这个报错尤其让人抓狂——因为它往往不是必现的,有时候跑几十个 epoch 都没事,有时候第一个 batch 就崩,而且崩的位置经常在torch/utils/data/_utils/或者multiprocessing/的源码里,堆栈信息指向的全是框架内部代码,跟你自己写的业务逻辑看起来毫无关系。
我最早遇到这个问题是在做一个图像分类任务的时候,DataLoader设置了num_workers=8,训练脚本跑着跑着就突然挂掉,终端只留下一行Segmentation fault (core dumped),连 Python 的 traceback 都没有。当时第一反应是数据有问题,检查了半天图片文件,结果发现跟数据本身没关系。后来逐步排查才定位到,问题出在多进程通信环节——具体来说,是multiprocessing.Queue在传递数据时,底层共享内存或者文件描述符出了状况。
这个问题的核心矛盾在于:PyTorch 的DataLoader在多进程模式下,主进程和 worker 进程之间需要通过队列来传递数据。这个队列不是 Python 层面的普通队列,而是基于操作系统 IPC 机制实现的跨进程队列。当数据量较大、传递频率较高、或者进程生命周期管理不当时,队列的底层实现就可能出现资源竞争、内存越界或者句柄泄漏,最终以段错误的形式爆发出来。
1.2 为什么这个问题值得单独拿出来讲
很多人遇到段错误的第一反应是“重装 PyTorch”或者“换个版本”,运气好的话确实能绕过,但大多数情况下问题依旧。因为这个错误的根源往往不在 PyTorch 本身,而在于使用方式——包括但不限于:共享内存策略配置不当、worker 进程的启动方式选择错误、数据传递时用了不安全的对象、以及进程退出时没有正确清理资源。
更麻烦的是,这个问题的表现形式非常多样。有时候是训练中途崩溃,有时候是验证阶段崩溃,有时候是程序正常跑完但在退出时崩溃。不同的表现对应不同的根因,如果只是笼统地搜“PyTorch 段错误”,很难找到针对性的解决方案。
我前后花了大概两周时间,在多个项目里反复复现和修复这个问题,踩了不少坑,也总结出了一套相对系统的排查和解决思路。这篇文章就是把这些经验整理出来,希望能帮到同样被这个问题折磨的人。不管你是刚接触 PyTorch 多进程的新手,还是已经用过一段时间但偶尔被这个报错困扰的开发者,下面的内容应该都能给你一些直接的参考。
1.3 适用场景与前置知识
这篇文章主要针对以下几类场景:
- 使用
torch.utils.data.DataLoader并设置了num_workers > 0的训练或推理任务 - 自己用
torch.multiprocessing或 Pythonmultiprocessing做多进程并行处理,进程间通过Queue通信 - 在多进程环境下使用共享内存、共享张量(
share_memory_())时遇到段错误 - 程序在退出阶段(正常结束或异常退出)出现段错误
阅读之前,你最好对以下概念有基本了解:Python 的multiprocessing模块、PyTorch 的DataLoader基本用法、Linux 下的进程和内存基本概念。如果这些还不太熟,也没关系,我会在涉及的地方做必要的解释。
注意:本文讨论的是 Linux 环境下的问题表现和解决方案。Windows 和 macOS 下的多进程机制不同,段错误的表现和根因也会有差异,不在本文覆盖范围内。
2. 根因分析:多进程 Queue 通信为什么会崩
2.1 PyTorch DataLoader 的多进程通信机制
要理解段错误怎么来的,得先搞清楚DataLoader在多进程模式下到底做了什么。当你设置num_workers > 0时,DataLoader会创建指定数量的 worker 进程,每个 worker 负责从数据集中读取数据、执行collate_fn、然后把处理好的 batch 通过队列送回主进程。主进程从队列里取数据,喂给模型训练。
这个队列的实现细节很关键。PyTorch 在torch/utils/data/_utils/下有一套自己的多进程封装,底层用的是 Python 的multiprocessing.Queue,但在上面加了一些优化,比如 pin memory 机制、共享内存策略等。队列传递的数据不是直接传对象本身,而是经过序列化(pickle)之后通过管道(pipe)或者共享内存传递。
这里有一个容易被忽略的点:multiprocessing.Queue在底层依赖操作系统的管道和信号量。当多个 worker 同时往队列里写数据,或者主进程读取速度跟不上写入速度时,队列的缓冲区会满,这时候就会涉及阻塞、唤醒、锁竞争等操作。如果这些操作在某个环节出了问题——比如文件描述符耗尽、共享内存段被意外释放、或者信号处理出现竞态——就可能触发段错误。
2.2 段错误的几种典型触发路径
根据我的排查经验,PyTorch 多进程 Queue 通信导致的段错误,主要有以下几条触发路径:
第一条路径:共享内存策略与文件描述符耗尽。PyTorch 默认使用file_system作为共享内存策略,这意味着每次传递张量时,会在/dev/shm下创建一个共享内存文件。如果 worker 数量多、数据量大、训练时间长,/dev/shm可能被占满,或者文件描述符耗尽。当系统无法再创建新的共享内存文件时,底层操作就会失败,进而触发段错误。
第二条路径:worker 进程异常退出后的资源清理问题。如果某个 worker 进程因为数据加载出错(比如图片损坏、文件不存在)而抛出异常,DataLoader会尝试终止所有 worker 并清理资源。但如果此时队列里还有未处理完的数据,或者主进程正在等待队列数据,清理过程就可能出现竞态条件,导致访问已释放的内存。
第三条路径:传递了不可序列化或包含指针的对象。多进程队列传递数据时需要序列化。如果Dataset的__getitem__返回了包含文件句柄、数据库连接、CUDA 张量等不可序列化对象的字典,序列化过程可能失败,或者在反序列化时出现内存问题。特别是 CUDA 张量,如果在 worker 进程里创建了 CUDA 张量并试图通过队列传给主进程,几乎必然出问题。
第四条路径:信号处理与进程终止的竞态。当主进程收到中断信号(比如 Ctrl+C)时,会尝试终止所有 worker。如果此时 worker 正在往队列里写数据,或者正在等待队列的锁,信号处理函数和正常的队列操作之间就可能产生竞态,导致段错误。这也是为什么很多段错误发生在程序退出阶段。
2.3 为什么 Python 层面看不到 traceback
这是很多人困惑的地方:明明是 Python 程序,为什么段错误发生时没有 Python 的 traceback?原因在于,段错误是操作系统级别的信号(SIGSEGV),它直接终止进程,不会给 Python 解释器机会去捕获和打印异常。当段错误发生在 C 扩展模块(比如 PyTorch 的底层 C++ 代码、multiprocessing的 C 实现)中时,Python 解释器根本来不及介入。
所以,排查这类问题不能只靠 Python 层面的日志和断点,需要借助系统工具,比如dmesg、gdb、strace等,来查看崩溃时的底层状态。这一点后面会详细讲。
3. 解决方案:从配置到代码的完整规范
3.1 共享内存策略的选择与配置
PyTorch 提供了两种共享内存策略:file_system和file_descriptor。默认是file_system,它在/dev/shm下创建共享内存文件。file_descriptor则使用文件描述符来共享内存,不依赖/dev/shm的容量。
如果你的训练环境/dev/shm容量较小(比如 Docker 容器默认只有 64MB),或者训练数据量很大,建议切换到file_descriptor策略。设置方法如下:
import torch.multiprocessing as mp # 在创建 DataLoader 之前设置 mp.set_sharing_strategy('file_descriptor')或者在启动脚本时通过环境变量设置:
export PYTHONPATH=$PYTHONPATH:. python -c "import torch.multiprocessing as mp; mp.set_sharing_strategy('file_descriptor')"但要注意,file_descriptor策略也不是万能的。它依赖文件描述符的数量,如果系统限制较低(默认通常是 1024),在 worker 数量多的时候同样会出问题。这时候需要同时调整系统的文件描述符限制:
ulimit -n 65536这个命令把当前 shell 的文件描述符上限调到 65536。如果是长期运行的服务,建议把这个配置写到/etc/security/limits.conf里。
实操心得:我一般会先检查
/dev/shm的大小,用df -h /dev/shm查看。如果小于 1GB,直接切到file_descriptor策略,同时把ulimit -n调大。这套组合拳解决了我遇到的大部分段错误。
3.2 DataLoader 参数的安全配置
DataLoader的几个关键参数直接影响多进程通信的稳定性:
| 参数 | 推荐值 | 说明 |
|---|---|---|
num_workers | 4-8 | 不要盲目设大,超过 CPU 核心数反而增加竞争 |
prefetch_factor | 2 | 每个 worker 预取的 batch 数,默认 2,设太大会占内存 |
persistent_workers | True | 避免每个 epoch 重新创建 worker,减少资源清理问题 |
pin_memory | 视情况 | GPU 训练时设为 True,但要注意 pinned memory 泄漏 |
timeout | 60 | 设置超时,避免 worker 卡死导致主进程无限等待 |
persistent_workers=True是我强烈推荐的一个设置。默认情况下,每个 epoch 结束后DataLoader会销毁所有 worker,下一个 epoch 重新创建。这个销毁和重建的过程正是段错误的高发时段。开启persistent_workers后,worker 进程会一直存活,只在训练结束时才销毁,大大降低了出问题的概率。
from torch.utils.data import DataLoader dataloader = DataLoader( dataset, batch_size=32, num_workers=4, prefetch_factor=2, persistent_workers=True, pin_memory=True, timeout=60, shuffle=True )3.3 自定义 Dataset 的编写规范
很多段错误的根源其实在Dataset的实现上。以下几条规范是我踩坑之后总结出来的,建议严格遵守:
第一条:__getitem__返回的对象必须可序列化。不要返回文件句柄、数据库连接、CUDA 张量、lambda 函数等。如果需要返回张量,确保是 CPU 张量。如果确实需要在 GPU 上训练,让主进程负责把数据搬到 GPU。
第二条:避免在__getitem__里做重量级初始化。比如加载大型模型、建立网络连接等。这些操作应该在__init__里完成,或者用懒加载的方式缓存起来。每个 worker 进程会独立执行__getitem__,如果每次都做重量级操作,不仅慢,还容易出资源问题。
第三条:注意随机种子的设置。多进程环境下,每个 worker 的随机种子如果不单独设置,可能导致数据重复或顺序错乱。PyTorch 的DataLoader会自动为每个 worker 设置不同的种子,但如果你在Dataset里用了自己的随机数生成器,需要手动处理。
import numpy as np import torch from torch.utils.data import Dataset, get_worker_info class SafeDataset(Dataset): def __init__(self, data_paths): self.data_paths = data_paths # 重量级初始化放在这里 self.cache = {} def __getitem__(self, index): # 获取当前 worker 信息,用于设置随机种子 worker_info = get_worker_info() if worker_info is not None: # 每个 worker 用不同的种子 np.random.seed(worker_info.seed % (2**32)) path = self.data_paths[index] # 返回纯 Python 对象或 CPU 张量 data = self._load_data(path) label = self._get_label(path) # 确保返回的是可序列化的对象 return { 'data': torch.from_numpy(data), # CPU 张量 'label': int(label) # 基本类型 } def _load_data(self, path): # 数据加载逻辑 pass def _get_label(self, path): # 标签获取逻辑 pass3.4 进程退出时的资源清理
程序退出阶段的段错误往往被忽视,但它的发生率其实很高。当训练结束或者程序被中断时,DataLoader需要终止所有 worker 进程并清理队列资源。如果清理顺序不对,或者有 worker 卡在队列操作上,就会出问题。
一个实用的技巧是在训练结束后显式地删除DataLoader对象,并给系统一点时间完成清理:
import gc import time # 训练循环结束后 del dataloader gc.collect() time.sleep(1) # 给 worker 进程一点时间退出如果是在try-except块里训练,确保在finally里也做同样的清理:
dataloader = None try: dataloader = DataLoader(...) # 训练循环 finally: if dataloader is not None: del dataloader gc.collect() time.sleep(1)注意:不要在
finally里直接调用dataloader.__del__()或者手动终止 worker 进程,这样反而容易触发竞态。让 Python 的垃圾回收机制自然处理,配合gc.collect()和短暂的sleep就足够了。
4. 实操排查:定位段错误的具体位置
4.1 用 dmesg 查看内核日志
段错误发生后,第一件事是查看内核日志,确认是哪个进程、哪个内存地址出了问题:
dmesg | tail -20输出里会有一行类似这样的信息:
[12345.67890] python[12345]: segfault at 7f8a9b0c0000 ip 00007f8a9b0c0000 sp 00007ffc12345678 error 4 in libtorch_cpu.so[7f8a9b000000+2000000]关键信息是ip(指令指针)和libtorch_cpu.so这样的库名。如果ip指向的是libtorch相关的库,说明问题出在 PyTorch 的底层 C++ 代码里,大概率跟张量操作或共享内存有关。如果指向的是libc或libpthread,则可能是队列的锁或信号量出了问题。
4.2 用 gdb 获取完整堆栈
dmesg只能告诉你崩溃发生在哪个库,要看到完整的调用堆栈,需要用gdb附加到 core 文件或者直接运行程序:
gdb python core进入 gdb 后,执行:
(gdb) bt这会打印出崩溃时的完整调用堆栈。如果堆栈里出现了multiprocessing.Queue、torch.utils.data相关的函数,就基本可以确认是队列通信的问题。
如果程序还在运行中崩溃,可以用gdb直接启动:
gdb --args python train.py (gdb) run崩溃后同样用bt查看堆栈。不过要注意,gdb下运行 PyTorch 程序可能会因为调试符号缺失而显示不完整的堆栈,这时候可以安装python-dbg和libtorch的调试符号包来改善。
4.3 用 strace 追踪系统调用
如果gdb的堆栈信息不够明确,可以用strace追踪进程的系统调用,看看崩溃前最后几个调用是什么:
strace -f -o strace.log python train.py-f表示追踪子进程(worker 进程),-o把输出写到文件。程序崩溃后,查看strace.log的最后几十行:
tail -50 strace.log重点关注shmget、shmat、shmdt、write、read、futex这些跟共享内存和进程同步相关的调用。如果看到shmget返回ENOSPC(没有空间)或者EMFILE(文件描述符耗尽),就找到了根因。
4.4 最小化复现:逐步缩小问题范围
排查段错误最有效的方法还是最小化复现。我的做法是:
- 先把
num_workers设为 0,确认单进程模式下没有问题。如果单进程也崩,那问题不在多进程通信上。 - 逐步增加
num_workers,从 1 开始,每次加 1,看什么时候开始崩。 - 简化
Dataset,让它只返回一个简单的张量,排除数据加载逻辑的干扰。 - 简化模型,用一个只有几层的网络,排除模型计算的干扰。
- 如果简化后不崩了,再逐步把原来的逻辑加回去,定位到具体是哪部分代码触发的。
这个过程听起来笨,但实际用起来非常有效。我遇到的一个案例就是通过这种方式定位到,问题出在Dataset里用了一个第三方库来读取特定格式的文件,那个库在多进程环境下有内存泄漏,最终导致段错误。
5. 常见问题速查与避坑指南
5.1 常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 训练中途随机崩溃 | /dev/shm满或文件描述符耗尽 | 切换共享内存策略,调大ulimit -n |
| 第一个 batch 就崩 | Dataset返回了不可序列化对象 | 检查__getitem__返回值,确保可 pickle |
| 程序退出时崩溃 | worker 清理竞态 | 显式删除 DataLoader,加gc.collect()和sleep |
| 增加 worker 数后崩溃 | 资源竞争加剧 | 减少 worker 数,开启persistent_workers |
| 特定数据集才崩 | 数据文件损坏或格式异常 | 检查数据完整性,加异常处理 |
| GPU 训练时崩溃 | worker 里创建了 CUDA 张量 | 确保 worker 只返回 CPU 张量 |
5.2 避坑技巧与经验总结
技巧一:用torch.multiprocessing的set_start_method控制启动方式。Linux 下默认是fork,这意味着子进程会继承父进程的内存状态。如果父进程已经加载了 CUDA 上下文,fork 出来的子进程可能会有问题。可以尝试改成spawn:
import torch.multiprocessing as mp mp.set_start_method('spawn', force=True)spawn会启动全新的 Python 解释器,不继承父进程状态,更安全但启动稍慢。这个改动解决了我遇到的一个诡异段错误——父进程初始化了 CUDA,然后 fork 出 worker,worker 里一碰张量就崩。
技巧二:监控/dev/shm的使用情况。在训练脚本里加一个后台线程,定期打印/dev/shm的占用:
import os import threading import time def monitor_shm(): while True: try: stat = os.statvfs('/dev/shm') used = (stat.f_blocks - stat.f_bfree) * stat.f_frsize / 1024 / 1024 total = stat.f_blocks * stat.f_frsize / 1024 / 1024 print(f"/dev/shm usage: {used:.1f}MB / {total:.1f}MB") except Exception as e: print(f"Monitor error: {e}") time.sleep(10) monitor_thread = threading.Thread(target=monitor_shm, daemon=True) monitor_thread.start()如果发现/dev/shm占用持续增长不释放,说明有共享内存泄漏,需要检查是否有张量没有正确释放。
技巧三:避免在Dataset里使用全局变量。多进程环境下,每个 worker 有自己独立的内存空间,全局变量的修改不会同步。如果Dataset依赖全局状态,可能导致不可预期的行为。所有状态都应该通过__init__参数传入,或者用get_worker_info()来区分不同 worker。
技巧四:用pin_memory时注意内存泄漏。pin_memory=True会使用 pinned memory 来加速 CPU 到 GPU 的数据传输,但如果 batch 大小设置不当或者数据没有及时释放,pinned memory 可能泄漏。建议配合pin_memory_device明确指定设备,并监控内存使用。
技巧五:升级 PyTorch 到较新版本。PyTorch 团队一直在修复多进程相关的 bug,很多段错误在较新版本里已经解决。如果条件允许,尽量用最新的稳定版。但要注意,升级后要重新测试,因为有些行为可能发生变化。
5.3 一个完整的排查案例
最后分享一个我实际遇到的案例。当时的情况是:一个图像分割任务,num_workers=8,训练到第 3 个 epoch 左右必崩,报Segmentation fault。按照上面的流程排查:
第一步,dmesg显示崩溃在libtorch_cpu.so,ip指向一个内存拷贝函数。
第二步,gdb堆栈显示调用链是DataLoader的_process_next_batch->Queue.get-> 底层内存操作。
第三步,检查/dev/shm,发现训练过程中占用持续增长,到第 3 个 epoch 时接近容量上限。
第四步,切换到file_descriptor策略,同时把ulimit -n调到 65536,问题消失。
第五步,进一步优化,开启persistent_workers=True,减少 worker 重建次数,训练稳定性明显提升。
这个案例的根因就是共享内存策略选择不当,加上文件描述符限制太低。两个因素叠加,导致训练到一定阶段后资源耗尽,触发段错误。解决方案并不复杂,但如果没有系统的排查思路,很容易在错误的方向上浪费时间。
我个人在实际操作中的体会是,PyTorch 多进程段错误虽然看起来吓人,但根因往往集中在几个固定的方向上:共享内存、文件描述符、序列化、进程生命周期管理。把这几个方向检查一遍,大部分问题都能定位到。另外,养成好的代码规范——比如Dataset只返回可序列化对象、训练结束显式清理资源、合理设置DataLoader参数——能从源头上避免很多麻烦。最后再分享一个小技巧:如果实在找不到原因,试试把num_workers设为 1,虽然慢一点,但能稳定跑通,至少可以先把实验做完,再慢慢排查多进程的问题。