Go 程式如何實現優雅退出?來看看 K8s 是怎麼做的——下篇

技术颜良發表於2024-08-27

Go 程式如何實現優雅退出?來看看 K8s 是怎麼做的——下篇

GoCN
2024年08月27日 08:02 浙江

以下文章來源於Go程式設計世界 ,作者江湖十年

Go程式設計世界.

不限於 Golang、Docker、Kubernetes,技術部落格 https://jianghushinian.cn/ 的移動版。

本文帶大家一起來詳細學習下 Go 中的優雅退出,由於文章過長,拆分成上下兩篇,本文為下篇。

上篇:《Go 程式如何實現優雅退出?來看看 K8s 是怎麼做的——上篇》

K8s 的優雅退出

現在,我們已經掌握了 Go 中 HTTP Server 程式如何實現優雅退出,是時候看一看 K8s 中提供的一種更為優雅的優雅退出退出方案了😄。

這要從 K8s API Server 啟動入口說起:

https://github.com/kubernetes/kubernetes/blob/release-1.31/cmd/kube-apiserver/apiserver.go

func main() {
command := app.NewAPIServerCommand()
code := cli.Run(command)
os.Exit(code)
}

K8s API Server 啟動入口程式碼非常簡單,我們可以進入 app.NewAPIServerCommand() 檢視更多細節:

https://github.com/kubernetes/kubernetes/blob/release-1.31/cmd/kube-apiserver/app/server.go#L122

// NewAPIServerCommand creates a *cobra.Command object with default parameters
func NewAPIServerCommand() *cobra.Command {
...
cmd := &cobra.Command{
...
RunE: func(cmd *cobra.Command, args []string) error {
...
return Run(cmd.Context(), completedOptions)
},
...
}
cmd.SetContext(genericapiserver.SetupSignalContext())

...
return cmd
}

NewAPIServerCommand 函式中,我們要關注的核心程式碼只有兩行:

一行是 cmd.SetContext(genericapiserver.SetupSignalContext()),這是在為 cmd 物件設定 ctx 屬性。

另一行是 RunE 屬性中最後一行程式碼 Run(cmd.Context(), completedOptions),這裡是啟動程式,並使用了 cmd 物件的 ctx 屬性。

很明顯,K8s 使用了 Go 語言中流行的 Cobra 命令列框架作為程式的啟動框架,Cobra 提供瞭如下兩個方法可以設定和獲取 Context

func (c *Command) Context() context.Context {
return c.ctx
}

func (c *Command) SetContext(ctx context.Context) {
c.ctx = ctx
}

NOTE: 如果你對 Cobra 不太熟悉,可以參考我的另一篇文章《萬字長文:Go 語言現代命令列框架 Cobra 詳解》

這裡的 ctx 就是串聯起 K8s 實現優雅退出的核心物件。

首先透過 genericapiserver.SetupSignalContext() 獲取到一個 context.Context 物件,根據函式名稱可以猜測到它可能跟訊號有關。

對於 Run(cmd.Context(), completedOptions) 方法的呼叫,由於巢狀層級比較深,邏輯比較複雜,我就不把整個程式碼呼叫鏈都貼出來講了。總之,這個啟動過程最終可以定位到 preparedGenericAPIServer.RunWithContext 這個方法的執行。在 RunWithContext 方法內部的第一行程式碼 stopCh := ctx.Done() 是重點,它拿到了一個控制程式退出時機的 channel(這跟我們前文講解的優雅退出示例中 quit := make(chan os.Signal, 1) 變數作用相同),而這個 ctx 實際上就是 genericapiserver.SetupSignalContext() 的返回值,如果你感興趣可以詳細研究下這個 stopCh 的使用過程。

我們直接去分析 genericapiserver.SetupSignalContext() 的實現:

https://github.com/kubernetes/apiserver/blob/release-1.31/pkg/server/signal.go

package server

import (
"context"
"os"
"os/signal"
)

var onlyOneSignalHandler = make(chan struct{})
var shutdownHandler chan os.Signal

// SetupSignalHandler registered for SIGTERM and SIGINT. A stop channel is returned
// which is closed on one of these signals. If a second signal is caught, the program
// is terminated with exit code 1.
// Only one of SetupSignalContext and SetupSignalHandler should be called, and only can
// be called once.
func SetupSignalHandler() <-chan struct{} {
return SetupSignalContext().Done()
}

