一台服务器上有 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/ls

cd 必须是 builtin,原因值得想一想。运行外部程序时,shell 会创建一个子进程去执行它。子进程能改的只是自己的工作目录,它退出后,父进程(shell)的工作目录纹丝不动。改当前目录、设变量、exit 这类要改 shell 自身状态的操作,只能在 shell 进程内部完成。“子进程改不了父进程的状态”会在后面反复出现。

查帮助:man ls 看完整手册,ls --help(GNU 工具)或 tldr ls(社区维护的常用例子)更快。

停一下,检查理解单选

假设 cd 不是 builtin,而是一个独立程序 /usr/bin/cd。在 shell 里运行 cd /tmp 后会发生什么?

参数在程序看到之前就被改写了

程序拿到的参数,往往不是你敲的字面样子。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: *.csv

bash 把没匹配上的 *.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.txt

bash 里不加引号的变量会按空格重新拆成多个参数,文件名带空格的脚本就是这样炸掉的。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=500

grep -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 的区别是什么?

退出码: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 found

127 是 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 因文件不存在而失败,接下来会怎样?

环境变量:父进程传给子进程的配置

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 CST

VAR=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") 得到什么?

管住进程:作业、信号与退出

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 5

sleep 的 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 就能拿回终端。

让进程停下的手段是信号:内核递给进程的一个编号通知。进程可以为大多数信号注册处理函数,没注册就执行默认动作(多半是退出)。命令行上常见的几种:

信号怎么发能否捕获用途
SIGINTCtrl-C能用户打断,Python 里变成 KeyboardInterrupt
SIGTSTPCtrl-Z能暂停,fg 恢复
SIGTERMkill PID(默认)能请程序收尾后退出
SIGKILLkill -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 143

SIGTERM 被接住,程序体面地退出了,退出码是它自己选的 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 断开后继续运行?

远程机器: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 读一遍。

记住这三件事

  1. shell 先改写命令行(拆词、展开变量和 glob、处理重定向),再按 PATH 找到程序并在子进程里运行它;子进程改不了父进程的状态。
  2. 程序之间靠五种约定协作:参数、stdin/stdout/stderr、环境变量、退出码、信号;管道只传 stdout,2>&1 是按顺序执行的赋值。
  3. 脚本开头写 set -euo pipefail 并用 trap 清理;停进程先 kill 再 kill -9,退出码 137 意味着被 SIGKILL。

延伸阅读