Shell 不是编程语言,是一种"粘合剂
这是 Linux 系列的第 4 篇。前一篇讲了"一切皆文件"——这一篇讲建立在它之上的另一条同样核心的哲学:用管道把小工具串起来。
0. 一个真实的需求
假设你拿到一份网站访问日志(10 万行),你想知道:
"今天哪 5 个 IP 访问最多?"
Python 写出来大概是:
from collections import Counter
ips = Counter()
with open('access.log') as f:
for line in f:
ip = line.split()[0]
ips[ip] += 1
for ip, n in ips.most_common(5):
print(n, ip)
10 行,要 import、要写循环、要排序。
Shell 写出来:
$ awk '{print $1}' access.log | sort | uniq -c | sort -rn | head -5
一行。而且四个工具加起来不到 200KB,跑 10 万行毫秒级。
这就是 shell 的本意——不是用来"写程序",是用来调动一支早就训练好的部队。
1. Unix 哲学的 4 句话
Doug McIlroy(管道发明人)1978 年写的:
- Make each program do one thing well. (让每个程序做一件事,做对)
- Expect the output of every program to become the input to another, as yet unknown, program. (预期每个程序的输出都将成为另一个未知程序的输入)
- Design and build software, even operating systems, to be tried early. (早试错,包括操作系统)
- Use tools in preference to unskilled help to lighten a programming task. (优先用工具而不是手写代码)
第 1 条解释了为什么 Linux 里有 200 多个看起来"功能很少"的命令:cut 只切列、sort 只排序、uniq 只去重——每个都是单功能,但都做到极致。
第 2 条是设计灵魂——所有工具默认从 stdin 读、向 stdout 写,这样它们才能用管道串起来。
2. | 这一个字符的内涵
| 是管道(pipe)。它做了一件特别简单的事:
把左边命令的 stdout 接到右边命令的 stdin。
机制上:
- shell 创建一个匿名管道(内核里一段 64KB 左右的环形缓冲)
- 左边进程的 fd 1(stdout)被重定向到管道的写端
- 右边进程的 fd 0(stdin)被重定向到管道的读端
- 两个进程同时跑,左边写、右边读,并发流式
关键点是 "同时跑" 和 "流式"——不是 A 跑完再给 B,而是边产生边消费。所以:
$ grep "ERROR" huge.log | head -10
不是先读完整个 huge.log 再 grep——是只读到出现 10 个 ERROR 就停。这种 lazy 流式行为是管道的杀手锏。
3. 一个小工具花园
学 shell 不是学 200 个命令,是认识这十几个最高频的"积木":
取数据的
| 工具 | 一句话 |
|---|---|
cat |
把文件吐到 stdout |
head -n N |
取前 N 行 |
tail -n N |
取后 N 行 |
tail -f |
实时跟踪日志 |
选数据的
| 工具 | 一句话 |
|---|---|
grep <pattern> |
按行筛选(正则) |
cut -f N -d X |
按列切(分隔符 X 的第 N 列) |
awk '{print $N}' |
按列切 + 简易计算 |
sed 's/A/B/g' |
按行替换 |
整理数据的
| 工具 | 一句话 |
|---|---|
sort |
排序 |
sort -n |
按数字排序 |
sort -rn |
倒序按数字 |
uniq -c |
去重并计数(前置 sort 是必须的) |
wc -l |
数行数 |
控制流的
| 工具 | 一句话 |
|---|---|
xargs |
把上游 stdin 当参数喂给下游 |
tee |
一份输出同时写文件和 stdout |
tr 'A' 'B' |
字符替换 |
10 个左右把这些组合起来——你能解决 90% 的"我要处理这堆文本"的问题。
4. 实战 5 例:组合的威力
例 1:日志里访问最多的 IP
awk '{print $1}' access.log | sort | uniq -c | sort -rn | head -5
四个工具:取列 → 排序 → 去重计数 → 倒序排 → 取前 5。比写循环快 5 倍。
例 2:当前最吃内存的 5 个进程
ps aux --sort=-%mem | head -6
或纯管道版:
ps aux | sort -k4 -rn | head -6
例 3:找占空间最大的目录
du -h --max-depth=1 /var | sort -hr | head -10
-h 是人类可读单位(K/M/G),sort -h 知道怎么按这种单位排。
例 4:当前 TCP 连接的状态统计
ss -tan | awk 'NR>1 {print $1}' | sort | uniq -c
输出大概:
6 ESTAB
12 LISTEN
2 TIME-WAIT
例 5:批量改一组文件名
ls *.JPG | sed 's/\.JPG$/\.jpg/' | xargs -n2 mv
# 或者更直观
for f in *.JPG; do mv "$f" "${f%.JPG}.jpg"; done
5. 一个反例:什么时候不该用 shell
shell 的强项是编排,弱项是算法。当出现这些信号时,立刻切 Python / Go:
- ❌ 需要嵌套数据结构(哈希里套数组)
- ❌ 需要 HTTP 客户端、JSON 解析、数据库连接(用
jq应付简单 JSON 可以,复杂的就别折磨自己) - ❌ 需要捕获多个异常分别处理
- ❌ 性能要求亚毫秒(shell 进程启动有几毫秒开销)
- ❌ 脚本超过 100 行还在长
经验法则:
如果一段 shell 脚本里出现了
if [ ... ]; then for ...; do if ... ; done; done,立刻 Ctrl+C,打开 Python 重写。
shell 的甜点是 5-20 行——再长就该升级。
6. 一个进阶技巧:process substitution
普通管道只能"一条流入一条出"。当你需要同时比较两个流时,<(cmd) 这个语法让你把命令的输出当作"文件"用:
# 对比两个目录的内容(不用先存到文件再 diff)
$ diff <(ls dir1) <(ls dir2)
# 对比远端文件和本地文件
$ diff <(ssh server cat /etc/nginx/nginx.conf) /etc/nginx/nginx.conf
<(cmd) 背后是个匿名命名管道——还是文件接口(呼应上一篇的"一切皆文件")。
7. 关于 |、&&、; 的区别(新手最容易混)
| 运算符 | 含义 |
|---|---|
a | b |
a 的输出喂给 b 的输入,两边同时跑 |
a && b |
a 成功了再跑 b(返回码 0 = 成功) |
a || b |
a 失败了才跑 b(fallback) |
a ; b |
a 跑完不管成不成都跑 b |
a & b |
a 后台跑,立刻接着跑 b |
混在一起的实战:
# 先 cd,cd 成功才 ls
$ cd /var/log && ls
# 编译,失败就发钉钉
$ make build || curl -X POST ...dingtalk-webhook...
# 跑测试 + 部署 + 记日志一气呵成
$ pytest && ./deploy.sh && date >> deploy.log
8. 现在做一件事
打开你机器的一份"有内容的文本文件"——可以是:
- Mac:
/var/log/system.log或某个项目的 git log - Linux:
/var/log/syslog或journalctl -n 1000的输出
跑下面这串,分析一下出现最多的关键词:
# 这一串干了什么?
$ tail -1000 /var/log/syslog \
| awk '{print $5}' \
| sort \
| uniq -c \
| sort -rn \
| head -10
读一遍每一步——tail 取最后 1000 行,awk 切第 5 列,sort 排序为 uniq 做准备,uniq -c 计数,再次 sort -rn 按计数倒序,head -10 取前 10。
你读懂了,你就有 shell 思维了。
下一篇:5 分钟逛遍 Linux 根目录——
/etc//var//usr//opt//proc//sys这些目录到底各管什么,装一个新软件文件会撒到哪里。