// SetupSignalContext is same as SetupSignalHandler, but a context.Context is returned.
// Only one of SetupSignalContext and SetupSignalHandler should be called, and only can
// be called once.
func SetupSignalContext() context.Context {
close(onlyOneSignalHandler) // panics when called twice

shutdownHandler = make(chan os.Signal, 2)

ctx, cancel := context.WithCancel(context.Background())
signal.Notify(shutdownHandler, shutdownSignals...)
go func() {
<-shutdownHandler
cancel()
<-shutdownHandler
os.Exit(1) // second signal. Exit directly.
}()

return ctx
}

// RequestShutdown emulates a received event that is considered as shutdown signal (SIGTERM/SIGINT)
// This returns whether a handler was notified
func RequestShutdown() bool {
if shutdownHandler != nil {
select {
case shutdownHandler <- shutdownSignals[0]:
return true
default:
}
}

return false
}

這裡程式碼不多,但卻相當精妙,可以一窺 K8s 設計之優雅。

我們從 SetupSignalContext 函式開始分析。

SetupSignalContext 函式第一行程式碼,透過呼叫 close(onlyOneSignalHandler) 來確保在整個程式中只呼叫一次 SetupSignalContext 函式,呼叫多次則直接 panic。這能強制呼叫方寫出正確的程式碼,避免出現意料之外的情況。

shutdownHandler 是一個包含了兩個緩衝區的 channel,而不像我們定義的 quit := make(chan os.Signal, 1) 那樣只有一個緩衝區大小。

我們前文講過,透過 signal.Notify(c chan<- os.Signal, sig ...os.Signal) 函式註冊所關注的訊號後,signal 包在給 c 傳送訊號時不會阻塞。因為我們要接收兩次退出訊號,所以 shutdownHandler 緩衝區大小為 2

這也是 SetupSignalContext 函式的精髓所在,它實現了收到一次 SIGINT/SIGTERM 訊號,程式優雅退出,收到兩次 SIGINT/SIGTERM 訊號,程式強制退出的功能。

程式碼片段如下:

ctx, cancel := context.WithCancel(context.Background())
signal.Notify(shutdownHandler, shutdownSignals...)
go func() {
<-shutdownHandler
cancel()
<-shutdownHandler
os.Exit(1) // second signal. Exit directly.
}()

這裡使用一個帶有取消功能的 Context,當第一次收到訊號時,就呼叫 cancel() 取消這個 ctx。而這個 ctx 會作為函式返回值返給呼叫方,呼叫方拿到它,就可以在需要的地方呼叫 <-ctx.Done() 來等待退出訊號了。這就是 preparedGenericAPIServer.RunWithContext 方法中呼叫 stopCh := ctx.Done() 拿到 channel,然後等待 <-stopCh 退出訊號的邏輯了。

這裡用到的 shutdownSignals 變數,定義在 signal_posix.go 檔案中:

https://github.com/kubernetes/apiserver/blob/release-1.31/pkg/server/signal_posix.go

package server

import (
"os"
"syscall"
)

var shutdownSignals = []os.Signal{os.Interrupt, syscall.SIGTERM}

shutdownSignals 是一個儲存了兩個訊號的切片物件。

os.Interrupt 實際上是一個變數,它的值等於 syscall.SIGINT

// The only signal values guaranteed to be present in the os package on all
// systems are os.Interrupt (send the process an interrupt) and os.Kill (force
// the process to exit). On Windows, sending os.Interrupt to a process with
// os.Process.Signal is not implemented; it will return an error instead of
// sending a signal.
var (
Interrupt Signal = syscall.SIGINT
Kill Signal = syscall.SIGKILL
)

這裡為實現優雅退出,監控了兩個訊號 SIGINTSIGTERM,並沒有監控 SIGQUIT 訊號。不過這已經足夠用了,根據我的經驗,絕大多數情況下我們都會使用 Ctrl + C 終止程式,而非使用 Ctrl + \

SetupSignalHandler 函式內部呼叫了 SetupSignalContext 函式,它唯一的作用就是直接返回給呼叫方 ctx.Done() 所返回的 channel,以此來方便呼叫方。

RequestShutdown 函式可以主動觸發退出事件訊號(SIGTERM/SIGINT),返回值表示是否觸發成功。

現在將 K8s 優雅退出方案整合進我們的 net/http 優雅退出示例程式中:

package main

import (
"context"
"errors"
"log"
"net/http"
"time"

genericapiserver "k8s.io/apiserver/pkg/server"
)

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) {
log.Fatalf("HTTP server error: %v", err)
}
log.Println("Stopped serving new connections")
}()

