PAYATHON 2026

Airflow 3 中 Python multiprocessing 完成后 Task 卡在 running

排障赵师傅

结论

问题更可能出在 Python 子进程或 multiprocessing.Manager 没有正常退出。现有信息不足以说明 Airflow 漏掉了大量 process signals。

XML 处理完成,只代表 multiprocessing_xml_handler() 中的业务代码已经执行完毕。Airflow 要等整个 Task Runner 进程退出并上报终态,才会把 Task 从 running 改为 success。当前代码中的日志:

logger.info("Finished processing the XML data")

位于 Pool 上下文内部。日志出现时,程序还没有执行 Pool.__exit__() 和 Manager.__exit__()。业务处理可能已经结束,程序却在之后的 Pool 清理、子进程回收、Manager 关闭、锁释放或日志刷新阶段卡住。

代码还存在一个需要优先修正的问题:它先通过默认上下文创建 Manager(),随后又使用 get_context() 创建 Pool,可能混用了不同 multiprocessing context 创建的对象。这个问题在 spawn 模式下更容易暴露。

为什么单独运行正常,放进 Airflow 就会卡住

Airflow Task 的运行环境比独立 Python 脚本复杂。运行 Task 的进程通常已经初始化了日志、数据库连接、线程、信号处理器和通信管道。

在这样的进程中使用 fork,子进程会继承父进程的内存状态,却不会完整复制父进程中的其他线程。如果 fork 时某个锁正被另一个线程持有,子进程可能一直等待该锁。常见对象包括:

  • Python logging handler 的锁;
  • SQLAlchemy 连接池和数据库 socket;
  • OpenSSL、XML 库或其他 C 扩展的内部锁;
  • Airflow Task Runner 使用的管道和文件描述符;
  • multiprocessing.Manager 的代理连接。

解析器在普通 Linux 脚本中运行正常,却在 Airflow 中出现问题,原因可能就是两种运行方式的父进程状态不同。

spawn 不会继承这些内存状态,一般更适合从复杂运行时中创建子进程。不过,传给 worker 的函数和参数必须能够被 pickle,相关模块也必须能由子进程安全地重新导入。下面这些情况都可能导致 spawn 启动失败:

  • worker 函数不是 module 顶层可导入对象;
  • 参数中包含不能 pickle 的对象;
  • DAG 文件或被导入模块在顶层直接创建 Pool;
  • standalone 入口缺少 if __name__ == "__main__":;
  • 子进程导入 DAG 模块时触发额外的初始化代码;
  • 混用不同 multiprocessing context 创建的 Lock、Queue、Manager 或 Pool。

所以,“fork 卡住”和”spawn 崩溃”通常是两个独立问题,不能只靠调整 Airflow 配置并在两种启动方式之间切换来解决。

先修正 Pool 的生命周期

可以暂时去掉嵌套的上下文管理器,显式调用 close()、join() 和 terminate(),以便确认程序究竟卡在哪一步。

import multiprocessing as mp
import logging

logger = logging.getLogger(__name__)

_worker_lock = None


def init_worker(lock):
    global _worker_lock
    _worker_lock = lock


def multiprocessing_xml_handler(args):
    chunk, table_query, csv_path, threshhold_processes = args
    chunk_xml_dataframes = {}

    for xml in chunk:
        try:
            xml_dataframes = xml_reader.process_xml(
                xml,
                table_query,
                csv_path,
                _worker_lock,
            )

            for csv_file, result_dict in xml_dataframes.items():
                if csv_file in chunk_xml_dataframes:
                    current = chunk_xml_dataframes[csv_file]
                    chunk_xml_dataframes[csv_file] = ToolBox.dict_extend(
                        result_dict,
                        current,
                    )
                else:
                    chunk_xml_dataframes[csv_file] = result_dict

        except Exception:
            logger.exception("Error while processing XML: %s", xml)
            raise

    ToolBox.flush_to_csv(chunk_xml_dataframes, _worker_lock)
    logger.info("Worker flushed its data")


def parse_xml(
    output_folder: str,
    input_folder: str,
    config_path: str | None = None,
):
    ctx = mp.get_context("spawn")
    lock = ctx.Lock()

    arguments = [
        (chunk, table_query, csv_path, threshhold_processes)
        for chunk in chunks
    ]

    pool = ctx.Pool(
        processes=pool_size,
        initializer=init_worker,
        initargs=(lock,),
    )

    try:
        logger.info("Submitting %d XML chunks", len(arguments))

        for _ in pool.imap_unordered(
            multiprocessing_xml_handler,
            arguments,
        ):
            pass

        logger.info("All worker results received")
        pool.close()

        logger.info("Waiting for worker processes to exit")
        pool.join()

        logger.info("All worker processes exited")
    except BaseException:
        logger.exception("XML multiprocessing failed")
        pool.terminate()
        pool.join()
        raise

