最近在运行一个基于图像内容匹配的脚本时,遇到了一个令人困惑的问题。脚本使用了多线程(ThreadPoolExecutor)来处理大量图像数据,但执行后一直卡在进度条的 0%:

匹配进度: 0%| | 0/4764 [00:01<?, ?it/s]
没有任何报错信息,CPU 占用很低,进程仿佛"假死"了。然而,就在前一天,同样的脚本在同一台机器上运行得飞快,今天却莫名其妙地卡住了。
既然是并发任务卡住,首先想到的是线程死锁或某个 Future 永远无法完成。于是我将 --workers 参数改为 1,即单线程运行:
python match_by_content.py --workers 1
奇迹出现了——单线程运行时,脚本立即抛出了错误:
ImportError: cannot import name 'structural_similarity' from 'skimage.metrics'
为了确认问题,我进一步检查了当前环境中已安装的 Python 包:
pip show opencv-python imagehash scikit-image
输出结果如下:
WARNING: Package(s) not found: scikit-image Name: opencv-python Version: 4.13.0.92 ... --- Name: ImageHash Version: 4.3.2 ...
果然,scikit-image 包根本未安装!而脚本中使用了 skimage.metrics.structural_similarity 来计算 SSIM(结构相似性),这极有可能是导致脚本“卡在0%”的根本原因。
这就引出了一个有趣的问题:同一个 ImportError,在单线程下会直接崩溃并打印堆栈,但在多线程环境下却导致程序永久卡在进度条 0%。要理解这一点,需要回顾 Python 的 concurrent.futures 机制。
脚本中计算 SSIM(结构相似性)的函数 compute_ssim 内部有一个导入语句:
def compute_ssim(img1, img2):
from skimage.metrics import structural_similarity as ssim
return ssim(img1, img2, data_range=255)
由于 scikit-image 未安装,当任何一个子线程第一次调用 compute_ssim 时,就会抛出 ImportError。
在 ThreadPoolExecutor 中,提交的任务函数 process_one 如果抛出了未捕获的异常,该异常会被 Future 对象捕获并存储,但不会自动打印到控制台。主线程通过 as_completed(futures) 等待任务完成时,如果某个 Future 因异常而"完成",调用 future.result() 会重新抛出异常。
然而,这里的关键是:as_completed 需要该 Future 被标记为完成状态。在某些 Python 版本或特定环境下(例如 Docker 容器),当线程因 ImportError 这种致命错误突然终止时,其对应的 Future 可能永远不会被正确标记为完成。结果就是 as_completed 一直在等待这个"幽灵"任务,而实际上该线程早已死亡,导致整个程序卡死。
| 运行模式 | 异常行为 | 排查难度 |
|---|---|---|
| 单线程 | 异常在主线程中抛出,直接终止程序并打印堆栈 | 问题暴露无遗,容易定位 |
| 多线程 | 异常发生在子线程中,且未能正确传递到主线程 | 主线程无限等待,表现为"假死" |
这就是为什么同样的依赖缺失问题,在单线程下能快速定位,而在多线程下却表现为"假死"。
pip install scikit-image
安装后,多线程脚本恢复正常运行。
为了避免未来再次发生类似问题,我对脚本进行了以下几项改造:
1) 启动时主动检查关键依赖
在 main() 开头添加依赖检查函数:
def check_dependencies():
required = {
'cv2': 'opencv-python',
'skimage.metrics': 'scikit-image',
'imagehash': 'imagehash',
'PIL': 'pillow',
}
for mod, pkg in required.items():
try:
__import__(mod)
except ImportError:
print(f"错误:缺少依赖 {mod},请安装: pip install {pkg}", file=sys.stderr)
sys.exit(1)
这样在脚本启动时就能立即发现缺失的依赖,而不是等到子线程执行时才报错。
2) 在任务函数中捕获所有异常并返回错误信息
修改 process_one,用 try...except 包裹整个函数体:
def process_one(triple_path):
try:
# ... 原有匹配逻辑
return (triple_path, gray_src, depth_src, None, triple_num_str)
except Exception as e:
import traceback
error_msg = f"处理 {triple_path.name} 时出错:n{traceback.format_exc()}"
return (triple_path, None, None, error_msg)
在主循环中判断返回值,如果有错误则打印,并继续处理其他任务:
for future in tqdm(as_completed(futures), total=len(triple_files)):
result = future.result()
if len(result) == 4 and result[3]: # 错误返回
print(result[3])
continue
# 正常复制文件...
这样任何子线程中的异常(ImportError、cv2.error、IOError 等)都会被捕获并显式输出,且不会导致主线程阻塞。
3) 使用 future.add_done_callback 作为额外保障
def handle_future(future):
try:
future.result()
except Exception as e:
print(f"线程任务异常: {e}", file=sys.stderr)
for fut in futures:
fut.add_done_callback(handle_future)
虽然主循环已经处理了异常,但回调函数可以在未来任何时刻捕获到未处理的异常,增加一层保险。
当你怀疑脚本卡死时,第一时间应该用单线程模式运行。单线程不仅执行逻辑简单,而且任何错误都会立即暴露,是排查问题的最高效手段。
| 阶段 | 最佳实践 |
|---|---|
| 开发时 | - 在脚本入口处检查关键依赖 - 尽量使用单线程调试,确认逻辑无误后再开启并发 |
| 编写并发代码 | - 任务函数必须用 try...except 捕获异常,返回错误信息而非抛出- 主线程处理返回值时检查错误字段并记录日志 |
| 生产环境 | - 使用 logging 模块记录详细日志- 为 - 监控线程池状态,必要时使用 |
| 遇到"假死"时 | - 立即切换到单线程模式运行,查看完整报错 - 检查系统资源(内存、CPU)是否正常 - 使用 |
以上就是 Python多线程脚本假死问题的排查与解决方法的详细内容,更多关于 Python多线程脚本假死问题的资料请关注本站其它相关文章!
您可能感兴趣的文章: