Go 如何實現熱重啟

騰訊技術工程發表於2020-09-09

最近在最佳化公司框架 trpc 時發現了一個熱重啟相關的問題,最佳化之餘也總結沉澱下,對 go 如何實現熱重啟這方面的內容做一個簡單的梳理。

1.什麼是熱重啟?

熱重啟(Hot Restart),是一項保證服務可用性的手段。它允許服務重啟期間,不中斷已經建立的連線,老服務程式不再接受新連線請求,新連線請求將在新服務程式中受理。對於原服務程式中已經建立的連線,也可以將其設為讀關閉,等待平滑處理完連線上的請求及連線空閒後再行退出。透過這種方式,可以保證已建立的連線不中斷,連線上的事務(請求、處理、響應)可以正常完成,新的服務程式也可以正常接受連線、處理連線上的請求。當然,熱重啟期間程式平滑退出涉及到的不止是連線上的事務,也有訊息服務、自定義事務需要關注。

這是我理解的熱重啟的一個大致描述。熱重啟現在還有沒有存在的必要?我的理解是看場景。

以後臺開發為例,假如運維平臺有能力在服務升級、重啟時自動踢掉流量,服務就緒後又自動加回流量,假如能夠合理預估服務 QPS、請求處理時長,那麼只要配置一個合理的停止前等待時間,是可以達到類似熱重啟的效果的。這樣的話,在後臺服務裡面支援熱重啟就顯得沒什麼必要。但是,如果我們開發一個微服務框架,不能對將來的部署平臺、環境做這種假設,也有可能使用方只是部署在一兩臺物理機上,也沒有其他的負載均衡設施,但不希望因為重啟受干擾,熱重啟就很有必要。當然還有一些更復雜、要求更苛刻的場景,也需要熱重啟的能力。

熱重啟是比較重要的一項保證服務質量的手段,還是值得了解下的,這也是本文介紹的初衷。

2.如何實現熱重啟?

如何實現熱重啟,這裡其實不能一概而論,要結合實際的場景來看(比如服務程式設計模型、對可用性要求的高低等)。大致的實現思路,可以先拋一下。

一般要實現熱重啟,大致要包括如下步驟:

  • 首先,要讓老程式,這裡稱之為父程式了,先要 fork 出一個子程式來代替它工作;
  • 然後,子程式就緒之後,通知父程式,正常接受新連線請求、處理連線上收到的請求;
  • 再然後,父程式處理完已建立連線上的請求後、連線空閒後,平滑退出。

聽上去是挺簡單的...

2.1.認識 fork

大家都知道fork() 系統呼叫,父程式呼叫 fork 會建立一個程式副本,程式碼中還可以透過 fork 返回值是否為 0 來區分是子程式還是父程式。

int main(char **argv, int argc) {
    pid_t pid = fork();
    if (pid == 0) {
        printf("i am child process");
    } else {
        printf("i am parent process, i have a child process named %d", pid);
    }
}

可能有些開發人員不知道 fork 的實現原理,或者不知道 fork 返回值為什麼在父子程式中不同,或者不知道如何做到父子程式中返回值不同……瞭解這些是要有點知識積累的。

2.2.返回值

簡單概括下,ABI 定義了進行函式呼叫時的一些規範,如何傳遞引數,如何返回值等等,以 x86 為例,如果返回值是 rax 暫存器能夠容的一般都是透過 rax 暫存器返回的。

如果 rax 暫存器位寬無法容納下的返回值呢?也簡單,編譯器會安插些指令來完成這些神秘的操作,具體是什麼指令,就跟語言編譯器實現相關了。

  • c 語言,可能會將返回值的地址,傳遞到 rdi 或其他暫存器,被調函式內部呢,透過多條指令將返回值寫入 rdi 代指的記憶體區;
  • c 語言,也可能在被調函式內部,用多個暫存器 rax,rdx...一起暫存返回結果,函式返回時再將多個暫存器的值賦值到變數中;
  • 也可能會像 golang 這樣,透過棧記憶體來返回;

2.3.fork 返回值

fork 系統呼叫的返回值,有點特殊,在父程式和子程式中,這個函式返回的值是不同的,如何做到的呢?

聯想下父程式呼叫 fork 的時候,作業系統核心需要幹些什麼呢?分配程式控制塊、分配 pid、分配記憶體空間……肯定有很多東西啦,這裡注意下程式的硬體上下文資訊,這些是非常重要的,在程式被排程演算法選中進行排程時,是需要還原硬體上下文資訊的。

Linux fork 的時候,會對子程式的硬體上下文進行一定的修改,我就是讓你 fork 之後拿到的 pid 是 0,怎麼辦呢?前面 2.2 節提過了,對於那些小整數,rax 暫存器存下綽綽有餘,fork 返回時就是將作業系統分配的 pid 放到 rax 暫存器的。

那,對於子程式而言,我只要在 fork 的時候將它的硬體上下文 rax 暫存器清 0,然後等其他設定全 ok 後,再將其狀態從不可中斷等待狀態修改為可執行狀態,等其被排程器排程時,會先還原其硬體上下文資訊,包括 PC、rax 等等,這樣 fork 返回後,rax 中值為 0,最終賦值給 pid 的值就是 0。

因此,也就可以透過這種判斷 “pid 是否等於 0” 的方式來區分當前程式是父程式還是子程式了。

2.4.侷限性

很多人清楚 fork 可以建立一個程式的副本並繼續往下執行,可以根據 fork 返回值來執行不同的分支邏輯。如果程式是多執行緒的,在一個執行緒中呼叫 fork 會複製整個程式嗎?

fork 只能建立呼叫該函式的執行緒的副本,程式中其他執行的執行緒,fork 不予處理。這就意味著,對於多執行緒程式而言,寄希望於透過 fork 來建立一個完整程式副本是不可行的。

前面我們也提到了,fork 是實現熱重啟的重要一環,fork 這裡的這個侷限性,就制約著不同服務程式設計模型下的熱重啟實現方式。所以我們說具體問題具體分析,不同程式設計模型下實際上可以採用不同的實現方式。

3.單程式單執行緒模型

單程式單執行緒模型,可能很多人一聽覺得它已經被淘汰了,生產環境中不能用,真的麼?強如 redis,不就是單執行緒。強調下並非單執行緒模型沒用,ok,收回來,現在關注下單程式單執行緒模型如何實現熱重啟。

單程式單執行緒,實現熱重啟會比較簡單些:

  • fork 一下就可以建立出子程式,
  • 子程式可以繼承父程式中的資源,如已經開啟的檔案描述符,包括父程式的 listenfd、connfd,
  • 父程式,可以選擇關閉 listenfd,後續接受連線的任務就交給子程式來完成了,
  • 父程式,甚至也可以關閉 connfd,讓子程式處理連線上的請求、回包等,也可以自身處理完已建立的連線上的請求;
  • 父程式,在合適的時間點選擇退出,子程式開始變成頂樑柱。

核心思想就是這些,但是具體到實現,就有多種方法:

  • 可以選擇 fork 的方式讓子程式拿到原來的 listenfd、connfd,
  • 也可以選擇 unixdomain socket 的方式父程式將 listenfd、connfd 傳送給子程式。

有同學可能會想,我不傳遞這些 fd 行嗎?

  • 比如我開啟了 reuseport,父程式直接處理完已建立連線 connfd 上的請求之後關閉,子程式裡 reuseport.Listen 直接建立新的 listenfd。

也可以!但是有些問題必須要提前考慮到:

  • reuseport 雖然允許多個程式在同一個埠上多次 listen,似乎滿足了要求,但是要知道只要 euid 相同,都可以在這個埠上 listen!是不安全的!
  • reuseport 實現和平臺有關係,在 Linux 平臺上在同一個 address+port 上 listen 多次,多個 listenfd 底層可以共享同一個連線佇列,核心可以實現負載均衡,但是在 darwin 平臺上卻不會!

