如何优雅地优雅退出?

3

写程序的时候会自然地关注如何启动系统中的各个组件,但很少关心如何关停。这和内存分配一样,使用高层编程语言(甚至是自然语言)编程的程序员们早已习惯垃圾回收机制,从不手动分配和释放内存。

不需要极佳的性能,确实没必要用 C 或者 Zig 等低层语言直接操作内存,但在高层语言里,仍有不少资源需要手动管理,系统中有些部分是成熟的框架也不一定会考虑到的。比方说,一些异步程序可能在共享内存区域或消息队列里存了尚未保存的数据,如果直接退出,这里面的数据就丢失了。

让程序监听并响应中断信号,在收到信号之后处理尚未释放的资源和尚未完成的请求,然后主动退出,这种做法一般叫优雅退出(graceful shutdown)。Java 的 Spring 框架就会在接收到信号的时候自动优雅退出,首先它会停止接收新的请求,然后处理完剩下的请求,最后停止进程。

我本来想看看 Spring 是怎么实现优雅退出的,结果只翻到了一大堆接口和抽象类(我第一次知道接口还能继承接口,而且可以多继承,Java 稳定发挥),我懒得去找实现类了,所以就直接开写吧。

中断是外部事件

程序运行时,main 的入口位置会被写入 程序计数器 ,是第一段被执行的代码。它负责启动系统,需要知道应该调用那些组件的哪些函数,要把哪个组件注入哪个组件,配置要从哪里加载等等。main 在我看来很像导演的角色,它了解所有肮脏的细节,什么都要管。

既然启动是它负责,那么停止也必然要经过它。很多 main 函数里只有启动逻辑,如果执行时按下 Ctrl + C 或者用 rc、systemd、Docker 和 Podman 等服务管理工具向程序发送停止信号,程序就会很生硬地终止。或许框架和语言会做某些处理,但写在内存里的数据是不会保存的,启动的子进程也会很生硬地结束。

假设你的程序启动了一个 Web 服务器,接收 HTTP 请求并发送响应,其中有些请求是异步的,要放进消息队列里处理,所以还要起一个处理器线程,而处理器是并发的,为了保证并发安全,避免竞争条件,还得起一个专门管理数据读取和写入的线程。中断这三个异步子线程的主线程,可能会有意想不到的事情发生:

  • 中断 Web 服务器,可能会导致一部分还没有完成的 HTTP 请求中断,用户要么一直收不到响应,最后超时了,要么会得到 EOF 或「服务器意外关闭了连接」之类的问题。
  • 中断处理器线程,会导致消息队列里还没处理完的任务得不到处理,程序退出之后,写在内存里的数据就全部丢失了。就算把消息写进数据库,也有可能出现某些消息还没来得及写进数据库的情况。
  • 中断数据管理线程,除了没来得及持久化的数据会丢失之外,还有可能出现 I/O 操作被打断,只写入了一半的情况。不过后者其实比较少见,只要不是直接读写文件,有用事务(transaction),完整性还是能够保证的。

不做优雅退出的后果可能没有那么严重,不过「中断」依旧是程序应当考虑的外部事件。设计系统的时候应该评估有哪些数据可能会在停止的时候丢失,哪些资源可能不会被自动回收。

中断也是内部事件

假设有个数据处理管线很长,中间有些逻辑可能卡住,难以检测也难以恢复。为了减少资源占用,就应该在一段时间后取消这个管线,也就是主动中断。

这也叫「取消」,Go 语言是用 context 处理取消逻辑的,下面是来自 官方文档 的代码:

func QueryWithTimeout(ctx context.Context) {
 // Create a Context with a timeout.
 queryCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
 defer cancel()

 // Pass the timeout Context with a query.
 rows, err := db.QueryContext(queryCtx, "SELECT * FROM album")
 if err != nil {
 log.Fatal(err)
 }
 defer rows.Close()

 // Handle returned rows.
}

context.WithTimeout 会创建带超时时间的上下文(context),同时还会返回一个函数 cancel(),可以调用这个函数手动取消这个上下文。defer cancel() 会在当前作用域的最后被调用,也就是函数执行完毕就会自动取消上下文。

上下文是有继承机制的,可以发现 WithTimeout 还接收了 ctx 作为参数,这个上下文是作为参数传进来的。其中 queryCtx 就是 ctx 的子上下文,无论是 queryCtx 本身的超时时间到了、函数执行完毕之后取消了,还是父上下文 ctx 被取消了,queryCtx 都会取消。

