开头
在 Airflow 集群里跑 Python 任务时,不少同学会遇到一个看似简单的问题:DAG 里有一段 CPU 密集型的批处理逻辑,跑得太慢,于是很自然地想到用 Python 的 multiprocessing 开几个进程并行处理。想法没问题,落地却经常翻车。我在生产环境里见过不止一次因为这种做法引发的“事故”:调度器莫名卡顿、数据库连接被打满、任务重试后出现一堆僵尸进程,甚至整个 worker 节点内存被吃光。问题不在于“多进程”本身,而在于你没有搞明白 Airflow 的任务进程到底是怎么跑起来的,就贸然把多进程塞进了调度链路里。
这篇博文想聊的就是:在 Airflow 里安全使用多进程的正确方式。哪些场景适合用、哪些场景千万别用、如果非用不可该怎么隔离和管控生命周期,以及进程间通信怎么选型最稳妥。内容主要面向已经在用 Airflow 的开发者,也适合刚接触 Airflow 不久、准备把 Python 并行逻辑接入 DAG 的同学。读完之后,你至少能避开我在生产环境里踩过的那些坑,并且拿到一套可以直接抄作业的方案。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
1. 先搞清楚:Airflow 里的任务到底跑在哪个进程里
1.1 一个 DAG 从调度到执行,进程是怎么流转的
很多人对 Airflow 的理解停留在“它是个定时调度工具”,但对底层的进程模型并不清晰。这个认知盲区,恰恰是乱用 multiprocessing 的根源。
简单梳理一下一次任务执行的完整链路。Scheduler 进程负责扫描 DAG 目录、计算调度时间,把满足条件的 DAG Run 实例化出来,再根据依赖关系把 Task Instance 交给 Executor。Executor 的类型决定了任务进程归属于谁:LocalExecutor 直接在本机用 subprocess 起一个新的 Python 进程来跑任务;CeleryExecutor 则是把任务塞进消息队列,由 Celery Worker 进程负责消费并执行;KubernetesExecutor 则是为每个任务动态创建 Pod。
这里要划一个重点:任务代码(也就是你在 DAG 里写的 Python 函数)是运行在 Executor 启动的任务进程里的,而不是运行在 Scheduler 进程里。很多人容易搞混这一点。Scheduler 负责“调度逻辑”,比如判断是否该执行、依赖是否满足、要不要重试;而“实际执行”是独立进程的事。
所以说,如果直接在 DAG 的 Python 函数里写 multiprocessing.Pool,你其实是在“任务进程”里再开子进程。听起来好像没什么大不了——任务进程已经和 Scheduler 分离了,我在任务里再开几个进程加速,有什么关系呢?关系很大,下面详细拆。
1.2 什么时候真的需要多进程:识别真实场景
在讨论怎么安全用之前,先冷静下来判断一下:你到底需不需要多进程?
我自己归纳过四个真实可能需要的场景,这四个场景之外的,基本都不需要自己开进程。
第一种,单机 CPU 密集型计算。比如在一个任务里做大规模的特征提取、图像处理、复杂数值计算,而且计算逻辑完全可以用多核并行。这时候你确实可以利用多进程来加速,但前提是你用的是 LocalExecutor 且任务所在节点有足够 CPU 资源。
第二种,调用非线程安全的第三方库。Python 的 multiprocessing 能绕开 GIL,如果某个 C 扩展库虽然是 CPU 密集型但是释放了 GIL,你用多线程也行;如果这个库明确不支持多线程,那多进程几乎是唯一选择。
第三种,需要在同一个任务里并行执行多个相互独立的 IO 密集型操作,而且这些操作不适合拆成多个 Task。比如一个任务里要同时向三个外部系统发请求,而这三个请求的结果要汇总后才能继续下一步。虽然更推荐用 AsyncIO,但很多人还是习惯用进程池来“一把梭”。
第四种,你要在 Airflow 里运行业务方自己编写的 Python 服务或者脚本,这个脚本内部已经用了多进程,你不能改,只能想办法把它安全地装进任务里。
如果你不属于上面任何一类,其实你大概率不需要自己在任务里开多进程。Airflow 天生的扩展方式是把大任务拆成多个小任务,让 Executor 来并行调度,而不是在单个任务内部开进程。这一点后面会重点讲。
2. 直接在 DAG 里用 multiprocessing 会踩哪些坑
2.1 fork 出来的子进程和父进程抢资源
Linux 下 multiprocessing 默认的启动方式是 fork。fork 的时候,子进程会复制父进程的内存空间。Python 解释器在所有导入的模块、全局变量、连接池、Logger 句柄等都会跟着复制一份,这本身对代码逻辑没什么影响,但带来一个直接后果:资源占用瞬间翻倍。
一个 Airflow 任务进程本身会加载不少东西,你写的 DAG 文件、Airflow 的版本库、你 import 的各种依赖,加起来内存占用常常在 300MB 到 1GB 之间。如果你再开 4 个进程,每个进程 fork 出来又是类似的内存占用,虽然是写时复制(Copy-on-Write),但在实际跑数据计算的时候,内存页会被逐渐写进来,不再是共享的。几个进程同时写后,内存就实打实地往上涨。
更糟的是,如果这个任务跑在 Celery Worker 所在节点上,Worker 进程本身还要负责并行执行多个任务。比如 Celery 配置了 worker_concurrency=8,意味着一个 Worker 进程组里已经有 8 个任务进程在跑了。其中任意一个任务又 fork 出 4 个子进程,节点总进程数可能会暴涨到几十个,CPU 争抢、内存交换、IO 抖动都会出现。最恶劣的情况是 OOM Killer 直接找到 Worker 进程下手,把整个 Worker 上的其他任务全部带崩。
2.2 数据库连接被 fork 复制后直接炸穿
这是我在踩坑经历里印象最深的一个问题,之前有个同事在 DAG 里用 multiprocessing.Pool 去并行处理一批数据,每个子进程都会写数据库。写库之前,他照常 import 了 SQLAlchemy session,然后创建连接。表面上看代码没问题,每个子进程自己建立自己的连接,各写各的。但一旦跑起来,数据库连接数飞快往上飙,直接逼近 PostgreSQL 的 max_connections 上限,导致其他所有依赖这个库的任务全部报错。
问题出在哪里?fork 子进程时,父进程里如果有 DB 连接对象,子进程会把已经建立的连接文件描述符一起复制。也就是说,子进程拿到的是一个已经被父进程打开过的数据库连接。两个进程同时用同一个连接对象去跟数据库通信,协议层的状态是完全错乱的。比如父进程发起了一个事务,子进程又拿同一个连接去执行查询,服务端收到的指令序列混在一起,轻则报 server closed the connection unexpectedly,重则把 PostgreSQL 的连接状态搞坏。
正确做法是在 fork 之后重新创建连接池,或者用 multiprocessing 的 spawn 方式启动子进程,但很多任务脚本根本没有考虑到这一点。
2.3 父进程退出后,子进程变成孤儿
Airflow 任务执行完会退出任务进程,如果任务超时或者被手动 Kill,同样会异常终止。这时候容易出现一个尴尬情况:你用 Pool 开出来的子进程还在跑,但已经没有人在等它们了。
我遇到过一次非常典型的场景:一个批处理任务设置了 execution_timeout=300,在规定时间内没跑完,Airflow 把任务进程 Kill 掉并标记为失败。但任务里 Pool 的子进程没有收到终止信号,继续在后台跑。任务重试后,又有新的一批子进程被拉起来,等重试也超时了,又留下一批孤儿进程。到最后节点上堆了几十个没人管的 Python 进程,把 CPU 占满,其他任务全部排队等资源,整个集群看起来就像死掉了一样。
这个问题的本质是:Airflow 的进程管理和 multiprocessing 的进程管理是两套体系,Airflow 只知道它自己启动的任务进程,不知道你内部又起了哪些子进程。任务终止时把父进程干掉,子进程并不会跟着退出。如果你没有额外的生命周期管理机制,孤儿进程就会一直留在节点上。
2.4 可观测性完全丢失
Airflow 的日志系统是按照 Task Instance 维度来收集的。任务进程里用标准的 logging 输出日志,Airflow 的日志处理器能把日志记录到对应任务的文件里。但当你用 multiprocessing 开启了子进程,子进程默认是不会继承父进程的日志 Handler 配置的,或者说即便继承了,在 fork 之后写日志也有锁竞争问题。
结果就是,子进程里所有的 print 和 logger.info 都打到了标准输出,直接丢失,Airflow 界面里看不到任何输出。任务出问题的时候,日志层面是一片空白,你连子进程跑了哪些数据、取到哪些结果、在哪一步报错都看不到,排查效率极低。
而且更进一步,如果你在进程池里用 queue 往回调函数里传数据,或者用 manager 创建共享对象,这些对象在任务结束后是否正常释放,从 Airflow 侧是完全观察不到的。出了问题只能进容器里手动 ps 找进程,属实难受。
3. 安全使用多进程的正确姿势
3.1 原则:能拆任务就别开进程
先讲最简单也最安全的方案:大多数情况下,你根本不需要在任务内部开多进程。
Airflow 天然支持任务的并行拆分。比如你有 100 个文件要处理,不要在一个任务里循环处理,而是拆成 100 个子任务,每个子任务处理一个文件,然后交给 Executor 去并行调度。如果你的 Executor 是 CeleryExecutor,这 100 个任务会自动分布到多个 Worker 节点上并行执行,并行度由集群规模决定,你完全不用关心进程管理的问题。
在 Airflow 2.3 之后的版本里,官方引入了动态任务映射(Task Mapping),用 expand() 方法可以很方便地把一个参数列表展开成多个 Task Instance。我后面会给出具体的改造示例。这套方案最安全,因为多进程的管理完全交给了执行器,Airflow 自己的重试、超时、日志机制全部生效,不会有任何“孤儿进程”和“连接池复制”的问题。
唯一的问题是:如果你的计算逻辑必须在同一个进程内保持状态,或者任务之间存在数据依赖,拆成多个 Task 会带来额外的数据传输成本。这种情况下,任务内多进程才有真正的用武之地。
3.2 必须用多进程时,如何隔离进程树
如果确实要走任务内多进程这条路,我强烈建议把多进程逻辑“包起来”,不要直接在 DAG 的执行函数里裸写 multiprocessing.Pool 代码。
我的做法是:把多进程计算的部分封装成一个独立的 Python 脚本或者模块,然后在 Airflow 任务里用 subprocess 或者 BashOperator 调用这个脚本。这里面的关键在于:通过 subprocess 创建一个隔离的进程组,当 Airflow 任务进程被终止时,我们可以主动给这个进程组发送信号,保证整个进程树被清理干净。
用代码说明一下。假设有一个任务脚本 worker.py,负责启动一组子进程去处理数据,我们可以这样写:
python复制# 错误的做法:在 Airflow 任务函数里直接开 Pool
def task_function():
with multiprocessing.Pool(4) as pool:
pool.map(process_one_file, files)
python复制# 正确的做法:用 subprocess 启动一个独立进程组
import subprocess
import os
import signal
def task_function():
proc = subprocess.Popen(
["python", "/opt/scripts/worker.py"],
start_new_session=True, # 关键:让子进程成为新的进程组组长
)
try:
return_code = proc.wait()
if return_code != 0:
raise Exception(f"worker failed with {return_code}")
except KeyboardInterrupt:
# 收到 Airflow 终止信号时,杀整个进程组
os.killpg(proc.pid, signal.SIGKILL)
proc.wait()
raise
关键点在于 start_new_session=True。这样一来,worker.py 以及它内部通过 multiprocessing fork 出来的所有子进程,都会属于同一个进程组。Airflow 任务进程收到终止信号退出时,我们会捕获 KeyboardInterrupt,然后 os.killpg 把这个进程组里的所有进程一次性干掉。这样就从根源上避免了“任务超时但子进程还在跑”的孤儿问题。
3.3 生命周期管理:让子进程必须“听爸爸的话”
哪怕你不想用 subprocess 方案,非要在任务内直接用 multiprocessing,生命周期的管理也绝对不能偷懒。有三个细节是必须做到的。
一是给子进程设置 daemon=True。multiprocessing.Process 的子进程默认是非守护的,父进程退出时不会强制终止子进程。如果你不设置 daemon=True,父进程等待子进程时被中断,子进程会一直留在系统里。而设置为守护进程之后,父进程退出,子进程会自动跟着退出。不过注意,multiprocessing.Pool 不支持直接用 daemon 进程,它内部用的是非 daemon 的进程,这也就是为什么 Pool 跑在 Airflow 里特别难管理。
二是用上下文管理器(context manager)来管理资源。with multiprocessing.Pool(4) as pool: 在退出时会调用 pool.terminate() 强制终止所有子进程,比单独调用 pool.close() 和 pool.join() 更安全。但前提是你必须让这个 with 块能正常执行到退出,如果父进程被 SIGKILL 直接杀掉,解释器连 with 里的清理逻辑都没机会运行,这依然是死路。
三是最稳妥的方式:在子进程内部处理信号。worker.py 里可以给每个子进程注册一个信号处理器,收到 SIGTERM 或 SIGINT 之后,主动清理正在处理的数据然后退出。这样虽然麻烦,但确实能保证任何情况下子进程都有机会优雅退出。我见过有的团队甚至用类似 supervisord 的方式管理任务内子进程,进程退出码和日志全部统一收集,虽然有点重,但稳定性确实好。
3.4 用 multiprocessing 的正确封装方式
如果上面这些你都觉得可控了,那我们可以谈一个相对标准的封装模板。这个模板我用了很长时间,基本没出过大问题。
python复制import multiprocessing as mp
import os
import signal
import time
class ManagedProcessPool:
"""在 Airflow 任务中安全使用的进程池封装"""
def __init__(self, processes: int):
self.processes = processes
self._pool = None
self._initialized = False
def __enter__(self):
# spawn 方式比 fork 更安全,避免复制文件描述符
self._pool = mp.get_context("spawn").Pool(
processes=self.processes,
initializer=self._worker_initializer,
)
self._initialized = True
return self._pool
def __exit__(self, exc_type, exc_val, exc_tb):
if self._pool is not None:
self._pool.terminate()
self._pool.join()
self._initialized = False
@staticmethod
def _worker_initializer():
# 子进程重新设置信号处理,避免继承父进程的 handler
signal.signal(signal.SIGINT, signal.SIG_IGN)
signal.signal(signal.SIGTERM, signal.SIG_IGN)
先解释为什么要用 mp.get_context("spawn") 而不是直接用 mp.Pool。Linux 上默认是 fork,会继承父进程所有打开的文件描述符,包括数据库连接、Redis 连接等,这些在子进程里全都是不可用的。spawn 方式会重新启动一个新的 Python 解释器,只 import 必要的模块,然后通过 pickle 把任务函数传过去。虽然启动速度慢一些,但安全性高很多。
然后是 terminate() 和 join()。terminate 会立刻终止所有子进程,join 确保子进程的退出状态被父进程回收,避免僵尸进程。__exit__ 保证即使任务函数抛出异常,进程池也能被清理掉。
用的时候是这样:
python复制from airflow.decorators import task
@task
def heavy_compute_task():
from my_worker import process_chunk
with ManagedProcessPool(4) as pool:
results = pool.map(process_chunk, list_of_data)
return results
这里还有一个容易踩的坑:pool.map 如果某个子进程抛异常,map 会把这个异常传播回父进程,但剩下的子进程还在跑。在 with 块的 __exit__ 里调用 terminate 也不会立即停止正在跑的子进程,因为子进程可能正阻塞在某个系统调用上。为了尽力解决这个问题,我通常会给子进程函数内部加上超时控制,或者用 pool.imap_unordered + timeout 参数来限制单个结果的等待时间。
4. 实操案例:一个批量文件处理任务的改造全过程
4.1 需求描述和最初的写法
用具体的例子来把上面的方案串起来。假设有一个需求:每天凌晨从 FTP 下载一批文件,每个文件大约 200MB,需要对每个文件做解析和特征提取,最后把结果写入数据库。文件数量不定,大概 50 到 200 个,总耗时主要花在解析和特征提取上,属于 CPU 密集型。
最初的写法很自然,一个 DAG 一个 Python 函数,循环调用 multiprocessing.Pool 并行处理文件。
python复制from airflow.decorators import dag, task
import multiprocessing as mp
from datetime import datetime
@dag(start_date=datetime(2024, 1, 1), schedule="@daily", catchup=False)
def file_process_dag():
@task
def process_files():
file_list = download_files() # 从FTP下载
with mp.Pool(8) as pool:
pool.map(parse_and_extract, file_list)
process_files()
看起来简洁,但按照前面分析,这个写法至少有三个隐患:fork 复制连接池、任务终止时子进程变孤儿、日志丢失。当时这个任务上线后,跑了一段时间,直到某天节点 OOM 才被拉去排查。
4.2 方案一:用 Task Mapping 替代多进程
如果你的文件之间没有顺序依赖,最正确的改造方式是把它拆成任务映射:文件列表的获取作为一个任务,获取到之后返回文件路径列表,然后用 expand 把“处理单个文件”的任务并行展开。
python复制from airflow.decorators import dag, task
from datetime import datetime
@dag(start_date=datetime(2024, 1, 1), schedule="@daily", catchup=False)
def file_process_dag():
@task
def get_file_list():
return download_files()
@task
def parse_one_file(file_path: str):
# 单个文件的解析逻辑
data = parse_and_extract(file_path)
write_to_db(data)
file_list = get_file_list()
parse_one_file.expand(file_path=file_list)
这个方案里,完全不存在多进程问题。每个 parse_one_file 都是一个独立的 Task Instance,由 Executor 统一调度执行。如果用的是 CeleryExecutor,任务会分发到多个 Worker 节点上,天然并行,比单机开 8 个进程的吞吐量高得多。而且每个任务实例都有独立的日志、独立的超时管理、独立的重试机制,出问题定位也很方便。
唯一需要留意的是,如果文件数量特别大(比如上万),Task Instance 数量也会很大,调度器会有一定压力。这时候可以按文件列表分片,让每个任务处理一批文件,比如每个任务处理 20 个文件,再 expand 几百个任务。
4.3 方案二:在任务内部用安全的封装
如果文件解析逻辑之间有共享的中间状态,或者你调用的是一个没法拆分的第三方库接口,必须要在一个任务进程里完成,那就用我前面提到的 ManagedProcessPool 封装,同时强制使用 spawn。
python复制from airflow.decorators import dag, task
from datetime import datetime
@dag(start_date=datetime(2024, 1, 1), schedule="@daily", catchup=False)
def file_process_dag():
@task
def process_files():
file_list = download_files()
# 使用 spawn 方式,避免 fork 复制文件描述符
ctx = mp.get_context("spawn")
with ctx.Pool(processes=8) as pool:
results = pool.map(parse_and_extract, file_list)
final_result = aggregate(results)
write_to_db(final_result)
return final_result
process_files()
这里我故意没有直接复用 ManagedProcessPool 类,而是写了最核心的裸代码,方便你理解原理。注意在 parse_and_extract 函数的编写上,因为是 spawn 方式,函数本身必须能被 pickle,也就是说它不能是 __main__ 模块里的匿名函数或闭包,必须定义在可导入的模块里。很多人在这个点上报错:Can't pickle local object,以为代码有问题,其实是你定义函数的位置不对。
另外,parse_and_extract 内部如果还要写数据库,必须自己创建连接对象,不能依赖任何从父进程继承过来的全局连接。也就是说,数据库连接的初始化逻辑要写在函数内部。
4.4 方案三:用 BashOperator 调用独立脚本
有时候业务方给你的是一个独立脚本,内部已经用 multiprocessing 写好了逻辑,比如他们用 torch.multiprocessing 或者 ray,你没法随便改。这时候最稳妥的做法是:不直接调用 Python 函数,而是用 BashOperator 或者 subprocess 启动一个独立进程组,脚本内部的多进程由脚本自己管理。
python复制from airflow.decorators import dag
from airflow.operators.bash import BashOperator
from datetime import datetime
@dag(start_date=datetime(2024, 1, 1), schedule="@daily", catchup=False)
def file_process_dag():
run_business_script = BashOperator(
task_id="run_business_script",
bash_command="start_new_session python /opt/scripts/business_pipeline.py",
execution_timeout=timedelta(hours=1),
)
run_business_script
关键还是在 start_new_session 这个前置命令,它会让整个 Python 进程变成一个进程组的组长。这样 BashOperator 的任务进程被终止时,Airflow 的进程组信号机制才有机会把整个进程组一起清理掉。虽然在 Airflow 内部,BashOperator 本身是用 subprocess.Popen 运行的,且 BashOperator 已经默认设置了 start_new_session=True,但这个习惯延伸到你自己写的脚本中,能规避很多边界问题。
有个小技巧:在脚本内部也设置一个 父进程 PID 的监测机制,定期检查父进程是否还在,如果不在了就主动自杀。这在被 SIGKILL 的情况下尤其有用,因为 SIGKILL 无法被捕获,你只能靠自我检测来兜底。
4.5 方案对比:三种方案的取舍
| 方案 | 并行机制 | 运维复杂度 | 适用场景 |
|---|---|---|---|
| Task Mapping | Executor 调度多个 Task | 低,和普通任务无差别 | 文件间无依赖、可拆分、任务量可控 |
任务内 spawn 进程池 |
单任务内多进程 | 中,需要处理日志和连接 | 计算逻辑不可拆分、必须共享内存状态 |
| BashOperator 独立脚本 | 脚本内自行管理 | 高,需要额外监控 | 引入第三方多进程脚本、无法改源码 |
从我个人经验来看,能用方案一就绝不用方案二和方案三。Task Mapping 带来的可观测性、稳定性和运维便利性,远超过省下的那一点点开发量。
5. 进程间通信:Queue、Pipe、Manager 与共享内存
5.1 子进程回传数据,哪些方式可靠
多进程不只是“开几个进程并行跑”,子进程算出来的结果怎么传回父进程,在高并发场景下也是一个问题。常用的方式有 multiprocessing.Queue、Pipe、Manager 和共享内存。
Queue 是 multiprocessing 里最常用的通信方式,适合多个子进程往一个队列里写结果,父进程统一消费。底层实现是管道(Pipe)加锁,可以保证数据不会乱,但要注意的是,如果子进程写的数据量很大而父进程消费不及时,队列会阻塞子进程。在这个场景下,我一般建议父进程在 with 退出之前就持续消费队列,不要等到所有子进程跑完再统一读。
Pipe 适合两个进程之间的点对点通信,比 Queue 轻量一些。但如果多个进程同时往同一根 Pipe 写数据,可能造成数据交叉错乱,需要自行加锁。在 Airflow 任务里我很少直接用 Pipe,因为子进程数量和数据处理逻辑通常比较复杂,Queue 的语义更清晰。
Manager 是最“重”的通信方式,它实际上启动了一个独立的 Server 进程,所有子进程通过代理对象访问共享数据。好处是支持复杂数据结构,比如 list、dict、Namespace,而且跨平台支持好。坏处是性能瓶颈明显,每次读写都要经过一次序列化和网络传输(即使是本机也是 Unix Socket)。只适合传小数据,比如状态、进度、结果摘要。
共享内存(shared_memory)是性能最好的方式,特别适合大数组、大矩阵。但 Python 的 shared_memory 在 spawn 模式下需要手动管理生命周期,创建后如果忘了释放,会在 /dev/shm 下留下文件,容器环境里通常限制了 /dev/shm 的大小(默认 64MB),用满之后新的 shared_memory 分配会失败。
5.2 通信方式选型建议
我给出一个简单的选型规则:小数据量、跨子进程汇总结果,用 Queue,简单可靠;需要共享复杂状态而且数据量很小,用 Manager;需要传输大数组或大矩阵,用 shared_memory 并且务必在 finally 里清理;其余情况,优先考虑把中间结果写临时文件或者数据库,让子进程之间解耦。
这里特别提醒一句:不管选哪种通信方式,都要避免子进程和 Airflow 的 TaskInstance 直接交互。我见过有人为了让子进程汇报进度,在子进程里直接调用 Airflow 的 xcom_push 或者写 task_instance 表,这会在子进程中建立新的数据库连接,一旦连接数多了,照样会把数据库打爆。子进程只需要把结果通过通信机制传给父进程,由父进程统一写库、统一推 XCom。
5.3 Linux 下进程通信的底层认知
刚才说的都是 multiprocessing 库层面的封装,往底层看,Linux 提供的进程通信机制包括管道(Pipe)、FIFO、Unix Socket、信号(Signal)、共享内存、消息队列等。Python 的 multiprocessing 底层在 Linux 上大量使用 Unix Socket 和共享内存。
理解这一点有什么用?有两个实际意义。第一,Queue、Pipe 本质上是文件描述符,fork 模式下子进程会继承这些描述符,所以如果你用 fork 方式启动子进程,父进程里任何已经创建好的 Queue 都会被复制给子进程,有可能导致多个子进程使用同一个写端,产生预期之外的竞争。第二,Unix Socket 和共享内存都受系统文件描述符数量和内存限制,如果你的任务里创建了大量临时队列,而任务又反复重试,文件描述符可能会泄露,导致节点上出现 Too many open files 的报错。
运维层面,建议对任务运行节点设置监控:进程数、文件描述符数、内存使用、/dev/shm 占用。这四项指标几乎能把多进程任务带来的问题全部覆盖住。
6. 常见问题与排查技巧实录
6.1 问题速查表
| 症状 | 可能原因 | 解决办法 |
|---|---|---|
任务执行时报 Can't pickle local object |
spawn 模式下任务函数无法被 pickle |
把任务函数定义到独立模块中,不要用闭包和 lambda |
| 数据库连接数飙升 | 子进程复制了父进程的数据库连接 | 改用 spawn,或在子进程内重建连接 |
| 任务超时被 Kill 后节点仍有进程残留 | 没有管理子进程生命周期 | 使用进程组 + 信号清理,或 daemon 子进程 |
| 任务界面无日志输出 | 子进程日志没有接入 Airflow 日志 Handler | 使用 subprocess 时捕获 stdout/stderr,手动记录日志 |
| 共享内存分配失败 | /dev/shm 容量被占满 |
释放未清理的 shared_memory 段,调整容器限制 |
| 多个子进程写数据库时死锁 | 子进程间竞争同一行数据 | 按数据 key 分片,或改用队列汇总后父进程单线程写库 |
| 重试后任务状态一直 running | 孤儿进程占住锁或者 xcom | 清理残留进程,检查数据库连接,把任务改为幂等设计 |
6.2 排查命令和高频排查流程
拿到一个“可能是多进程引起的”故障,我一般按下面的顺序排查。
先看进程树。ps -ef f 或者 pstree -p <pid> 能直观看到当前节点上有哪些进程、它们的父子关系是什么。如果发现某个 Airflow 任务进程下面挂着几十个 python 子进程,而且父进程 PID 已经不存在了,那基本可以确认是孤儿进程。
再看资源占用。top -H 或 htop 看每个线程的 CPU 和内存占比,如果发现一堆进程的 %CPU 加起来远超节点核数,说明并行度开太高了。
然后看日志。如果是 subprocess 启动的进程组,日志通常打到了错误目标上,我建议把所有 stdout 和 stderr 都重定向到日志文件,排查时直接 tail 那个文件。
最后看数据库连接。SELECT * FROM pg_stat_activity; 看连接来源 IP 和进程名,如果发现很多连接来自同一个 Worker 节点,且 application_name 都是同一个任务的标识,那基本就是子进程打开的连接没有复用或者没有关闭。
6.3 我踩过的几个坑和最终的处置方案
有一个项目印象很深。当时任务里用了 Manager().dict() 来汇总各子进程的处理结果,数据量不大,但任务每次跑到一半就卡住,整个任务既不结束也不报错。排查了好久,最后发现是 Manager 启动的 Server 进程和父进程之间的心跳机制超时了。原因是子进程自身负载太高,CPU 全被计算消耗完,没时间处理 Manager 的代理请求,导致 Server 进程认为父进程失联,锁死了任务。
那次之后,我基本不再在 CPU 密集型的多进程任务里使用 Manager,改用Queue+ 一次性汇总的方式。只有非常轻量的共享状态才考虑 Manager。
还有一次是子进程内部用了 logging,因为 fork 的关系,多个进程同时写同一个日志文件,日志出现大量交错和丢失。后来改成每个子进程写独立日志文件,文件名带上进程号,父进程再统一合并。这个方案虽然有点笨,但在排查问题时真的香,日志一清二楚。
7. 最后分享一点个人经验
在 Airflow 里折腾多进程,我最想强调的就一句话:Airflow 本身的定位是工作流编排,不是并行计算框架。它的优势是任务调度、依赖管理、重试、告警、日志收集,这些能力只对“Task”生效,对 Task 内部的子进程完全是盲区。所以只要你能把问题拆成多个 Task,就千万别把计算塞进单任务的多进程里。
如果真的有一些复杂逻辑必须用多进程,那它的管理责任就在我们自己身上,Airflow 不会替你兜底。这时候我建议你至少做到:用 spawn 隔离文件描述符、用进程组清理生命周期、用独立日志文件解决可观测性、用 Queue 而不是 Manager 回传数据、控制子进程并发数不超过节点核数的 75%。把这五条做到,你的任务基本不会出大乱子。
我看了不少团队,其实很多所谓的“多进程需求”,最后都用 Task Mapping 解决了,而且跑得又稳又快。你如果正在犹豫要不要在 DAG 里开进程池,不妨先想一个问题:这个任务能不能拆细一点?能拆,就直接拆,不要自己开进程。不能拆,再回头看看这篇文章里的安全封装方式,照着做,会省很多心。