當然這裡提到的這些問題,在多執行緒模型下肯定也存在。

4.單程式多執行緒模型

前面提到的問題,在多執行緒模型中也會出現:

  • fork 只能複製 calling thread,not whole process!
  • reuseport 多次在相同地址+埠 listen 得到的多個 fd,不同平臺有不同的表現,可能無法做到接受連線時的 load banlance!
  • 非 reuseport 情況下,多次 listen 會失敗!
  • 不傳遞 fd,直接透過 reuseport 來重新 listen 得到 listenfd,不安全,不同服務程式例項可能會在同一個埠上監聽,gg!
  • 父程式平滑退出的邏輯,關閉 listenfd,等待 connfd 上請求處理結束,關閉 connfd,一切妥當後,父程式退出,子程式挑大樑!

5. 其他執行緒模型

其他執行緒都基本上避不開上述 3、4 的實現或者組合,對應問題相仿,不再贅述。

6. go 實現熱重啟:觸發時機

需要選擇一個時機來觸發熱重啟,什麼時候觸發呢?作業系統提供了訊號機制,允許程式做出一些自定義的訊號處理。

殺死一個程式,一般會透過kill -9傳送 SIGKILL 訊號給程式,這個訊號不允許捕獲,SIGABORT 也不允許捕獲,這樣可以允許程式所有者或者高許可權使用者控制程式生死,達到更好的管理效果。

kill 也可以用來傳送其他訊號給程式,如傳送 SIGUSR1、SIGUSR2、SIGINT 等等,程式中可以接收這些訊號,並針對性的做出處理。這裡可以選擇 SIGUSR1 或者 SIGUSR2 來通知程式熱重啟。

go func() {
    ch := make(chan os.Signal, 1)
    signal.Notify(ch, os.SIGUSR2)
    <- ch

    //接下來就可以做熱重啟相關的邏輯了
    ...
}()

7. 如何判斷熱重啟

那一個 go 程式重新啟動之後,所有執行時狀態資訊都是新的,那如何區分自己是否是子程式呢,或者說我是否要執行熱重啟邏輯呢?父程式可以透過設定子程式初始化時的環境變數,比如加個 HOT_RESTART=1。

這就要求程式碼中在合適的地方要先檢測環境變數 HOT_RESTART 是否為 1,如果成立,那就執行熱重啟邏輯,否則就執行全新的啟動邏輯。

8. ForkExec

假如當前程式收到 SIGUSR2 訊號之後,希望執行熱重啟邏輯,那麼好,需要先執行 syscall.ForkExec(...)來建立一個子程式,注意 go 不同於 cc++,它本身就是依賴多執行緒來排程協程的,天然就是多執行緒程式,只不過是他沒有使用 NPTL 執行緒庫來建立,而是透過 clone 系統呼叫來建立。

前面提過了,如果單純 fork 的話,只能複製呼叫 fork 函式的執行緒,對於程式中的其他執行緒無能為力,所以對於 go 這種天然的多執行緒程式,必須從頭來一遍,再 exec 一下。所以 go 標準庫提供的函式是 syscall.ForkExec 而不是 syscall.Fork。

9. go 實現熱重啟: 傳遞 listenfd

go 裡面傳遞 fd 的方式,有這麼幾種,父程式 fork 子程式的時候傳遞 fd,或者後面透過 unix domain socket 傳遞。需要注意的是,我們傳遞的實際上是 file description,而非 file descriptor。

附上一張類 unix 系統下 file descriptor、file description、inode 三者之間的關係圖:

Go 如何實現熱重啟

fd 分配都是從小到大分配的,父程式中的 fd 為 10,傳遞到子程式中之後有可能就不是 10。那麼傳遞到子程式的 fd 是否是可以預測的呢?可以預測,但是不建議。所以我提供了兩種實現方式。