可以看到 db.QueryContext 接收了 queryCtx 作为参数,这个函数内部也有处理上下文的逻辑。三层上下文中的任何一个以任何原因被取消,最底下的查询都会终止。

具体是怎么终止的?这个逻辑是由调用栈最顶端,也就是最后调用的那个函数实现的。上下文都实现了 Done() 方法,会返回一个 channel,但上下文被取消,这个 channel 就会送一个空结构体 struct{} 过来,作为中断信号。

下面是 gobyexample.com 的例子

select {
 case <-time.After(10 * time.Second):
 fmt.Fprintf(w, "hello\n")
 case <-ctx.Done():
 err := ctx.Err()
 fmt.Println("server:", err)
 internalError := http.StatusInternalServerError
 http.Error(w, err.Error(), internalError)
}

select 是 Go 语言用来做并发控制的结构,简单来说,哪个 case 里的 channel 先来消息,就先处理哪个。如果十秒钟过后,time.After... 发送了消息过来,就会打印 "hello\n";如果这十秒内,上下文取消了,ctx.Done() 先来消息,那么就会执行错误处理逻辑。

要实现优雅退出,逻辑也和上下文取消类似:先给个各子系统或组件发送中断信号,等待他们内部处理完成,然后再退出程序。

用闭包隐藏状态

优雅退出似乎要管理很多状态:

  1. 对组件本身的引用;
  2. 给组件发送中断信号的管道;
  3. 接收组件完成信号的管道。

main 组件要做的事情是,在接收到外部的中断信号时,给组件发送内部的中断信号,然后等待他们处理完剩下的活儿,发送完成信号,告诉 main 可以退出了。

听起来就很麻烦,有没有办法把这些都隐藏在组件内部,而 main 就不需要关心怎么发信号、怎么收信号了,只需要等着退出就好?

有的,我们可以用闭包函数。

闭包函数就是能访问函数周围作用域的函数,比方说我在一个作用域里定义了变量 x,而同一个作用域里的 f 能够访问 x(但 x 并不是在 f 内部定义的),那么 f 就可以当作包含 x 的闭包函数。

既然要谈闭包(closure),不用 Clojure 就说不过去了。(什么逻辑?)

还记得 Go 语言里创建 context 的时候会返回 cancel() 函数吗?我们完全可以在启动组件时,返回一个用于停止系统的闭包函数,这个闭包函数包含了组件内部的各种细节,但我们要做的就是调用函数,然后等函数执行完成。

假设有这样一个函数:

(defn start-system []
 (let [resource (get-resource)]
 (make-use-of resource)
 ;; 返回回收资源的函数
 (fn []
 (collect resource)))) ;; 这个函数访问了定义在它之外的 resource

在 main 函数里启动之后,注册 SignalHandler,并在接收到 INT(应该还有 TERM)信号时执行启动时返回的函数。假设函数是同步的,执行完毕就代表资源回收完毕,此时就可以安全退出了。