// NOTE: 只需要替換這 3 行程式碼,Gin 版本同理
// quit := make(chan os.Signal, 1)
// signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
// <-quit

// 可以直接丟棄,context.Context.Done() 返回的就是普通空結構體
<-genericapiserver.SetupSignalHandler()

log.Println("Shutdown Server...")

ctx, cancel := context.WithTimeout(context.Background(), 10*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")
}

我們只需要將如下 3 行程式碼:

quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit

替換成 K8s 提供的 SetupSignalHandler 函式呼叫即可:

<-genericapiserver.SetupSignalHandler()

其他程式碼都不用修改。

執行示例程式,按一次 Ctrl + C 測試優雅退出:

$ go build -o main main.go && ./main
^C2024/08/22 09:24:46 Shutdown Server...
2024/08/22 09:24:46 Stopped serving new connections
2024/08/22 09:24:49 HTTP server graceful shutdown completed
$ echo $?
0
$ curl "http://localhost:8000/sleep?duration=5s"
Welcome HTTP Server

執行示例程式,按兩次 Ctrl + C 測試強制退出:

$ go build -o main main.go && ./main
^C2024/08/22 09:25:28 Shutdown Server...
2024/08/22 09:25:28 Stopped serving new connections
^C
$ echo $?
1
$ curl "http://localhost:8000/sleep?duration=5s"
curl: (52) Empty reply from server

完美,K8s 為我們提供了優雅退出的新思路。這樣在開發環境,為了方便除錯,我們可以無需等待優雅退出,只要連續傳送兩次 SIGTERM/SIGINT 即可強制退出程式。在生產環境傳送一次 SIGTERM/SIGINT 訊號等待優雅退出。

使用 Gin 框架開發的 Web 程式也可以這樣修改,你可以自行嘗試。

gRPC 的優雅退出

gRPC Server 優雅退出

接下來我們一起看下 gRPC Server 程式如何實現優雅退出。

示例程式目錄結構如下:

$ tree grpc
grpc
├── Makefile
├── client
│ └── main.go
├── pb
│ ├── helloworld.pb.go
│ ├── helloworld.proto
│ └── helloworld_grpc.pb.go
└── server
└── main.go

helloworld.proto 中定義了 gRPC Server 支援的服務介面:

syntax = "proto3";

option go_package = ".;pb";

// The greeting service definition.
service Greeter {
// Sends a greeting
rpc SayHello (HelloRequest) returns (HelloReply) {}
}

// The request message containing the user's name.
message HelloRequest {
string name = 1;
string duration = 2;
}

// The response message containing the greetings
message HelloReply {
string message = 1;
}

server/main.go 中 Server 端程式碼如下:

// Package main implements a server for Greeter service.
package main

import (
"context"
"flag"
"fmt"
"log"
"net"
"time"

"google.golang.org/grpc"
genericapiserver "k8s.io/apiserver/pkg/server"

"github.com/jianghushinian/blog-go-example/gracefulstop/grpc/pb"
)

var (
port = flag.Int("port", 50051, "The server port")
)

// server is used to implement helloworld.GreeterServer.
type server struct {
pb.UnimplementedGreeterServer
}

// SayHello implements helloworld.GreeterServer
func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
log.Printf("Received: %v", in.GetName())

duration, _ := time.ParseDuration(in.GetDuration())
time.Sleep(duration)

return &pb.HelloReply{Message: "Hello " + in.GetName()}, nil
}

func main() {
flag.Parse()
lis, err := net.Listen("tcp", fmt.Sprintf(":%d", *port))
if err != nil {
log.Fatalf("failed to listen: %v", err)
}

s := grpc.NewServer()
pb.RegisterGreeterServer(s, &server{})
log.Printf("server listening at %v", lis.Addr())

go func() {
if err := s.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}()

<-genericapiserver.SetupSignalHandler()
log.Printf("Shutdown Server...")
s.GracefulStop()
log.Println("gRPC server graceful shutdown completed")
}

這與 HTTP Server 的優雅退出邏輯基本相同,同樣由 grpc 包提供了優雅退出方法 GracefulStop

在接收到退出訊號以後,呼叫 s.GracefulStop() 方法即可實現優雅退出。可以發現,這其實是一個優雅退出的套路。

client/main.go 中 Client 端程式碼如下:

// Package main implements a client for Greeter service.
package main

import (
"context"
"flag"
"log"
"time"

"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"

"github.com/jianghushinian/blog-go-example/gracefulstop/grpc/pb"
)

const (
defaultName = "world"
)

