4. Process定义与执行

Process 是 Nextflow 流程的基本执行单元,它把一段命令、一组输入、一组输出和运行环境封装成一个可被调度、可被缓存、可被复用的任务。理解 Process,关键不是记住语法,而是理解它如何把“数据依赖”翻译成“任务依赖”:输入声明决定任务何时具备启动条件,输出声明决定下游任务如何接续,脚本块决定任务真正执行什么,而错误处理决定失败时流程如何响应。

输入输出声明:把数据依赖写成契约

Process 的输入用 input: 声明,输出用 output: 声明。它们不是注释,而是 Nextflow 构建任务依赖图的依据。常见输入限定符有 path、val、tuple、each、env、stdin 等。其中 path 表示文件或目录,Nextflow 会负责把文件暂存到任务工作目录;val 表示普通值,如样本名、阈值、字符串;tuple 用于把多个值打包成一个输入项,常用于“样本名 + 双端测序文件”这类成组数据。

例如,一个质控 Process 可以这样声明:

process fastqc {
    container 'biocontainers/fastqc:0.12.1'

    input:
    tuple val(sample_id), path(reads)

    output:
    path("${sample_id}_fastqc.html")
    path("${sample_id}_fastqc.zip")

    script:
    """
    fastqc -o . ${reads}
    """
}

这里 tuple val(sample_id), path(reads) 表示每个任务接收一个样本 ID 和对应的测序文件;path(reads) 中的 reads 在脚本里可直接作为文件路径使用。输出声明中的 path(...) 告诉 Nextflow:任务完成后,这些文件是产物,需要被记录并传递给下游。若输出文件实际未生成,Nextflow 会在任务完成检查阶段报错,而不是让错误悄悄流入后续步骤。

脚本块:script、shell 与 exec 的差异

Process 的执行体通常写在 script: 块中。最常见的是三引号字符串,它会被渲染成一段 shell 脚本,由任务环境中的 shell 执行。这种方式适合需要管道、重定向、变量展开或多条命令组合的场景。例如:

script:
"""
mkdir -p out
samtools sort -@ ${task.cpus} -o out/${sample_id}.sorted.bam ${bam}
samtools index out/${sample_id}.sorted.bam
"""

如果希望更明确地控制 shell 行为,可以使用 shell: 块。它同样生成 shell 脚本,但语义上更强调“这是一段 shell 命令”。而 exec: 块则不同:它把命令按参数列表直接执行,不经过 shell 解释。也就是说,exec: 中不能使用管道、通配符、重定向或 $VAR 展开,除非这些符号由程序自身处理。它的优势是避免 shell 注入和转义问题,适合参数边界清晰、命令简单的场景。

选择原则可以概括为:需要管道、重定向、复杂变量展开时用 script 或 shell;命令参数固定、希望避免 shell 解析歧义时用 exec。在生物信息流程中,script 更常见,因为很多工具需要管道串联,例如 bwa mem ... | samtools sort ...。

条件执行:when、if 与动态分支

Process 可以通过 when: 指令决定是否执行。它接收一个布尔表达式,只有为真时任务才会被调度。例如,只对 RNA-seq 样本执行定量:

process quantify {
    input:
    tuple val(sample_id), path(reads), val(assay)

    when:
    assay == 'rna-seq'

    script:
    """
    salmon quant -i index -l A -1 ${reads[0]} -2 ${reads[1]} -o ${sample_id}_quant
    """
}

当 assay 不是 rna-seq 时,该任务不会进入执行队列,也不会产生输出。这比在脚本里写 if 更清晰,因为它把“是否执行”提升到流程调度层,避免生成空任务或无效输出。需要注意的是,when 中只能使用输入通道中的值或全局变量,不能依赖任务运行后才产生的文件内容。

在脚本块内部,也可以使用 Groovy 的 if/else 动态拼接命令。例如根据 paired 标志决定使用单端还是双端参数:

script:
def mode = paired ? "-1 ${reads[0]} -2 ${reads[1]}" : "-U ${reads[0]}"
"""
salmon quant -i index -l A ${mode} -o ${sample_id}_quant
"""

这种写法适合参数组合变化,但不应把大量业务逻辑塞进脚本块,否则流程可读性和可测试性都会下降。

错误处理:errorStrategy、maxRetries 与 validExitStatus

默认情况下,任务返回非零退出码时,Nextflow 会终止整个流程。实际生产中,我们需要区分“可重试的瞬时错误”和“不可恢复的确定性错误”。errorStrategy 支持 terminate、finish、ignore、retry 四种策略。retry 会在失败时重新调度任务,常与 maxRetries 搭配使用:

process align {
    errorStrategy 'retry'
    maxRetries 3

    input:
    tuple val(sample_id), path(reads)

    output:
    path("${sample_id}.bam")

    script:
    """
    bwa mem -t ${task.cpus} ref.fa ${reads} | samtools sort -o ${sample_id}.bam
    """
}

如果任务因内存不足失败,可以结合 memory 和 retry 实现“失败后提高资源再试”。Nextflow 允许在 errorStrategy 中使用动态表达式,例如根据 task.attempt 调整内存:

process align {
    errorStrategy { task.exitStatus in 137..140 ? 'retry' : 'terminate' }
    maxRetries 3
    memory { 8.GB * task.attempt }

    script:
    """
    bwa mem -t ${task.cpus} ref.fa ${reads} | samtools sort -o ${sample_id}.bam
    """
}

这里 137 通常对应 SIGKILL,常见于内存超限;task.attempt 从 1 开始递增,因此第二次尝试会申请 16 GB。这样既避免了对所有错误盲目重试,又给资源不足类错误留出了恢复空间。

validExitStatus 用于声明哪些退出码算成功。默认是 0,但有些工具用 1 表示“无匹配结果”而非失败。此时可以设置 validExitStatus 0, 1,让流程继续。但必须谨慎:把真实错误码加入白名单会掩盖问题,只应在明确工具语义时使用。

执行过程与结果判断

一个 Process 从提交到完成,大致经历:输入通道就绪 → 任务进入队列 → 在工作目录中暂存输入 → 渲染脚本并执行 → 检查退出码与输出文件 → 发布输出到下游。每个任务都有独立工作目录,位于 work/xx/yyyy... 下。排查问题时,可以查看该目录中的 .command.sh(实际执行的脚本)、.command.log(标准输出与错误)、.exitcode(退出码)以及 .command.err。这些文件是判断“任务为什么失败”的第一手依据。

判断任务是否成功,不能只看流程是否报错。应同时确认:退出码是否为 0 或合法值;输出文件是否存在且非空;输出文件数量与命名是否符合预期;下游任务是否成功消费了这些输出。若输出声明为 path("${sample_id}.bam"),但实际生成的是 ${sample_id}.sorted.bam,Nextflow 会在输出检查阶段失败,提示缺少声明文件。这类错误往往比命令本身报错更隐蔽,需要优先核对输出路径与脚本生成路径是否一致。

Process 执行与错误处理路径 输入通道 tuple(sample_id, reads) when 条件 assay == 'rna-seq' 任务调度 work/xx/yyyy... 脚本渲染 script / shell / exec 跳过任务 不产生输出 执行命令 bwa mem | samtools sort 退出码检查 validExitStatus 输出检查 path 文件存在且非空 exit 0 errorStrategy retry / terminate / ignore 非零退出码 retry + maxRetries 资源升级 memory = 8.GB * task.attempt 137/140 排查文件 .command.sh / .command.log .exitcode / .command.err 下游 Process 消费输出通道

把上述机制串起来看,Process 的核心就是四件事:用输入输出声明建立数据契约,用脚本块描述实际计算,用条件执行控制任务是否进入调度,用错误策略决定失败后的走向。真正稳定的流程,不是从不失败,而是失败时能被准确识别、合理重试,并在输出检查阶段及时暴露问题。