4. 进程与任务:定义进程、输入输出、脚本块、资源声明
好,我们直接进入正题。进程(Process)是 Nextflow 管线的核心执行单元。说白了,每一个数据处理步骤,从文件格式转换到分子对接,都会被封装成一个进程。我个人习惯把进程看作一个“黑箱”——你给它明确的输入,它按照你写的脚本干活,然后吐出输出。至于它在哪台机器上跑、用了多少内存,那是资源声明要考虑的事。
这一章我们就把进程的骨架拆开来看。你会发现,定义进程其实就那么几个块:输入、输出、脚本、资源。掌握了它们,你就能写出干净又可控的管线。
4.1 进程的基本结构
一个最简单的进程长什么样?我直接上代码,你看看就明白了:
process sayHello {
input:
val x
output:
stdout
script:
"""
echo "Hello, $x!"
"""
}
嗯,这里要注意几个地方:input 和 output 是声明式的,Nextflow 根据它们来构建任务依赖图。而 script 块里写的,就是真正要在执行节点上运行的命令。我曾经见过有人把文件路径硬编码在脚本块里,结果换到集群上就崩了——所以,所有输入输出必须通过通道传递,绝对不要写死路径。
4.2 输入输出:管线的血管
输入输出决定了数据怎么流进进程、又怎么流出去。这是 Nextflow 最优雅的地方——数据流是隐式的,你只需要声明类型和名称。
4.2.1 输入声明
你可以声明多种输入类型,常用的有这几种:
| 输入类型 | 关键字 | 典型场景 |
|---|---|---|
| 值 | val |
参数、ID、阈值 |
| 文件 | path |
PDB 文件、参数文件 |
| 元组 | tuple |
文件+标签 |
| 标准输入 | stdin |
流式处理 |
举个例子,在药物发现管线里,我经常需要把一个配体文件和一个蛋白质文件打包输入:
input:
path protein_pdb
path ligand_sdf
这里有个坑:path 声明的文件,Nextflow 会自动做文件 staging 和路径管理。你想想看,如果不用 path 而用字符串拼接路径,在多节点环境下跑,文件根本找不到。
4.2.2 输出声明
输出和输入是对称的。你需要告诉 Nextflow,这个进程会产生什么文件或者值:
| 输出类型 | 关键字 | 说明 |
|---|---|---|
| 标准输出 | stdout |
捕获 echo/print 内容 |
| 文件 | path |
产出的结果文件 |
| 值 | val |
解析出来的数值或状态 |
| 元组 | tuple |
多文件/多值打包 |
看一个实际的分子对接输出声明:
output:
path "docked_*.sdf", emit: docked_poses
path "log.txt", emit: log
这里的 emit: 是我特别想强调的——给它一个名字,下游进程引用起来就清晰多了。我在做虚拟筛选管线时,每个进程都会有十几个输出,没命名的话后期维护简直是噩梦。
4.3 脚本块:真正干活的地方
script 块里写的是 shell 命令,但要注意它是 Groovy 字符串模板,可以嵌入变量和表达式。
4.3.1 基本用法
用双引号包裹的字符串,变量会被解析:
script:
"""
autodock_gpu --ligand $ligand_sdf --receptor $protein_pdb --out docked_poses.sdf
"""
4.3.2 多命令与条件执行
一个脚本块可以写多行命令,Nextflow 会按顺序执行。我曾经遇到一个问题:忘记设置 set -e,结果某步命令失败后管线照常往下跑,输出了垃圾数据。所以我现在每个脚本块开头都会加这个:
script:
"""
set -e
mkdir -p output
obabel $ligand_sdf -O output/ligand.mol2
smina -r $protein_pdb -l output/ligand.mol2 --autobox_ligand $ligand_sdf -o output/poses.sdf
"""
你还可以用 Groovy 的条件逻辑来选择不同的命令:
script:
if (params.protocol == "docking") {
"""
run_docking.sh $protein_pdb $ligand_sdf
"""
} else if (params.protocol == "screening") {
"""
run_screening.sh $protein_pdb --library $ligand_sdf
"""
} else {
error "Unknown protocol: ${params.protocol}"
}
\$PATH。否则 Nextflow 会尝试解析它成 Groovy 变量,大概率报错。
4.4 资源声明:管好你的计算预算
在药物发现管线里,不同步骤对资源的需求差异巨大。分子动力学模拟动辄几百核跑几天,而简单的格式转换可能几秒就完事。资源声明就是用来给每个进程分配合理资源的。
4.4.1 常用资源指令
| 指令 | 含义 | 常用值示例 |
|---|---|---|
cpus |
CPU 核数 | 1, 4, 16, 128 |
memory |
内存大小 | 8.GB, 64.GB, 512.GB |
time |
最大运行时间 | 1.h, 10.h, 72.h |
disk |
本地磁盘(某些调度器支持) | 100.GB, 1.TB |
accelerator |
GPU 或加速卡 | 1, 2 (需配置类型) |
举个实际例子,给分子对接步骤分配 4 核和 8GB 内存:
process docking {
cpus 4
memory 8.GB
time 2.h
input:
path receptor
path ligand
output:
path "poses.sdf"
script:
"""
vina --receptor $receptor --ligand $ligand --out poses.sdf --cpu $task.cpus
"""
}
注意这里用到了 $task.cpus——它会自动扩展成你在 cpus 4 声明的值。这样做的好处是,将来你只需要改进程声明,脚本里的参数会自动同步。
cpus,MPI 程序可能需要不同的声明方式。
4.4.2 动态资源分配
有时候资源需求取决于输入数据大小。比如,处理一个 10MB 的蛋白文件和处理一个 2GB 的轨迹文件,不能一刀切。Nextflow 支持在 script 块里动态计算资源:
process analyze_trajectory {
memory { 2.GB * file(trajectory).size() / (1.MB) }
cpus 4
input:
path trajectory
script:
"""
analyze_traj $trajectory > results.txt
"""
}
这种写法看起来有点绕,但其实很实用。我刚才说的文件 staging 问题,在动态资源里也要注意——file() 取的是 staged 之后的文件,不是原始路径。
4.5 进程与任务的执行生命周期
理解了一个进程的代码结构,我们来看看它在运行时的生命周期。我画了一张图帮你理清思路:
这张图展示了核心流程。你想想看,每个输入组合都会生成一个独立的任务。如果一个进程接到 1000 个小分子文件,它就会创建 1000 个任务,并行度取决于你的资源设定和执行器配置。
4.6 进程定义的最佳实践
最后,分享几个我在实际项目中积累的习惯:
- 进程命名要见名知意。用
prepare_ligands而不是step1。半年后你回头再看管线,好名字能救你一命。 - 每个进程只做一件事。如果一个脚本块里既做格式转换又做对接又做打分,拆成三个进程。这样调试和复用都方便。
- 显式声明所有输出。不要偷懒只写
stdout,把emit:名字写清楚。我在项目后期加功能时,命名清晰的进程直接拿来用,不用重写。 - 资源声明按最坏情况保守估计。但不要无脑给 64 核 500GB——一是浪费钱,二是排队时间会更长。调优的办法是先跑几个小测试,看看实际资源使用曲线。
核心记忆点:进程是 input + output + script + 资源指令的组合。输入输出定义数据流,脚本块定义逻辑,资源声明定义运行环境。三者缺一不可。