9.1 ForkExec+ProcAttr{Files: []uintptr{}}

要傳遞一個 listenfd 很簡單,假如是型別 net.Listener,那就透過tcpln := ln.(*net.TCPListener); file, _ := tcpln.File(); fd := file.FD() 來拿到 listener 底層 file description 對應的 fd。

需要注意的是,這裡的 fd 並非底層的 file description 對應的初始 fd,而是被 dup2 複製出來的一個 fd(呼叫 tcpln.File()的時候就已經分配了),這樣底層 file description 引用計數就會+1。如果後面想透過 ln.Close()關閉監聽套接字的話,sorry,關不掉。這裡需要顯示的執行 file.Close() 將新建立的 fd 關掉,使對應的 file description 引用計數-1,保證 Close 的時候引用計數為 0,才可以正常關閉。

試想下,我們想實現熱重啟,是一定要等連線上接收的請求處理完才可以退出程式的,但是這期間父程式不能再接收新的連線請求,如果這裡不能正常關閉 listener,那我們這個目標就無法實現。所以這裡對 dup 出來的 fd 的處理要慎重些,不要遺忘。

OK,接下來說下 syscall.ProcAttr{Files: []uintptr{}},這裡就是要傳遞的父程式中的 fd,比如要傳遞 stdin、stdout、stderr 給子程式,就需要將這幾個對應的 fd 塞進去 os.Stdin.FD(), os.Stdout.FD(), os.Stderr.FD(),如果要想傳遞剛才的 listenfd,就需要將上面的file.FD()返回的 fd 塞進去。

子程式中接收到這些 fd 之後,在類 unix 系統下一般會按照從 0、1、2、3 這樣遞增的順序來分配 fd,那麼傳遞過去的 fd 是可以預測的,假如除了 stdin, stdout, stderr 再傳兩個 listenfd,那麼可以預測這兩個的 fd 應該是 3,4。在類 unix 系統下一般都是這麼處理的,子程式中就可以根據傳遞 fd 的數量(比如透過環境變數傳遞給子程式 FD_NUM=2),來從 3 開始計算,哦,這兩個 fd 應該是 3,4。

父子程式可以透過一個約定的順序,來組織傳遞的 listenfd 的順序,以方便子程式中按相同的約定進行處理,當然也可以透過 fd 重建 listener 之後來判斷對應的監聽 network+address,以區分該 listener 對應的是哪一個邏輯 service。都是可以的!

需要注意的是,file.FD()返回的 fd 是非阻塞的,會影響到底層的 file description,在重建 listener 先將其設為 nonblock, syscall.SetNonBlock(fd),然後file, _ := os.NewFile(fd); tcplistener := net.FileListener(file),或者是 udpconn := net.PacketConn(file),然後可以獲取 tcplistener、udpconn 的監聽地址,來關聯其對應的邏輯 service。

前面提到 file.FD()會將底層的 file description 設定為阻塞模式,這裡再補充下,net.FileListener(f), net.PacketConn(f)內部會呼叫 newFileFd()->dupSocket(),這幾個函式內部會將 fd 對應的 file description 重新設定為非阻塞。父子程式中共享了 listener 對應的 file description,所以不需要顯示設定為非阻塞。

有些微服務框架是支援對服務進行邏輯 service 分組的,google pb 規範中也支援多 service 定義,這個在騰訊的 goneat、trpc 框架中也是有支援的。

當然了,這裡我不會寫一個完整的包含上述所有描述的 demo 給大家,這有點佔篇幅,這裡只貼一個精簡版的例項,其他的讀者感興趣可以自己編碼測試。須知紙上得來終覺淺,還是要多實踐。

package main

import (
 "fmt"
 "io/ioutil"
 "log"
 "net"
 "os"
 "strconv"
 "sync"
 "syscall"
 "time"
)

const envRestart = "RESTART"
const envListenFD = "LISTENFD"