这个版本做了以下调整:

  1. Lock 和 Pool 都由同一个 ctx 创建。
  2. worker 放在 module 顶层,方便 spawn 导入和 pickle。
  3. Lock 通过 Pool initializer 注入,不再随每个任务重复传入。
  4. Finished 类日志移到 pool.join() 之后。
  5. 子进程异常会重新抛出,不会只写入日志后继续运行。
  6. 日志能够区分”结果已收齐”和”子进程已退出”。

如果确实需要 Manager,也应使用同一个 context 创建:

ctx = mp.get_context("spawn")

with ctx.Manager() as manager:
    lock = manager.Lock()
    # 使用同一个 ctx 创建 Pool

从当前代码看,这里可能只需要一个跨进程锁。可以优先使用 ctx.Lock(),不必额外启动 Manager server process。

检查 spawn 的导入和 pickle 问题

独立运行入口应保留 main guard:

def main():
    parse_xml(
        output_folder="/path/to/output",
        input_folder="/path/to/input",
    )


if __name__ == "__main__":
    main()

DAG 文件及其导入的模块不应在 module 顶层调用 parse_xml()、创建 Manager 或启动 Pool。顶层代码只用于定义 DAG、Task 和可导入函数。

可以先单独验证 worker 和参数是否能够被 pickle:

import pickle

pickle.dumps(multiprocessing_xml_handler)
pickle.dumps(arguments[0])

这种检查不能覆盖所有 spawn 问题,但能快速发现常见的不可序列化对象。如果 spawn 持续崩溃,应先保留第一次出现的完整 traceback。后续异常可能只是子进程反复启动失败引起的连锁反应。

确认程序卡在哪个退出阶段

在每个资源清理点前后添加日志:

logger.info("Before pool.close()")
pool.close()
logger.info("After pool.close()")

logger.info("Before pool.join()")
pool.join()
logger.info("After pool.join()")

如果仍然使用 Manager,也要记录 Manager shutdown 前后的状态:

logger.info("Before manager shutdown")
manager.shutdown()
logger.info("After manager shutdown")

根据最后一条日志,通常可以缩小排查范围:

  • 卡在 imap_unordered():至少有一个任务没有返回,或者结果管道未能正常传递数据。
  • 卡在 pool.join():某个 worker、Pool helper thread 或 worker 中的非守护线程没有退出。
  • Pool 已退出,但程序在关闭 Manager 前卡住:检查是否仍有 Manager proxy、Lock 或连接被持有。
  • 函数已经 return,但 Task 仍是 running:检查 Task Runner 中残留的线程、子进程、文件描述符,以及 Airflow 的终态上报链路。

在 Linux 上查看进程状态

先找到执行该 Task 的父进程,再查看完整的父子进程关系:

ps -eo pid,ppid,pgid,sid,stat,wchan:32,etime,cmd --forest

如果系统提供 pstree:

pstree -ap <task-runner-pid>

重点查看以下状态:

  • Z:子进程已经退出,但父进程尚未回收。
  • D:进程正在等待不可中断的 I/O,可能与文件系统或磁盘有关。
  • S 且 wchan 为 futex_*:通常表示进程正在等待线程锁或进程锁。
  • wait_*:父进程正在等待子进程退出。
  • pipe_*、poll_*:进程可能正在等待 Pool、Manager 或日志管道。
  • XML worker 已经消失,但 Manager、resource tracker 或其他子进程仍然存活。

查看进程持有的文件描述符:

ls -l /proc/<pid>/fd

如果安装了 lsof:

lsof -p <pid>

这些信息可以帮助定位尚未关闭的 pipe、socket、日志文件或临时文件。

在测试环境中,还可以短时间附加 strace:

strace -ff \
  -p <pid> \
  -e trace=process,signal,futex,wait4,read,write \
  -s 128

可以按下面的方式判断:

  • 持续停在 futex():很可能是锁或线程清理问题。
  • 持续停在 wait4():父进程仍在等待某个子进程。
  • 持续读取某个 pipe:Pool、Manager 或日志通道可能没有关闭。
  • 反复收到信号并创建子进程:检查 spawn 导入模块时是否重复执行了启动代码。

strace 会影响进程时序,适合在开发环境中使用。

输出 Python 线程栈

如果进程无法退出,又无法从进程状态看出它在等待什么,可以启用 faulthandler:

import faulthandler
import signal

faulthandler.enable()
faulthandler.register(signal.SIGUSR2, all_threads=True)

进程卡住后,向 Task Runner 或目标 worker 发送:

kill -USR2 <pid>

Python 会把所有线程的堆栈写入标准错误或 Task 日志。通过堆栈通常可以判断主线程卡在 pool.join()、Manager shutdown、logging flush,还是某个业务锁中。

如果环境允许安装并附加 py-spy,也可以执行:

py-spy dump --pid <pid>

py-spy 展示 Python 调用栈时通常比系统调用跟踪更直观,但能否附加取决于主机的 ptrace 权限。

检查日志和异常处理

除了 Task 日志,还要检查运行 Task 的组件日志,以及 scheduler 和 executor 的相关日志。重点查找:

  • worker 的真实退出码;
  • BrokenPipeError、EOFError 或 ConnectionResetError;
  • pickle 和 import 错误;
  • daemonic processes are not allowed to have children;
  • resource_tracker 关于 semaphore 或 shared memory 的警告;
  • Task Runner 是否已经退出;
  • executor 是否收到 Task 的终态;
  • SIGTERM、SIGKILL、OOM 或系统级 abort 记录。

当前 worker 的异常处理会掩盖失败:

except Exception as e:
    logger.error(f"Error:{e} while processing: {xml}")

这段代码只记录异常文本,没有 traceback,也不会让 Pool 任务失败。至少应改为:

except Exception:
    logger.exception("Error while processing XML: %s", xml)
    raise

否则,某个库的内部异常可能被误判成”XML 已经处理完成”。

还应确认操作系统是否杀死过相关进程:

journalctl -k

重点搜索 OOM、segfault、killed process 和 denied 等信息。解析大量 XML 时,多进程会增加内存占用。如果多个 LocalExecutor Task 又分别创建 Pool,实际进程数和内存消耗可能远高于 pool_size。

Airflow 配置不能替代应用自身的 multiprocessing context

Airflow 的 mp_start_method 等配置主要控制 Airflow 自己创建进程时使用的启动方式。应用代码显式调用:

ctx = multiprocessing.get_context("spawn")

应用 Pool 的启动方式就由这个 ctx 决定。Airflow 配置与它有关,但修改 Airflow 配置不会自动修复嵌套 Pool。

execute_tasks_new_python_interpreter 会改变 Task 的启动环境,也无法解决 worker 不能被 pickle、Manager context 混用或 Pool 无法 join 等问题。

排查时可以先固定以下条件:

  1. 保持 LocalExecutor 配置不变。
  2. 应用统一使用 spawn。
  3. 所有 Lock、Queue、Manager 和 Pool 都通过同一个 ctx 创建。
  4. 先解决 spawn 的第一条完整 traceback。
  5. 确认 pool.join() 和 Manager shutdown 都能返回。
  6. 再比较其他启动方式。

不要同时修改多个 Airflow 参数和应用代码,否则很难判断是哪项改动影响了结果。

是否可能是 Airflow 没有处理好大量信号

进程管理程序确实可能遇到退出信号、子进程回收或状态上报问题,但 Task 停在 running 本身不能证明 Airflow 被大量 signals 压垮。

要支持这个判断,至少需要以下一项证据:

  • worker 和父进程都已退出,但 executor 没有收到终态;
  • Airflow 日志明确记录了异常退出码或信号处理错误;
  • 进程表中已经没有相关 Task 进程,数据库状态却长期保持 running;
  • 相同代码在不同进程数量下,能够稳定复现信号或回收异常。

如果 Task Runner 仍然存在,并卡在 futex()、wait4()、pipe read 或 pool.join(),问题还在 Python 进程内部。此时 Airflow 尚未获得把 Task 标记为成功的机会。

更稳妥的处理方式

如果每个 worker 最终都要争用同一个锁来写入 CSV,串行写入可能抵消多进程带来的收益。可以让 worker 只负责解析 XML,并分别写入独立的临时文件,再由父进程合并结果。这样就能去掉共享 Manager 和跨进程写锁。

数据量允许时,也可以让 worker 返回结果,再由父进程统一写入:

results = []

for result in pool.imap_unordered(parse_chunk, arguments):
    results.append(result)

pool.close()
pool.join()

for result in results:
    write_result(result)

返回大对象会增加进程间序列化和内存开销。数据量较大时,使用独立临时文件一般更合适。

还可以为 Airflow Task 设置执行超时,防止故障发生后长期占用 LocalExecutor slot。超时只能用于兜底,不能替代对子进程退出问题的修复。

备注:内容仅供参考。