0Pricing
Linux Command Line & Bash Scripting Mastery · 课时

分析脚本性能并避免无用的子 Shell

测量脚本耗时,并用内置替代方案取代类似 cat-grep 链的高频派生进程模式。

分析脚本性能并避免无用的子 Shell 是 CoddyKit 上的免费 Linux Command Line & Bash Scripting Mastery 课时。 这是第 1 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 Linux Command Line & Bash Scripting Mastery 学习路径的一部分,你的进度在网页和 CoddyKit 应用中同步。 Linux Command Line & Bash Scripting Mastery 课程共包含 4 节课。

脚本性能为何重要

运行缓慢的 Bash 脚本会浪费持续集成时间、阻塞定时任务,并让用户感到烦恼。大多数性能缓慢并非源于复杂逻辑,而是源于不必要的进程派生:您调用的每个外部命令都会启动一个新的子进程。

在本课中,您将学习如何:

  • 使用 time 和 bash -x 测量时间究竟花在哪里
  • 识别诸如无谓地使用 cat之类的高派生进程反模式
  • 用更快的 shell 内置命令替代外部命令
  • 有意识地使用子 shell,并在它们没有带来价值时避免使用

目标是用更少的子进程和更短的实际耗时,完成相同的工作。

使用 time 内置命令为脚本计时

最简单的性能分析工具是 shell 内置命令 time。在任意命令或管道前加上它,即可获得三项测量结果:

  • real——实际经过的挂钟时间(您真正等待的时间)
  • user——在用户空间代码中消耗的 CPU 时间
  • sys——在内核中消耗的 CPU 时间(系统调用、输入输出)

real 与 user+sys 之间存在较大差距,通常意味着脚本正在等待输入输出,或正在派生许多子进程。先在整个脚本外层运行 time,确认确实存在问题后再进行优化。

#!/usr/bin/env bash
# Time a whole script block
time {
  for i in $(seq 1 1000); do
    echo "line $i"
  done | grep -c "5"
}
# Output example:
# 271
# real  0m0.045s
# user  0m0.038s
# sys   0m0.012s

使用 bash -x 和 PS4 跟踪执行

bash -x 会在每条命令运行前打印该命令,这就是执行跟踪。它可以显示哪些行执行得最频繁,以及调用外部程序的次数是否超出预期。

默认情况下,每条被跟踪的行前面都会加上 +。您可以使用 PS4 丰富此前缀,加入时间戳,从而将跟踪变成轻量级性能分析工具:

  • PS4 会在每条被跟踪的命令执行前展开
  • 加入 $EPOCHREALTIME(bash 5+)或 $(date +%s%N) 可获得纳秒级精度
  • 将标准错误重定向到文件,然后对其进行后处理,以找出缓慢的部分
#!/usr/bin/env bash
# Run with:  bash -x ./myscript.sh  2>trace.log
# Or embed tracing inside the script:
export PS4='+ [${EPOCHREALTIME}] ${BASH_SOURCE}:${LINENO}: '
set -x

slow_function() {
  local result
  result=$(cat /etc/hostname)   # fork — slow
  echo "host: $result"
}

slow_function
set +x

# trace.log now contains timestamps so you can diff
# adjacent lines to find which step took longest.

什么是无谓的子 shell

子 shell是当前 shell 进程的一个子副本。以下操作会创建子 shell:

  • 命令替换:$(command)
  • 使用括号分组:( commands )
  • 将内容通过管道传入 shell 结构:cmd | while read ...

当您确实需要隔离环境或管道时,子 shell 是必要的。但如果只是为了调用 shell 本身能够处理的外部程序,或无缘无故地给内置命令套上额外的派生层,它们就变得没有必要。

在现代 Linux 系统中,每次派生子 shell 的开销约为 1–5 毫秒。在运行 10,000 次的循环中,1000 个无谓的子 shell 会增加 1–5 秒的纯额外开销。

经典反模式:无谓地使用 cat

cat file | grep pattern 是最著名的高派生进程反模式。它会启动两个进程(cat 和 grep),并通过管道连接它们;实际上,单独使用 grep 就可以直接读取文件。

修复方法很简单:直接将文件名传给能够处理文件的命令。如果工具不接受文件名,这称为输入重定向;如果接受,则只需省略 cat。

  • 慢:cat file | grep pattern——2 个进程,1 个管道
  • 快:grep pattern file——1 个进程,无管道
  • 同样快:grep pattern < file——1 个进程,标准输入重定向(无管道缓冲区)
#!/usr/bin/env bash
# Create a sample file
seq 1 10000 > /tmp/numbers.txt

# --- Slow: useless cat ---
time cat /tmp/numbers.txt | grep -c "^5"

# --- Fast: grep reads the file directly ---
time grep -c "^5" /tmp/numbers.txt

