在写 Go 程序时,优雅退出是一个老生常谈的问题,也是我们在微服务开发过程中的标配,本文就来介绍下工作中常见的几种优雅退出场景,以及带大家一起来看一下 K8s 中的优雅退出是怎么实现的。
优雅退出
我们一般可以通过如下方式执行一个 Go 程序:
$ go build -o main main.go
$ ./main
如果要停止正在运行的程序,通常可以这样做:
- 在正在运行程序的终端执行
Ctrl + C。 - 在正在运行程序的终端执行
Ctrl + \。 - 在终端执行
kill命令,如kill pid或kill -9 pid。
以上是几种比较常见的终止程序的方式。
这几种操作本身没什么问题,不过它们的默认行为都比较“暴力”。它们会直接强制关闭进程,这就有可能导致出现数据不一致的问题。
比如,一个 HTTP Server 程序正在处理用户下单请求,用户付款操作已经完成,但订单状态还没来得及从「待支付」变更为「已支付」,进程就被杀死退出了。
这种情况肯定是要避免的,于是就有了优雅退出的概念。
所谓的优雅退出,其实就是在关闭进程的时候,不能“暴力”关闭,而是要等待进程中的逻辑(比如一次完整的 HTTP 请求)处理完成后,才关闭进程。
os/singal 信号机制
其实上面介绍的几种终止程序的方式,都是通过向正在执行的进程发送信号来实现的。
- 在终端执行
Ctrl + C发送的是SIGINT信号,这个信号表示中断,默认行为就是终止程序。 - 在终端执行
Ctrl + \发送的是SIGQUIT信号,这个信号其实跟SIGINT信号差不多,不过它会生成 core 文件,并在终端会打印很多日志内容,不如Ctrl + C常用。 kill命令与上面两个快捷键相比,更常用于结束以后台模式启动的进程,kill pid发送的是SIGTERM信号,而kill -9 pid则发送SIGKILL信号。
以上几种方式中我们见到了 4 种终止进程的信号:SIGINT、SIGQUIT、SIGTERM 和 SIGKILL。
这其中,前 3 种信号是可以被 Go 进程内部捕获并处理的,而 SIGKILL 信号则无法捕获,它会强制杀死进程,没有回旋余地。
在写 Go 代码时,默认情况下,我们没有关注任何信号,Go 程序会自行处理接收到的信号。对于 SIGINT、SIGTERM、SIGQUIT 这几个信号,Go 的处理方式是直接强制终止进程。
这在 os/signal 包的 官方文档 中有提及:
The signals SIGKILL and SIGSTOP may not be caught by a program, and therefore cannot be affected by this package.
By default, a synchronous signal is converted into a run-time panic. A SIGHUP, SIGINT, or SIGTERM signal causes the program to exit. A SIGQUIT, SIGILL, SIGTRAP, SIGABRT, SIGSTKFLT, SIGEMT, or SIGSYS signal causes the program to exit with a stack dump. A SIGTSTP, SIGTTIN, or SIGTTOU signal gets the system default behavior (these signals are used by the shell for job control). The SIGPROF signal is handled directly by the Go runtime to implement runtime.CPUProfile. Other signals will be caught but no action will be taken.
译文如下:
SIGKILL 和 SIGSTOP 两个信号可能不会被程序捕获,因此不会受到此包的影响。
默认情况下,一个同步信号会被转换为运行时恐慌(panic)。在收到 SIGHUP、SIGINT 或 SIGTERM 信号时,程序将退出。收到 SIGQUIT、SIGILL、SIGTRAP、SIGABRT、SIGSTKFLT、SIGEMT 或 SIGSYS 信号时,程序会在退出时生成堆栈转储(stack dump)。SIGTSTP、SIGTTIN 或 SIGTTOU 信号将按照系统的默认行为处理(这些信号通常由 shell 用于作业控制)。SIGPROF 信号由 Go 运行时直接处理,用于实现 runtime.CPUProfile。其他信号将被捕获但不会采取任何行动。
从这段描述中,我们可以发现,Go 程序在收到 SIGINT 和 SIGTERM 两种信号时,程序会直接退出,在收到 SIGQUIT 信号时,程序退出并生成 stack dump,即退出后控制台打印的那些日志。
我们可以写一个简单的小程序,来实验一下 Ctrl + C 终止 Go 程序的效果。
示例代码如下:
package main
import (
"fmt"
"time"
)
func main() {
fmt.Println("main enter")
time.Sleep(time.Second)
fmt.Println("main exit")
}
按照如下方式执行示例程序:
$ go build -o main main.go && ./main
main enter
^C
$ echo $?
130
这里先启动 Go 程序,然后在 Go 程序执行到 time.Sleep 的时,按下 Ctrl + C,程序会立即终止。
并且通过 echo $? 命令可以看到程序退出码为 130,表示异常退出,程序正常退出码通常为 0。
NOTE: 这里之所以使用
go build命令先将 Go 程序编译成二进制文件然后再执行,而不是直接使用go run命令执行程序。是因为不管程序执行结果如何,go run命令返回的程序退出状态码始终为1。只有先将 Go 程序编译成二进制文件以后,再执行二进制文件才能获得(可以使用echo $?命令)正常的进程退出码。
如果我们在 Go 代码中自行处理收到的 Ctrl + C 传来的信号 SIGINT,我们就能够控制程序的退出行为,这也是实现优雅退出的机会所在。
现在我们就来一起学习下 Go 为我们提供的信号处理包 os/singal。
Go 为我们提供了 os/singal 内置包用来处理信号,os/singal 包提供了如下 6 个函数 供我们使用:
// 忽略一个或多个指定的信号
func Ignore(sig ...os.Signal)
// 判断指定的信号是否被忽略了
func Ignored(sig os.Signal) bool
// 注册需要关注的某些信号,信号会被传递给函数的第一个参数(channel 类型的参数 c)
func Notify(c chan<- os.Signal, sig ...os.Signal)
// 带有 Context 版本的 Notify
func NotifyContext(parent context.Context, signals ...os.Signal) (ctx context.Context, stop context.CancelFunc)
// 取消关注指定的信号(之前通过调用 Notify 所关注的信号)
func Reset(sig ...os.Signal)
// 停止向 channel 发送所有关注的信号
func Stop(c chan<- os.Signal)
此包中的函数允许程序更改 Go 程序处理信号的默认方式。
这里我们最需要关注的就是 Notify 函数,它可以用来注册我们需要关注的某些信号,这会禁用给定信号的默认行为,转而通过一个或多个已注册的通道(channel)传送它们。
我们写一个代码示例程序来看一下 os/singal 如何使用:
package main
import (
"fmt"
"os"
"os/signal"
"syscall"
)
func main() {
fmt.Println("main enter")
quit := make(chan os.Signal, 1)
// 注册需要关注的信号:SIGINT、SIGTERM、SIGQUIT
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
// 阻塞当前 goroutine 等待信号
sig := <-quit
fmt.Printf("received signal: %d-%s\n", sig, sig)
fmt.Println("main exit")
}
如你所见,os/singal 包使用起来非常简单。
首先我们定义了一个名为 quit 的 channel,类型为 os.Signal,长度为 1,用来接收关注的信号。
调用 signal.Notify 函数对我们要关注的信号进行注册。
然后调用 sig := <-quit 将会阻塞当前 main 函数所在的主 goroutine,直到进程接收到 SIGINT、SIGTERM、SIGQUIT 中的任意一个信号。
当我们关注了这几个信号以后,Go 不会自行处理这几个信号,需要我们自己来处理。
程序中打印了几条日志用来观察效果。
这里需要特别注意的是:我们通过 signal.Notify(c chan<- os.Signal, sig ...os.Signal) 函数注册所关注的信号,signal 包在收到对应信号时,会向 c 这个 channel 发送信号,但是发送信号时不会阻塞。也就是说,如果 signal 包发送信号到 c 时,由于 c 满了而导致阻塞,signal 包会直接丢弃信号。
在 signal.Notify 函数签名上方的注释中有详细说明:
// Notify causes package signal to relay incoming signals to c.
// If no signals are provided, all incoming signals will be relayed to c.
// Otherwise, just the provided signals will.
//
// Package signal will not block sending to c: the caller must ensure
// that c has sufficient buffer space to keep up with the expected
// signal rate. For a channel used for notification of just one signal value,
// a buffer of size 1 is sufficient.
//
// It is allowed to call Notify multiple times with the same channel:
// each call expands the set of signals sent to that channel.
// The only way to remove signals from the set is to call Stop.
//
// It is allowed to call Notify multiple times with different channels
// and the same signals: each channel receives copies of incoming
// signals independently.
func Notify(c chan<- os.Signal, sig ...os.Signal) {
...
}
译文如下:
Notify 会让 signal 包将收到的信号转发到通道 c 中。如果没有提供具体的信号类型,则所有收到的信号都会被转发到 c。否则,只会将指定的信号转发到 c。 signal 包向 c 发送信号时不会阻塞:调用者必须确保 c 具有足够的缓冲区空间以应对预期的信号速率。如果一个通道仅用于通知单个信号值,那么一个大小为 1 的缓冲区就足够了。 允许使用同一个通道多次调用 Notify:每次调用都会扩展发送到该通道的信号集。要移除信号集中的信号,唯一的方法是调用 Stop。 允许使用不同的通道和相同的信号多次调用 Notify:每个通道都会独立接收传入信号的副本。
在 signal.Notify 函数内部通过 go watchSignalLoop() 方式启动了一个新的 goroutine,用来监控信号:
var (
// watchSignalLoopOnce guards calling the conditionally
// initialized watchSignalLoop. If watchSignalLoop is non-nil,
// it will be run in a goroutine lazily once Notify is invoked.
// See Issue 21576.
watchSignalLoopOnce sync.Once
watchSignalLoop func()
)
...
func Notify(c chan<- os.Signal, sig ...os.Signal) {
...
add := func(n int) {
if n < 0 {
return
}
if !h.want(n) {
h.set(n)
if handlers.ref[n] == 0 {
enableSignal(n)
// The runtime requires that we enable a
// signal before starting the watcher.
watchSignalLoopOnce.Do(func() {
if watchSignalLoop != nil {
// 监控信号循环
go watchSignalLoop()
}
})
}
handlers.ref[n]++
}
}
...
}
可以发现 watchSignalLoop 函数只会执行一次,并且采用 goroutine 的方式执行。
watchSignalLoop 函数的定义可以在 os/signal/signal_unix.go 中找到:
func loop() {
for {
process(syscall.Signal(signal_recv()))
}
}
func init() {
watchSignalLoop = loop
}
可以看到,这里开启了一个无限循环,来执行 process 函数:
func process(sig os.Signal) {
n := signum(sig)
if n < 0 {
return
}
handlers.Lock()
defer handlers.Unlock()
for c, h := range handlers.m {
if h.want(n) {
// send but do not block for it
select {
case c <- sig:
default: // 当向 c 发送信号遇到阻塞时,default 逻辑直接丢弃了 sig 信号,没做任何处理
}
}
}
// Avoid the race mentioned in Stop.
for _, d := range handlers.stopping {
if d.h.want(n) {
select {
case d.c <- sig:
default:
}
}
}
}
这个 process 函数就是 os/signal 包向我们注册的 channel 发送信号的核心逻辑。
os/signal 包在收到我们使用 signal.Notify 注册的信号时,会通过 c <- sig 向通道 c 发送信号。如果向 c 发送信号遇到阻塞,default 逻辑会直接丢弃 sig 信号,不做任何处理。这也就是为什么我们在创建 quit := make(chan os.Signal, 1) 时一定要给 channel 分配至少 1 个缓冲区。
我们可以尝试执行这个示例程序,得到如下输出:
$ go build -o main main.go && ./main
main enter
^Creceived signal: 2-interrupt
main exit
$ echo $?
0
首先使用 go build -o main main.go && ./main 命令编译并执行程序。
然后程序会打印 main enter 日志并阻塞在那里。
此时我们按下 Ctrl + C,控制台会打印日志 ^Creceived signal: 2-interrupt,然后输出 main exit 并退出。
这里第二行日志开头的 ^C 就表示我们按下了 Ctrl + C,收到的信号值为为 2,字符串表示形式为 interrupt。
程序退出码为 0,因为信号被我们捕获并处理,然后程序正常退出,我们改变了 Go 程序对 SIGINT 信号处理的默认行为。
其实每一个信号在 os/signal 包中都被定义为一个常量:
// A Signal is a number describing a process signal.
// It implements the os.Signal interface.
type Signal int
// Signals
const (
SIGINT = Signal(0x2)
SIGQUIT = Signal(0x3)
SIGTERM = Signal(0xf)
...
)
对应的字符串表示形式为:
// Signal table
var signals = [...]string{
2: "interrupt",
3: "quit",
15: "terminated",
...
}
NOTE: 示例程序中,我们是在
main函数中调用signal.Notify注册关注的信号,即在主的 goroutine 中调用。其实将其放在子 goroutine 中调用也是可以的,并不会影响程序效果,你可以自行尝试。
现在我们已经知道了在 Go 中如何使用 os/signal 来接收并处理进程退出信号,那么接下来要关注的就是在进程退出前,如何保证主逻辑操作完成,以实现 Go 程序的优雅退出。
net/http 的优雅退出
讲解完了前置知识,终于可以进入讲解优雅退出的环节了。
首先我们就以一个 HTTP Server 为例,讲解下在 Go 程序中如何实现优雅退出。
HTTP Server 示例程序
这是一个简单的 HTTP Server 示例程序:
package main
import (
"log"
"net/http"
"time"
)
func main() {
srv := &http.Server{
Addr: ":8000",
}
http.HandleFunc("/sleep", func(w http.ResponseWriter, r *http.Request) {
duration, err := time.ParseDuration(r.FormValue("duration"))
if err != nil {
http.Error(w, err.Error(), 400)
return
}
time.Sleep(duration)
_, _ = w.Write([]byte("Hello World!"))
})
if err := srv.ListenAndServe(); err != nil {
// Error starting or closing listener:
log.Fatalf("HTTP server ListenAndServe: %v", err)
}
log.Println("Stopped serving new connections")
}
NOTE: 注意示例程序中在处理
srv.ListenAndServe()返回的错误时,使用了log.Fatalf来打印日志并退出程序,这会调用os.Exit(1),如果函数中有defer语句则不会被执行,所以log.Fatalf仅建议在测试程序中使用,生产环境中谨慎使用。
示例中的 HTTP Server 监听了 8000 端口,并提供一个 /sleep 接口,根据用户传入的 duration 参数 sleep 对应的时间,然后返回响应。
执行示例程序:
$ go build -o main main.go && ./main
然后新打开另外一个终端访问这个 HTTP Server:
$ curl "http://localhost:8000/sleep?duration=0s"
Hello World!
传递 duration=0s 参数表示不进行 sleep,我们立即得到了正确的响应。
现在我们增加一点 sleep 时间进行请求:
$ curl "http://localhost:8000/sleep?duration=5s"
在 5s 以内回到运行 HTTP Server 的终端,并用 Ctrl + C 终止程序:
$ go build -o main main.go && ./main
^C
这次我们的客户端请求没有得到正确的响应:
$ curl "http://localhost:8000/sleep?duration=5s"
curl: (52) Empty reply from server
这就是没有实现优雅退出所带来的后果。
当一个客户端请求正在进行中,此时终止 HTTP Server 进程,请求还没有来得及完成,连接就被断开了,客户端无法得到正确的响应结果。
为了改变这一局面,就需要进行优雅退出操作。
HTTP Server 优雅退出
我们来分析下一个 HTTP Server 要进行优雅退出,需要做哪些事情:
- 首先,我们要关闭 HTTP Server 监听的端口,即通过
net.Listen所开启的Listener,以免新的请求进来。 - 接着,我们要关闭所有空闲的 HTTP 连接。
- 然后,我们还需要等待所有正在处理请求的 HTTP 连接变为空闲状态之后关闭它们。这里应该可以进行无限期等待,也可以设置一个超时时间,超过一定时间后强制断开连接,以免程序永远无法退出。
- 最后,正常退出进程。
幸运的是针对以上 HTTP Server 的优雅退出流程,net/http 包已经帮我们实现好了。
在 Go 1.8 版本之前,我们需要自己实现以上流程,或者有一些流行的第三方包也能帮我们做到。而从 Go 1.8 版本开始,net/http 包自身为我们提供了 http.Server.Shutdown 方法可以实现优雅退出的完整流程。
可以在 Go 仓库 issues/4674 中看到对 net/http 包加入优雅退出功能的讨论,这个问题最早在 2013 年就被提出了,不过却从 Go 1.1 版本拖到了 Go 1.8 版本才得以支持。
我们可以在 net/http 文档 中找到关于 http.Server.Shutdown 方法的说明:
Shutdown gracefully shuts down the server without interrupting any active connections. Shutdown works by first closing all open listeners, then closing all idle connections, and then waiting indefinitely for connections to return to idle and then shut down. If the provided context expires before the shutdown is complete, Shutdown returns the context's error, otherwise it returns any error returned from closing the Server's underlying Listener(s).
译文如下:
Shutdown 会优雅地关闭服务器,而不会中断任何活动的连接。它的工作原理是先关闭所有已打开的监听器(listeners),然后关闭所有空闲的连接,并无限期地等待所有连接变为空闲状态后再关闭服务器。如果在关闭完成之前,传入的上下文(context)过期,Shutdown 会返回上下文的错误,否则它将返回关闭服务器底层监听器时所产生的任何错误。
现在,结合前文介绍的 os/signal 包以及 net/http 包提供的 http.Server.Shutdown 方法,我们可以写出如下优雅退出 HTTP Server 代码:
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
srv := &http.Server{
Addr: ":8000",
}
http.HandleFunc("/sleep", func(w http.ResponseWriter, r *http.Request) {
duration, err := time.ParseDuration(r.FormValue("duration"))
if err != nil {
http.Error(w, err.Error(), 400)
return
}
time.Sleep(duration)
_, _ = w.Write([]byte("Hello World!"))
})
go func() {
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
<-quit
log.Println("Shutdown Server...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// We received an SIGINT/SIGTERM/SIGQUIT signal, shut down.
if err := srv.Shutdown(ctx); err != nil {
// Error from closing listeners, or context timeout:
log.Printf("HTTP server Shutdown: %v", err)
}
log.Println("HTTP server graceful shutdown completed")
}()
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
// Error starting or closing listener:
log.Fatalf("HTTP server ListenAndServe: %v", err)
}
log.Println("Stopped serving new connections")
}
示例中,为了让主 goroutine 不被阻塞,我们开启了一个新的 goroutine 来支持优雅退出。
quit 用来接收关注的信号,我们关注了 SIGINT、SIGTERM、SIGQUIT 这 3 个程序退出信号。
<-quit 收到退出信号以后,程序将进入优雅退出环节。
调用 srv.Shutdown(ctx) 进行优雅退出时,传递了一个 10s 超时的 Context,这是为了超过一定时间后强制退出,以免程序无限期等待下去,永远无法退出。
并且我们修改了调用 srv.ListenAndServe() 时的错误处理判断代码,因为调用 srv.Shutdown(ctx) 后,srv.ListenAndServe() 立即返回 http.ErrServerClosed 错误,这是符合预期的错误,所以我们将其排除在错误处理流程之外。
执行示例程序:
$ go build -o main main.go && ./main
打开新的终端,访问 HTTP Server:
$ curl "http://localhost:8000/sleep?duration=5s"
5s 以内回到 HTTP Server 启动终端,按下 Ctrl + C:
$ go build -o main main.go && ./main
^C2024/08/22 09:15:20 Shutdown Server...
2024/08/22 09:15:20 Stopped serving new connections
可以发现程序进入了优雅退出流程 Shutdown Server...,并最终打印 Stopped serving new connections 日志后退出。
遗憾的是,客户端请求并没有接收到成功的响应信息:
$ curl "http://localhost:8000/sleep?duration=5s"
curl: (52) Empty reply from server
看来,我们的优雅退出实现并没有生效。
其实仔细观察你会发现,我们的程序少打印了一行 HTTP server graceful shutdown completed 日志,说明实现优雅退出的 goroutine 并没有执行完成。
根据 net/http 文档 的描述:
When Shutdown is called, Serve, ListenAndServe, and ListenAndServeTLS immediately return ErrServerClosed. Make sure the program doesn't exit and waits instead for Shutdown to return.
即当调用 srv.Shutdown(ctx) 进入优雅退出流程后,Serve、ListenAndServe 和 ListenAndServeTLS 这三个方法会立即返回 ErrServerClosed 错误,我们要确保程序没有退出,而是等待 Shutdown 方法执行完成并返回。
因为进入优雅退出流程后,srv.ListenAndServe() 会立即返回,主 goroutine 会马上执行最后一行代码 log.Println("Stopped serving new connections") 并退出。
此时子 goroutine 中运行的优雅退出逻辑 srv.Shutdown(ctx) 还没来得及处理完成,就跟随主 goroutine 一同退出了。
这是一个有坑的实现。
我们必须保证程序主 goroutine 等待 Shutdown 方法执行完成并返回后才退出。
修改后的代码如下所示:
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
srv := &http.Server{
Addr: ":8000",
}
http.HandleFunc("/sleep", func(w http.ResponseWriter, r *http.Request) {
duration, err := time.ParseDuration(r.FormValue("duration"))
if err != nil {
http.Error(w, err.Error(), 400)
return
}
time.Sleep(duration)
_, _ = w.Write([]byte("Welcome HTTP Server"))
})
go func() {
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
// Error starting or closing listener:
log.Fatalf("HTTP server ListenAndServe: %v", err)
}
log.Println("Stopped serving new connections")
}()
// 可以注册一些 hook 函数,比如从注册中心下线逻辑
srv.RegisterOnShutdown(func() {
log.Println("Register Shutdown 1")
})
srv.RegisterOnShutdown(func() {
log.Println("Register Shutdown 2")
})
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
<-quit
log.Println("Shutdown Server...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// We received an SIGINT/SIGTERM/SIGQUIT signal, shut down.
if err := srv.Shutdown(ctx); err != nil {
// Error from closing listeners, or context timeout:
log.Printf("HTTP server Shutdown: %v", err)
}
log.Println("HTTP server graceful shutdown completed")
}
这一次我们将优雅退出逻辑 srv.Shutdown(ctx) 和服务监听逻辑 srv.ListenAndServe() 代码位置进行了互换。
将 srv.Shutdown(ctx) 放在主 goroutine 中,而 srv.ListenAndServe() 放在了子 goroutine 中。这样的目的显而易见,主 goroutine 只有等待 srv.Shutdown(ctx) 执行完成才会退出,所以也就保证了优雅退出流程能过执行完成。
此外,我还顺便使用 srv.RegisterOnShutdown() 注册了两个函数到优雅退出流程中,srv.Shutdown(ctx) 内部会执行这里注册的函数。所以这里可以注册一些带有清理功能的函数,比如从注册中心下线逻辑等。
现在再次执行示例程序:
$ go build -o main main.go && ./main
^C2024/08/22 09:16:21 Shutdown Server...
2024/08/22 09:16:21 Stopped serving new connections
2024/08/22 09:16:21 Register Shutdown 1
2024/08/22 09:16:21 Register Shutdown 2
2024/08/22 09:16:24 HTTP server graceful shutdown completed
$ curl "http://localhost:8000/sleep?duration=5s"
Welcome HTTP Server
根据这两段日志来看,这一次的优雅退出流程一切正常。
并且可以观察到,我们使用 srv.RegisterOnShutdown 注册的 2 个 hook 函数是按照注册顺序依次执行的。
我们还可以测试下超时退出的逻辑,先启动 HTTP Server,然后使用 curl 命令请求服务时设置一个 20s 超时,这样在通过 Ctrl + C 进行优雅退出操作时,srv.Shutdown(ctx) 就会因为等待超过 10s 没有处理完请求而强制退出。
执行日志如下:
$ go build -o main main.go && ./main
^C2024/08/22 09:17:09 Shutdown Server...
2024/08/22 09:17:09 Stopped serving new connections
2024/08/22 09:17:09 Register Shutdown 1
2024/08/22 09:17:09 Register Shutdown 2
2024/08/22 09:17:19 HTTP server Shutdown: context deadline exceeded
2024/08/22 09:17:19 HTTP server graceful shutdown completed
$ curl "http://localhost:8000/sleep?duration=20s"
curl: (52) Empty reply from server
日志输出结果符合预期。
NOTE: 我们当前示例的实现方式有些情况下存在一个小问题,当
srv.ListenAndServe()返回后,如果子 goroutine 出现了panic,由于我们没有使用recover语句捕获panic,则main函数中的defer语句不会执行,这一点在生产环境下你要小心。 其实net/http包提供了另一种实现优雅退出的 示例代码,示例中还是将srv.ListenAndServe()放在主 goroutine 中,srv.Shutdown(ctx)放在子 goroutine 中,利用一个新的channel阻塞主 goroutine 的方式来实现。感兴趣的读者可以点击进去学习。
HTTP Handler 中有 goroutine 的情况
我们在 HTTP Handler 函数中加上一段异步代码,修改后的程序如下:
http.HandleFunc("/sleep", func(w http.ResponseWriter, r *http.Request) {
duration, err := time.ParseDuration(r.FormValue("duration"))
if err != nil {
http.Error(w, err.Error(), 400)
return
}
time.Sleep(duration)
// 模拟需要异步执行的代码,比如注册接口异步发送邮件、发送 Kafka 消息等
go func() {
log.Println("Goroutine enter")
time.Sleep(time.Second * 5)
log.Println("Goroutine exit")
}()
_, _ = w.Write([]byte("Welcome HTTP Server"))
})
这里新启动了一个 goroutine 模拟需要异步执行的代码,比如注册接口异步发送邮件、发送 Kafka 消息等。这在实际工作中非常常见。
执行示例程序,再次测试优雅退出流程:
$ go build -o main main.go && ./main
^C2024/08/22 09:18:53 Shutdown Server...
2024/08/22 09:18:53 Stopped serving new connections
2024/08/22 09:18:53 Register Shutdown 1
2024/08/22 09:18:53 Register Shutdown 2
2024/08/22 09:18:56 Goroutine enter
2024/08/22 09:18:56 HTTP server graceful shutdown completed
$ curl "http://localhost:8000/sleep?duration=5s"
Welcome HTTP Server
客户端请求被正确处理了,但是根据日志输出可以发现,处理函数 Handler 中的子 goroutine 并没有正常执行完成就退出了,Goroutine enter 日志有被打印,Goroutine exit 日志并没有被打印。
出现这种情况的原因,同样是因为主 goroutine 已经退出,子 goroutine 还没来得及处理完成,就跟随主 goroutine 一同退出了。
这会导致数据不一致问题。
为了解决这一问题,我们对示例程序做如下修改:
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
)
type Service struct {
wg sync.WaitGroup
}
func (s *Service) FakeSendEmail() {
s.wg.Add(1)
go func() {
defer s.wg.Done()
defer func() {
if err := recover(); err != nil {
log.Printf("Recovered panic: %v\n", err)
}
}()
log.Println("Goroutine enter")
time.Sleep(time.Second * 5)
log.Println("Goroutine exit")
}()
}
func (s *Service) GracefulStop(ctx context.Context) {
log.Println("Waiting for service to finish")
quit := make(chan struct{})
go func() {
s.wg.Wait()
close(quit)
}()
select {
case <-ctx.Done():
log.Println("context was marked as done earlier, than user service has stopped")
case <-quit:
log.Println("Service finished")
}
}
func (s *Service) Handler(w http.ResponseWriter, r *http.Request) {
duration, err := time.ParseDuration(r.FormValue("duration"))
if err != nil {
http.Error(w, err.Error(), 400)
return
}
time.Sleep(duration)
// 模拟需要异步执行的代码,比如注册接口异步发送邮件、发送 Kafka 消息等
s.FakeSendEmail()
_, _ = w.Write([]byte("Welcome HTTP Server"))
}
func main() {
srv := &http.Server{
Addr: ":8000",
}
svc := &Service{}
http.HandleFunc("/sleep", svc.Handler)
go func() {
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
// Error starting or closing listener:
log.Fatalf("HTTP server ListenAndServe: %v", err)
}
log.Println("Stopped serving new connections")
}()
// 错误写法
// srv.RegisterOnShutdown(func() {
// svc.GracefulStop(ctx)
// })
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
<-quit
log.Println("Shutdown Server...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// We received an SIGINT/SIGTERM/SIGQUIT signal, shut down.
if err := srv.Shutdown(ctx); err != nil {
// Error from closing listeners, or context timeout:
log.Printf("HTTP server Shutdown: %v", err)
}
// 优雅退出 service
svc.GracefulStop(ctx)
log.Println("HTTP server graceful shutdown completed")
}
这里对示例程序进行了重构,定义一个 Service 结构体用来承载业务逻辑,它包含一个 sync.WaitGroup 对象用来控制异步程序执行。
Handler 中的异步代码被移到了 s.FakeSendEmail() 方法中,s.FakeSendEmail() 方法内部会启动一个新的 goroutine 模拟异步发送邮件。
并且我们还为 Service 提供了一个优雅退出方法 GracefulStop,GracefulStop 使用 s.wg.Wait() 方式,来等待它关联的所有已开启的 goroutine 执行完成再退出。
在 main 函数中,执行 srv.Shutdown(ctx) 完成后,再调用 svc.GracefulStop(ctx) 实现优雅退出。
现在,执行示例程序,再次测试优雅退出流程:
$ go build -o main main.go && ./main
^C2024/08/22 09:20:03 Shutdown Server...
2024/08/22 09:20:03 Stopped serving new connections
2024/08/22 09:20:06 Goroutine enter
2024/08/22 09:20:06 Waiting for service to finish
2024/08/22 09:20:11 Goroutine exit
2024/08/22 09:20:11 Service finished
2024/08/22 09:20:11 HTTP server graceful shutdown completed
$ curl "http://localhost:8000/sleep?duration=5s"
Welcome HTTP Server
这一次 Goroutine exit 日志被正确打印出来,说明优雅退出生效了。
这也提醒我们,在开发过程中,不要随意创建一个不知道何时退出的 goroutine,我们要主动关注 goroutine 的生命周期,以免程序失控。
细心的读者应该已经发现,我在示例程序中注释了一段错误写法的代码:
// 错误写法
// srv.RegisterOnShutdown(func() {
// svc.GracefulStop(ctx)
// })
对于 Service 优雅退出子 goroutine 的场景的确不适用于将其注册到 srv.RegisterOnShutdown 中。
这是因为 svc.Handler 中的代码执行到 time.Sleep(duration) 时,程序还没开始执行 svc.FakeSendEmail(),这时如果我们按 Ctrl + C 退出程序,srv.Shutdown(ctx) 内部会先执行 srv.RegisterOnShutdown 注册的函数,svc.GracefulStop 会立即执行完成并退出,之后等待几秒,svc.Handler 中的逻辑才会走到 svc.FakeSendEmail(),此时就已经无法实现优雅退出 goroutine 了。
未完待续,下一篇我讲介绍 net/http.Server.Shutdown 源码,记得来学习。