一台服务器上有 40 万行访问日志,你想知道返回 500 的请求最多来自哪几个用户。写一个 Python 脚本要十几行;在 shell 里,一行 grep | sort | uniq -c | sort -rn | head 就够了。shell 的力量不在任何一个命令,而在于它把很多只做一件事的小程序组合起来,并且这种组合本身就是一门编程语言。
前置:无。下文所有输出来自 macOS 上的 zsh(另注明 bash 的除外)。
一条命令是怎么被执行的
在提示符后敲下 ls -l docs/,shell 先按空格把这一行拆成词:第一个词是要运行的程序,后面的词全部是参数,原样(都是字符串)交给程序。参数里本身有空格时要加引号 "my notes.txt" 或者转义 my\ notes.txt,否则会被拆成两个参数。
接下来 shell 要找到 ls 这个程序在哪。它查的是环境变量 PATH:一个用 : 分隔的目录列表,shell 从左到右逐个目录找,第一个命中就停。
$ echo $PATH | tr ':' '\n' | head -4
/Users/liangyue/.npm-global/bin
/opt/miniconda3/bin
/opt/miniconda3/condabin
/Users/liangyue/.local/bin
$ which -a python3 | head -3
/opt/miniconda3/bin/python3
/opt/homebrew/bin/python3
/Library/Frameworks/Python.framework/Versions/3.13/bin/python3这台机器上有三个 python3,敲 python3 跑的永远是排在 PATH 最前面目录里的那个。“我装了新版本为什么没生效”,十有八九是 PATH 顺序问题。虚拟环境的 activate 也只是把 venv/bin 插到 PATH 最前面,见 调试测试与工程实践。
不是所有命令都是独立程序:
$ type cd
cd is a shell builtin
$ type ls
ls is /bin/lscd 必须是 builtin,原因值得想一想。运行外部程序时,shell 会创建一个子进程去执行它。子进程能改的只是自己的工作目录,它退出后,父进程(shell)的工作目录纹丝不动。改当前目录、设变量、exit 这类要改 shell 自身状态的操作,只能在 shell 进程内部完成。“子进程改不了父进程的状态”会在后面反复出现。
查帮助:man ls 看完整手册,ls --help(GNU 工具)或 tldr ls(社区维护的常用例子)更快。
假设 cd 不是 builtin,而是一个独立程序 /usr/bin/cd。在 shell 里运行 cd /tmp 后会发生什么?
外部程序在子进程里运行,它把自己的工作目录改成 /tmp 后就退出了,父进程 shell 的工作目录不受影响。所以 cd 必须在 shell 内部执行。
这和权限无关;"切换成功只是慢"错在以为子进程能改父进程的状态;每个终端是独立的 shell 进程,更不会互相影响。
参数在程序看到之前就被改写了
程序拿到的参数,往往不是你敲的字面样子。shell 在运行程序之前会做几轮展开,最常用的是 glob(通配符):* 匹配任意多个字符,? 匹配一个字符。
$ ls
my notes.txt
report.txt
$ touch a1.txt a2.txt
$ printf '[%s]\n' a*.txt
[a1.txt]
[a2.txt]printf 收到的是两个已经展开好的文件名,它从头到尾没见过 *。所以 rm *.log 里删除什么是 shell 决定的,rm 只是照单执行。花括号展开更机械,不管文件存不存在都展开:echo {x,y}.log 输出 x.log y.log,cp config.{yml,yml.bak} 等于 cp config.yml config.yml.bak。
当 glob 一个文件也匹配不上时,bash 和 zsh 行为不同:
$ bash -c 'echo *.csv'
*.csv
$ zsh -c 'echo *.csv; echo after'
zsh:1: no matches found: *.csvbash 把没匹配上的 *.csv 原样传给程序;zsh 直接报错并中止这条命令(在脚本里,后面的 echo after 也没执行)。想把 * 作为字面字符传给程序,例如 find . -name '*.py',就必须加单引号,让 find 自己去匹配。
引号决定了展开发生多少:单引号里什么都不展开,双引号里展开变量但不做拆词和 glob。
$ f='my notes.txt'
$ echo "$f" '$f'
my notes.txt $f
$ bash -c 'f="my notes.txt"; ls $f'
ls: my: No such file or directory
ls: notes.txt: No such file or directory
$ bash -c 'f="my notes.txt"; ls "$f"'
my notes.txtbash 里不加引号的变量会按空格重新拆成多个参数,文件名带空格的脚本就是这样炸掉的。zsh 默认不拆,但写脚本时照样给变量加双引号,因为脚本常常被 bash 执行。
还有两个细节:flag 是以 - 开头的参数,短 flag 可以合并(ls -la);-- 表示”后面不再有 flag”,文件名以 - 开头时靠它:rm -- -oops。macOS 自带的是 BSD 版工具,很多 GNU 长选项(ls --all)和 sed -i 的写法都不同,sed -i '' 's/a/b/' f 才是 macOS 的原地替换。
流:把小程序接成流水线
每个进程启动时都有三个打开的”文件”:stdin(0 号)、stdout(1 号)、stderr(2 号)。默认它们都连着终端。管道 a | b 把 a 的 stdout 接到 b 的 stdin;重定向 > 把 stdout 接到文件(先清空),>> 追加,< 让文件充当 stdin。
回到开头的问题。手头有一份 40 行的访问日志:
$ head -3 access.log
2026-10-21T09:01:00 GET /api/item/1 user=alice status=200
2026-10-21T09:02:00 GET /api/item/2 user=alice status=200
2026-10-21T09:03:00 GET /api/item/3 user=bob status=200
$ wc -l < access.log
40一级一级往上搭,每加一级先看一眼中间结果:
$ grep 'status=500' access.log | wc -l
4
$ grep -o 'user=[a-z]*' access.log | sort | uniq -c | sort -rn | head -3
17 user=alice
12 user=bob
6 user=carol
$ awk '{print $NF}' access.log | sort | uniq -c
33 status=200
3 status=404
4 status=500grep -o 只输出匹配到的部分;uniq -c 只合并相邻的重复行,所以前面必须先 sort;sort -rn 按数字倒序;awk '{print $NF}' 取每行最后一列。每个程序都不知道别人的存在,它们之间唯一的契约是”一行一条记录的文本”。这就是 Unix 哲学的全部:程序只做一件事,用文本流连接。
管道里的程序是同时启动的,前一个边产出、后一个边消费,所以 cat huge.log | head -1 不必等整个文件读完。
stderr 不走管道。这是故意的:错误信息不该混进数据流被下游当成数据处理。
$ ls /nope | grep -c nope
ls: /nope: No such file or directory
0报错直接打到了屏幕上,grep 一行都没收到。要把 stderr 也重定向,就要理解 2>&1 的确切含义:“把 2 号指向 1 号此刻指向的地方”。它是一次赋值,不是永久绑定,所以顺序有影响:
$ ls /nope > a.txt 2>&1; cat a.txt
ls: /nope: No such file or directory
$ ls /nope 2>&1 > b.txt; wc -c < b.txt
ls: /nope: No such file or directory
0第一行先把 stdout 指向 a.txt,再让 stderr 跟过去,两者都进文件。第二行先让 stderr 指向 stdout 当时的目标(终端),之后才把 stdout 改到 b.txt,stderr 仍留在终端。丢弃全部输出的标准写法因此是 > /dev/null 2>&1;bash 和 zsh 也接受简写 &> file。tee 把输入同时写到屏幕和文件,适合”既要看又要留”的长任务:make 2>&1 | tee build.log。
cmd > out.txt 2>&1 和 cmd 2>&1 > out.txt 的区别是什么?
重定向从左到右执行,2>&1 是把 fd 2 复制成 fd 1 当时的目标。前者执行到 2>&1 时 fd 1 已指向文件;后者执行到 2>&1 时 fd 1 还是终端,之后 fd 1 再改到文件也不影响 fd 2。
顺序有关,所以"声明式"不对;两种写法都合法,后者只是常常不是你想要的效果。
退出码:shell 的布尔值
每个进程结束时交给父进程一个 0–255 的退出码,$? 读取上一条命令的值。0 表示成功,非 0 表示失败,具体数字由程序约定。
$ grep -q alice access.log; echo $?
0
$ grep -q zed access.log; echo $?
1
$ nosuchcmd 2>/dev/null; echo $?
127
$ grep -q zed access.log && echo found || echo "not found"
not found127 是 shell 约定的”命令不存在”。&& 在前一条成功时才执行后一条,|| 在失败时才执行。if、while 判断的也是退出码而不是什么布尔表达式,[ "$n" -gt 3 ] 其实是在运行一个叫 [(即 test)的命令。
把命令写进文件,第一行加 shebang #!/bin/bash 告诉内核用哪个解释器,chmod +x 之后就能 ./script.sh 直接运行。脚本的默认行为对错误非常宽容:某条命令失败了,脚本照样往下跑。于是有了几乎每个脚本都该加的一行:
#!/bin/bash
set -eu
grep foo missing.txt | sort
echo "still running"
set -o pipefail
grep foo missing.txt | sort
echo "never printed"$ bash p.sh; echo "exit=$?"
grep: missing.txt: No such file or directory
still running
grep: missing.txt: No such file or directory
exit=2-e 让失败的命令终止脚本,-u 让引用未定义变量报错。但第一个 pipeline 里 grep 失败了,脚本仍然打印了 still running:一条 pipeline 的退出码默认只看最后一个命令,sort 读到空输入也正常返回 0。打开 pipefail 后,任何一级失败都算整条失败,脚本带着 grep 的退出码 2 结束。完整的护栏是 set -euo pipefail。
脚本中途退出时,临时文件谁来删?trap ... EXIT 注册一段在脚本结束时一定执行的清理代码,正常结束、exit、set -e 触发的退出都算:
$ cat t.sh
#!/bin/bash
tmp=$(mktemp)
trap 'rm -f "$tmp"; echo "cleaned up"' EXIT
echo "working"
exit 3
$ bash t.sh; echo "exit=$?"
working
cleaned up
exit=3$(cmd) 是命令替换,把输出当字符串用。shell 脚本适合几十行的胶水代码;超过百行、需要数据结构或复杂错误处理时,换 Python。写 shell 时用 shellcheck 检查,它能抓出大部分漏引号之类的问题。
脚本开头只写了 set -eu。执行 grep foo missing.txt | sort 时 grep 因文件不存在而失败,接下来会怎样?
没有 pipefail 时,pipeline 的退出码只看最后一级,sort 返回 0,set -e 认为这条命令成功了。上面的实测里 still running 就是这样被打印出来的。
-u 只管变量是否定义;管道两端同时启动,grep 失败不会阻止 sort 运行。
环境变量:父进程传给子进程的配置
x=hello 创建的是 shell 变量,只活在当前 shell 里。子进程启动时继承的是另一张表,环境变量,要用 export 放进去:
$ x=hello; zsh -c 'echo "child sees: [$x]"'
child sees: []
$ export x; zsh -c 'echo "child sees: [$x]"'
child sees: [hello]
$ TZ=Asia/Tokyo date -r 0 '+%H:%M %Z'
09:00 JST
$ date -r 0 '+%H:%M %Z'
08:00 CSTVAR=value cmd 只给这一条命令设环境变量,命令结束后 shell 里的 TZ 不受影响。赋值的等号两边不能有空格,x = hello 会被当成”运行程序 x,参数是 = 和 hello”。
环境变量是程序读取配置的通用方式:PATH 决定找哪个程序,HOME 是家目录,CUDA_VISIBLE_DEVICES 决定 PyTorch 能看到哪几张 GPU,OMP_NUM_THREADS 限制线程数。Python 用 os.environ.get("DEBUG") 读取,读到的永远是字符串。printenv 列出当前环境,unset 删除。
因为子进程只能继承、不能回写,“在脚本里 export 了变量,跑完脚本当前 shell 里却没有”是必然的:脚本在子进程里运行。想让脚本修改当前 shell,要用 source script.sh(或 . script.sh)在当前进程里执行它,activate 虚拟环境必须 source 就是这个原因。
在交互 shell 里执行 DEBUG=1(没有 export),再运行 python3 app.py。app.py 里的 os.environ.get("DEBUG") 得到什么?
子进程只继承环境变量。单独的 DEBUG=1 只创建 shell 变量,要 export DEBUG=1 或写成 DEBUG=1 python3 app.py 才能传进去,上面 child sees: [] 的实测就是这个现象。
环境变量的值都是字符串,没有类型;名字大写只是惯例。
管住进程:作业、信号与退出
shell 启动的每条命令都是一个进程,有自己的 PID 和父进程 PPID。ps 能看到这层父子关系:
$ zsh -c 'sleep 5 & ps -o pid,ppid,stat,command -p $$,$!; kill $!'
PID PPID STAT COMMAND
5900 5897 S zsh -c sleep 5 & ps -o pid,ppid,stat,command -p $$,$!; kill $!
5901 5900 SN sleep 5sleep 的 PPID 正是 zsh 的 PID。$$ 是当前 shell 的 PID,$! 是最近一个后台进程的 PID。日常用 ps aux | grep train.py 找进程,top / htop 实时看 CPU 和内存,lsof -nP -iTCP:8080 -sTCP:LISTEN 查谁占着端口。
命令末尾加 & 放到后台运行。交互 shell 里,Ctrl-Z 暂停前台程序,jobs 列出作业,bg %1 让 1 号作业在后台继续,fg %1 拉回前台。忘了加 & 的长命令,Ctrl-Z 再 bg 就能拿回终端。
让进程停下的手段是信号:内核递给进程的一个编号通知。进程可以为大多数信号注册处理函数,没注册就执行默认动作(多半是退出)。命令行上常见的几种:
| 信号 | 怎么发 | 能否捕获 | 用途 |
|---|---|---|---|
| SIGINT | Ctrl-C | 能 | 用户打断,Python 里变成 KeyboardInterrupt |
| SIGTSTP | Ctrl-Z | 能 | 暂停,fg 恢复 |
| SIGTERM | kill PID(默认) | 能 | 请程序收尾后退出 |
| SIGKILL | kill -9 PID | 不能 | 内核直接结束进程 |
| SIGHUP | 终端关闭、SSH 断开 | 能 | 终端里的作业默认随之退出 |
下面的 worker.py 注册了 SIGTERM 处理函数,收到后把当前这步做完再”保存”退出:
import signal, sys, time
stop = False
def on_term(signum, frame):
global stop
stop = True # 信号可能在一步中间到达,只设标志,等这一步做完再退出
signal.signal(signal.SIGTERM, on_term)
step = 0
while not stop:
time.sleep(0.1); step += 1
print(f"got SIGTERM, saved at step {step}", flush=True)
sys.exit(0)$ for s in TERM KILL; do python3 worker.py & pid=$!; sleep 1; kill -$s $pid; wait $pid; echo "kill -$s -> exit $?"; done
got SIGTERM, saved at step 10
kill -TERM -> exit 0
kill -KILL -> exit 137
$ sleep 30 & pid=$!; kill $pid; wait $pid; echo "exit $?"
exit 143SIGTERM 被接住,程序体面地退出了,退出码是它自己选的 0;SIGKILL 根本到不了 Python 代码,进程当场消失。没有处理函数的 sleep 被 SIGTERM 默认动作杀死。被信号 n 杀死的进程,shell 报告的退出码是 128+n:137 = 128+9(KILL),143 = 128+15(TERM),130 = 128+2(INT)。线上任务以 137 退出,第一反应应该是”被 SIGKILL 了”,在 Linux 上最常见的凶手是内存耗尽时的 OOM killer。
所以停一个程序的正确顺序是先 kill PID 给它收尾的机会,等几秒还不退再 kill -9。docker stop、Kubernetes、Slurm 都是这么做的:先 TERM,宽限期过后 KILL。直接 kill -9 相当于拔电源,写了一半的 checkpoint 会损坏。信号在内核里如何递送、处理函数何时被调用,属于 进程异常与信号。
远程跑长任务还有 SIGHUP 的问题:SSH 一断,终端里的作业都会收到它。两种对策:
nohup python train.py > train.log 2>&1 &:让进程忽略 SIGHUP,输出写进日志。之后没法再回到那个终端交互。- tmux:会话由后台的 tmux 服务器持有,SSH 只是”连上去看”。
tmux new -s train新建会话,启动训练,按Ctrl-b再按d脱离;下次tmux attach -t train接回,实时输出还在。Ctrl-b %和Ctrl-b "左右、上下分屏,一般首选它。
在远程服务器上启动一个要跑两天的训练,哪些做法能让它在 SSH 断开后继续运行?
nohup 让进程忽略 SIGHUP;tmux 让进程属于 tmux 服务器的会话,SSH 断开不会给它发 SIGHUP。
只加 & 的作业仍属于这个终端会话,照样收到 SIGHUP;Ctrl-Z 只是暂停,训练不会推进,断线后同样被挂断。
远程机器:ssh
ssh alice@server 登录远程机器,拿到的是远程的一个 shell。也可以只在远程执行一条命令:ssh server 'nvidia-smi'。引号很关键:ssh server ls | wc -l 里,管道由本地 shell 解析,ls 在远程跑、wc 在本地跑;写成 ssh server 'ls | wc -l' 才全部在远程。这又是”展开发生在谁那里”的问题。
每次输密码既慢又不安全,应该用公钥登录。ssh-keygen -t ed25519 生成一对密钥:私钥 ~/.ssh/id_ed25519 留在本机、绝不外传,公钥 id_ed25519.pub 追加到服务器的 ~/.ssh/authorized_keys(ssh-copy-id server 自动完成)。登录时服务器用公钥出一道题,只有持有私钥的一方答得出来,私钥本身从不经过网络。给私钥设 passphrase,配合 ssh-agent 只需解锁一次。
经常连的机器写进 ~/.ssh/config:
Host gpu
HostName 10.0.0.42
User alice
IdentityFile ~/.ssh/id_ed25519
LocalForward 8888 localhost:8888之后 ssh gpu 即可,scp、rsync、git 也都认这个别名。LocalForward 把本机 8888 端口转发到远程的 8888,远程跑的 Jupyter 在本地浏览器 localhost:8888 就能打开。传文件用 scp a.txt gpu:~/;大目录用 rsync -av --partial src/ gpu:dst/,它只传有变化的文件,断了能续。
把环境变成你的:rc 文件与 dotfiles
交互 zsh 启动时会执行 ~/.zshrc(bash 是 ~/.bashrc),所以 PATH、alias、提示符这些设置写在那里才会每次生效。在命令行里直接 export PATH=... 只对当前窗口有效,关掉就没了。
export PATH="$HOME/.local/bin:$PATH" # 放前面:让自己装的版本优先于系统版本
alias ll="ls -lh"
alias gs="git status -sb"alias 只是文本替换,参数只能接在末尾;需要把参数插到中间时写 shell 函数。type ll 能看到它的定义,\ls 绕过同名 alias。Ctrl-R 反向搜索历史命令,装上 fzf 后变成模糊搜索,是命令行里性价比最高的一个习惯。
~/.zshrc、~/.gitconfig、~/.ssh/config、~/.tmux.conf 这些以点开头的配置文件统称 dotfiles。把它们放进一个 Git 仓库,用一个脚本 ln -s 链接回家目录,换新机器时 clone 下来跑一遍就恢复了整套环境。参考别人的 dotfiles 可以,整份照抄不行,装满插件的 oh-my-zsh 会让 shell 启动慢到可感知。
软件用包管理器装:macOS 上是 Homebrew(brew install ripgrep fd),Ubuntu 上是 apt。网上常见的 curl ... | bash 安装方式会直接执行你没看过的远程代码,至少先下载下来用 less 读一遍。
记住这三件事
- shell 先改写命令行(拆词、展开变量和 glob、处理重定向),再按 PATH 找到程序并在子进程里运行它;子进程改不了父进程的状态。
- 程序之间靠五种约定协作:参数、stdin/stdout/stderr、环境变量、退出码、信号;管道只传 stdout,
2>&1是按顺序执行的赋值。 - 脚本开头写
set -euo pipefail并用trap清理;停进程先kill再kill -9,退出码 137 意味着被 SIGKILL。
延伸阅读
- MIT Missing Semester: The Shell / Command-line Environment
- Bash Reference Manual(展开顺序、重定向的权威说明)
- ShellCheck:在线检查 shell 脚本
- tldr pages:常用命令的例子速查
- The Art of Command Line(有中文版)
man ssh_config、man tmux