func main() {

 v := os.Getenv(envRestart)

 if v != "1" {

  ln, err := net.Listen("tcp", "localhost:8888")
  if err != nil {
   panic(err)
  }

  wg := sync.WaitGroup{}
  wg.Add(1)
  go func() {
   defer wg.Done()
   for {
    ln.Accept()
   }
  }()

  tcpln := ln.(*net.TCPListener)
  f, err := tcpln.File()
  if err != nil {
   panic(err)
  }

  os.Setenv(envRestart, "1")
  os.Setenv(envListenFD, fmt.Sprintf("%d", f.Fd()))

  _, err = syscall.ForkExec(os.Args[0], os.Args, &syscall.ProcAttr{
   Env:   os.Environ(),
   Files: []uintptr{os.Stdin.Fd(), os.Stdout.Fd(), os.Stderr.Fd(), f.Fd()},
   Sys:   nil,
  })
  if err != nil {
   panic(err)
  }
  log.Print("parent pid:", os.Getpid(), ", pass fd:", f.Fd())
  f.Close()
  wg.Wait()

 } else {

  v := os.Getenv(envListenFD)
  fd, err := strconv.ParseInt(v, 10, 64)
  if err != nil {
   panic(err)
  }
  log.Print("child pid:", os.Getpid(), ", recv fd:", fd)

  // case1: 理解上面提及的file descriptor、file description的關係
  // 這裡子程式繼承了父程式中傳遞過來的一些fd,但是fd數值與父程式中可能是不同的
  // 取消註釋來測試...
  //ff := os.NewFile(uintptr(fd), "")
  //if ff != nil {
  // _, err := ff.Stat()
  // if err != nil {
  //  log.Println(err)
  // }
  //}

  // case2: 假定父程式中共享了fd 0\1\2\listenfd給子程式,那再子程式中可以預測到listenfd=3
  ff := os.NewFile(uintptr(3), "")
  fmt.Println("fd:", ff.Fd())
  if ff != nil {
   _, err := ff.Stat()
   if err != nil {
    panic(err)
   }

   // 這裡pause, 執行命令lsof -P -p $pid,檢查下有沒有listenfd傳過來,除了0,1,2,應該有看到3
   // ctrl+d to continue
   ioutil.ReadAll(os.Stdin)

   fmt.Println("....")
   _, err = net.FileListener(ff)
   if err != nil {
    panic(err)
   }

   // 這裡pause, 執行命令lsof -P -p $pid, 會發現有兩個listenfd,
   // 因為前面呼叫了ff.FD() dup2了一個,如果這裡不顯示關閉,listener將無法關閉
   ff.Close()

   time.Sleep(time.Minute)
  }

  time.Sleep(time.Minute)
 }
}

這裡用簡單的程式碼大致解釋瞭如何用 ProcAttr 來傳遞 listenfd。這裡有個問題,假如後續父程式中傳遞的 fd 修改了呢,比如不傳 stdin, stdout, stderr 的 fd 了,怎麼辦?服務端是不是要開始預測應該從 0 開始編號了?我們可以透過環境變數通知子程式,比如傳遞的 fd 從哪個編號開始是 listenfd,一共有幾個 listenfd,這樣也是可以實現的。

這種實現方式可以跨平臺。

感興趣的話,可以看下 facebook 提供的這個實現grace

9.2 unix domain socket + cmsg

另一種,思路就是透過 unix domain socket + cmsg 來傳遞,父程式啟動的時候依然是透過 ForkExec 來建立子程式,但是並不透過 ProcAttr 來傳遞 listenfd。

父程式在建立子程式之前,建立一個 unix domain socket 並監聽,等子程式啟動之後,建立到這個 unix domain socket 的連線,父程式此時開始將 listenfd 透過 cmsg 傳送給子程式,獲取 fd 的方式與 9.1 相同,該注意的 fd 關閉問題也是一樣的處理。

