1. 启动开销与解析成本
一句话总结: Bash 每次启动要读配置、解析脚本;脚本越小、配置越精简,启动越快;高频短脚本尤其要减少初始化负担。
每次执行 bash script.sh 都是一次完整的进程启动:读取 ~/.bashrc(若 login shell)、解析整个脚本、逐个执行命令。对长脚本这部分占比不大,对高频小脚本则是主要成本。
# 计时一个空脚本的启动成本
time bash -c ':' # 约 5~20ms,含解释器启动
# 频繁被调用的入口函数避免每次都启动新 bash
source ./lib.sh # 当前 shell 内加载,无新进程
1.1 减少不必要的初始化
# 非交互脚本用 sh 或 dash 更轻
time dash -c ':'
# 不要在该用非交互时加载 bashrc
# 把公共函数放进独立 lib.sh,按需 source
if [[ -f ./lib.sh ]]; then
source ./lib.sh
fi
一句话总结: 短小高频的任务选更轻的解释器(dash 比 bash 快),公共代码抽成 lib 按需加载,别让启动初始化成为主开销。
1.2 解析大脚本的成本
# 语法解析成本随脚本行数上升
# 一次性解析 vs 多次调用:把逻辑合并进单个脚本
time bash -n big_script.sh # -n 只解析不执行,测解析耗时
2. 子进程是性能杀手
一句话总结: 每条外部命令都是一次 fork+exec,循环里密集调用外部命令最慢;能少 fork 就少 fork,能合并管道就合并。
# 慢:循环里每次 echo 都是外部命令? 不,echo 是内置;但 wc/cut 等是外部
# 对 1 万行文件逐行 wc 极慢
while read -r line; do
echo "${#line}" # 内置,快
done < big.txt
# 更慢的例子:循环里调用外部命令
while read -r ip; do
ping -c 1 "$ip" >/dev/null # 每次循环都 fork 一个 ping
done < ip_list.txt
2.1 fork 代价测量
# 对比内置与外部命令
time for i in {1..1000}; do :; done # 内置,毫秒级
time for i in {1..1000}; do /bin/true; done # 外部,显著更慢
# 减少 fork:能用 shell 语法就别起外部命令
# 慢:echo 接管道再 wc
lines=$(cat file.txt | wc -l)
# 快:纯内置
lines=$(wc -l < file.txt) # 一个外部命令
一句话总结: 循环体是 fork 重灾区:每轮迭代都起外部命令,1 万次就是 1 万次 fork。把过滤、计数、变换搬进循环内用内置语法,或整体交给单个工具处理。
2.2 循环外推与批处理
# 慢:循环内逐条 grep
while read -r f; do
grep -q error "$f" && echo "$f"
done < files.txt
# 快:一次 grep 全量处理
grep -l error $(cat files.txt)
# 慢:逐行 sed
while read -r l; do echo "$l" | sed 's/a/b/'; done < f.txt
# 快:整体管道
sed 's/a/b/' f.txt
3. 内置命令优先
一句话总结: Bash 内置了
echoread[[((:local等常用命令,零 fork 开销;能用内置就不调外部。
# 字符串操作用内置参数展开,而非外部命令
s="Hello World"
echo "${s//o/0}" # 内置替换
echo "${s,,}" # 内置转小写
# 而不是
echo "$s" | tr 'o' '0' # 外部命令
# 数值运算用 (( )) 或 $(( ))
echo $(( 3 * 4 )) # 内置
# 而不是
echo 3 \* 4 | bc # 外部
3.1 条件判断用 [[ ]] 而非外部
# [[ ]] 是关键字,比 [ (test 外部命令) 快且安全
if [[ "$a" == "$b" ]]; then :; fi
if (( a > b )); then :; fi
# 正则匹配内置
if [[ "$ip" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; then :; fi
# 避免外部 grep 判断
if grep -q pattern file; then :; fi # 外部
if [[ $(<file) == *pattern* ]]; then :; fi # 内置读文件
一句话总结:
[[ ]]比较、${var//pat/rep}替换、(( ))运算都是内置;用它们替代 test/grep/tr/bc,循环里能省下可观的时间。
3.2 内置命令清单
| 内置命令 | 用途 | 替代的外部命令 |
|---|---|---|
echo/printf | 输出 | /bin/echo |
read | 读行 | 无 |
[[ ]] | 比较/正则 | test、grep |
(( ))/$(( )) | 算术 | bc、expr |
${var#/%%//} | 字符串处理 | sed、tr |
: | 空操作/占位 | true |
source | 加载脚本 | 无(新 bash) |
4. 命令缓存与哈希
一句话总结: Bash 用 hash 表缓存命令的完整路径,首次查找后不再遍历 PATH;PATH 越长、目录越多,命中缓存越关键。
# 查看命令哈希缓存
hash
# 强制重新哈希(脚本改动了命令时)
hash -r
# 统计缓存
hash | wc -l
4.1 PATH 顺序与查找开销
# PATH 越短查找越快;把最常用的目录放前面
export PATH="/usr/local/bin:/usr/bin:/bin:$HOME/bin"
# 减少 PATH 中同义目录
# 慢:PATH 里有大量冗余目录
# 快:精简 PATH
一句话总结: 首次调用外部命令 Bash 会遍历 PATH 找到路径并缓存;把常用目录放前面、精简 PATH,首次查找也会更快。
4.2 缓存失效场景
# 脚本运行中安装/替换了命令,需 hash -r 刷新
apt install -y newtool
hash -r
newtool --version
# 或直接调用绝对路径绕开哈希
/usr/local/bin/newtool --version
5. 大文件流式处理
一句话总结: 大文件处理的关键是「流式」:一行一行读、不整块载入内存;awk/sed/管道天然流式,Bash 循环读文件也应按行消费。
# 流式:awk 逐行处理,内存恒定
awk '{sum += $2} END {print sum}' huge.csv
# 流式管道,不产生中间文件
grep ERROR app.log | awk '{print $1}' | sort | uniq -c
# 避免整读
# 慢:content=$(cat huge.log) 把全文件装进内存
# 快:while read 流式消费
while IFS= read -r line; do
((len += ${#line}))
done < huge.log
5.1 分块与并行加速
# xargs 分块并行处理大文件
cat huge.log | xargs -P 4 -n 1000 sh -c 'grep ERROR "$@"' _ | sort
# split 切分再并行汇总
split -l 100000 huge.log part_
grep -c ERROR part_* | awk -F: '{s += $2} END {print s}'
rm -f part_*
一句话总结: 大文件先「流式单机」处理,仍嫌慢再「split 切分 + xargs -P 并行」;awk 一句流式往往就够,别急着上并行。
5.2 避免重复扫描
# 一次 awk 完成多指标,避免对同一文件扫多遍
awk '{n++; bytes += $5; if ($9 ~ /^5/) err++}
END {printf "行数%d 字节%d 错误%d\n", n, bytes, err}' access.log
# 而不是
wc -l < access.log
awk '{s+=$5} END{print s}' access.log
awk '$9 ~ /^5/{c++} END{print c}' access.log
6. 循环与算法优化
一句话总结: 循环里少做重复工作:不变计算提出循环外、避免重复读取文件、用关联数组做查表替代重复 grep。
# 慢:每次迭代都读一次文件
while read -r k; do
grep "^$k" map.txt
done < keys.txt
# 快:把映射读进关联数组,循环内纯内存查找
declare -A map
while IFS= read -r k v; do map[$k]=$v; done < map.txt
while read -r k; do
echo "${map[$k]:-缺失}"
done < keys.txt
6.1 减少管道与中间进程
# 少一层管道就少一个进程
# 慢
cat file.txt | grep x | cut -d: -f1 | sort
# 快(等价)
cut -d: -f1 file.txt | grep x | sort
# 能用重定向避免的用重定向
# 慢:echo "$var" | grep pattern
# 快:[[ "$var" == *pattern* ]]
一句话总结: 管道每段都是一个子进程;把可省的
cat去掉、用grep ... file代替cat file | grep,长管道能砍掉一半进程。
6.2 提前返回与短路
# 条件短路:先做快判断,失败早退
if ! command -v jq >/dev/null; then
echo "缺少 jq"; exit 1
fi
# 函数内尽早 return
process() {
[[ -r "$1" ]] || return 1
# 后续才做重活
}
7. 基准与剖析
一句话总结: 优化前先测,
time量总耗时、date +%s%N量片段、set -x看执行流、bash -x数命令条数——先定位热点再优化。
# 总耗时
time ./script.sh
# 片段计时
start=$(date +%s%N)
# ... 被测片段 ...
end=$(date +%s%N)
echo "耗时 $(( (end - start) / 1000000 )) ms"
# 逐命令执行流
bash -x script.sh > trace.txt
7.1 用 trace 找热点
# 统计每条命令被执行的次数,定位循环热点
bash -x script.sh 2>&1 | sed 's/^+ //' | sort | uniq -c | sort -rn | head -20
# 只看执行的命令名(去参数)
bash -x script.sh 2>&1 | grep '^+' | awk '{print $2}' | sort | uniq -c | sort -rn
一句话总结:
bash -x输出每个 + 开头的命令行,按命令名聚合次数,循环里被调用上万次的外部命令立刻现形。
7.2 对照实验
# 同一任务两种写法计时对比
time bash -c 'while read -r l; do echo "${#l}"; done < big.txt'
time awk '{print length}' big.txt
# 多次取中位数,避免偶然抖动
for i in {1..5}; do
/usr/bin/time -f '%e s' ./script.sh 2>&1
done
8. 总结
| 环节 | 要点 |
|---|---|
| 启动开销 | 高频短脚本用轻解释器,公共代码按需 source |
| 子进程 | fork+exec 昂贵,循环内少调外部命令 |
| 内置优先 | [[ ]]、${//}、(( ))、read 零 fork |
| 命令缓存 | PATH 精简、hash 命中、装新命令后 hash -r |
| 流式处理 | 按行消费不整读,awk/sed 天然流式 |
| 循环优化 | 查表用关联数组、不变计算提出循环外、去多余管道 |
| 基准剖析 | time 总耗时、date +%s%N 片段、bash -x 定位热点 |
| 方法论 | 先测后优、对照实验、多次取中位数 |
性能优化的顺序永远是「先测后改、改一测一」:启动开销看解释器,循环性能看 fork 次数,大文件看是否流式。Shell 性能的本质是「少起进程、多用内置、保持流式」。本专题到此为止,把语法、文本、进程、健壮、工程、安全与性能串成完整链路。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。