管道与重定向进阶:stdin/stdout/stderr/文件描述符

2421 字
12 分钟
管道与重定向进阶:stdin/stdout/stderr/文件描述符

生信流程本质是数据在管道中流动:FASTQ → 质控 → 比对 → SAM → 排序 → BAM → 定量 → 计数矩阵 → 差异表达。每一步都是 stdin → tool → stdout。理解管道和重定向不只是语法技巧,而是理解整个流程数据流的基础。

本文不讲 |> 的基础语法,聚焦 进程替换、tee 分流、命名管道、文件描述符操作 等进阶技巧,覆盖 SAM 排序压缩、FASTQ 质控过滤、批量 GFF 处理等场景。

实测环境:Debian 12,Bash 5.2.32。

1. 三兄弟:stdin、stdout、stderr#

每个 Linux 进程生来就有三个文件描述符:

描述符名称编号默认指向生信用途
stdin标准输入0键盘管道传入的数据
stdout标准输出1屏幕正常结果(FASTQ、BAM、TSV)
stderr标准错误2屏幕日志、警告、进度信息

理解了这个,重定向语法就一通百通:

Terminal window
# 基础重定向
command > file # stdout → file(覆盖)
command >> file # stdout → file(追加)
command < file # file → stdin
command 2> file # stderr → file
command 2>&1 # stderr → stdout(合并到同一个流)
command &> file # stdout + stderr → file(Bash 4.0+)
# 生信中最常见的模式
samtools view -bS aligned.sam > aligned.bam 2> samtools.log
# stdout(BAM 内容)→ aligned.bam
# stderr(日志)→ samtools.log

1.1 进程视角的公式#

如果用数学表达一个进程的 I/O 模型:

Process:(fd0,fd1,fd2)transformoutput\text{Process}: (fd_0, fd_1, fd_2) \xrightarrow{\text{transform}} \text{output}

Shell 的 >< 就是重新绑定 fd1fd_1fd0fd_0 的目的地:

fd1fileinstead of terminalfd_1 \leftarrow \text{file} \quad \text{instead of terminal}

2. 管道——生信流程的骨架#

2.1 管道的基础模型#

Terminal window
# 管道连接 stdout → stdin
samtools view aligned.bam | head -100
# 等价于:
# samtools view 的 stdout → head 的 stdin

管道的理论基础是生产者-消费者模型

Pthroughput=min(producer_rate,consumer_rate)P_{throughput} = \min(\text{producer\_rate}, \text{consumer\_rate})

如果前一步(producer)快、后一步(consumer)慢,管道会自动缓冲(默认 64KB)。如果前一步慢、后一步快,消费者会阻塞等待。管道的速度由最慢的步骤决定。

2.2 生信管道实战场景#

Terminal window
# 场景1:FASTQ 质控 + 比对一气呵成
fastp -i R1.fastq.gz -I R2.fastq.gz --stdout 2> fastp.log \
| bwa mem -t 8 -p /opt/refs/hg38.fa - 2> bwa.log \
| samtools sort -@ 4 -o aligned.sorted.bam - 2> sort.log
# 这里用了三个技巧:
# 1. fastp --stdout:输出到 stdout 而非文件
# 2. bwa mem 用 - 表示从 stdin 读取
# 3. samtools sort 也用 - 从 stdin 读
# 场景2:统计 BAM 中每条染色体的 reads 数
samtools idxstats aligned.bam \
| awk '$3 > 0' \
| sort -k3 -nr \
| head -10
# 场景3:从 VCF 中提取高质量 SNP 并统计
bcftools view -i 'QUAL>30 && TYPE="snp"' variants.vcf.gz \
| bcftools query -f '%CHROM\t%POS\t%REF\t%ALT\n' \
| wc -l

3. tee——数据流的分叉器#

tee 把 stdin 复制两份:一份继续走管道,一份写入文件。比喻就是河流分叉:

Terminal window
# 在管道中间"偷看"数据,同时保存
samtools view aligned.bam \
| tee intermediate.sam \
| awk '{print $3}' \
| sort | uniq -c | sort -rn \
| tee chr_stats.txt \
| head -10
# 第一条 tee:把 SAM 内容保存到文件,同时继续传给 awk
# 第二条 tee:把统计结果保存,同时传给 head 显示

3.1 同时输出到 stdout 和 stderr#

Terminal window
# 用进程替换,tee 把日志同时打到终端和文件
samtools view -bS aligned.sam 2>&1 | tee samtools.log
# 2>&1 先把 stderr 合并到 stdout,tee 再复制

3.2 多个文件 + 管道#