子程式連線上 unix domain socket,開始接收 cmsg,核心幫子程式收訊息的時候,發現裡面有一個父程式的 fd,核心找到對應的 file description,併為子程式分配一個 fd,將兩者建立起對映關係。然後回到子程式中的時候,子程式拿到的就是對應該 file description 的 fd 了。透過 os.NewFile(fd)就可以拿到 file,然後再透過 net.FileListener 或者 net.PacketConn 就可以拿到 tcplistener 或者 udpconn。

剩下的獲取監聽地址,關聯邏輯 service 的動作,就與 9.1 小結描述的一致了。

這裡我也提供一個可執行的精簡版的 demo,供大家瞭解、測試用。

package main

import (
 "fmt"
 "io/ioutil"
 "log"
 "net"
 "os"
 "strconv"
 "sync"
 "syscall"
 "time"

 passfd "github.com/ftrvxmtrx/fd"
)

const envRestart = "RESTART"
const envListenFD = "LISTENFD"
const unixsockname = "/tmp/xxxxxxxxxxxxxxxxx.sock"

func main() {

 v := os.Getenv(envRestart)

 if v != "1" {

  ln, err := net.Listen("tcp", "localhost:8888")
  if err != nil {
   panic(err)
  }

  wg := sync.WaitGroup{}
  wg.Add(1)
  go func() {
   defer wg.Done()
   for {
    ln.Accept()
   }
  }()

  tcpln := ln.(*net.TCPListener)
  f, err := tcpln.File()
  if err != nil {
   panic(err)
  }

  os.Setenv(envRestart, "1")
  os.Setenv(envListenFD, fmt.Sprintf("%d", f.Fd()))

  _, err = syscall.ForkExec(os.Args[0], os.Args, &syscall.ProcAttr{
   Env:   os.Environ(),
   Files: []uintptr{os.Stdin.Fd(), os.Stdout.Fd(), os.Stderr.Fd(), /*f.Fd()*/}, // comment this when test unixsock
   Sys:   nil,
  })
  if err != nil {
   panic(err)
  }
  log.Print("parent pid:", os.Getpid(), ", pass fd:", f.Fd())

  os.Remove(unixsockname)
  unix, err := net.Listen("unix", unixsockname)
  if err != nil {
   panic(err)
  }
  unixconn, err := unix.Accept()
  if err != nil {
   panic(err)
  }
  err = passfd.Put(unixconn.(*net.UnixConn), f)
  if err != nil {
   panic(err)
  }

  f.Close()
  wg.Wait()

 } else {

  v := os.Getenv(envListenFD)
  fd, err := strconv.ParseInt(v, 10, 64)
  if err != nil {
   panic(err)
  }
  log.Print("child pid:", os.Getpid(), ", recv fd:", fd)

  // case1: 有同學認為以透過環境變數傳fd,透過環境變數肯定是不行的,fd根本不對應子程式中的fd
  //ff := os.NewFile(uintptr(fd), "")
  //if ff != nil {
  // _, err := ff.Stat()
  // if err != nil {
  //  log.Println(err)
  // }
  //}

  // case2: 如果只有一個listenfd的情況下,那如果fork子程式時保證只傳0\1\2\listenfd,那子程式中listenfd一定是3
  //ff := os.NewFile(uintptr(3), "")
  //if ff != nil {
  // _, err := ff.Stat()
  // if err != nil {
  //  panic(err)
  // }
  // // pause, ctrl+d to continue
  // ioutil.ReadAll(os.Stdin)
  // fmt.Println("....")
  // _, err = net.FileListener(ff) //會dup一個fd出來,有多個listener
  // if err != nil {
  //  panic(err)
  // }
  // // lsof -P -p $pid, 會發現有兩個listenfd
  // time.Sleep(time.Minute)
  //}
  // 這裡我們暫停下,方便執行系統命令來檢視程式當前的一些狀態
  // run: lsof -P -p $pid,檢查下listenfd情況

  ioutil.ReadAll(os.Stdin)
  fmt.Println(".....")

  unixconn, err := net.Dial("unix", unixsockname)
  if err != nil {
   panic(err)
  }

  files, err := passfd.Get(unixconn.(*net.UnixConn), 1, nil)
  if err != nil {
   panic(err)
  }

  // 這裡再執行命令:lsof -P -p $pid再檢查下listenfd情況

  f := files[0]
  f.Stat()

  time.Sleep(time.Minute)
 }
}

