最近在優(yōu)化公司框架 trpc 時發(fā)現(xiàn)了一個熱重啟相關的問題,優(yōu)化之余也總結沉淀下,對 go 如何實現(xiàn)熱重啟這方面的內(nèi)容做一個簡單的梳理。
1.什么是熱重啟?
熱重啟(Hot Restart),是一項保證服務可用性的手段。它允許服務重啟期間,不中斷已經(jīng)建立的連接,老服務進程不再接受新連接請求,新連接請求將在新服務進程中受理。對于原服務進程中已經(jīng)建立的連接,也可以將其設為讀關閉,等待平滑處理完連接上的請求及連接空閑后再行退出。通過這種方式,可以保證已建立的連接不中斷,連接上的事務(請求、處理、響應)可以正常完成,新的服務進程也可以正常接受連接、處理連接上的請求。當然,熱重啟期間進程平滑退出涉及到的不止是連接上的事務,也有消息服務、自定義事務需要關注。
這是我理解的熱重啟的一個大致描述。熱重啟現(xiàn)在還有沒有存在的必要?我的理解是看場景。
以后臺開發(fā)為例,假如運維平臺有能力在服務升級、重啟時自動踢掉流量,服務就緒后又自動加回流量,假如能夠合理預估服務 QPS、請求處理時長,那么只要配置一個合理的停止前等待時間,是可以達到類似熱重啟的效果的。這樣的話,在后臺服務里面支持熱重啟就顯得沒什么必要。但是,如果我們開發(fā)一個微服務框架,不能對將來的部署平臺、環(huán)境做這種假設,也有可能使用方只是部署在一兩臺物理機上,也沒有其他的負載均衡設施,但不希望因為重啟受干擾,熱重啟就很有必要。當然還有一些更復雜、要求更苛刻的場景,也需要熱重啟的能力。
熱重啟是比較重要的一項保證服務質量的手段,還是值得了解下的,這也是本文介紹的初衷。
2.如何實現(xiàn)熱重啟?
如何實現(xiàn)熱重啟,這里其實不能一概而論,要結合實際的場景來看(比如服務編程模型、對可用性要求的高低等)。大致的實現(xiàn)思路,可以先拋一下。
一般要實現(xiàn)熱重啟,大致要包括如下步驟:
- 首先,要讓老進程,這里稱之為父進程了,先要 fork 出一個子進程來代替它工作;
- 然后,子進程就緒之后,通知父進程,正常接受新連接請求、處理連接上收到的請求;
- 再然后,父進程處理完已建立連接上的請求后、連接空閑后,平滑退出。
聽上去是挺簡單的...
2.1.認識 fork
大家都知道fork()
系統(tǒng)調用,父進程調用 fork 會創(chuàng)建一個進程副本,代碼中還可以通過 fork 返回值是否為 0 來區(qū)分是子進程還是父進程。
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);
}
}
可能有些開發(fā)人員不知道 fork 的實現(xiàn)原理,或者不知道 fork 返回值為什么在父子進程中不同,或者不知道如何做到父子進程中返回值不同……了解這些是要有點知識積累的。
2.2.返回值
簡單概括下,ABI 定義了進行函數(shù)調用時的一些規(guī)范,如何傳遞參數(shù),如何返回值等等,以 x86 為例,如果返回值是 rax 寄存器能夠容的一般都是通過 rax 寄存器返回的。
如果 rax 寄存器位寬無法容納下的返回值呢?也簡單,編譯器會安插些指令來完成這些神秘的操作,具體是什么指令,就跟語言編譯器實現(xiàn)相關了。
- c 語言,可能會將返回值的地址,傳遞到 rdi 或其他寄存器,被調函數(shù)內(nèi)部呢,通過多條指令將返回值寫入 rdi 代指的內(nèi)存區(qū);
- c 語言,也可能在被調函數(shù)內(nèi)部,用多個寄存器 rax,rdx...一起暫存返回結果,函數(shù)返回時再將多個寄存器的值賦值到變量中;
- 也可能會像 golang 這樣,通過棧內(nèi)存來返回;
2.3.fork 返回值
fork 系統(tǒng)調用的返回值,有點特殊,在父進程和子進程中,這個函數(shù)返回的值是不同的,如何做到的呢?
聯(lián)想下父進程調用 fork 的時候,操作系統(tǒng)內(nèi)核需要干些什么呢?分配進程控制塊、分配 pid、分配內(nèi)存空間……肯定有很多東西啦,這里注意下進程的硬件上下文信息,這些是非常重要的,在進程被調度算法選中進行調度時,是需要還原硬件上下文信息的。
Linux fork 的時候,會對子進程的硬件上下文進行一定的修改,我就是讓你 fork 之后拿到的 pid 是 0,怎么辦呢?前面 2.2 節(jié)提過了,對于那些小整數(shù),rax 寄存器存下綽綽有余,fork 返回時就是將操作系統(tǒng)分配的 pid 放到 rax 寄存器的。
那,對于子進程而言,我只要在 fork 的時候將它的硬件上下文 rax 寄存器清 0,然后等其他設置全 ok 后,再將其狀態(tài)從不可中斷等待狀態(tài)修改為可運行狀態(tài),等其被調度器調度時,會先還原其硬件上下文信息,包括 PC、rax 等等,這樣 fork 返回后,rax 中值為 0,最終賦值給 pid 的值就是 0。
因此,也就可以通過這種判斷 “pid 是否等于 0” 的方式來區(qū)分當前進程是父進程還是子進程了。
2.4.局限性
很多人清楚 fork 可以創(chuàng)建一個進程的副本并繼續(xù)往下執(zhí)行,可以根據(jù) fork 返回值來執(zhí)行不同的分支邏輯。如果進程是多線程的,在一個線程中調用 fork 會復制整個進程嗎?
fork 只能創(chuàng)建調用該函數(shù)的線程的副本,進程中其他運行的線程,fork 不予處理。這就意味著,對于多線程程序而言,寄希望于通過 fork 來創(chuàng)建一個完整進程副本是不可行的。
前面我們也提到了,fork 是實現(xiàn)熱重啟的重要一環(huán),fork 這里的這個局限性,就制約著不同服務編程模型下的熱重啟實現(xiàn)方式。所以我們說具體問題具體分析,不同編程模型下實際上可以采用不同的實現(xiàn)方式。
3.單進程單線程模型
單進程單線程模型,可能很多人一聽覺得它已經(jīng)被淘汰了,生產(chǎn)環(huán)境中不能用,真的么?強如 redis,不就是單線程。強調下并非單線程模型沒用,ok,收回來,現(xiàn)在關注下單進程單線程模型如何實現(xiàn)熱重啟。
單進程單線程,實現(xiàn)熱重啟會比較簡單些:
- fork 一下就可以創(chuàng)建出子進程,
- 子進程可以繼承父進程中的資源,如已經(jīng)打開的文件描述符,包括父進程的 listenfd、connfd,
- 父進程,可以選擇關閉 listenfd,后續(xù)接受連接的任務就交給子進程來完成了,
- 父進程,甚至也可以關閉 connfd,讓子進程處理連接上的請求、回包等,也可以自身處理完已建立的連接上的請求;
- 父進程,在合適的時間點選擇退出,子進程開始變成頂梁柱。
核心思想就是這些,但是具體到實現(xiàn),就有多種方法:
- 可以選擇 fork 的方式讓子進程拿到原來的 listenfd、connfd,
- 也可以選擇 unixdomain socket 的方式父進程將 listenfd、connfd 發(fā)送給子進程。
有同學可能會想,我不傳遞這些 fd 行嗎?
- 比如我開啟了 reuseport,父進程直接處理完已建立連接 connfd 上的請求之后關閉,子進程里 reuseport.Listen 直接創(chuàng)建新的 listenfd。
也可以!但是有些問題必須要提前考慮到:
- reuseport 雖然允許多個進程在同一個端口上多次 listen,似乎滿足了要求,但是要知道只要 euid 相同,都可以在這個端口上 listen!是不安全的!
- reuseport 實現(xiàn)和平臺有關系,在 Linux 平臺上在同一個 address+port 上 listen 多次,多個 listenfd 底層可以共享同一個連接隊列,內(nèi)核可以實現(xiàn)負載均衡,但是在 darwin 平臺上卻不會!
當然這里提到的這些問題,在多線程模型下肯定也存在。
4.單進程多線程模型
前面提到的問題,在多線程模型中也會出現(xiàn):
- fork 只能復制 calling thread,not whole process!
- reuseport 多次在相同地址+端口 listen 得到的多個 fd,不同平臺有不同的表現(xiàn),可能無法做到接受連接時的 load banlance!
- 非 reuseport 情況下,多次 listen 會失??!
- 不傳遞 fd,直接通過 reuseport 來重新 listen 得到 listenfd,不安全,不同服務進程實例可能會在同一個端口上監(jiān)聽,gg!
- 父進程平滑退出的邏輯,關閉 listenfd,等待 connfd 上請求處理結束,關閉 connfd,一切妥當后,父進程退出,子進程挑大梁!
5. 其他線程模型
其他線程都基本上避不開上述 3、4 的實現(xiàn)或者組合,對應問題相仿,不再贅述。
6. go 實現(xiàn)熱重啟:觸發(fā)時機
需要選擇一個時機來觸發(fā)熱重啟,什么時候觸發(fā)呢?操作系統(tǒng)提供了信號機制,允許進程做出一些自定義的信號處理。
殺死一個進程,一般會通過kill -9
發(fā)送 SIGKILL 信號給進程,這個信號不允許捕獲,SIGABORT 也不允許捕獲,這樣可以允許進程所有者或者高權限用戶控制進程生死,達到更好的管理效果。
kill 也可以用來發(fā)送其他信號給進程,如發(fā)送 SIGUSR1、SIGUSR2、SIGINT 等等,進程中可以接收這些信號,并針對性的做出處理。這里可以選擇 SIGUSR1 或者 SIGUSR2 來通知進程熱重啟。
go func() {
ch := make(chan os.Signal, 1)
signal.Notify(ch, os.SIGUSR2)
- ch
//接下來就可以做熱重啟相關的邏輯了
...
}()
7. 如何判斷熱重啟
那一個 go 程序重新啟動之后,所有運行時狀態(tài)信息都是新的,那如何區(qū)分自己是否是子進程呢,或者說我是否要執(zhí)行熱重啟邏輯呢?父進程可以通過設置子進程初始化時的環(huán)境變量,比如加個 HOT_RESTART=1。
這就要求代碼中在合適的地方要先檢測環(huán)境變量 HOT_RESTART 是否為 1,如果成立,那就執(zhí)行熱重啟邏輯,否則就執(zhí)行全新的啟動邏輯。
8. ForkExec
假如當前進程收到 SIGUSR2 信號之后,希望執(zhí)行熱重啟邏輯,那么好,需要先執(zhí)行 syscall.ForkExec(...)來創(chuàng)建一個子進程,注意 go 不同于 cc++,它本身就是依賴多線程來調度協(xié)程的,天然就是多線程程序,只不過是他沒有使用 NPTL 線程庫來創(chuàng)建,而是通過 clone 系統(tǒng)調用來創(chuàng)建。
前面提過了,如果單純 fork 的話,只能復制調用 fork 函數(shù)的線程,對于進程中的其他線程無能為力,所以對于 go 這種天然的多線程程序,必須從頭來一遍,再 exec 一下。所以 go 標準庫提供的函數(shù)是 syscall.ForkExec 而不是 syscall.Fork。
9. go 實現(xiàn)熱重啟: 傳遞 listenfd
go 里面?zhèn)鬟f fd 的方式,有這么幾種,父進程 fork 子進程的時候傳遞 fd,或者后面通過 unix domain socket 傳遞。需要注意的是,我們傳遞的實際上是 file description,而非 file descriptor。
附上一張類 unix 系統(tǒng)下 file descriptor、file description、inode 三者之間的關系圖:
fd 分配都是從小到大分配的,父進程中的 fd 為 10,傳遞到子進程中之后有可能就不是 10。那么傳遞到子進程的 fd 是否是可以預測的呢?可以預測,但是不建議。所以我提供了兩種實現(xiàn)方式。
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()的時候就已經(jīng)分配了),這樣底層 file description 引用計數(shù)就會+1。如果后面想通過 ln.Close()關閉監(jiān)聽套接字的話,sorry,關不掉。這里需要顯示的執(zhí)行 file.Close() 將新創(chuàng)建的 fd 關掉,使對應的 file description 引用計數(shù)-1,保證 Close 的時候引用計數(shù)為 0,才可以正常關閉。
試想下,我們想實現(xiàn)熱重啟,是一定要等連接上接收的請求處理完才可以退出進程的,但是這期間父進程不能再接收新的連接請求,如果這里不能正常關閉 listener,那我們這個目標就無法實現(xiàn)。所以這里對 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 系統(tǒng)下一般會按照從 0、1、2、3 這樣遞增的順序來分配 fd,那么傳遞過去的 fd 是可以預測的,假如除了 stdin, stdout, stderr 再傳兩個 listenfd,那么可以預測這兩個的 fd 應該是 3,4。在類 unix 系統(tǒng)下一般都是這么處理的,子進程中就可以根據(jù)傳遞 fd 的數(shù)量(比如通過環(huán)境變量傳遞給子進程 FD_NUM=2),來從 3 開始計算,哦,這兩個 fd 應該是 3,4。
父子進程可以通過一個約定的順序,來組織傳遞的 listenfd 的順序,以方便子進程中按相同的約定進行處理,當然也可以通過 fd 重建 listener 之后來判斷對應的監(jiān)聽 network+address,以區(qū)分該 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 的監(jiān)聽地址,來關聯(lián)其對應的邏輯 service。
前面提到 file.FD()會將底層的 file description 設置為阻塞模式,這里再補充下,net.FileListener(f), net.PacketConn(f)內(nèi)部會調用 newFileFd()->dupSocket(),這幾個函數(shù)內(nèi)部會將 fd 對應的 file description 重新設置為非阻塞。父子進程中共享了 listener 對應的 file description,所以不需要顯示設置為非阻塞。
有些微服務框架是支持對服務進行邏輯 service 分組的,google pb 規(guī)范中也支持多 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數(shù)值與父進程中可能是不同的
// 取消注釋來測試...
//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,檢查下有沒有l(wèi)istenfd傳過來,除了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, 會發(fā)現(xiàn)有兩個listenfd,
// 因為前面調用了ff.FD() dup2了一個,如果這里不顯示關閉,listener將無法關閉
ff.Close()
time.Sleep(time.Minute)
}
time.Sleep(time.Minute)
}
}
這里用簡單的代碼大致解釋了如何用 ProcAttr 來傳遞 listenfd。這里有個問題,假如后續(xù)父進程中傳遞的 fd 修改了呢,比如不傳 stdin, stdout, stderr 的 fd 了,怎么辦?服務端是不是要開始預測應該從 0 開始編號了?我們可以通過環(huán)境變量通知子進程,比如傳遞的 fd 從哪個編號開始是 listenfd,一共有幾個 listenfd,這樣也是可以實現(xiàn)的。
這種實現(xiàn)方式可以跨平臺。
感興趣的話,可以看下 facebook 提供的這個實現(xiàn)grace。
9.2 unix domain socket + cmsg
另一種,思路就是通過 unix domain socket + cmsg 來傳遞,父進程啟動的時候依然是通過 ForkExec 來創(chuàng)建子進程,但是并不通過 ProcAttr 來傳遞 listenfd。
父進程在創(chuàng)建子進程之前,創(chuàng)建一個 unix domain socket 并監(jiān)聽,等子進程啟動之后,建立到這個 unix domain socket 的連接,父進程此時開始將 listenfd 通過 cmsg 發(fā)送給子進程,獲取 fd 的方式與 9.1 相同,該注意的 fd 關閉問題也是一樣的處理。
子進程連接上 unix domain socket,開始接收 cmsg,內(nèi)核幫子進程收消息的時候,發(fā)現(xiàn)里面有一個父進程的 fd,內(nèi)核找到對應的 file description,并為子進程分配一個 fd,將兩者建立起映射關系。然后回到子進程中的時候,子進程拿到的就是對應該 file description 的 fd 了。通過 os.NewFile(fd)就可以拿到 file,然后再通過 net.FileListener 或者 net.PacketConn 就可以拿到 tcplistener 或者 udpconn。
剩下的獲取監(jiān)聽地址,關聯(lián)邏輯 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: 有同學認為以通過環(huán)境變量傳fd,通過環(huán)境變量肯定是不行的,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,那子進程中l(wèi)istenfd一定是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, 會發(fā)現(xiàn)有兩個listenfd
// time.Sleep(time.Minute)
//}
// 這里我們暫停下,方便運行系統(tǒng)命令來查看進程當前的一些狀態(tài)
// 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)
}
}
這種實現(xiàn)方式,僅限類 unix 系統(tǒng)。
如果有服務混布的情況存在,需要考慮下使用的 unix domain socket 的文件名,避免因為重名所引起的問題,可以考慮通過”進程名.pid“來作為 unix domain socket 的名字,并通過環(huán)境變量將其傳遞給子進程。
10. go 實現(xiàn)熱重啟: 子進程如何通過 listenfd 重建 listener
前面已經(jīng)提過了,當拿到 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 實現(xiàn)熱重啟:父進程平滑退出
父進程如何平滑退出呢,這個要看父進程中都有哪些邏輯要平滑停止了。
11.1. 處理已建立連接上請求
可以從這兩個方面入手:
- shutdown read,不再接受新的請求,對端繼續(xù)寫數(shù)據(jù)的時候會感知到失敗;
- 繼續(xù)處理連接上已經(jīng)正常接收的請求,處理完成后,回包,close 連接;
也可以考慮,不進行讀端關閉,而是等連接空閑一段時間后再 close,是否盡快關閉更符合要求就要結合場景、要求來看。
如果對可用性要求比較苛刻,可能也會需要考慮將 connfd、connfd 上已經(jīng)讀取寫入的 buffer 數(shù)據(jù)也一并傳遞給子進程處理。
11.2. 消息服務
- 確認下自己服務的消息消費、確認機制是否合理
- 不再收新消息
- 處理完已收到的消息后,再退出
11.3. 自定義 AtExit 清理任務
有些任務會有些自定義任務,希望進程在退出之前,能夠執(zhí)行到,這種可以提供一個類似 AtExit 的注冊函數(shù),讓進程退出之前能夠執(zhí)行業(yè)務自定義的清理邏輯。
不管是平滑重啟,還是其他正常退出,對該支持都是有一定需求的。
12. 其他
有些場景下也希望傳遞 connfd,包括 connfd 上對應的讀寫的數(shù)據(jù)。
比如連接復用的場景,客戶端可能會通過同一個連接發(fā)送多個請求,假如在中間某個時刻服務端執(zhí)行熱重啟操作,服務端如果直接連接讀關閉會導致后續(xù)客戶端的數(shù)據(jù)發(fā)送失敗,客戶端關閉連接則可能導致之前已經(jīng)接收的請求也無法正常響應。這種情況下,可以考慮服務端繼續(xù)處理連接上請求,等連接空閑再關閉。會不會一直不空閑呢?有可能。
其實服務端不能預測客戶端是否會采用連接復用模式,選擇一個更可靠的處理方式會更好些,如果場景要求比較苛刻,并不希望通過上層重試來解決的話。這種可以考慮將 connfd 以及 connfd 上讀寫的 buffer 數(shù)據(jù)一并傳遞給子進程,交由子進程來處理,這個時候需要關注的點更多,處理起來更復雜,感興趣的可以參考下 mosn 的實現(xiàn)。
13. 總結
熱重啟作為一種保證服務平滑重啟、升級的實現(xiàn)方式,在今天看來依然非常有價值。本文描述了實現(xiàn)熱重啟的一些大致思路,并且通過 demo 循序漸進地描述了在 go 服務中如何予以實現(xiàn)。雖然沒有提供一個完整的熱重啟實例給大家,但是相信大家讀完之后應該已經(jīng)可以親手實現(xiàn)了。
由于作者本人水平有限,難免會有描述疏漏之處,歡迎大家指正。
參考文章
Unix 高級編程:進程間通信,Steven Richards
mosn 啟動流程: https://mosn.io/blog/code/mosn-startup/
到此這篇關于Go 實現(xiàn)熱重啟的詳細介紹的文章就介紹到這了,更多相關go熱重啟內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
您可能感興趣的文章:- 詳解如何熱重啟golang服務器
- 在Go中構建并發(fā)TCP服務器
- Go語言的http/2服務器功能及客戶端使用
- 解析Go 標準庫 http.FileServer 實現(xiàn)靜態(tài)文件服務
- MongoDB4.0在windows10下的安裝與服務配置教程詳解
- goland服務熱重啟的配置文件