Terminal window
# 同时写入多个文件(生信:备份 + 继续处理)
cat sample_list.txt \
| tee >(grep "^S[0-9]" > samples.txt) \
| tee >(grep "^C[0-9]" > controls.txt) \
> all.txt
# >(...) 是进程替换——把命令当作"文件"

4. 进程替换——最被低估的 Bash 特性#

4.1 本质#

进程替换 <(command)>(command) 创建一个匿名管道(named pipe 的一种),把命令的 stdout/stdin 伪装成文件路径:

Terminal window
# 不用临时文件直接比较两个命令的输出
diff <(cut -f1 file1.txt | sort) <(cut -f1 file2.txt | sort)
# 等价但无需中间文件:
# cut -f1 file1.txt | sort > /tmp/file1_sorted
# cut -f1 file2.txt | sort > /tmp/file2_sorted
# diff /tmp/file1_sorted /tmp/file2_sorted
# rm /tmp/file1_sorted /tmp/file2_sorted

4.2 生信场景#

Terminal window
# 场景1:比较两个样本的基因列表(不生成中间文件)
comm -12 \
<(awk '$8>0 {print $1}' sample1_counts.tsv | sort) \
<(awk '$8>0 {print $1}' sample2_counts.tsv | sort) \
> common_genes.txt
# 场景2:快速检查两个 BAM 文件的 header 差异
diff <(samtools view -H sample1.bam) <(samtools view -H sample2.bam)
# 场景3:GFF 和 BED 坐标交集(不需要中间文件)
bedtools intersect \
-a <(awk '$3=="gene"' annotation.gff3 | gff2bed) \
-b <(awk '$5>10' peaks.bed) \
> overlapping_genes.bed
# 场景4:把 stderr 也送入管道处理
samtools flagstat aligned.bam 2> >(grep "mapped" > mapping_stats.txt)

5. 命名管道——跨进程通信#

5.1 创建和使用#

Terminal window
# 创建命名管道(FIFO)
mkfifo my_pipe
# 终端1:写入端
gzip -c huge.fastq > my_pipe
# 终端2:读取端(另一个终端/进程)
bwa mem ref.fa my_pipe > aligned.sam
# 清理
rm my_pipe

5.2 生信实战——并行解压与比对#

#!/bin/bash
# 并行解压 + 比对,避免磁盘 I/O 瓶颈
PIPE=$(mktemp -u)
mkfifo "${PIPE}"
# 后台:解压 FASTQ 到命名管道
gzip -dc sample.fastq.gz > "${PIPE}" &
# 前台:从管道读取并用 bwa 比对
bwa mem -t 16 /opt/refs/hg38.fa "${PIPE}" \
| samtools sort -@ 8 -o aligned.sorted.bam -
rm "${PIPE}"
# 优势:解压和比对并行,避免把解压后的几十 GB 数据写入磁盘

对于磁盘 I/O 瓶颈的场景,命名管道能显著提速。时间模型:

Tpipe=max(Tdecompress,Talign)T_{pipe} = \max(T_{decompress}, T_{align})

Tdisk=Tdecompress_to_disk+Tread_from_disk+TalignT_{disk} = T_{decompress\_to\_disk} + T_{read\_from\_disk} + T_{align}

当解压出的数据量很大时,TpipeTdiskT_{pipe} \ll T_{disk}

6. xargs——跨越管道的限制#

管道的核心限制:每个 | 右边只能是一个命令的 stdin,不能是命令参数。xargs 就是打破这个限制的工具。

6.1 基础用法#

Terminal window
# 问题:管道只能传 stdin,不能传参数
# 下面的写法是错的:
echo "aligned.bam" | samtools view -h # 错误!samtools view 需要参数不是 stdin
# 正确:用 xargs 把 stdin 变成参数
echo "aligned.bam" | xargs samtools view -h | head -20
# xargs 的核心:把 stdin 的每一行变成后面命令的参数

6.2 并行 xargs——生信批量处理加速#

Terminal window
# 对所有 BAM 文件并行生成索引(-P 指定并行数)
find . -name "*.bam" | xargs -P 8 -I {} samtools index {}
# -P 8 → 同时运行 8 个进程
# -I {} → {} 是占位符,代表每个输入行

对于独立任务(每个 BAM 文件互不影响),加速比近似为:

S(n)=TserialTparalleln1+α(n1)S(n) = \frac{T_{serial}}{T_{parallel}} \approx \frac{n}{1 + \alpha(n - 1)}

其中 nn 是并行数,α\alpha 是无法并行的比例(I/O 等待通常占 10-20%)。

6.3 生信批量处理模板#