這種實現方式,僅限類 unix 系統。

如果有服務混布的情況存在,需要考慮下使用的 unix domain socket 的檔名,避免因為重名所引起的問題,可以考慮透過”程式名.pid“來作為 unix domain socket 的名字,並透過環境變數將其傳遞給子程式。

10. go 實現熱重啟: 子程式如何透過 listenfd 重建 listener

前面已經提過了,當拿到 fd 之後還不知道它對應的是 tcp 的 listener,還是 udpconn,那怎麼辦?都試下唄。

file, err := os.NewFile(fd)
// check error

tcpln, err := net.FileListener(file)
// check error

udpconn, err := net.PacketConn(file)
// check error

11. go 實現熱重啟:父程式平滑退出

父程式如何平滑退出呢,這個要看父程式中都有哪些邏輯要平滑停止了。

11.1. 處理已建立連線上請求

可以從這兩個方面入手:

  • shutdown read,不再接受新的請求,對端繼續寫資料的時候會感知到失敗;
  • 繼續處理連線上已經正常接收的請求,處理完成後,回包,close 連線;

也可以考慮,不進行讀端關閉,而是等連線空閒一段時間後再 close,是否儘快關閉更符合要求就要結合場景、要求來看。

如果對可用性要求比較苛刻,可能也會需要考慮將 connfd、connfd 上已經讀取寫入的 buffer 資料也一併傳遞給子程式處理。

11.2. 訊息服務

  • 確認下自己服務的訊息消費、確認機制是否合理
  • 不再收新訊息
  • 處理完已收到的訊息後,再退出

11.3. 自定義 AtExit 清理任務

有些任務會有些自定義任務,希望程式在退出之前,能夠執行到,這種可以提供一個類似 AtExit 的註冊函式,讓程式退出之前能夠執行業務自定義的清理邏輯。

不管是平滑重啟,還是其他正常退出,對該支援都是有一定需求的。

12. 其他

有些場景下也希望傳遞 connfd,包括 connfd 上對應的讀寫的資料。

比如連線複用的場景,客戶端可能會透過同一個連線傳送多個請求,假如在中間某個時刻服務端執行熱重啟操作,服務端如果直接連線讀關閉會導致後續客戶端的資料傳送失敗,客戶端關閉連線則可能導致之前已經接收的請求也無法正常響應。這種情況下,可以考慮服務端繼續處理連線上請求,等連線空閒再關閉。會不會一直不空閒呢?有可能。

其實服務端不能預測客戶端是否會採用連線複用模式,選擇一個更可靠的處理方式會更好些,如果場景要求比較苛刻,並不希望透過上層重試來解決的話。這種可以考慮將 connfd 以及 connfd 上讀寫的 buffer 資料一併傳遞給子程式,交由子程式來處理,這個時候需要關注的點更多,處理起來更復雜,感興趣的可以參考下 mosn 的實現。

13. 總結

熱重啟作為一種保證服務平滑重啟、升級的實現方式,在今天看來依然非常有價值。本文描述了實現熱重啟的一些大致思路,並且透過 demo 循序漸進地描述了在 go 服務中如何予以實現。雖然沒有提供一個完整的熱重啟例項給大家,但是相信大家讀完之後應該已經可以親手實現了。

由於作者本人水平有限,難免會有描述疏漏之處,歡迎大家指正。

參考文章

  1. Unix 高階程式設計:程式間通訊,Steven Richards
  2. mosn 啟動流程: https://mosn.io/blog/code/mosn-startup/

相關文章