Python 并行批处理:限制待执行任务,并汇总每个文件的失败
批量检查几万个文件时,给每个文件都调用一次 submit(),即使只开 4 个线程,也可能堆出大量待处理任务。另一个常见问题是只看到“任务已提交”,没有读取 Future 的结果,文件读取失败却没有进入最终报告。
本文面向初学者到中级读者,用 Python 标准库完成一个只读的 UTF-8 文本行数检查器:边读文件清单边提交任务,限制未回收任务数量,记录每个文件的成功或失败,并用退出码控制下一步自动化。
1. 方法:同时限制线程和待回收任务
已有的进程池、线程池与协程入门介绍了并发模型。本篇把线程池接到一个可以直接运行的批处理入口,采用以下规则:
--workers默认 4,可设为 1–16;最多同时运行这么多个工作线程。pending最多保留2 × workers个 Future,包括排队、运行以及已完成但尚未回收的任务。达到上限就先回收,再继续读清单。- 每个完成的文件输出一行 JSON;缺失文件和非法 UTF-8 也进入报告。最终只要有一项失败,退出码就是 1。
ThreadPoolExecutor 官方文档定义了工作线程上限;wait(..., FIRST_COMPLETED)可以等到至少一个任务完成。队列上限由本文代码另行实现。
这里选择线程来演示独立文件读取,不保证比串行更快。磁盘吞吐、文件大小以及解码和计数成本都会影响效果;先从 2–4 个线程开始测量自己的工作负载。
2. 准备环境和输入
使用已有的 Python 3.9 或更新版本,不需要第三方包。示例在 Linux、Python 3.11.4 上验证;命令适用于 bash 或 zsh。
- 新建演示目录,后续命令保持在同一终端和目录执行。重复实验时换一个目录名:
python3 --version
mkdir parallel-batch-demo
cd parallel-batch-demo
printf 'alpha\nbeta\n' > a.txt
printf 'gamma' > 'b space.txt'
printf 'a.txt\nb space.txt\n' > good.txt
- 将下一节保存为
count_files.py,或下载完整脚本。 good.txt是无 BOM 的 UTF-8 清单,每行一个路径。相对路径以清单文件所在目录为基准,也允许绝对路径;空行跳过,文件名中的空格保留。这个清单格式不支持名称中含换行的文件,重复路径会重复处理。
仅对已写完、不会在读取期间改变的普通本地文本文件使用该示例。不要把 FIFO、设备文件或可能长期阻塞的远程挂载路径放入清单。
3. 完整实现
"""Count UTF-8 text lines with bounded parallel submissions. Python 3.9+."""
import argparse
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
import json
from pathlib import Path
import sys
def worker_count(value):
number = int(value)
if not 1 <= number <= 16:
raise argparse.ArgumentTypeError("workers must be 1..16")
return number
def count_lines(path):
with path.open(encoding="utf-8", errors="strict") as source:
return sum(1 for _ in source)
def collect(pending):
done, _ = wait(pending, return_when=FIRST_COMPLETED)
failed = 0
for future in done:
path = pending.pop(future)
record = {"type": "file", "path": str(path)}
try:
record.update(ok=True, lines=future.result())
except (OSError, UnicodeError, ValueError) as exc:
failed += 1
record.update(ok=False, error=f"{type(exc).__name__}: {exc}")
print(json.dumps(record), flush=True)
return len(done), failed
def run(manifest, workers):
manifest = manifest.absolute()
pending = {}
processed = failed = peak_pending = 0
with manifest.open(encoding="utf-8", errors="strict") as source:
with ThreadPoolExecutor(max_workers=workers) as pool:
for line in source:
name = line.rstrip("\r\n")
if not name:
continue
path = manifest.parent / name
pending[pool.submit(count_lines, path)] = path
peak_pending = max(peak_pending, len(pending))
if len(pending) >= 2 * workers:
count, errors = collect(pending)
processed += count
failed += errors
while pending:
count, errors = collect(pending)
processed += count
failed += errors
print(json.dumps({
"type": "summary", "processed": processed, "failed": failed,
"workers": workers, "peak_pending": peak_pending,
}), flush=True)
return 1 if failed else 0
def main():
parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
parser.add_argument("manifest", type=Path, help="UTF-8 path list, one per line")
parser.add_argument("--workers", type=worker_count, default=4)
args = parser.parse_args()
try:
return run(args.manifest, args.workers)
except (OSError, UnicodeError, ValueError) as exc:
print(f"FATAL: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
count_lines() 只读取文件,不修改输入。空文件为 0 行;没有末尾换行的最后一段仍算一行,LF、CRLF 和 CR 都由文本读取处理。这里数的是文本行,不是 CSV 记录;CSV 字段里可能包含换行,参见按记录拆分 CSV。
主线程统一输出报告,工作线程只返回行数。Future.result() 文档说明,它会重新抛出任务中的异常,因此在回收处捕获预期的文件和编码错误。意外的程序错误仍会导致程序异常退出,不会伪造成功汇总。
报告采用回收顺序,同一轮完成的任务顺序不固定,不能按行号与输入清单配对;请使用 path。只有全部任务回收后才输出 summary。清单自身打不开或解码失败时,stderr 输出 FATAL,返回 1,stdout 可能已有部分记录,但没有完整汇总。
4. 运行并验证正常结果
- 查看帮助并处理两份文件。重定向会创建或截断报告,请使用新的报告路径:
python3 count_files.py --help
python3 count_files.py good.txt --workers 2 > good-report.jsonl
cat good-report.jsonl
两条 type: file 记录中的 lines 分别是 2 和 1;最后一条为:
{"type": "summary", "processed": 2, "failed": 0, "workers": 2, "peak_pending": 2}
- 用路径匹配而不是输出顺序验证内容:
python3 - <<'PY_CHECK'
import json
from pathlib import Path
rows = [json.loads(line) for line in
Path("good-report.jsonl").read_text(encoding="utf-8").splitlines()]
files = rows[:-1]
assert {Path(row["path"]).name: row["lines"] for row in files} == {
"a.txt": 2, "b space.txt": 1,
}
assert all(row["ok"] for row in files)
assert rows[-1] == {
"type": "summary", "processed": 2, "failed": 0,
"workers": 2, "peak_pending": 2,
}
print("PASS: both files counted; pending bound respected")
PY_CHECK
peak_pending 是本次保留 Future 数量的峰值,不是线程实际并行运行数量。即使文件很快读完,在回收前也仍计入 pending。
5. 验证失败记录和退出码
- 创建非法 UTF-8 文件,并让健康文件、缺失文件、坏编码文件混在同一清单里:
python3 - <<'PY_DATA'
from pathlib import Path
Path("bad.bin").write_bytes(b"\xff\n")
Path("mixed.txt").write_text(
"a.txt\nmissing.txt\nbad.bin\nb space.txt\n", encoding="utf-8"
)
PY_DATA
if python3 count_files.py mixed.txt --workers 2 > mixed-report.jsonl; then
printf 'UNEXPECTED: all inputs accepted\n'
else
batch_status=$?
printf 'exit=%s\n' "$batch_status"
fi
python3 - <<'PY_CHECK'
import json
from pathlib import Path
rows = [json.loads(line) for line in
Path("mixed-report.jsonl").read_text(encoding="utf-8").splitlines()]
assert rows[-1]["processed"] == 4
assert rows[-1]["failed"] == 2
assert rows[-1]["peak_pending"] <= 4
bad = {Path(row["path"]).name: row["error"]
for row in rows[:-1] if not row["ok"]}
assert bad["missing.txt"].startswith("FileNotFoundError:")
assert bad["bad.bin"].startswith("UnicodeDecodeError:")
print("PASS: two failures recorded; healthy files still completed")
PY_CHECK
预期显示 exit=1 和 PASS。四个条目都得到报告,其中两项失败;健康文件仍然完成。失败详情在 JSONL 的 error 字段中,因此 stderr 为空也不能视为全部成功。
- 使用退出码作为后续任务的门槛,再检查参数错误。在交互式终端运行最后两行;启用
set -e的脚本应把预期失败的命令放入if条件:
if python3 count_files.py good.txt --workers 2 > next-report.jsonl; then
python3 -c 'print("NEXT: every listed file passed")'
else
printf 'STOP: inspect the report and stderr\n' >&2
fi
python3 count_files.py good.txt --workers 0
printf 'exit=%s\n' "$?"
正常清单会打印 NEXT;换成 mixed.txt 后只打印 STOP。--workers 0 返回 2,这是 argparse 参数校验的默认错误退出码。空清单产生 processed: 0、failed: 0 并返回 0;若业务要求至少一个输入,应额外检查条目数。
6. 常见问题与边界
| 现象 | 具体处理 |
|---|---|
| 文件很多时内存上涨 | 保留边读清单边提交的循环,不要先把所有 Future 放进列表;单条超长文本行仍可能占用较多内存。 |
FileNotFoundError |
按清单文件的目录检查相对路径,确认名称和大小写;运行期间不要移动输入。 |
UnicodeDecodeError |
从源工具导出 UTF-8,或明确修改代码中的输入编码;不要用 errors="ignore" 隐藏坏字节。 |
| 增加线程后变慢 | 降低 --workers,比较相同输入的实际耗时;线程数不是性能保证。 |
| 等待迟迟不结束 | 此示例没有任务超时或强制终止线程;先定位阻塞输入,再为对应操作添加自身的超时机制。 |
Too many open files |
降低并发,按文件句柄泄漏排查确认是否还有其他代码未关闭文件。 |
Future 数量上限不等于进程内存上限:单个清单行、文本行和系统缓冲仍会占用空间。此脚本按批次持续收集结果,不是遇到错误立即停止的模式,也不做重试、持久化进度或线程强制取消。
总结
并行批处理要同时限制运行中的工作线程和保留的 Future,并逐项读取结果。用路径标识报告、汇总失败数、返回可靠退出码,再把检查器接到自动化流程中。先用健康文件和故障文件一起验证,再按自己的磁盘和任务调整并发。
- 原文作者:春江暮客
- 原文链接:https://www.bobobk.com/python-bounded-parallel-batch.html
- 版权声明:本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。