Racket 编程入门

第 4 部分 · 命令式与状态

Custodian——Racket 资源的生命周期管家

Racket 给每份资源配一个 custodian 当管家:管家一关,名下的端口、线程、子管家一并收摊;父管家一断,整条子链级联坍塌。

你写过那种程序吧:打开文件忘了关,连了一堆网络连接没断,起了几个线程没等它们结束就返回了。程序能跑,但句柄在悄悄漏。垃圾回收能回收内存,可你有没有想过:端口、socket、线程这些资源攥着的底层句柄,谁来回收?

多数语言把这事甩给程序员——记得 close、记得 join、记得在 finally 里收拾。Racket 给了另一种答案:给资源配一个管家,管家的开关就是这堆资源的总闸。

GC 管内存,管不了句柄

先说清为什么需要它。Racket 的垃圾回收器负责回收内存,这件事它做得很好。但端口、TCP 连接、线程这类资源,底层攥着的是操作系统的句柄,GC 看不见也管不着——一个没人引用的端口对象,GC 会回收它在 Racket 里的内存,但底层那个文件描述符仍然开着。

所以传统做法是手动配对:打开了就要关闭,中途抛异常就用 dynamic-wind 兜底。代码一长、分支一多、线程一掺和,漏一个就是一个泄漏。 GC 回收内存,却回收不了句柄。 这就是 custodian 要补的缺口。

custodian:给资源配个管家

make-custodian 造一个新管家。current-custodian 是个参数,决定“接下来新建的资源归谁管”:

(define worker-box (make-custodian))

;; 把当前管家临时换成 worker-box,这期间新建的线程归它管
(parameterize ([current-custodian worker-box])
 (thread (λ () (sleep 60)))
 (thread (λ () (sleep 60))))

;; 总闸一拉,worker-box 名下的线程全部终止
(custodian-shutdown-all worker-box)

parameterize 在这块作用域里把 current-custodian 换成 worker-box,于是期间 thread 出来的线程,经理(managing custodian)就是 worker-box,不再是默认的根管家。custodian-shutdown-all 一调,这些线程全部被杀,名下的端口全部关闭——你不必一个个去记。

关一个,塌一串

custodian 真正的杀手锏不是“管一堆资源”,而是“管一堆会连坐的资源”。make-custodian 默认把新管家的父亲设成当前管家,于是管家之间形成一棵树:

(define root (make-custodian))

;; root 当家时造 child,child 的父亲就是 root
(define child
 (parameterize ([current-custodian root])
 (make-custodian)))

;; child 当家时造线程
(parameterize ([current-custodian child])
 (thread (λ () (sleep 100))))

现在关掉 root:

(custodian-shutdown-all root)
;; child 跟着关,child 名下的线程也跟着关

这就是级联关闭:关掉一个父管家,它下面所有子管家、子子管家名下的资源一次性全部回收。 父 custodian 一关,整条子链跟着塌。 后面所有用法都建立在这条性质之上。

custodian-hierarchy-cascade

想确认某个管家是否还活着,用 custodian-shut-down?:

(custodian-shut-down? child)
;; #t —— 被父级带垮了

(custodian-shut-down? root)
;; #t —— 它自己也关了

被级联带关的管家,custodian-shut-down? 同样返回 #t。一个管家只要还活着,这个谓词就是 #f,可以放心往里塞新资源。

parameterize 划出资源边界

理解了级联,就能用 parameterize 给一段逻辑划出干净的资源边界。典型场景:处理一个请求、跑一批数据,期间起的线程、开的端口,都挂在一个临时管家下,逻辑一结束就整体收摊。

(define (process-batch data)
 (define box (make-custodian))
 (parameterize ([current-custodian box])
 ;; 这里新建的线程、端口都归 box 管
 (do-work data))
 ;; 逻辑一结束就清理
 (custodian-shutdown-all box))

更稳的写法是把清理放进 dynamic-wind 的后置动作,这样无论正常返回还是中途抛异常都会执行:

(define (process-batch data)
 (define box (make-custodian))
 (dynamic-wind
 void
 (λ () (parameterize ([current-custodian box]) (do-work data)))
 (λ () (custodian-shutdown-all box))))