(import '(sun.misc Signal SignalHandler))

(defn -main [& args]
 (let [stop-system (start-system)]
 (Signal/handle (Signal. "INT")
 (reify SignalHandler
 (handle [_ sig]
 (stop-system))))))

回到一开始的例子,你的系统里有:Web 服务器、处理器线程和数据管理线程。这三个系统都返回三个函数,一个个调用似乎也有点麻烦?

那就全部放进一个函数里!

(defn start-system []
 (let [stop-io-worker (io-worker/start)
 stop-processor (processor/start)
 stop-webserver (webserver/start)])
 (fn []
 (stop-webserver)
 (stop-processor)
 (stop-io-worker))

注意看,启动顺序和停止顺序是相反的。因为处理器依赖 I/O 管理线程,要先启动;Web 服务器如果先开始接收请求,请求就不能直接传递给处理器。我们不可避免地遇到了时序耦合(temporal coupling)问题,也就是函数调用的顺序会影响实际行为。这是反函数式编程范式的,因为纯函数不应该有副作用、不应该依赖可变状态,自然也就不会因为调用顺序而影响行为。

但不是所有东西都能写成纯函数的,尤其是在系统边界,需要与外部信号、数据库和 I/O 打交道的部分。在我看来,只要保证系统内部的大部分代码都是函数式的,状态不可变、副作用可控,就没问题。

停机问题?

前面的例子可能有点太简单了,并不是所有情况下,函数都有办法返回闭包函数。尤其是内部为循环结构的函数,它根本没有办法停下来返回一个函数。这和经典的 停机问题 很像。

int H(procedure, Input);
int U(P)
{
 while(H(P, P)){}
 return 0;
}

U(U) 的结果是什么?H(U,U) 如果输出停机 1,就会陷入死循环;如果输出不停机 0,那么循环就退出了,与不停机的输出矛盾。

要怎么知道正在运行的循环何时停下呢?要怎么通知它停下?

假设有一个异步运行在线程里的循环,它负责操作数据库,它做的事情就是一直从一个 channel 里接收数据库操作,然后执行这些操作。以下代码修改自 weepinbell :

 (go-loop [operation (<! writer-chan)]
 (let [{:keys [op args]} operation]
 (when (not= op :stop)
 (apply (op storage) args)
 (recur (<! writer-chan)))))

operation 是个映射,包含 op(操作)和 args(参数)两个字段。正常情况下会调用 op 在 storage 里对应的函数,传入 args 参数,执行写入或删除的操作。如果 op 是 :stop 关键字,这个循环就不会 recur,也就退出了。

所以,即便没有可以直接调用的函数,还是可以通过往 channel 里放结束信号的方式来停止循环。而且这样做有个好处:channel 里的消息本身就是按顺序排列的,当 :stop 信号被放入 channel 时,循环会像无事发生一样继续处理剩下的请求,直到拿到 :stop 信号才退出,这样队列里没处理的数据就不会丢失了。

但是还有一个问题没解决,我当然可以用 (put! writer-chan :stop) 发送停止信号,但我怎么知道队列里的消息已经被处理完了呢?要是我在 :stop 还没有被处理的时候就终止程序了呢?

那就再用一个 channel 用来传递「线程已经停止」的信号吧。

(defn start-worker
 [storage writer-chan]
 (let [queue-closed? (chan)]
 (go-loop [operation (<! writer-chan)]
 (let [{:keys [op args]} operation]
 (if (not= op :stop)
 (do 
 (apply (op storage) args)
 (recur (<! writer-chan)))
 (do
 (close! writer-chan)
 (close! queue-closed?)))))
 ;; return a function that stops the loop
 (fn []
 (put! writer-chan {:op :stop})
 (println "waiting for storage worker to shut down")
 (<!! queue-closed?))))

在处理 :stop 信号的分支里,我们调用 close! 关闭了 queue-closed? 这个 channel。在 Clojure 里,如果 channel 被关闭,尝试获取它里面的值就会立刻返回 nil;如果 channel 没有被关闭,尝试获取它的值会阻塞当前线程。

在上述例子中,最终返回的闭包函数就在最后用 <!! 获取 queue-closed? 的值,但从始至终,都没有任何人往里面写东西,所以会一直阻塞,直到 :stop 信号被处理、queue-closed? 被关闭。

调用 start-worker 这个函数返回的停止函数,就会在消息队列被彻底清空之后才停止阻塞,停止阻止程序退出。

如果你感兴趣的话,我这里还有一个同时等待多个循环线程退出的 例子 :

(defn start-processor
 "Starts webmention processing pipelines. Returns a stopper function."
 [metadata]
 {:pre [(s/valid? ::processor-meta metadata)]
 :post [fn?]}
 ;; wmc is webmention queue; stopped is channel where processors 
 ;; put signals into, to notify that they're shut down
 (let [wmc (:wmc metadata), stopped (chan)
 concurrency (get-in metadata [:config :pipeline-concurrency])]
 ;; dispatch pipelines
 (doseq [n (range concurrency)
 :let [pipeline (assemble-pipeline metadata)]]
 (dispatch-pipeline-loop n wmc stopped pipeline))
 ;; return a function that stops the loop 
 (fn []
 (dotimes [_ concurrency] (put! wmc {:stop? true}))
 ;; after putting the stop signal, processor might not
 ;; stop immediately. wait for it to process what's left 
 ;; inside webmention queue and close it.
 (timbre/info "waiting for queue to clear, and processor to shut down")
 (dotimes [_ concurrency] (<!! stopped)))))

最后

尽管优雅退出机制研究起来挺有趣,但不可否认,上面这些代码简直是我写过最丑的 Clojure 代码了。处理系统外围的各种事件并不如写抽象的业务逻辑那么优雅,无时无刻都在干脏活,但这活儿总得有人干的。

回见!


  1. 代码看起来有点臃肿,因为 Clojure 寄宿在 Java 上,处理系统信号必须用 Java 类,自然啰唆。
参与讨论