var (
addr = flag.String("addr", "localhost:50051", "the address to connect to")
name = flag.String("name", defaultName, "Name to greet")
)

func main() {
flag.Parse()
// Set up a connection to the server.
conn, err := grpc.NewClient(*addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Fatalf("did not connect: %v", err)
}
defer conn.Close()
c := pb.NewGreeterClient(conn)

// Contact the server and print out its response.
ctx, cancel := context.WithTimeout(context.Background(), time.Second*60)
defer cancel()
r, err := c.SayHello(ctx, &pb.HelloRequest{Name: *name, Duration: "10s"})
if err != nil {
log.Fatalf("could not greet: %v", err)
}
log.Printf("Greeting: %s", r.GetMessage())
}

執行示例程式,測試優雅退出邏輯:

# 執行服務端程式碼
$ go build -o main main.go && ./main
2024/08/22 09:26:17 server listening at [::]:50051
2024/08/22 09:26:24 Received: world
^C2024/08/22 09:26:26 Shutdown Server...
2024/08/22 09:26:34 gRPC server graceful shutdown completed
$ echo $?
0
# 執行客戶端程式碼
$ go build -o main main.go && ./main
2024/08/22 09:26:34 Greeting: Hello world

優雅退出生效。

既然 gRPC Server 中的優雅退出方案已經介紹完了,同講解 HTTP Server 優雅退出一樣,接下來我再帶你一起深入瞭解一下 GracefulStop 的原始碼是如何實現的。

GracefulStop 原始碼

GracefulStop 方法原始碼如下:

https://github.com/grpc/grpc-go/blob/v1.65.0/server.go#L1882

// GracefulStop stops the gRPC server gracefully. It stops the server from
// accepting new connections and RPCs and blocks until all the pending RPCs are
// finished.
func (s *Server) GracefulStop() {
s.stop(true)
}

func (s *Server) stop(graceful bool) {
s.quit.Fire()
defer s.done.Fire()

s.channelzRemoveOnce.Do(func() { channelz.RemoveEntry(s.channelz.ID) })
s.mu.Lock()
s.closeListenersLocked()
// Wait for serving threads to be ready to exit. Only then can we be sure no
// new conns will be created.
s.mu.Unlock()
s.serveWG.Wait()

s.mu.Lock()
defer s.mu.Unlock()

if graceful {
s.drainAllServerTransportsLocked()
} else {
s.closeServerTransportsLocked()
}

for len(s.conns) != 0 {
s.cv.Wait()
}
s.conns = nil

if s.opts.numServerWorkers > 0 {
// Closing the channel (only once, via grpcsync.OnceFunc) after all the
// connections have been closed above ensures that there are no
// goroutines executing the callback passed to st.HandleStreams (where
// the channel is written to).
s.serverWorkerChannelClose()
}

if graceful || s.opts.waitForHandlers {
s.handlersWG.Wait()
}

if s.events != nil {
s.events.Finish()
s.events = nil
}
}

GracefulStop 方法直接呼叫了 s.stop(true) 方法。

stop 方法的 graceful 引數用來決定是否啟用優雅退出,傳遞 true 表示優雅退出,傳遞 false 表示強制退出。

stop 方法第一段程式碼邏輯如下:

s.quit.Fire()
defer s.done.Fire()

Server 物件的 quitdone 屬性型別都為 *grpcsync.Event,前者用來標記 gRPC Server 正在執行退出流程,後者標記退出完成。

Event 定義如下:

https://github.com/grpc/grpc-go/blob/v1.65.0/internal/grpcsync/event.go

// Event represents a one-time event that may occur in the future.
type Event struct {
fired int32
c chan struct{}
o sync.Once
}

// Fire causes e to complete. It is safe to call multiple times, and
// concurrently. It returns true iff this call to Fire caused the signaling
// channel returned by Done to close.
func (e *Event) Fire() bool {
ret := false
e.o.Do(func() {
atomic.StoreInt32(&e.fired, 1)
close(e.c)
ret = true
})
return ret
}

// Done returns a channel that will be closed when Fire is called.
func (e *Event) Done() <-chan struct{} {
return e.c
}

// HasFired returns true if Fire has been called.
func (e *Event) HasFired() bool {
return atomic.LoadInt32(&e.fired) == 1
}

// NewEvent returns a new, ready-to-use Event.
func NewEvent() *Event {
return &Event{c: make(chan struct{})}
}

