编程语言研究书库

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.Walkfs.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. 可带走的

  1. signal.Notify(chan) + goroutine + switch 是 Go 处理信号的唯一姿势;chan 容量 1。
  2. SIGKILL/SIGSTOP 不可捕获;Linux 没有 SIGINFO,用 SIGUSR1 代替。
  3. FileMode 低 9 位权限、高位类型;Stat 跟软链、Lstat 不跟——遍历一律 Lstat。
  4. fs.FS 抽象:磁盘(os.DirFS)、二进制内(embed.FS)、内存共用一套工具; fs.WalkDir + fs.Sub 是遍历与限界的标准组合。
  5. embed 用 //go:embed 注释驱动,文件必须和源文件同目录树下。
  6. tree(1) 三个暗招:Lstat 防循环、entries[:0] 原地过滤、Dev+Ino 判同一文件。
  7. 批量操作把「结果结构」与「执行函数」分离,部分失败成为一等公民。
  8. dd(1):io.ReadFull 按块读 + atomic.AddInt64 记账 + 信号打进度—— 校验和记账代码通常比核心逻辑长,这是系统工具的正常形态。

13. 原文地图

主题原书章原文位置
章节对仗:秩序与中断UNIX Signals and File Systemstext/68-ch06.txt:2(搜「structure and interruption」)
「复杂度不在搬字节」UNIX Signals and File Systemstext/68-ch06.txt:19(搜「copying bytes or walking directories」)
signal.Notify 与容量 1A storytext/69-fm-a-story.txt:26(搜「capacity of this particular channel」)
SIGKILL/SIGSTOP 不可捕获A storytext/69-fm-a-story.txt:36(搜「cannot be caught」)
信号实测(kill 三连)A storytext/70-fm-differentiating-between-file-types.txt:3(搜「kill -SIGINT」) · :8(搜「Execution time: 26.378」)
fileType() 七类判定Differentiating between file typestext/70-fm-differentiating-between-file-types.txt:42(搜「func fileType」) · :6(搜「symbolic link」)
fileInfo 输出(go 二进制)Finding more information about filestext/72-fm-the-io-fs-package.txt:20(搜「which go」) · :23(搜「14099458」)
io/fs 抽象与 embed.FSThe io/fs packagetext/72-fm-the-io-fs-package.txt:38(搜「introduced in Go 1.16」)
WalkDir vs WalkTraversing directory treestext/73-fm-traversing-directory-trees.txt:21(搜「filepath.Walk()」)
walkTree 统计与 Top NTraversing directory treestext/73-fm-traversing-directory-trees.txt:78(搜「fs.WalkDir」) · :177(搜「220 directories」)
//go:embed 与同一套遍历Embedding static assets in Go binariestext/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 binariestext/74-fm-embedding-static-assets-in-go-binaries.txt:340(搜「os.Lstat」) · :379(搜「cyclic structures」)
tree(1):entries[:0] 原地过滤Embedding static assets in Go binariestext/74-fm-embedding-static-assets-in-go-binaries.txt:405(搜「filtered := entries[:0]」)
modeString 与 sameFileEmbedding static assets in Go binariestext/74-fm-embedding-static-assets-in-go-binaries.txt:526(搜「func modeString」) · :604(搜「func sameFile」)
tree 实际输出Embedding static assets in Go binariestext/75-fm-copying-a-single-file-into-multiple-directories.txt:21(搜「3 directories, 10 files」)
一文件拷多目录与 CopyResultCopying a single file into multiple directoriestext/75-fm-copying-a-single-file-into-multiple-directories.txt:44(搜「CopyResult struct」) · :154(搜「/thisWillFail」)
dd(1):拷贝循环与原子计数Creating the dd(1) utility in Gotext/76-fm-creating-the-dd-1-utility-in-go.txt:35(搜「io.ReadFull」) · :64(搜「atomic.AddInt64」)
dd(1):信号进度实测Creating the dd(1) utility in Gotext/76-fm-creating-the-dd-1-utility-in-go.txt:75(搜「Process ID: 13555」)

Footnotes

  1. 出处:「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

  2. 出处:「A story」第 18–39 段(text/69-fm-a-story.txt:19,搜「asynchronous notification」;:26,搜「capacity of this particular channel」)。SIGINT/SIGTERM 的定义在同段。 ↩ ↩2

  3. 出处:「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 的完整输出与逐行解释。 ↩

  4. 出处:「A story」第 37–39 段(text/69-fm-a-story.txt:36,搜「cannot be caught」)。「SIGKILL and SIGSTOP cannot be blocked, caught, or ignored」。 ↩ ↩2

  5. 出处:「A story」Note(text/69-fm-a-story.txt:108,搜「not supported」)。 ↩

  6. 出处:「Differentiating between file types」(text/70-fm-differentiating-between-file-types.txt:42,搜「func fileType」)。位检查的顺序与七类输出。 ↩

  7. 出处:「Differentiating between file types」(text/71-fm-finding-more-information-about-files.txt:6,搜「symbolic link」;:9,搜「regular file」)。/tmp 报软链、/bin/ls 报普通文件。 ↩

  8. 出处:「Finding more information about files」(text/71-fm-finding-more-information-about-files.txt:24,搜「fileInfo.go」;:23,搜「14099458 bytes」)。 ↩

  9. 出处:「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」)。 ↩

  10. 出处:「Traversing directory trees」第 20–36 段(text/73-fm-traversing-directory-trees.txt:21,搜「filepath.Walk()」)。FileInfo vs DirEntry 的效率对比在同段。 ↩ ↩2

  11. 出处:「Traversing directory trees」(text/73-fm-traversing-directory-trees.txt:78,搜「fs.WalkDir」;:177,搜「220 directories」;:183,搜「Top 5 largest files」)。 ↩

  12. 出处:「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 行。 ↩

  13. 出处:「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」)。 ↩

  14. 出处:「Creating the tree(1) utility in Go」(text/74-fm-embedding-static-assets-in-go-binaries.txt:188,搜「tree(1)」)。五函数分工说明在同段。 ↩

  15. 出处:「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

  16. 出处:「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 行。 ↩

  17. 出处:「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 行。 ↩

  18. 出处:「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」汇总。 ↩

  19. 出处:「Creating the tree(1) utility in Go」(text/74-fm-embedding-static-assets-in-go-binaries.txt:604,搜「func sameFile」)。Dev+Ino 的唯一性说明在同段。 ↩

  20. 出处:「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 行。 ↩

  21. 出处:「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」)。 ↩

  22. 出处:「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 行。 ↩

  23. 出处:「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」)。 ↩

  24. 出处:「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

On this page