Unix 信号与文件系统:接住 Ctrl+C,看穿文件类型,走遍历,再亲手造 tree(1) 和 dd(1)
Unix 信号与文件系统:中断与秩序
这一章的书名就概括了它的结构:一边是信号(中断),一边是文件系统(秩序)。 前半回答:操作系统会在你不指望的时刻拍你的肩膀(Ctrl+C、kill 命令), 程序怎么从容应对而不是当场猝死?后半回答:文件不止「有内容」, 还有类型、权限、属主、链接——以及怎么遍历整棵树、把静态文件嵌进二进制、 并亲手造出 tree(1) 和 dd(1) 这两个真家伙。 读完你会拿到「进程与外界事件」和「进程与文件系统」两张完整的交互图。
1. 这一章在原书里的位置
原书第 6 章,第二部分(systems programming)的第二站。 第 5 章学会了和一个文件打交道,本章升级到两件事: 和操作系统的事件(信号)打交道,和整棵目录树打交道。 书里开篇的对仗值得记住:文件系统体现秩序,信号引入混乱, 而「真正的复杂度很少在拷贝字节或遍历目录里,它藏在参数校验、错误处理、 让每个操作完事后系统仍处于一致状态里」1——这一句是后面四个工具的说明书。
2. 顶层全景
信号(§3–§4) 文件系统(§5–§10)
OS 异步通知 ──▶ channel 类型与元数据(FileMode 位)
──▶ goroutine 里 switch │
(SIGINT/SIGTERM/SIGINFO…) ├─ io/fs 抽象:fs.FS ── os.DirFS / embed.FS
├─ 遍历:fs.WalkDir(回调逐条收)
└─ 工具:空目录 / tree(1) / 多目录拷贝 / dd(1)
(后两个把信号也缝进来)图说:左半是「响应」,右半是「巡视」。dd(1) 是两者交汇的点。
3. 信号:操作系统拍肩膀的方式
信号(signal)是 OS 发给进程的轻量异步通知:用户按 Ctrl+C → 进程收到 SIGINT;
管理员 kill 进程 → 默认发 SIGTERM;还有 SIGHUP(终端挂断)、SIGINFO(状态查询,
macOS 的 Ctrl+T)……2
Go 接信号的姿势只有一套,值得背下来:
sigs := make(chan os.Signal, 1) // 容量 1:信号能终止程序,一次处理一个就够
signal.Notify(sigs) // 注册:进程收到的所有信号都投进这个 channel
go func() {
for {
sig := <-sigs // 阻塞等信号
switch sig {
case syscall.SIGINT:
fmt.Println("Execution time:", time.Since(start))
case syscall.SIGINFO:
handleSignal(sig)
os.Exit(0) // 这个信号我们定义为「报完状态就退出」
default:
fmt.Println("Caught:", sig)
}
}
}()书上实测(PID 85877,另开终端发信号):kill -SIGINT → 打印已运行 26.37 秒;
kill -SIGHUP → 落进 default 分支打印「Caught: hangup」;
kill -SIGINFO → 走专用分支报状态并退出3。
一条纪律和一个冷知识:channel 容量 1 是刻意设计(信号可能终结程序,没必要排队);
SIGKILL 和 SIGSTOP 不可捕获、不可忽略——Unix 给特权用户和内核留了
「必杀任何进程」的后门,软件层面无解4。
冷知识二:Linux 不支持 SIGINFO,要用 SIGUSR1/SIGUSR2 代替(书里给了 Go 的
issue 链接)5。
4. 文件类型:Mode 位里的世界观
Unix 里「一切皆文件」,但文件分七类:os.FileInfo.Mode() 返回一个 FileMode
——一个位集合,低 9 位是权限(rwxrwxrwx),高位是类型标志。
书上的 fileType() 按顺序查6:
mode.IsRegular() → 普通文件
mode.IsDir() → 目录
mode & os.ModeSymlink → 符号链接(软链,存的是另一个路径)
mode & os.ModeNamedPipe → 命名管道(FIFO)
mode & os.ModeSocket → 套接字
mode & os.ModeDevice → 设备;再查 ModeCharDevice 分字符/块设备
都不中 → unknown实测:/tmp 在很多 macOS 上是软链 → 报「symbolic link」而不是目录;
/bin/ls → 普通文件7。
元数据层面(fileInfo.go,一个轻量 stat(1)):Size(字节数)、
Mode(权限字符串如 -rwxr-xr-x)、ModTime(修改时间)、IsDir——
go 二进制本体的输出:14 099 458 字节、全员可执行8。
5. io/fs:把「文件系统」变成接口
io/fs 包(Go 1.16)定义了一组接口,核心是 fs.FS:
「能 Open 出文件的只读文件系统」的抽象。谁实现了它,谁就能被同一套工具操作——
真实磁盘(os.DirFS(root))、嵌进二进制的文件(embed.FS)、内存里的假文件系统
(测试用),全都算9。
遍历的两个轮子,书里的对比很清晰10:
filepath.Walk | fs.WalkDir | |
|---|---|---|
| 回调收到的条目 | os.FileInfo(完整元数据,代价是额外系统调用) | fs.DirEntry(轻量,查类型不花系统调用) |
| 工作对象 | 真实文件系统 | 任何 fs.FS(含虚拟/嵌套) |
| 定性 | 旧 | 新、更省、更通用 |
walkTree.go 是遍历的第一个成品:WalkDir 回调里数目录、收文件大小, 完事按大小排序输出 Top N——「220 个目录、479 个文件、共 484 954 字节, 最大的是 ch05/profReading.go(12 122 字节)」11。
6. embed:把文件织进二进制
//go:embed assets/* 加一句注释,编译器就把 assets/ 下的文件编进二进制,
运行时用 embed.FS(它实现了 fs.FS!)按文件系统的方式读。
见证抽象威力的时刻:上一节遍历磁盘的 fs.WalkDir 代码,
原封不动就能遍历嵌套文件系统——只是第一参数从 os.DirFS(root) 换成
fs.Sub(embeddedFiles, "assets")(Sub 切出子视图,限制访问范围)12。
输出长这样:[DIR] .、[FILE] file (16 bytes)——和走真磁盘时一模一样13。
部署收益书里点明:单文件交付、不怕资产丢失、发布流程干净 (回想第 1 章「静态链接单文件」的卖点,这里是它的内容版)。
7. 主线例子(一):tree(1)——本技法密度最高的一节
tree(1) 把目录画成 ├── └── │ 的树。书上的实现拆成五个函数,
每个函数都有值得单独拿走的东西14:
walkDir() 的核心决策:Lstat 而不是 Stat。 os.Stat 会跟随符号链接,
os.Lstat 看链接本身。为什么必须 Lstat:跟随软链可能走回父目录(循环遍历,死循环),
甚至逃出当前子树、把整盘文件都画出来15。遍历程序默认该用 Lstat,这是本章最硬的规矩。
readDir() 里的原地过滤巧思:
filtered := entries[:0] // 新切片头:同一块底层数组,长度 0
for _, e := range entries {
if 隐藏文件 && !opt.all { continue }
if opt.dirsOnly && !e.IsDir() { continue }
filtered = append(filtered, e) // 一边读一边写同一块数组,零分配
}图说:filtered 和 entries 共享底层数组,append 覆盖已读过的位置——
过滤过程会把 entries 的内容搅乱(它还有原长度),但反正我们已经不读它了。
省一次分配;书里明说「需要仔细理解才敢用」16。
formatEntry() 与 modeString(): 符号链接读出目标拼成 name -> target;
modeString 把 FileMode 翻成 drwxr-xr-x——先一个字母定类型(d/l/s/p/b/c/-),
再九个位逐个查 rwx,最后处理 setuid/setgid/sticky 三个特殊位(s/S、t/T)17。
实际输出(ch07 目录):根、十个条目、缩进对齐,末尾一行
3 directories, 10 files18。
sameFile(): 判断两个 FileInfo 是不是同一个文件——优先比 syscall.Stat_t
里的 Dev(设备号)+ Ino(inode 号),这对组合在文件系统内唯一(硬链接也认得出);
拿不到 Stat_t 才退回「名字+大小+模式」的启发式19。这是「同一性」在文件系统层的定义。
8. 工具二:一文件拷多目录——校验与执行分离
cpMultiple.go 把一个文件复制到 N 个目录,输出逐个目录的成功/失败报告
(/thisWillFail - FAILED: directory does not exist,退出码 2)20。
架构上的话值得记住:用 CopyResult 结构把「结果记录」从「拷贝执行」里剥出来——
copyFileToDirectories 只做编排和前置校验(源存在吗?是文件吗?),
copyToSingleDirectory 做目标侧校验(目录在吗?同名文件已存在吗?——防覆盖),
copyFile 才真正搬字节。「每个目录一条结果」让部分失败成为一等公民,
而不是第一个错误就 panic 收场。
9. 主线例子(二):dd(1)——信号与 I/O 的会师
dd 按固定块大小拷贝(名字来自 disk dump)。书上这版有两个真本事: 原子计数器和纯信号驱动的进度报告21。
拷贝循环(核心其实很短):
for {
if count > 0 && blocksCopied >= int64(count) { break }
n, readErr := io.ReadFull(in, buf) // 尽力读满一块
if n > 0 {
out.Write(buf[:n])
atomic.AddInt64(&blocksCopied, 1) // 原子加:进度 goroutine 不会读到撕裂的数
atomic.AddInt64(&totalBytes, int64(n))
}
if readErr == io.EOF || readErr == io.ErrUnexpectedEOF { break }
// 其他读错误 → 报 stderr、退 1
}信号部分:SIGUSR1 → 打印当前块数、字节数和速度(真 dd 的同款约定);
SIGINT/SIGTERM → 打最终报告、优雅退出22。
实测:2 字节块拷 1 GB 文件,慢到够你发信号——
Progress: 8 918 330 blocks copied (17 836 660 bytes, 1.5 MB/s),
Ctrl+C 后 Interrupted, exiting.23。
这里缝上了前三章的全部线头:channel 接信号(本章 §3)、
atomic.AddInt64(第 3 章 mutex 一节提过、第 4 章没展开的原子操作,在这里第一次真用)、
缓冲读写(第 5 章)、defer 清理。书上对代码构成的观察一针见血:
参数校验、错误处理、并发记账、信号管理占了代码的大头,真正搬字节的循环反而是小头24。
10. 作者的判断与证据
| 判断 | 性质 | 依据 |
|---|---|---|
| 复杂度在参数校验和错误处理,不在搬字节 | 作者的工程主张 | 第 1 节原句 + dd 一节代码构成分析124 |
| 遍历用 Lstat 防软链循环 | 语言事实 + 作者强调 | 「could lead to cyclic structures」15 |
| fs.WalkDir 是新代码的默认遍历选择 | 作者立场(官方趋势一致) | 与 filepath.Walk 的对比表10 |
| signal channel 容量 1 足够 | 作者的设计判断 | 「no need to try to handle another signal at the same time」2 |
| SIGKILL/SIGSTOP 不可捕获 | Unix 事实 | 原文底线声明4 |
11. 边界与局限
- 信号一节只教了「捕获」,没教「优雅关停的完整模式」(关闭 listener、 等 in-flight 请求、超时兜底)——第 9 章 HTTP 服务一节才有完整形态。
- fs.WalkDir 的回调里不能随意并发:walkDir() 里递归是自己的实现所以可控, fs.WalkDir 本身是单线程顺序回调,大树的并行遍历要自己切分(第 14 章的索引服务会做)。
- tree(1) 的原地过滤技巧(entries[:0])有数据竞争式的阅读陷阱:append 覆盖 entries 的前提是你不再读它;若回调里还引用着 entries,会读到被改写的数据。 书里提醒了「需要仔细理解」,但没列触发条件。
- embed 的文件路径相对源文件目录,跨目录引用会报「no matching files」—— 书里以注意事项带过,这是新手最常撞的编译错误之一。
- dd(1) 的进度 goroutine 与拷贝循环共享计数器,靠原子量解决—— 若改成普通 int64,第 3 章的 -race 立刻会抓(书里没把这层连回去)。
12. 可带走的
signal.Notify(chan)+ goroutine + switch 是 Go 处理信号的唯一姿势;chan 容量 1。- SIGKILL/SIGSTOP 不可捕获;Linux 没有 SIGINFO,用 SIGUSR1 代替。
- FileMode 低 9 位权限、高位类型;
Stat跟软链、Lstat不跟——遍历一律 Lstat。 fs.FS抽象:磁盘(os.DirFS)、二进制内(embed.FS)、内存共用一套工具;fs.WalkDir+fs.Sub是遍历与限界的标准组合。- embed 用
//go:embed注释驱动,文件必须和源文件同目录树下。 - tree(1) 三个暗招:Lstat 防循环、
entries[:0]原地过滤、Dev+Ino判同一文件。 - 批量操作把「结果结构」与「执行函数」分离,部分失败成为一等公民。
- dd(1):
io.ReadFull按块读 +atomic.AddInt64记账 + 信号打进度—— 校验和记账代码通常比核心逻辑长,这是系统工具的正常形态。
13. 原文地图
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| 章节对仗:秩序与中断 | UNIX Signals and File Systems | text/68-ch06.txt:2(搜「structure and interruption」) |
| 「复杂度不在搬字节」 | UNIX Signals and File Systems | text/68-ch06.txt:19(搜「copying bytes or walking directories」) |
| signal.Notify 与容量 1 | A story | text/69-fm-a-story.txt:26(搜「capacity of this particular channel」) |
| SIGKILL/SIGSTOP 不可捕获 | A story | text/69-fm-a-story.txt:36(搜「cannot be caught」) |
| 信号实测(kill 三连) | A story | text/70-fm-differentiating-between-file-types.txt:3(搜「kill -SIGINT」) · :8(搜「Execution time: 26.378」) |
| fileType() 七类判定 | Differentiating between file types | text/70-fm-differentiating-between-file-types.txt:42(搜「func fileType」) · :6(搜「symbolic link」) |
| fileInfo 输出(go 二进制) | Finding more information about files | text/72-fm-the-io-fs-package.txt:20(搜「which go」) · :23(搜「14099458」) |
| io/fs 抽象与 embed.FS | The io/fs package | text/72-fm-the-io-fs-package.txt:38(搜「introduced in Go 1.16」) |
| WalkDir vs Walk | Traversing directory trees | text/73-fm-traversing-directory-trees.txt:21(搜「filepath.Walk()」) |
| walkTree 统计与 Top N | Traversing directory trees | text/73-fm-traversing-directory-trees.txt:78(搜「fs.WalkDir」) · :177(搜「220 directories」) |
| //go:embed 与同一套遍历 | Embedding static assets in Go binaries | text/74-fm-embedding-static-assets-in-go-binaries.txt:23(搜「go:embed」) · :60(搜「fs.WalkDir(assetsFS」) · :116(搜「aDir/emptyFile」) |
| tree(1):Lstat 防循环 | Embedding static assets in Go binaries | text/74-fm-embedding-static-assets-in-go-binaries.txt:340(搜「os.Lstat」) · :379(搜「cyclic structures」) |
| tree(1):entries[:0] 原地过滤 | Embedding static assets in Go binaries | text/74-fm-embedding-static-assets-in-go-binaries.txt:405(搜「filtered := entries[:0]」) |
| modeString 与 sameFile | Embedding static assets in Go binaries | text/74-fm-embedding-static-assets-in-go-binaries.txt:526(搜「func modeString」) · :604(搜「func sameFile」) |
| tree 实际输出 | Embedding static assets in Go binaries | text/75-fm-copying-a-single-file-into-multiple-directories.txt:21(搜「3 directories, 10 files」) |
| 一文件拷多目录与 CopyResult | Copying a single file into multiple directories | text/75-fm-copying-a-single-file-into-multiple-directories.txt:44(搜「CopyResult struct」) · :154(搜「/thisWillFail」) |
| dd(1):拷贝循环与原子计数 | Creating the dd(1) utility in Go | text/76-fm-creating-the-dd-1-utility-in-go.txt:35(搜「io.ReadFull」) · :64(搜「atomic.AddInt64」) |
| dd(1):信号进度实测 | Creating the dd(1) utility in Go | text/76-fm-creating-the-dd-1-utility-in-go.txt:75(搜「Process ID: 13555」) |
Footnotes
-
出处:「UNIX Signals and File Systems」第 19–21 段(
text/68-ch06.txt:19,搜「copying bytes or walking directories」)。「the real complexity rarely lies in copying bytes or walking directories」。 ↩ ↩2 -
出处:「A story」第 18–39 段(
text/69-fm-a-story.txt:19,搜「asynchronous notification」;:26,搜「capacity of this particular channel」)。SIGINT/SIGTERM 的定义在同段。 ↩ ↩2 -
出处:「A story」(
text/70-fm-differentiating-between-file-types.txt:3,搜「kill -SIGINT」;:8,搜「Execution time: 26.378」;:10,搜「Caught: hangup」;:12,搜「information request」)。三次 kill 的完整输出与逐行解释。 ↩ -
出处:「A story」第 37–39 段(
text/69-fm-a-story.txt:36,搜「cannot be caught」)。「SIGKILL and SIGSTOP cannot be blocked, caught, or ignored」。 ↩ ↩2 -
出处:「A story」Note(
text/69-fm-a-story.txt:108,搜「not supported」)。 ↩ -
出处:「Differentiating between file types」(
text/70-fm-differentiating-between-file-types.txt:42,搜「func fileType」)。位检查的顺序与七类输出。 ↩ -
出处:「Differentiating between file types」(
text/71-fm-finding-more-information-about-files.txt:6,搜「symbolic link」;:9,搜「regular file」)。/tmp 报软链、/bin/ls 报普通文件。 ↩ -
出处:「Finding more information about files」(
text/71-fm-finding-more-information-about-files.txt:24,搜「fileInfo.go」;:23,搜「14099458 bytes」)。 ↩ -
出处:「The io/fs package」(
text/72-fm-the-io-fs-package.txt:38,搜「introduced in Go 1.16」;text/73-fm-traversing-directory-trees.txt:5,搜「embed.FS implements the fs.FS interface」)。 ↩ -
出处:「Traversing directory trees」第 20–36 段(
text/73-fm-traversing-directory-trees.txt:21,搜「filepath.Walk()」)。FileInfo vs DirEntry 的效率对比在同段。 ↩ ↩2 -
出处:「Traversing directory trees」(
text/73-fm-traversing-directory-trees.txt:78,搜「fs.WalkDir」;:177,搜「220 directories」;:183,搜「Top 5 largest files」)。 ↩ -
出处:「Embedding static assets in Go binaries」(
text/74-fm-embedding-static-assets-in-go-binaries.txt:23,搜「go:embed」;:43,搜「fs.Sub」)。「Notice how the same traversal logic now applies to a virtual filesystem?」见第 78 行。 ↩ -
出处:「Embedding static assets in Go binaries」(
text/74-fm-embedding-static-assets-in-go-binaries.txt:117,搜「[FILE] file (16 bytes)」)。注意 embed 的路径约束见第 35–36 行(「assets should also be under the ch06 directory」)。 ↩ -
出处:「Creating the tree(1) utility in Go」(
text/74-fm-embedding-static-assets-in-go-binaries.txt:188,搜「tree(1)」)。五函数分工说明在同段。 ↩ -
出处:「Creating the tree(1) utility in Go」(
text/74-fm-embedding-static-assets-in-go-binaries.txt:379,搜「cyclic structures」)。「following symbolic links could lead to cyclic structures」与逃逸子树的警告。 ↩ ↩2 -
出处:「Creating the tree(1) utility in Go」(
text/74-fm-embedding-static-assets-in-go-binaries.txt:405,搜「filtered := entries[:0]」;:439,搜「requires careful understanding」)。共享底层数组的机理见第 440–444 行。 ↩ -
出处:「Creating the tree(1) utility in Go」(
text/74-fm-embedding-static-assets-in-go-binaries.txt:479,搜「os.ModeSymlink」;:526,搜「func modeString」;:548,搜「rwx := []fs.FileMode」)。setuid/setgid/sticky 的调整见第 564–577 行。 ↩ -
出处:「Creating the tree(1) utility in Go」(
text/75-fm-copying-a-single-file-into-multiple-directories.txt:5,搜「go run ch06/tree.go ch07」)。含缩进树与「3 directories, 10 files」汇总。 ↩ -
出处:「Creating the tree(1) utility in Go」(
text/74-fm-embedding-static-assets-in-go-binaries.txt:604,搜「func sameFile」)。Dev+Ino 的唯一性说明在同段。 ↩ -
出处:「Copying a single file into multiple directories」(
text/75-fm-copying-a-single-file-into-multiple-directories.txt:44,搜「CopyResult struct」;:62,搜「func copyFileToDirectories」;:113,搜「copyToSingleDirectory」;:154,搜「/thisWillFail」)。「separation of validation from execution」见第 39–42 行。 ↩ -
出处:「Creating the dd(1) utility in Go」第 5–29 段(
text/76-fm-creating-the-dd-1-utility-in-go.txt:6,搜「signal-aware」;:12,搜「SIGUSR1」)。 ↩ -
出处:「Creating the dd(1) utility in Go」第 12–16 段(
text/76-fm-creating-the-dd-1-utility-in-go.txt:12,搜「SIGUSR1」;:13,搜「SIGINT or SIGTERM」)。原子计数器的必要性见第 14–17、64–67 行。 ↩ -
出处:「Creating the dd(1) utility in Go」(
text/76-fm-creating-the-dd-1-utility-in-go.txt:75,搜「Process ID: 13555」;:78,搜「Interrupted, exiting」)。 ↩ -
出处:「Creating the dd(1) utility in Go」第 19–29 段(
text/76-fm-creating-the-dd-1-utility-in-go.txt:19,搜「most of the code」)。「parameter validation, error handling, concurrency-safe bookkeeping, and signal management – often account for the majority of the code」。 ↩ ↩2