可以發現,*Event 物件的 Fire 方法就是將 fired 欄位值置為 1,並且關閉型別為 channel 的欄位 c,所以其實只要呼叫了 Fire,那麼呼叫 Done 方法將立即返回,呼叫 HasFired 方法就是在判斷 fired 欄位值是否為 1(即是否呼叫過 Fire 方法)。

s.quit.Fire() 程式碼被呼叫以後,Serve 方法就能夠感知到當前服務正在退出,接下來就不會再接收新的請求進來了。

Serve 方法原始碼如下:

// Serve accepts incoming connections on the listener lis, creating a new
// ServerTransport and service goroutine for each. The service goroutines
// read gRPC requests and then call the registered handlers to reply to them.
// Serve returns when lis.Accept fails with fatal errors. lis will be closed when
// this method returns.
// Serve will return a non-nil error unless Stop or GracefulStop is called.
//
// Note: All supported releases of Go (as of December 2023) override the OS
// defaults for TCP keepalive time and interval to 15s. To enable TCP keepalive
// with OS defaults for keepalive time and interval, callers need to do the
// following two things:
// - pass a net.Listener created by calling the Listen method on a
// net.ListenConfig with the `KeepAlive` field set to a negative value. This
// will result in the Go standard library not overriding OS defaults for TCP
// keepalive interval and time. But this will also result in the Go standard
// library not enabling TCP keepalives by default.
// - override the Accept method on the passed in net.Listener and set the
// SO_KEEPALIVE socket option to enable TCP keepalives, with OS defaults.
func (s *Server) Serve(lis net.Listener) error {
s.mu.Lock()
s.printf("serving")
s.serve = true
if s.lis == nil {
// Serve called after Stop or GracefulStop.
s.mu.Unlock()
lis.Close()
return ErrServerStopped
}

s.serveWG.Add(1)
defer func() {
s.serveWG.Done()
// 判斷當前服務是否正在退出
if s.quit.HasFired() {
// Stop or GracefulStop called; block until done and return nil.
<-s.done.Done()
}
}()

ls := &listenSocket{
Listener: lis,
channelz: channelz.RegisterSocket(&channelz.Socket{
SocketType: channelz.SocketTypeListen,
Parent: s.channelz,
RefName: lis.Addr().String(),
LocalAddr: lis.Addr(),
SocketOptions: channelz.GetSocketOption(lis)},
),
}
s.lis[ls] = true

defer func() {
s.mu.Lock()
if s.lis != nil && s.lis[ls] {
ls.Close()
delete(s.lis, ls)
}
s.mu.Unlock()
}()

s.mu.Unlock()
channelz.Info(logger, ls.channelz, "ListenSocket created")

var tempDelay time.Duration // how long to sleep on accept failure
for {
rawConn, err := lis.Accept()
if err != nil {
if ne, ok := err.(interface {
Temporary() bool
}); ok && ne.Temporary() {
if tempDelay == 0 {
tempDelay = 5 * time.Millisecond
} else {
tempDelay *= 2
}
if max := 1 * time.Second; tempDelay > max {
tempDelay = max
}
s.mu.Lock()
s.printf("Accept error: %v; retrying in %v", err, tempDelay)
s.mu.Unlock()
timer := time.NewTimer(tempDelay)
select {
case <-timer.C:
// 判斷當前服務是否正在退出
case <-s.quit.Done():
timer.Stop()
return nil
}
continue
}
s.mu.Lock()
s.printf("done serving; Accept = %v", err)
s.mu.Unlock()

// 判斷當前服務是否正在退出
if s.quit.HasFired() {
return nil
}
return err
}
tempDelay = 0
// Start a new goroutine to deal with rawConn so we don't stall this Accept
// loop goroutine.
//
// Make sure we account for the goroutine so GracefulStop doesn't nil out
// s.conns before this conn can be added.
s.serveWG.Add(1)
go func() {
s.handleRawConn(lis.Addr().String(), rawConn)
s.serveWG.Done()
}()
}
}

我們可以直接跳到 for 迴圈部分的程式碼段,每次透過 rawConn, err := lis.Accept() 接收到一個新的請求進來,都會使用 if s.quit.HasFired() 來判斷當前服務是否正在退出,如果返回結果為 true,則 Serve 方法直接退出。

此時 Serve 方法的 defer 語句開始執行,這裡會再次使用 if s.quit.HasFired() 判斷當前服務是否正在退出(之所以判斷兩次,因為 Serve 方法也可能由於其他原因導致退出,進入 defer 邏輯),如果是,則呼叫 <-s.done.Done() 阻塞在這裡,直到 GracefulStop 優雅退出邏輯執行完成。

