Go 程序如何實現優雅退出?來看看 K8s 是怎麼做的——上篇
在寫 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 函數簽名上方的註釋中有詳細說明:
https://github.com/golang/go/blob/release-branch.go1.22/src/os/signal/signal.go#L121
// 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 中找到:
https://github.com/golang/go/blob/release-branch.go1.22/src/os/signal/signal_unix.go
func loop() {
for {
process(syscall.Signal(signal_recv()))
}
}
func init() {
watchSignalLoop = loop
}
可以看到,這裏開啓了一個無限循環,來執行 process 函數:
https://github.com/golang/go/blob/release-branch.go1.22/src/os/signal/signal.go#L232
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 了。
至此,HTTP Server 中基本的常見優雅退出場景及方案我們就介紹完了,接下來我再帶你一起深入瞭解一下 Shutdown 的源碼是如何實現的。
Shutdown 源碼
Shutdown 方法源碼如下:
https://github.com/golang/go/blob/release-branch.go1.22/src/net/http/server.go#L2990
// 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).
//
// 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.
//
// Shutdown does not attempt to close nor wait for hijacked
// connections such as WebSockets. The caller of Shutdown should
// separately notify such long-lived connections of shutdown and wait
// for them to close, if desired. See [Server.RegisterOnShutdown] for a way to
// register shutdown notification functions.
//
// Once Shutdown has been called on a server, it may not be reused;
// future calls to methods such as Serve will return ErrServerClosed.
func (srv *Server) Shutdown(ctx context.Context) error {
srv.inShutdown.Store(true)
srv.mu.Lock()
lnerr := srv.closeListenersLocked()
for _, f := range srv.onShutdown {
go f()
}
srv.mu.Unlock()
srv.listenerGroup.Wait()
pollIntervalBase := time.Millisecond
nextPollInterval := func() time.Duration {
// Add 10% jitter.
interval := pollIntervalBase + time.Duration(rand.Intn(int(pollIntervalBase/10)))
// Double and clamp for next time.
pollIntervalBase *= 2
if pollIntervalBase > shutdownPollIntervalMax {
pollIntervalBase = shutdownPollIntervalMax
}
return interval
}
timer := time.NewTimer(nextPollInterval())
defer timer.Stop()
for {
if srv.closeIdleConns() {
return lnerr
}
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
timer.Reset(nextPollInterval())
}
}
}
首先 Shutdown 方法註釋寫的非常清晰:
Shutdown 會優雅地關閉服務器,而不會中斷任何活動的連接。它的工作原理是先關閉所有已打開的監聽器(listeners),然後關閉所有空閒的連接,並無限期地等待所有連接變爲空閒狀態後再關閉服務器。如果在關閉完成之前,傳入的上下文(context)過期,Shutdown 會返回上下文的錯誤,否則它將返回關閉服務器底層監聽器時所產生的任何錯誤。
當調用 Shutdown 時,[Serve]、[ListenAndServe] 和 [ListenAndServeTLS] 會立即返回 [ErrServerClosed] 錯誤。請確保程序不會直接退出,而是等待 Shutdown 返回後再退出。
Shutdown 不會嘗試關閉或等待被劫持的連接(例如 WebSocket)。Shutdown 的調用者應單獨通知這些長時間存在的連接關於關閉的信息,並根據需要等待它們關閉。可以參考 [Server.RegisterOnShutdown] 來註冊關閉通知函數。
一旦在服務器上調用了 Shutdown,它將無法再次使用;之後對 Serve 等方法的調用將返回 ErrServerClosed 錯誤。
通過這段註釋,我們就能對 Shutdown 方法執行流程有個大概理解。
接着我們來從上到下依次分析下 Shutdown 源碼。
第一行代碼如下:
srv.inShutdown.Store(true)
Shutdown 首先將 inShutdown 標記爲 true,inShutdown 是 atomic.Bool 類型,它用來標記服務器是否正在關閉。
這裏使用了 atomic 來保證操作的原子性,以免其他方法讀取到錯誤的 inShutdown 標誌位,發生錯誤。避免 HTTP Server 進程已經開始處理結束邏輯,還會有新的請求進入到 srv.Serve 方法。
接着 Shutdown 會關閉監聽的端口:
srv.mu.Lock()
lnerr := srv.closeListenersLocked()
for _, f := range srv.onShutdown {
go f()
}
srv.mu.Unlock()
代碼中的 srv.closeListenersLocked() 就是在關閉所有的監聽器(listeners)。
方法定義如下:
func (s *Server) closeListenersLocked() error {
var err error
for ln := range s.listeners {
if cerr := (*ln).Close(); cerr != nil && err == nil {
err = cerr
}
}
return err
}
這一操作,就對應了在前文中講解的 HTTP Server 優雅退出流程中的第 1 步,關閉所有開啓的 net.Listener 對象。
接下來循環遍歷 srv.onShutdown 中的函數,並依次啓動新的 goroutine 對其進行調用。
onShutdown 是 []func() 類型,其切片內容正是在我們調用 srv.RegisterOnShutdown 的時候註冊進來的。
srv.RegisterOnShutdown 定義如下:
// RegisterOnShutdown registers a function to call on [Server.Shutdown].
// This can be used to gracefully shutdown connections that have
// undergone ALPN protocol upgrade or that have been hijacked.
// This function should start protocol-specific graceful shutdown,
// but should not wait for shutdown to complete.
func (srv *Server) RegisterOnShutdown(f func()) {
srv.mu.Lock()
srv.onShutdown = append(srv.onShutdown, f)
srv.mu.Unlock()
}
這是我們在前文中的使用示例:
// 可以註冊一些 hook 函數,比如從註冊中心下線邏輯
srv.RegisterOnShutdown(func() {
log.Println("Register Shutdown 1")
})
srv.RegisterOnShutdown(func() {
log.Println("Register Shutdown 2")
})
接着代碼執行到這一步:
srv.listenerGroup.Wait()
根據這個操作的屬性名和方法名可以猜到,listenerGroup 明顯是 sync.WaitGroup 類型。
既然有 Wait(),那就應該會有 Add(1) 操作。在源碼中搜索 listenerGroup.Add(1) 關鍵字,可以搜到如下方法:
// trackListener adds or removes a net.Listener to the set of tracked
// listeners.
//
// We store a pointer to interface in the map set, in case the
// net.Listener is not comparable. This is safe because we only call
// trackListener via Serve and can track+defer untrack the same
// pointer to local variable there. We never need to compare a
// Listener from another caller.
//
// It reports whether the server is still up (not Shutdown or Closed).
func (s *Server) trackListener(ln *net.Listener, add bool) bool {
s.mu.Lock()
defer s.mu.Unlock()
if s.listeners == nil {
s.listeners = make(map[*net.Listener]struct{})
}
if add {
if s.shuttingDown() {
return false
}
s.listeners[ln] = struct{}{}
s.listenerGroup.Add(1)
} else {
delete(s.listeners, ln)
s.listenerGroup.Done()
}
return true
}
trackListener 用於添加或移除一個 net.Listener 到已跟蹤的監聽器集合中。
這個方法會被 Serve 方法調用,而實際上我們執行 srv.ListenAndServe() 的方法內部,也是在調用 Serve 方法。
Serve 方法定義如下:
// Serve accepts incoming connections on the Listener l, creating a
// new service goroutine for each. The service goroutines read requests and
// then call srv.Handler to reply to them.
//
// HTTP/2 support is only enabled if the Listener returns [*tls.Conn]
// connections and they were configured with "h2" in the TLS
// Config.NextProtos.
//
// Serve always returns a non-nil error and closes l.
// After [Server.Shutdown] or [Server.Close], the returned error is [ErrServerClosed].
func (srv *Server) Serve(l net.Listener) error {
if fn := testHookServerServe; fn != nil {
fn(srv, l) // call hook with unwrapped listener
}
origListener := l
l = &onceCloseListener{Listener: l}
defer l.Close()
if err := srv.setupHTTP2_Serve(); err != nil {
return err
}
// 將 `net.Listener` 添加到已跟蹤的監聽器集合中
// 內部會通過調用 s.shuttingDown() 判斷是否正在進行退出操作,如果是,則返回 ErrServerClosed
if !srv.trackListener(&l, true) {
return ErrServerClosed
}
// Serve 函數退出時,將 `net.Listener` 從已跟蹤的監聽器集合中移除
defer srv.trackListener(&l, false)
baseCtx := context.Background()
if srv.BaseContext != nil {
baseCtx = srv.BaseContext(origListener)
if baseCtx == nil {
panic("BaseContext returned a nil context")
}
}
var tempDelay time.Duration // how long to sleep on accept failure
ctx := context.WithValue(baseCtx, ServerContextKey, srv)
for {
rw, err := l.Accept()
if err != nil {
// 每次新的請求進來,先判斷當前服務是否已經被標記爲正在關閉,如果是,則直接返回 ErrServerClosed
if srv.shuttingDown() {
return ErrServerClosed
}
if ne, ok := err.(net.Error); ok && ne.Temporary() {
if tempDelay == 0 {
tempDelay = 5 * time.Millisecond
} else {
tempDelay *= 2
}
if max := 1 * time.Second; tempDelay > max {
tempDelay = max
}
srv.logf("http: Accept error: %v; retrying in %v", err, tempDelay)
time.Sleep(tempDelay)
continue
}
return err
}
connCtx := ctx
if cc := srv.ConnContext; cc != nil {
connCtx = cc(connCtx, rw)
if connCtx == nil {
panic("ConnContext returned nil")
}
}
tempDelay = 0
c := srv.newConn(rw)
c.setState(c.rwc, StateNew, runHooks) // before Serve can return
go c.serve(connCtx)
}
}
在 Serve 方法內部,我們先重點關注如下代碼段:
if !srv.trackListener(&l, true) {
return ErrServerClosed
}
defer srv.trackListener(&l, false)
這說明 Serve 在啓動的時候會將一個新的監聽器(net.Listener)加入到 listeners 集合中。
Serve 函數退出時,會對其進行移除。
並且,srv.trackListener 內部又調用了 s.shuttingDown() 判斷當前服務是否正在進行退出操作,如果是,則返回 ErrServerClosed。
// 標記爲關閉狀態,就不會有請求進來,直接返回錯誤
if srv.shuttingDown() {
return ErrServerClosed
}
shuttingDown 定義如下:
func (s *Server) shuttingDown() bool {
return s.inShutdown.Load()
}
同理,在 for 循環中,每次 rw, err := l.Accept() 收到新的請求,都會先判斷當前服務是否已經被標記爲正在關閉,如果是,則直接返回 ErrServerClosed。
這裏其實就是在跟 Shutdown 方法中的 srv.inShutdown.Store(true) 進行配合操作。
Shutdown 收到優雅退出請求,就將 inShutdown 標記爲 true。此時 Serve 方法內部爲了不再接收新的請求進來,每次都會調用 s.shuttingDown() 進行判斷。保證不會再有新的請求進來,導致 Shutdown 無法退出。
這跟前文講解完全吻合,在 Shutdown 方法還沒執行完成的時候,Serve 方法其實已經退出了。也是我們爲什麼將 srv.ListenAndServe() 代碼放到子 goroutine 中的原因。
Shutdown 接着往下執行:
pollIntervalBase := time.Millisecond
nextPollInterval := func() time.Duration {
// Add 10% jitter.
interval := pollIntervalBase + time.Duration(rand.Intn(int(pollIntervalBase/10)))
// Double and clamp for next time.
pollIntervalBase *= 2
if pollIntervalBase > shutdownPollIntervalMax {
pollIntervalBase = shutdownPollIntervalMax
}
return interval
}
timer := time.NewTimer(nextPollInterval())
defer timer.Stop()
for {
if srv.closeIdleConns() {
return lnerr
}
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
timer.Reset(nextPollInterval())
}
}
這一段邏輯比較多,並且 nextPollInterval 函數看起來比較迷惑,不過沒關係,我們一點點來分析。
我們把這段代碼中的 nextPollInterval 函數單獨拿出來跑一下,就能大概知道它的意圖了:
package main
import (
"fmt"
"math/rand"
"time"
)
func main() {
const shutdownPollIntervalMax = 500 * time.Millisecond
pollIntervalBase := time.Millisecond
nextPollInterval := func() time.Duration {
// Add 10% jitter.
interval := pollIntervalBase + time.Duration(rand.Intn(int(pollIntervalBase/10)))
// Double and clamp for next time.
pollIntervalBase *= 2
if pollIntervalBase > shutdownPollIntervalMax {
pollIntervalBase = shutdownPollIntervalMax
}
return interval
}
for i := 0; i < 20; i++ {
fmt.Println(nextPollInterval())
}
}
執行這段程序,輸入結果如下:
$ go run main.go
1.078014ms
2.007835ms
4.151327ms
8.474296ms
17.487625ms
34.403371ms
64.613106ms
136.696655ms
273.873977ms
516.290814ms
502.815326ms
516.160214ms
523.34143ms
537.808701ms
518.913897ms
526.711692ms
518.421559ms
527.229427ms
526.904891ms
502.738764ms
我們可以把隨機數再去掉。
把這行代碼:
interval := pollIntervalBase + time.Duration(rand.Intn(int(pollIntervalBase/10)))
改成這樣:
interval := pollIntervalBase + time.Duration(pollIntervalBase/10)
重新執行這段程序,輸入結果如下:
$ go run main.go
1.1ms
2.2ms
4.4ms
8.8ms
17.6ms
35.2ms
70.4ms
140.8ms
281.6ms
550ms
550ms
550ms
550ms
550ms
550ms
550ms
550ms
550ms
550ms
550ms
根據輸出結果,我們可以清晰的看出,nextPollInterval 函數執行返回值,開始時按照 2 倍方式增長,最終固定在 550ms。
pollIntervalBase 最終值等於 shutdownPollIntervalMax。
根據公式計算 interval := pollIntervalBase + time.Duration(pollIntervalBase/10),即 interval = 500 + 500 / 10 = 550ms,計算結果與輸出結果相吻合。
說白了,這段代碼寫這麼複雜,其核心目的就是爲 Shutdown 方法中等待空閒連接關閉的輪詢操作設計一個動態的、帶有抖動(jitter)的時間間隔。這種設計確保服務器在執行優雅退出時,能夠有效地處理剩餘的空閒連接,同時避免不必要的資源浪費。
現在來看 for 循環這段代碼,就非常好理解了:
timer := time.NewTimer(nextPollInterval())
defer timer.Stop()
for {
if srv.closeIdleConns() {
return lnerr
}
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
timer.Reset(nextPollInterval())
}
}
這就是 Go 常用的定時器慣用法。
根據 nextPollInterval() 返回值大小,每次定時循環調用 srv.closeIdleConns() 方法。
並且這裏有一個 case 執行了 case <-ctx.Done(),這正是我們調用 srv.Shutdown(ctx) 時,用來控制超時時間傳遞進來的 Context。
另外,值得一提的是,在 Go 1.15 及以前的版本的 Shutdown 代碼中這段定時器代碼並不是這樣實現的。
舊版本代碼實現如下:
https://github.com/golang/go/blob/release-branch.go1.15/src/net/http/server.go
var shutdownPollInterval = 500 * time.Millisecond
...
ticker := time.NewTicker(shutdownPollInterval)
defer ticker.Stop()
for {
if srv.closeIdleConns() && srv.numListeners() == 0 {
return lnerr
}
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
}
}
舊版本代碼實現更加簡單,並沒有使用 time.NewTimer,而是使用了 time.NewTicker。這樣實現的好處是代碼簡單,邏輯清晰,沒花哨的功能。
其實我們在工作中寫代碼也是一樣的道理,先讓代碼 run 起來,後期再考慮優化的問題。Shutdown 方法在 Go 1.8 版本被加入,直到 Go 1.16 版本這段代碼才發生改變。
舊版本代碼使用 time.NewTicker 是因爲每次定時循環的週期都是固定值,不需要改變。
新版本代碼使用 time.NewTimer 是爲了在每次循環週期中調用 timer.Reset 重置間隔時間。
這也是一個值得學習的小技巧。我們在工作中經常會遇到類似的需求:每隔一段時間,執行一次操作。最簡單的方式就是使用 time.Sleep 來做間隔時長,然後就是 time.NewTicker 和 time.NewTimer 這兩種方式。這 3 種方式其實都能實現每隔一段時間執行一次操作,但它們適用場景又有所不同。
time.Sleep 是用阻塞當前 goroutine 的方式來實現的,它需要調度器先喚醒當前 goroutine,然後才能執行後續代碼邏輯。
time.Ticker 創建了一個底層數據結構定時器 runtimeTimer,並且監聽 runtimeTimer 計時結束後產生的信號。因爲 Go 爲其進行了優化,所以它的 CPU 消耗比 time.Sleep 小很多。
time.Timer 底層也是定時器 runtimeTimer,只不過我們可以方便的使用 timer.Reset 重置間隔時間。
所以這 3 者都有各自適用的場景。
現在我們需要繼續跟蹤的代碼就剩下 srv.closeIdleConns() 了,根據方法命名我們也能大概猜測到它的用途就是爲了關閉空閒連接。
closeIdleConns 方法定義如下:
// closeIdleConns closes all idle connections and reports whether the
// server is quiescent.
func (s *Server) closeIdleConns() bool {
s.mu.Lock()
defer s.mu.Unlock()
quiescent := true
for c := range s.activeConn {
st, unixSec := c.getState()
// Issue 22682: treat StateNew connections as if
// they're idle if we haven't read the first request's
// header in over 5 seconds.
// 這裏預留 5s,防止在第一次讀取連接頭部信息時超過 5s
if st == StateNew && unixSec < time.Now().Unix()-5 {
st = StateIdle
}
if st != StateIdle || unixSec == 0 {
// Assume unixSec == 0 means it's a very new
// connection, without state set yet.
// // unixSec == 0 代表這個連接是非常新的連接,則標誌位被置爲 false
quiescent = false
continue
}
c.rwc.Close()
delete(s.activeConn, c)
}
return quiescent
}
這個方法比較核心,所以整個操作做了加鎖處理。
使用 for 循環遍歷所有連接,activeConn 是一個集合,類型爲 map[*conn]struct{},裏面記錄了所有存活的連接。
c.getState() 能夠獲取連接的當前狀態,對應的還有一個 setState 方法能夠設置狀態,setState 方法會在 Serve 方法中被調用。這其實就形成閉環了,每次有新的請求進來,都會設置連接狀態(Serve 會根據當前處理請求的進度,將連接狀態設置成 StateNew、StateActive、StateIdle、StateClosed 等),而在 Shutdown 方法中獲取連接狀態。
接着,代碼中會判斷連接中的請求是否已經完成操作(即:是否處於空閒狀態 StateIdle),如果是,就直接將連接關閉,並從連接集合中移除,否則,跳過此次循環,等待下次循環週期。
這裏調用 c.rwc.Close() 關閉連接,調用 delete(s.activeConn, c) 將當前連接從集合中移除,直到集合爲空,表示全部連接已經被關閉釋放,循環退出。
closeIdleConns 方法最終返回的 quiescent 標誌位,是用來標記是否所有的連接都已經關閉。如果是,返回 true,否則,返回 false。
這個方法的邏輯,其實就對應了前文講解優雅退出流程中的第 2、3 兩步。
至此,Shutdown 的源碼就分析完成了。
Shutdown 方法的整個流程也完全是按照我們前文中講解的優雅退出流程來的。
NOTE: 除了使用
Shutdown進行優雅退出,net/http包還爲我們提供了Close方法用來強制退出,你可以自行嘗試。
Gin 的優雅退出
在工作中我們開發 Go Web 程序時,往往不會直接使用 net/http 包,而是引入一個第三方庫或框架。其中 Gin 作爲 Go 生態中最爲流行的 Web 庫,我覺得有必要講解一下在 Gin 中如何進行優雅退出。
不過,學習了 net/http 的優雅退出,實際上 Gin 框架的優雅退出是一樣的,因爲 Gin 只是一個路由庫,提供 HTTP Server 能力的還是 net/http。
Gin 框架中的優雅退出示例代碼如下:
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin"
)
func main() {
router := gin.Default()
router.GET("/sleep", func(c *gin.Context) {
duration, err := time.ParseDuration(c.Query("duration"))
if err != nil {
c.String(http.StatusBadRequest, err.Error())
return
}
time.Sleep(duration)
c.String(http.StatusOK, "Welcome Gin Server")
})
srv := &http.Server{
Addr: ":8000",
Handler: router,
}
go func() {
// 服務連接
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
// Error starting or closing listener:
log.Fatalf("HTTP server ListenAndServe: %v", err)
}
log.Println("Stopped serving new connections")
}()
// 等待中斷信號以優雅地關閉服務器(設置 5 秒的超時時間)
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(), 5*time.Second)
defer cancel()
// We received an SIGINT/SIGTERM 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")
}
可以發現,在 Gin 框架中實現優雅退出代碼與我們在 net/http 包中的實現沒什麼不同。
只不過我們在實例化 http.Server 對象時,將 *gin.Engine 作爲了 Handler 複製給 Handler 屬性:
srv := &http.Server{
Addr: ":8000",
Handler: router,
}
執行示例程序測試優雅退出,得到如下輸出:
$ go build -o main main.go && ./main
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.
[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
- using env: export GIN_MODE=release
- using code: gin.SetMode(gin.ReleaseMode)
[GIN-debug] GET /sleep --> main.main.func1 (3 handlers)
^C2024/08/22 09:23:36 Shutdown Server...
2024/08/22 09:23:36 Stopped serving new connections
[GIN] 2024/08/22 - 09:23:39 | 200 | 5.001282167s | 127.0.0.1 | GET "/sleep?duration=5s"
2024/08/22 09:23:39 HTTP server graceful shutdown completed
$ curl "http://localhost:8000/sleep?duration=5s"
Welcome Gin Server
這裏同樣在處理請求的過程中,按下 Ctrl + C,根據日誌可以發現,Gin 示例代碼中的優雅退出沒有問題。
Gin 框架文檔 也提到了在 Go 1.8 版本之前可以使用如下幾個第三方庫實現的優雅退出替代方案:
-
manners:可以優雅關機的 Go Http 服務器。
-
graceful:Graceful 是一個 Go 擴展包,可以優雅地關閉 http.Handler 服務器。
-
grace:Go 服務器平滑重啓和零停機時間部署。
當然我們使用的是 Go 1.8 以上版本,可以不需要這些庫。因爲 net/http 已經提供了原生的優雅退出方案,所以幾乎用不到它們,感興趣的可以自行研究下。
NOTE: 可以參閱 Gin 完整的 graceful-shutdown 示例。
延伸閱讀
-
os/signal Documentation:https://pkg.go.dev/os/signal@go1.22.0
-
os/signal 源碼:https://github.com/golang/go/blob/release-branch.go1.22/src/os/signal/signal.go
-
net/http Documentation:https://pkg.go.dev/net/http@go1.22.0
-
net/http Server Shutdown 源碼:https://github.com/golang/go/blob/release-branch.go1.22/src/net/http/server.go
-
Gin Web Framework 文檔:https://gin-gonic.com/zh-cn/docs/examples/graceful-restart-or-stop/
-
Gin graceful-shutdown examples:https://github.com/gin-gonic/examples/tree/master/graceful-shutdown
-
gRPC Quick start:https://grpc.io/docs/languages/go/quickstart/
-
gRPC-Go Examples:https://github.com/grpc/grpc-go/tree/master/examples
-
gRPC Server GracefulStop 源碼:https://github.com/grpc/grpc-go/blob/v1.65.0/server.go
-
Kubernetes 優雅退出信號處理源碼:https://github.com/kubernetes/apiserver/blob/release-1.31/pkg/server/signal.go
-
Proper HTTP shutdown in Go:https://dev.to/mokiat/proper-http-shutdown-in-go-3fji
-
Golang: graceful shutdown:https://dev.to/antonkuklin/golang-graceful-shutdown-3n6d
-
How to stop http.ListenAndServe():https://stackoverflow.com/questions/39320025/how-to-stop-http-listenandserve
-
本文 GitHub 示例代碼:https://github.com/jianghushinian/blog-go-example/tree/main/gracefulstop
聯繫我
-
公衆號:Go 編程世界
-
微信:jianghushinian
-
郵箱:jianghushinian007@outlook.com
-
博客:https://jianghushinian.cn
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/UR6Rf1ewthI8qU3A7Szfzg