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}"
}
小技巧:如果脚本里的变量名和 Nextflow 的变量冲突,用反斜杠转义,比如 \$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 声明的值。这样做的好处是,将来你只需要改进程声明,脚本里的参数会自动同步。

踩坑提醒:我刚开始用 Nextflow 时,给一个串行程序分配了 64 核,结果它只用了 1 核,其他 63 核白白空转浪费资源。所以记得:核数要和你的软件并行能力匹配。比如 OpenMP 程序就用 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 进程与任务的执行生命周期

理解了一个进程的代码结构,我们来看看它在运行时的生命周期。我画了一张图帮你理清思路:

进程执行生命周期 输入通道 (value / path / tuple) 进程 Process (定义 + 指令) 脚本块 Script (Shell / 命令) 任务 Task (每个输入/分区 = 一个Task) 输出通道 (emit / stdout / path) 说明 1. 每个进程定义生成一个“进程模块”。 2. 每对输入值(或每份输入文件)生成一个独立“任务”。 3. 任务并行执行,完成后将结果发送至输出通道。 资源声明 (cpus/memory/time)

这张图展示了核心流程。你想想看,每个输入组合都会生成一个独立的任务。如果一个进程接到 1000 个小分子文件,它就会创建 1000 个任务,并行度取决于你的资源设定和执行器配置。

4.6 进程定义的最佳实践

最后,分享几个我在实际项目中积累的习惯:

  • 进程命名要见名知意。用 prepare_ligands 而不是 step1。半年后你回头再看管线,好名字能救你一命。
  • 每个进程只做一件事。如果一个脚本块里既做格式转换又做对接又做打分,拆成三个进程。这样调试和复用都方便。
  • 显式声明所有输出。不要偷懒只写 stdout,把 emit: 名字写清楚。我在项目后期加功能时,命名清晰的进程直接拿来用,不用重写。
  • 资源声明按最坏情况保守估计。但不要无脑给 64 核 500GB——一是浪费钱,二是排队时间会更长。调优的办法是先跑几个小测试,看看实际资源使用曲线。

核心记忆点:进程是 input + output + script + 资源指令的组合。输入输出定义数据流,脚本块定义逻辑,资源声明定义运行环境。三者缺一不可。