# Both print the same count; the second is measurably faster
# because it skips the cat process and the inter-process pipe.

用 shell 内置命令替代外部命令

许多单行转换都有对应的内置写法,可以完全避免进程派生。请比较以下常见替代方式:

  • 使用 echo ${#var} 代替 echo "$var" | wc -c——获取字符串长度
  • 使用 ${var^^} 和 ${var,,} 代替 echo "$var" | tr 'a-z' 'A-Z'——转换大小写(bash 4+)
  • 使用 ${var//search/replace} 代替 echo "$var" | sed 's/search/replace/'——进行简单替换
  • 使用 [[ "$var" =~ pattern ]] 代替 echo "$var" | grep -q pattern——进行正则匹配
  • 使用 read -r line < file 代替 line=$(head -n1 file)——读取第一行

这些内置命令都不会派生子进程。每次调用节省的时间很少,但在循环中会产生显著的累积效果。

#!/usr/bin/env bash
sentence="hello world from bash"

# --- Fork-heavy ---
upper_slow=$(echo "$sentence" | tr 'a-z' 'A-Z')
length_slow=$(echo "$sentence" | wc -c)

# --- Builtin equivalents (zero extra processes) ---
upper_fast=${sentence^^}
length_fast=${#sentence}

echo "Slow upper : $upper_slow"
echo "Fast upper : $upper_fast"
echo "Slow length: $length_slow"
echo "Fast length: $length_fast"

避免在循环中使用子 shell

循环中的命令替换会按照迭代次数放大进程派生开销。一个运行 500 次、其中包含一次 $(date) 调用的循环,仅为获取时间戳就会启动 500 个子进程。

降低循环开销的策略:

  • 将不变量命令移到循环外部(计算一次,重复使用)
  • 优先使用算术展开 $(( expr ))——它是内置功能,不会派生进程
  • 只需要格式化时,使用 printf,不要调用 date
  • 批量执行外部调用:先收集数据,再在循环外一次性处理
#!/usr/bin/env bash
# Demonstrate: compute-once vs fork-per-iteration

# Bad: $(date) forks 1000 times
time (
  for i in $(seq 1 1000); do
    ts=$(date +%s)   # fork each iteration
    echo "$i $ts" > /dev/null
  done
)

# Good: capture once, reuse
time (
  ts=$(date +%s)   # fork exactly once
  for i in $(seq 1 1000); do
    echo "$i $ts" > /dev/null
  done
)

管道子 shell 与变量作用域陷阱

在 bash 中(不同于 ksh/zsh),管道中的每条命令都会在自己的子 shell 中运行。这意味着在管道内部设置的变量会在管道结束后丢失。

这既是正确性问题,也是性能问题——您可能会通过管道传给 while read,以为可以收集数据,却发现之后变量为空。

有两种解决方案:

  • 使用进程替换 while read line; do ...; done < <(command)——while 循环在当前 shell 中运行,而不是子 shell
  • 使用 lastpipe 选项(shopt -s lastpipe)——让管道的最后一段在当前 shell 中运行(bash 4.2+)
#!/usr/bin/env bash
count=0

# --- Bug: count is always 0 after pipe (subshell) ---
seq 1 5 | while read -r n; do
  (( count++ ))
done
echo "After pipe   : count=$count"   # prints 0

# --- Fix 1: process substitution (no subshell for while) ---
count=0
while read -r n; do
  (( count++ ))
done < <(seq 1 5)
echo "Process sub  : count=$count"   # prints 5

# --- Fix 2: lastpipe option ---
shopt -s lastpipe
count=0
seq 1 5 | while read -r n; do
  (( count++ ))
done
echo "lastpipe     : count=$count"   # prints 5

使用微基准测试量化子 shell 开销

通过一个小型基准测试,很容易证明子 shell 的额外开销。请比较使用 $(( ))(内置功能)执行算术运算,与将相同运算通过管道传给 expr(外部进程)的结果。

在典型的 Linux 计算机上,调用 expr 10,000 次大约需要 5 秒,而调用相同次数的 $(( )) 不到 0.1 秒——对于相同的输出,两者相差约 50 倍。

当您想要测量任何优化的效果时,这种基准测试模式同样有用:在循环中运行两个版本 N 次,然后使用 time 进行比较。

#!/usr/bin/env bash
N=500

# External command (fork per call)
time (
  x=0
  for ((i=0; i<N; i++)); do
    x=$(expr $x + 1)   # forks expr each time
  done
  echo "expr result: $x"
)

# Arithmetic builtin (no fork)
time (
  x=0
  for ((i=0; i<N; i++)); do
    (( x++ ))          # pure builtin
  done
  echo "builtin result: $x"
)

使用 here-string 避免 echo 管道

一种常见模式是使用 echo "$var" | command 将变量作为标准输入传给命令。这会派生两个进程(echo 和 command)并创建一个管道。使用 here-string(<<<)可以达到相同效果,同时只需一个进程——外部命令从内核管理的临时缓冲区读取数据。

  • grep pattern <<< "$var"——一个进程,无管道
  • read -r field1 field2 <<< "$line"——无需外部工具即可拆分变量
  • wc -w <<< "$sentence"——统计变量中的单词数

here-string 在每次进程派生都很重要的紧凑循环中尤其有价值。

#!/usr/bin/env bash
data="The quick brown fox"

# --- Fork-heavy: echo spawns a child ---
word_count_slow=$(echo "$data" | wc -w)
echo "Slow word count: $word_count_slow"

# --- Fast: here-string, only wc spawns ---
word_count_fast=$(wc -w <<< "$data")
echo "Fast word count: $word_count_fast"

# --- Even better: use parameter expansion (zero forks) ---
# Split into array, count elements
read -ra words <<< "$data"
echo "Zero-fork count: ${#words[@]}"

实用重构:前后对比

下面将分析一个处理日志文件的真实脚本,并应用所学的全部内容。原始版本通过管道串联 cat、grep、awk 和 tr。重构后,进程数从 8 个减少到 2 个。

所做的主要更改:

  • 移除 cat——由 grep 直接读取文件
  • 使用 ${var^^} 替代 tr '[:lower:]' '[:upper:]'
  • 使用 [[ $line =~ ]] 替代 echo "$line" | grep -q
  • 使用带进程替换的 read -r,替代通过管道连接的 while 循环

完成重构后,再次运行 time ./script.sh,确认性能有所改善。请始终进行测量,不要凭猜测判断。

#!/usr/bin/env bash
# Create a sample log
printf 'ERROR: disk full\nINFO: started\nERROR: timeout\nINFO: done\n' \
  > /tmp/sample.log

# === BEFORE (fork-heavy) ===
time (
  cat /tmp/sample.log \
    | grep 'ERROR' \
    | while read -r line; do
        label=$(echo "$line" | tr '[:lower:]' '[:upper:]')
        echo "[ALERT] $label"
      done
)

# === AFTER (builtin-first) ===
time (
  while IFS= read -r line; do
    echo "[ALERT] ${line^^}"
  done < <(grep 'ERROR' /tmp/sample.log)
)

知识检查:子Shell作用域

测试您对管道子Shell的理解,以及如何避免丢失在管道内部进行的变量更改。

课程回顾:先分析性能,少创建进程

在本课中,您学习了如何识别并消除 Bash 脚本中最常见的不必要进程创建来源。

要点:

  • 使用 time 和添加了 PS4 信息的 bash -x,在优化前先进行测量
  • 无用的 cat 是最普遍的反模式——将文件名直接传递给能够接受文件名的命令
  • 将 echo "$var" | command 替换为 here-string(command <<< "$var")或内置命令
  • 参数展开(${var^^}、${var//s/r}、${#var})可以替代许多 tr、sed 和 wc 调用
  • 管道子Shell会吞掉变量变更——请使用进程替换或 shopt -s lastpipe
  • 将不变的命令调用移到循环外;相比 expr,优先使用 $(( )) 算术运算

经验法则是:先测量,在可能的情况下用内置命令替换外部命令,然后通过第二次测量验证改进效果。

常见问题解答

「分析脚本性能并避免无用的子 Shell」课时是免费的吗?

是的 — 「分析脚本性能并避免无用的子 Shell」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 Linux Command Line & Bash Scripting Mastery 课程的其余内容,请升级到 CoddyKit PRO。 Linux Command Line & Bash Scripting Mastery 课程共包含 4 节课。

「分析脚本性能并避免无用的子 Shell」这节课中我会学到什么?

测量脚本耗时,并用内置替代方案取代类似 cat-grep 链的高频派生进程模式。 你通过在浏览器中直接运行的动手代码来练习 Linux Command Line & Bash Scripting Mastery,全天候 AI 导师会在你学习这节课的过程中回答你的问题。

学习 Linux Command Line & Bash Scripting Mastery 需要有经验吗?

无需任何先前经验。CoddyKit 上的 Linux Command Line & Bash Scripting Mastery 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 1 节课,共 4 节。

「分析脚本性能并避免无用的子 Shell」课时需要多长时间?

大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。

我能在这节 Linux Command Line & Bash Scripting Mastery 课中编写并运行代码吗?

能。每节 Linux Command Line & Bash Scripting Mastery 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。

此课程中的所有课时

  1. 分析脚本性能并避免无用的子 Shell
  2. 使用 xargs -P 与后台任务实现并行
  3. 使用 GNU parallel 编排工作负载
  4. 流式管道与命名管道吞吐
← 返回 Linux Command Line & Bash Scripting Mastery