此外,for 迴圈內部的 select 語句中,有一個 case 呼叫了 <-s.quit.Done(),也是在判斷當前服務是否正在退出,如果是,則呼叫 timer.Stop() 清理定時器後,Serve 方法直接退出。

我們接著往下看:

s.channelzRemoveOnce.Do(func() { channelz.RemoveEntry(s.channelz.ID) })
s.mu.Lock()
s.closeListenersLocked()
// Wait for serving threads to be ready to exit. Only then can we be sure no
// new conns will be created.
s.mu.Unlock()
s.serveWG.Wait()

這裡的 channelz 是 gRPC 的一個監控工具,用於跟蹤 gRPC 的內部狀態,RemoveEntry 會移除該伺服器的監控資料。

s.closeListenersLocked() 方法是不是很熟悉,這與 net/http 包中的 Shutdown 方法命名都一樣,作用也就不言而喻了。

定義如下:

// s.mu must be held by the caller.
func (s *Server) closeListenersLocked() {
for lis := range s.lis {
lis.Close()
}
s.lis = nil
}

對於 s.serveWG.Wait() 這行程式碼,根據這個操作的屬性名和方法名可以猜到,serveWG 明顯是 sync.WaitGroup 型別。

既然有 Wait(),那就應該會有 Add(1) 操作。其正在前文中貼出 Serve 程式碼中。

剛進入 Serve 方法時,就會呼叫 s.serveWG.Add(1)Serve 方法退出時執行 s.serveWG.Done()

s.serveWG.Add(1)
defer func() {
s.serveWG.Done()
if s.quit.HasFired() {
// Stop or GracefulStop called; block until done and return nil.
<-s.done.Done()
}
}()

並且,在 Serve 方法的 for 迴圈邏輯中,每次有新的請求進來,s.serveWG.Add(1)s.serveWG.Done() 也會被呼叫一次:

s.serveWG.Add(1)
go func() {
s.handleRawConn(lis.Addr().String(), rawConn)
s.serveWG.Done()
}()

所以,這裡其實是在等待 Serve 方法執行完成並退出。

接下來的程式碼段是根據是否要進行優雅退出,執行不同的邏輯:

s.mu.Lock()
defer s.mu.Unlock()

if graceful {
s.drainAllServerTransportsLocked()
} else {
s.closeServerTransportsLocked()
}

可以發現,接下來的全部操作都加鎖處理。

優雅退出走 s.drainAllServerTransportsLocked() 邏輯:

// s.mu must be held by the caller.
func (s *Server) drainAllServerTransportsLocked() {
if !s.drain {
for _, conns := range s.conns {
for st := range conns {
st.Drain("graceful_stop")
}
}
s.drain = true
}
}

它的主要作用是在伺服器優雅停止的過程中,讓所有的伺服器傳輸層(ServerTransports)停止接收新的請求,但繼續處理現有的請求,直到它們完成。

這裡有一行註釋:s.mu must be held by the caller

說明了在呼叫 drainAllServerTransportsLocked 方法之前,呼叫者必須已經持有 s.mu 鎖。這是為了確保在執行方法體時,伺服器的狀態不會被併發修改。

對於 if !s.drain 這個條件判斷,其用於確保 drainAllServerTransportsLocked 方法只會執行一次。

s.conns 屬性儲存了所有連線,是 map[string]map[transport.ServerTransport]bool 型別。

透過巢狀的 for 迴圈遍歷每個連線中的 ServerTransport 例項。ServerTransport 是 gRPC 中的一個概念,它表示一個抽象的傳輸層實現,負責處理客戶端和伺服器之間的實際資料傳輸。

呼叫 st.Drain("graceful_stop") 方法的作用,是告訴傳輸層不要再接收新的請求或連線了,但允許繼續處理現有的請求,直到它們完成。

這個方法會向客戶端傳送訊號,表明伺服器正在進行優雅關閉。它會給所有的客戶端傳送一個控制幀 GOAWAY(因為 gRPC 是基於 HTTP/2 的,所以才會這樣處理),告訴客戶端關閉 TCP 連線。

Drain 方法實現如下:

func (t *http2Server) Drain(debugData string) {
t.mu.Lock()
defer t.mu.Unlock()
if t.drainEvent != nil {
return
}
t.drainEvent = grpcsync.NewEvent()
t.controlBuf.put(&goAway{code: http2.ErrCodeNo, debugData: []byte(debugData), headsUp: true})
}

