春江暮客

春江暮客的个人学习分享网站

Python 并行批处理:限制待执行任务,并汇总每个文件的失败

2026-10-23 技术
Python 并行批处理:限制待执行任务,并汇总每个文件的失败

批量检查几万个文件时,给每个文件都调用一次 submit(),即使只开 4 个线程,也可能堆出大量待处理任务。另一个常见问题是只看到“任务已提交”,没有读取 Future 的结果,文件读取失败却没有进入最终报告。

本文面向初学者到中级读者,用 Python 标准库完成一个只读的 UTF-8 文本行数检查器:边读文件清单边提交任务,限制未回收任务数量,记录每个文件的成功或失败,并用退出码控制下一步自动化。

1. 方法:同时限制线程和待回收任务

已有的进程池、线程池与协程入门介绍了并发模型。本篇把线程池接到一个可以直接运行的批处理入口,采用以下规则:

  1. --workers 默认 4,可设为 1–16;最多同时运行这么多个工作线程。
  2. pending 最多保留 2 × workers 个 Future,包括排队、运行以及已完成但尚未回收的任务。达到上限就先回收,再继续读清单。
  3. 每个完成的文件输出一行 JSON;缺失文件和非法 UTF-8 也进入报告。最终只要有一项失败,退出码就是 1。

ThreadPoolExecutor 官方文档定义了工作线程上限;wait(..., FIRST_COMPLETED)可以等到至少一个任务完成。队列上限由本文代码另行实现。

这里选择线程来演示独立文件读取,不保证比串行更快。磁盘吞吐、文件大小以及解码和计数成本都会影响效果;先从 2–4 个线程开始测量自己的工作负载。

2. 准备环境和输入

使用已有的 Python 3.9 或更新版本,不需要第三方包。示例在 Linux、Python 3.11.4 上验证;命令适用于 bash 或 zsh。

  1. 新建演示目录,后续命令保持在同一终端和目录执行。重复实验时换一个目录名:
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
  1. 将下一节保存为 count_files.py,或下载完整脚本。
  2. 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. 运行并验证正常结果

  1. 查看帮助并处理两份文件。重定向会创建或截断报告,请使用新的报告路径:
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}
  1. 用路径匹配而不是输出顺序验证内容:
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. 验证失败记录和退出码

  1. 创建非法 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 为空也不能视为全部成功。

  1. 使用退出码作为后续任务的门槛,再检查参数错误。在交互式终端运行最后两行;启用 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,并逐项读取结果。用路径标识报告、汇总失败数、返回可靠退出码,再把检查器接到自动化流程中。先用健康文件和故障文件一起验证,再按自己的磁盘和任务调整并发。

标签

1024 12306 ablang adsense agents.md ai ai-agent ai-agents ai-seo algorithm amp antibodies apparmor argparse automation backup bash batch-processing binarycif bioinformatics biopython blockchain boltz bootstrapping boxes bubblewrap c-index cca cdn chatgpt checkpoint cli cloudflare codex cofoldarena concurrency copy cpu监控 cron csv cuda curl data-leakage data-processing data-quality data-validation datascience datavisualization datetime debugging decimal deployment desktop-app devtools disk-space disown dns docker dovecot download electron encoding esm esm2 esm3 esmc esmfold2 faceswap fasta fastmcp faulthandler ffmpeg file-descriptors file-io flashppi flask folium frontend game generator git github-actions google google-research grep gzip harness hls html http hugo imports indexnow javascript jev json jsonl just k-means kaggle langfuse leecode linux list litellm llm llms.txt logging logs lollipop lsof m3u8 machine-learning macos manacher matplotlib mcp mirror mmcif model-evaluation monitoring mp3 mp4 mpnn multiomics mutation mysql nanobert nanobodies networkx nginx normalize numpy ollama omegatherm pandas password pathlib pdb pep-723 phaser pillow pip postfix preprocessing print protein-alignment protein-design protein-embeddings protein-interactions protein-language-models protein-stability protein-structure proxy pydantic pyecharts pyqt python python3 r raincloud reproducibility requests reservoir-sampling rfoptimization rg ripgrep rosettafold3 roundcube rrsi rsi rsync s-tui sampling scale scikit-learn scrapy screen seaborn security selenium seo sequence-identity sha256 shell signals sklearn socket solana somaticsignatures spl sqlite ssh standardize static-site subprocess symlink sysadmin system-one tarfile tcp tensorflow tkinter tls toml tron tronpy troubleshooting turtle typesafe-ai ubuntu unicode urllib usdt utf-8 uv vhhbert vite webp wordcloud wordpress workflow yaml zipfile zoneinfo 后台 寓言 概率 经济 贸易 迅雷解析 钱包

友情链接

其它