Terminal window
# 1. 批量质控
ls *.fastq.gz | xargs -P 4 -I {} fastqc {} -o qc_results/
# 2. 批量比对
ls *_R1.fastq.gz | sed 's/_R1.fastq.gz//' | xargs -P 8 -I {} sh -c \
'bwa mem -t 2 /opt/refs/hg38.fa {}_R1.fastq.gz {}_R2.fastq.gz | samtools sort -@ 2 -o aligned/{}_sorted.bam -'
# 3. 批量统计
ls *.bam | xargs -P 8 -I {} sh -c 'samtools flagstat {} > stats/{}.flagstat'

6.4 处理文件名中的空格#

Terminal window
# 文件名含空格时,用 -0 配合 find 的 -print0
find . -name "*.bam" -print0 | xargs -0 -P 4 -I {} samtools index {}
# -print0:用 \0 分隔(而不是换行)
# -0:xargs 以 \0 作为分隔符

7. 高级重定向技巧#

7.1 交换 stdout 和 stderr#

Terminal window
# 3>&2 2>&1 1>&3- 这个咒语的拆解
# 场景:想让 samtools 的日志走管道,但 BAM 数据写入文件
exec 3>&1 # 备份原始 stdout 到 fd3
samtools view -bS aligned.sam 2>&1 1>&3 | tee samtools.log
exec 3>&- # 关闭 fd3
# 解释:2>&1 让 stderr 合并到当前的 stdout(此时是终端)
# 1>&3 让 stdout 恢复到原始的 fd3→终端
# 结果:stderr → 管道 → tee,stdout → 文件(通过重定向)

7.2 Here Documents——内嵌数据#

Terminal window
# 把多行文本直接传给命令
bcftools view -i 'QUAL>30' << 'EOF'
##fileformat=VCFv4.2
#CHROM POS ID REF ALT QUAL FILTER INFO
chr1 12345 . A G 45.2 PASS .
chr1 23456 . C T 12.1 PASS .
EOF
# 在脚本里内嵌 GTF 注解的某几行
grep "BRCA1" << 'END_GTF' | cut -f1,4,5
chr17 41196312 41197819 BRCA1
chr17 41199659 41199719 BRCA1
END_GTF

7.3 Here Strings——单行快速输入#

Terminal window
# <<< 把字符串当 stdin
bcftools view -h variants.vcf.gz | grep "fileDate" <<< ""
# 或
grep "SRR12345678" <<< "$(cat sample_list.txt)"
# 用 bc 做简单计算
bc <<< "scale=4; 35000000 / 1000000"
# 输出:35.0000

8. 踩坑记录#

坑1:管道里的 set -e 不生效。 set -e 只看管道最后一个命令的退出码。如果 false | true,整个管道返回 0(因为 true 成功了)。解决方案:set -o pipefail 让任何一步失败都导致管道失败。

坑2:大管道缓冲溢出。 管道缓冲区默认 64KB(Linux),如果前一步输出快、后一步处理慢,超出缓冲区后前一步会阻塞。在 SAM→BAM 这种场景不明显,但在超大数据流(如全基因组测序 raw data)中可能成为瓶颈。可以用 pv 监控管道流量:cmd1 | pv | cmd2

坑3:xargs 遇到空输入时默认仍执行命令。 echo "" | xargs rm 会执行 rm(无参数,可能报错)。用 xargs -r--no-run-if-empty)防止空输入时执行。

坑4:命名管道没有读取端时写入端会阻塞。 mkfifo mypipe && gzip -c data > mypipe 如果没有另一个进程在读 mypipe,这个命令会永久挂起。务必先准备好读取端,或在写入端设置超时。

坑5:进程替换 <(...) 的退出码无法直接获取。 diff <(cmd1) <(cmd2)cmd1cmd2 失败了你不知道。Bash 4.4+ 可以用 wait 或临时文件来获取退出码。

坑6:2>&11>&2 的区别。 2>&1 是把 stderr 重定向到 stdout 当前指向的地方(注意”当前”两个字——在重定向链中顺序至关重要)。command 2>&1 > file 是错的:先把 stderr 指向当前 stdout(终端),再把 stdout 指向 file。正确是 command > file 2>&1

坑7:xargs 中复合命令的引号嵌套地狱。 xargs -I {} sh -c 'command "{}"' 里引号嵌套很容易出错。改用 xargs -I {} bash -c "command '{}'" 或直接写成独立脚本。生信批量处理推荐写成独立脚本然后用 xargs 调用。


本文于 2025-11-22 在 Debian 12 上实测。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

管道与重定向进阶:stdin/stdout/stderr/文件描述符
https://fg.ink/posts/pipe-redirection-advanced/
作者
风观
发布于
2024-06-01
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
风观
风有来路,观有所思
分类
标签
站点统计
文章
50
分类
1
标签
29
总字数
61,837
运行时长
0
最后活动
0 天前

文章目录