在完成對所有連線的遍歷和 Drain 操作後,將 s.drain 設定為 true,表示伺服器已經進入了 "drain" 狀態,這樣後續不會再次執行相同的操作。

結合之前持有鎖的操作,這裡不會重複執行。

相對來說,沒有采用優雅退出的另一個方法 closeServerTransportsLocked 就要暴力一些:

// s.mu must be held by the caller.
func (s *Server) closeServerTransportsLocked() {
for _, conns := range s.conns {
for st := range conns {
st.Close(errors.New("Server.Stop called"))
}
}
}

這裡直接呼叫 Close 方法關閉連線,省略了控制幀 GOAWAY 的傳送。

stop 函式接下來會等待所有現有的連線被安全關閉:

for len(s.conns) != 0 {
s.cv.Wait()
}
s.conns = nil

繼續往下執行:

if s.opts.numServerWorkers > 0 {
// Closing the channel (only once, via grpcsync.OnceFunc) after all the
// connections have been closed above ensures that there are no
// goroutines executing the callback passed to st.HandleStreams (where
// the channel is written to).
s.serverWorkerChannelClose()
}

這段程式碼用於關閉工作執行緒的 channel,確保所有處理程式都已經終止,不會再處理新的請求。

s.serverWorkerChannelClose 在初始化操作時被賦值:

// initServerWorkers creates worker goroutines and a channel to process incoming
// connections to reduce the time spent overall on runtime.morestack.
func (s *Server) initServerWorkers() {
s.serverWorkerChannel = make(chan func())
s.serverWorkerChannelClose = grpcsync.OnceFunc(func() {
close(s.serverWorkerChannel)
})
for i := uint32(0); i < s.opts.numServerWorkers; i++ {
go s.serverWorker()
}
}

接下來,如果是優雅退出或者配置了 s.opts.waitForHandlers 選項,則程式碼會呼叫 s.handlersWG.Wait(),等待所有的處理程式完成:

if graceful || s.opts.waitForHandlers {
s.handlersWG.Wait()
}

不知你有沒有注意到,Serve 方法內部,呼叫了 s.handleRawConn(lis.Addr().String(), rawConn) 來處理每一個請求。

handleRawConn 方法內部會呼叫 s.serveStreams(context.Background(), st, rawConn) 方法。

serveStreams 方法定義如下:

func (s *Server) serveStreams(ctx context.Context, st transport.ServerTransport, rawConn net.Conn) {
ctx = transport.SetConnection(ctx, rawConn)
ctx = peer.NewContext(ctx, st.Peer())
for _, sh := range s.opts.statsHandlers {
ctx = sh.TagConn(ctx, &stats.ConnTagInfo{
RemoteAddr: st.Peer().Addr,
LocalAddr: st.Peer().LocalAddr,
})
sh.HandleConn(ctx, &stats.ConnBegin{})
}

defer func() {
st.Close(errors.New("finished serving streams for the server transport"))
for _, sh := range s.opts.statsHandlers {
sh.HandleConn(ctx, &stats.ConnEnd{})
}
}()

streamQuota := newHandlerQuota(s.opts.maxConcurrentStreams)
st.HandleStreams(ctx, func(stream *transport.Stream) {
s.handlersWG.Add(1)
streamQuota.acquire()
f := func() {
defer streamQuota.release()
defer s.handlersWG.Done()
s.handleStream(st, stream)
}

if s.opts.numServerWorkers > 0 {
select {
case s.serverWorkerChannel <- f:
return
default:
// If all stream workers are busy, fallback to the default code path.
}
}
go f()
})
}

serveStreams 方法內部會呼叫 s.handlersWG.Add(1)s.handlersWG.Done() 操作。

serveStreams 執行完成後,stop 方法就可以繼續執行了:

if s.events != nil {
s.events.Finish()
s.events = nil
}

stop 方法最後,對事件進行了清理,檢查 s.events 是否不為空,如果不為空,則呼叫 s.events.Finish(),完成事件的清理工作。

至此,GracefulStop 的原始碼就分析完成了。

可以發現 grpc 的優雅退出跟 net/http 的優雅退出還是有很多相似之處的,這也很好理解,gRPC 是基於 HTTP/2 的,所以底層也是 HTTP 協議。

普通 Go 程式的優雅退出

在日常開發中,除了 HTTP Server 或者 gRPC Server 程式,我們也可能會開發一些其他需要優雅退出的程式。

在文章的最後一個小節,我再來介紹一種常見的週期性執行任務的 Go 程式如何實現優雅退出。

示例程式碼如下:

package main