不必为每一种异常各写一段清理,总闸一拉,全清。这也是 custodian 比手写 try/finally 顺手的地方:它按“归属”清理,不按“代码路径”清理。如果你想捕获异常再做点别的事,套一层 with-handlers 就行——清理仍然交给 custodian。

实战:一个能整体关停的服务组

把前面的东西攒成一个能跑的例子。起两个常驻服务(一个模拟 web 请求处理,一个模拟后台 worker),挂在同一个管家下,需要时一键全停:

#lang racket

(define service-box (make-custodian))

(define (run-web) ; 模拟处理 web 请求
 (let loop () (displayln "web 处理中...") (sleep 2) (loop)))

(define (run-worker) ; 模拟后台 worker
 (let loop () (displayln "worker 运行中...") (sleep 3) (loop)))

;; 两个服务都挂在 service-box 名下
(parameterize ([current-custodian service-box])
 (thread run-web)
 (thread run-worker))

(sleep 5) ; 让它们跑一会儿

(custodian-shut-down? service-box)
;; #f —— 还活着

(custodian-shutdown-all service-box)
;; 一键全停,两个线程立即终止

“停止”就是最后那行 custodian-shutdown-all——不必维护一个“活跃线程列表”挨个 kill-thread,管家替你记着。

custodian-managed-list 的第二个参数

想知道一个管家名下到底挂着什么,用 custodian-managed-list。这里有个容易踩的坑:它的第二个参数不是资源类型,而是一个上级管家,用来证明你有权查看:

(custodian-managed-list service-box (current-custodian))
;; 列出 service-box 直接管理的资源和子管家

box 必须是 (current-custodian) 的下级,否则报契约错误——cust 要严格从属于 super。这个设计是故意的:一个管家只能在被授权的范围内盘点资源,防止子模块偷看兄弟名下的东西。所以 (custodian-managed-list c 'threads) 这种写法跑不通,'threads 不是管家。 第二个参数验的是权限,不是类型。

返回的列表包含直接管理的对象(端口、线程等)和直接子管家。想数其中线程有几个,按类型过滤:

(define box (make-custodian))
(parameterize ([current-custodian box])
 (thread (λ () (sleep 100)))
 (thread (λ () (sleep 100))))

(filter thread? (custodian-managed-list box (current-custodian)))
;; => (#<thread> #<thread>)

如果 box 已经被关掉,这个调用直接返回 '(),不会再抛错。

关闭是强制的,没有协商

custodian-shutdown-all 是“拔电源”式的关闭。它不问线程“你忙完了吗”,而是在任意点直接终止——线程可能停在某个调用的中途,文件可能写到一半。 关闭是拔电源,不是协商。

所以它最适合的资源是“可丢弃”的:一次请求的连接、一批临时任务、一个插件的运行环境。如果你的线程在做不可中断的事(比如正在写数据库),就该用更软的机制——让线程监听一个通道信号、自己走到安全点再退出,custodian 只兜最后的底。

被关掉的资源也不能再用了。一个已终止的线程,thread-wait 它能正常返回(等它结束),但你别指望它还能跑代码。动手前用 custodian-shut-down? 探一下,避免对一具尸体操作。

一个暗刃:内存算在谁的头上

custodian 还能限额:custodian-limit-memory 给管家设一个内存上限,超了就触发 GC,再不行直接关掉它。但这里藏着一把暗刃,和直觉相反——parameterize 换的只是“新资源归谁管”,可线程跑起来之后分配的内存,算在管这个线程的管家头上,不是 parameterize 里那个新管家:

(parameterize ([current-custodian (make-custodian)])
 (custodian-limit-memory (current-custodian) (* 1024 1024))
 ;; 这一行 make-bytes 占的内存,算给当前线程的管家
 ;; 不是上面新建的那个——所以限额没触发,分配照常成功
 (make-bytes (* 4 1024 1024)))

跑这段代码的是当前线程,它早在 parameterize 之前就存在,归原来的管家管。新管家只接手“新建”的资源,接手不了“已经在跑”的线程的内存账。

这意味着你没法靠“在子管家作用域里分配”给一段代码单独设内存预算——真正决定内存归属的,是跑这段代码的线程归谁管。 custodian 的树管的是“谁负责关”,内存账本走的是另一套。 等哪天内存限额不生效,你会回来想这一节。