import (
"log"
"time"

genericapiserver "k8s.io/apiserver/pkg/server"
)

type Syncer struct {
interval time.Duration
}

func (s *Syncer) Run(quit <-chan struct{}) error {
ticker := time.NewTicker(s.interval)
defer ticker.Stop()

for {
select {
case <-ticker.C:
// 業務邏輯
log.Println("do something")
case <-quit:
log.Println("Stop loop")
s.Stop()
return nil
}
}
}

func (s *Syncer) Stop() {
log.Println("Stop Syncer start")
time.Sleep(time.Second * 5)
log.Println("Stop Syncer done")
}

func main() {
s := Syncer{interval: time.Second}
quit := genericapiserver.SetupSignalHandler()

if err := s.Run(quit); err != nil {
log.Fatalf("Syncer run err: %s", err.Error())
}
}

這裡定義了一個 Syncer 結構體,用來實現週期性執行一段程式碼邏輯。

Run 方法中用到了 time.Ticker,在 for 迴圈中週期執行某些業務邏輯,比如定時同步資料狀態、生成資料包表等。

Run 方法中還用到了類似 http.Server.Shutdown 方法內部的 for + select 程式碼結構,來實現接收退出訊號 <-quit。當收到退出訊號,就呼叫 s.Stop() 進行優雅退出邏輯。

執行示例程式碼,中途按 Ctrl + C 進行優雅退出操作:

$ go build -o main main.go && ./main
2024/08/22 09:27:34 do something
2024/08/22 09:27:35 do something
2024/08/22 09:27:36 do something
^C2024/08/22 09:27:37 Stop loop
2024/08/22 09:27:37 Stop Syncer start
2024/08/22 09:27:42 Stop Syncer done

也可以嘗試強制退出:

$ go build -o main main.go && ./main
2024/08/22 09:28:05 do something
2024/08/22 09:28:06 do something
2024/08/22 09:28:07 do something
^C2024/08/22 09:28:07 Stop loop
2024/08/22 09:28:07 Stop Syncer start
^C

兩種操作都沒有問題。

可以將這個示例程式作為優雅退出的程式碼模板,整合進你的 Go 程式中。

至此,本文要講解的內容就全部都寫完了。

總結

所謂的優雅退出,其實就是在關閉程序的時候,不能“暴力”關閉,而是要等待程序中的邏輯(比如一次完整的 HTTP 請求)處理完成後,才關閉程序。

Go 為我們提供的 os/singal 包進行訊號處理。預設情況下接收到退出訊號 Go 程式會立即退出,使用 signal.Notify 註冊關注的退出訊號以後,我們可以實現自己的處理邏輯。

常見退出訊號有 SIGINTSIGQUITSIGTERMSIGKILLSIGKILL 不能被 Go 程式捕獲。

我們分別為 HTTP Server、gRPC Server 以及週期性執行任務的 Go 程式實現了優雅退出功能,並且對 net/http 包的 Shutdown 原始碼以及 grpc 包的 GracefulStop 原始碼都進行了分析和講解。

本文還重點講解了 K8s 為我們提供了一種更加優雅的方式,來實現優雅退出功能。我們可以實現收到一次 SIGINT/SIGTERM 訊號,程式優雅退出,收到兩次 SIGINT/SIGTERM 訊號,程式強制退出。

切記,忽略優雅退出可能會導致資料的不一致問題,因此實現優雅退出功能是非常有必要的。

實現優雅退出最核心的兩點:

  1. 接收退出訊號。
  2. 如何等待正在處理的任務完成,還要考慮超時機制。

這兩步做完,程式就可以退出了。

本文示例原始碼我都放在了 GitHub 中,歡迎點選檢視。

希望此文能對你有所啟發。

延伸閱讀

  • 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

- END -


推薦閱讀:

6 個必須嘗試的將程式碼轉換為引人注目的圖表的工具

Go 1.23新特性前瞻

Gopher的Rust第一課:第一個Rust程式

Go早期是如何在Google內部發展起來的

2024 Gopher Meetup 武漢站活動

go 中更加強大的 traces

「GoCN酷Go推薦」我用go寫了魔獸世界登入器?

Go區不大,創造神話,科目三殺進來了

想要了解Go更多內容,歡迎掃描下方👇關注公眾號,掃描 [實戰群]二維碼 ,即可進群和我們交流~


- 掃碼即可加入實戰群 -

圖片

圖片

分享、在看與點贊Go 圖片

閱讀 479
寫留言
寫留言
留言

暫無留言

相關文章