先說點(diǎn)題外話迈倍,最近在開發(fā)公司級(jí)的網(wǎng)關(guān)伤靠,雖然沒有明說,但是對(duì)于我們大家來說Nginx就是我們對(duì)標(biāo)的對(duì)象。但是說實(shí)話宴合,想要對(duì)標(biāo)Nginx的性能焕梅,用Go開發(fā)基本上是不可能的,人家沒有scheduler調(diào)度這一項(xiàng)就可以吊打Go了卦洽,更別說Go還有GC了贞言。跑Benchmark的時(shí)候就能很明顯地看到,隨著并發(fā)請(qǐng)求的增多阀蒂,Ngx的響應(yīng)時(shí)間幾乎就是一條完美的直線该窗。而我們用Go開發(fā)的網(wǎng)關(guān),在并發(fā)數(shù)小于等于閾值X的時(shí)候還能跟Ngx不相上下蚤霞,雖然有周期性GC帶來的毛刺酗失,但是總體影響不大,毛刺主要影響的是99分位的響應(yīng)時(shí)間昧绣。但是规肴,一旦并發(fā)數(shù)超過閾值X,Go網(wǎng)關(guān)的響應(yīng)時(shí)間便指數(shù)級(jí)地上升了夜畴。
這個(gè)現(xiàn)象拖刃,直接通過pprof觀察可以發(fā)現(xiàn),在并發(fā)數(shù)小于X時(shí)贪绘,總Goroutine數(shù)量基本保持穩(wěn)定兑牡,但是一旦超過閾值X,goroutine數(shù)量則快速飆升上去税灌。為什么呢发绢?簡(jiǎn)單來說就是,由于Go的runtime需要調(diào)度goroutine(sleep哪個(gè)喚醒哪個(gè)搶占哪個(gè))垄琐,在Goroutine數(shù)量巨大的時(shí)候這個(gè)調(diào)度的開銷非常大,每個(gè)goroutine被喚起的周期變得很長(zhǎng)经柴,因此會(huì)導(dǎo)致響應(yīng)變長(zhǎng)狸窘。同時(shí),由于大量goroutine并沒有完成其任務(wù)坯认,導(dǎo)致無法回收翻擒,新到的請(qǐng)求就只能new goroutine,導(dǎo)致goroutine的數(shù)量進(jìn)一步增加牛哺,使得響應(yīng)時(shí)間進(jìn)一步惡化…然后基本上服務(wù)就不可用了陋气,pprof能夠看到,絕大部分時(shí)間都在執(zhí)行runtime.findRunableG引润。
這個(gè)現(xiàn)象讓我想起了以前大學(xué)時(shí)代學(xué)習(xí)的二極管的雪崩擊穿巩趁。雖然,Go號(hào)稱goroutine非常輕量級(jí)淳附,可以輕松地開到十萬百萬級(jí)议慰,但是這話是省略了很多上下文和限制條件的蠢古。它只告訴你可以有millions of goroutine,但是沒告訴你后果是啥别凹,怎樣才能開到millions草讶,輕松開millions of goroutines是相對(duì)誰來說輕松…總之一句話就是,太美的承諾都不能信炉菲。
說回正題堕战,fasthttp!為什么我進(jìn)入主題之前說這么多題外話拍霜,本質(zhì)上的目的就是想表明嘱丢,對(duì)處理高并發(fā)場(chǎng)景的應(yīng)用,goroutine的代價(jià)其實(shí)是不可忽視的沉御,一定要省著用屿讽!fasthttp為什么比標(biāo)準(zhǔn)庫net/http快,就是因?yàn)樗⒉皇莵硪粋€(gè)請(qǐng)求就開一個(gè)goroutine吠裆,而是維護(hù)了一個(gè)workerPool伐谈,盡可能復(fù)用goroutine。當(dāng)然還有很多別的優(yōu)化试疙,比如盡量減少數(shù)據(jù)copy诵棵,這些在fasthttp的API里就有很直觀的體現(xiàn)。
先來簡(jiǎn)單看看fasthttp的大框架結(jié)構(gòu):
func (s *Server) Serve(ln net.Listener) error {
var lastOverflowErrorTime time.Time
var lastPerIPErrorTime time.Time
var c net.Conn
var err error
// 略
maxWorkersCount := s.getConcurrency()
s.concurrencyCh = make(chan struct{}, maxWorkersCount)
wp := &workerPool{
WorkerFunc: s.serveConn,
MaxWorkersCount: maxWorkersCount,
LogAllErrors: s.LogAllErrors,
Logger: s.logger(),
connState: s.setState,
}
wp.Start()
atomic.AddInt32(&s.open, 1)
defer atomic.AddInt32(&s.open, -1)
for {
if c, err = acceptConn(s, ln, &lastPerIPErrorTime); err != nil {
//略
}
s.setState(c, StateNew)
atomic.AddInt32(&s.open, 1)
if !wp.Serve(c) {
// 略
}
c = nil
}
}
上面是一個(gè)經(jīng)過刪減提煉后的代碼祝旷,從上面可以看到履澳,fasthttp在啟動(dòng)時(shí)先實(shí)例化并啟動(dòng)了一個(gè)workerPool,然后進(jìn)入到了一個(gè)大循環(huán)中怀跛,也是每當(dāng)accept一個(gè)連接之后就教給workerPool去Serve距贷。
可以簡(jiǎn)單地對(duì)比以下標(biāo)準(zhǔn)庫net/http對(duì)應(yīng)邏輯的偽代碼:
for {
c,err := accept(s)
go s.serve(c)
}
可以看到最大的區(qū)別就是fasthttp是wp.serve(c)
而標(biāo)準(zhǔn)庫是直接起一個(gè)goroutinego s.serve(c)
。為什么不像標(biāo)準(zhǔn)庫直接啟動(dòng)一個(gè)goroutine去處理呢吻谋?當(dāng)然是為了優(yōu)化爸一取!前面也說了漓拾,高并發(fā)下goroutine代價(jià)也是很高的阁最,盡量復(fù)用goroutine。
接著我們來講講workerPool骇两。和別的語言中線程池的實(shí)現(xiàn)思路基本一致速种,偽代碼如下:
func init() {
for i:=0 i<N; i++ {
signalReceiver := make(chan net.Conn)
ready = append(ready, signalReceiver)
go job(signalReceiver)
}
}
// 每個(gè)worker都有一個(gè)自己的channel,通過從channel中接收消息來獲得執(zhí)行權(quán)
func job(ch chan net.Conn) {
for c := range ch {
doSomething(c)
//當(dāng)完成任務(wù)后低千,把自己的channel放到ready隊(duì)列里配阵,表示自己是空閑狀態(tài)
lock()
ready = append(ready, ch)
unlock()
}
}
var ready = []chan net.Conn{}
// 每次從ready隊(duì)列里取一個(gè)空閑的channel,然后通知該job來執(zhí)行任務(wù)
func serve(c net.Conn) {
lock()
readyJob := ready[len(ready)-1]
ready = ready[:len(ready)-1]
unlock()
readyJob <- c
}
以上便是一個(gè)最簡(jiǎn)單的(問題多多)的workerPool。我們一開始啟動(dòng)了N個(gè)worker闸餐,每個(gè)worker都由一個(gè)自己的channel用于接收數(shù)據(jù)饱亮,然后一開始把所有worker的channel都放到ready隊(duì)列里,表示所有的worker都處于空閑狀態(tài)舍沙。每次接收到一個(gè)請(qǐng)求時(shí)近上,serve就通過ready去看哪個(gè)worker是空閑的,然后向那個(gè)worker的channel發(fā)消息拂铡,從而讓該worker執(zhí)行當(dāng)前任務(wù)壹无。
確保你完全明白上面的workerPool的實(shí)現(xiàn)思路,我們?cè)倮^續(xù)看fasthttp的實(shí)現(xiàn)感帅。
fasthttp的serve和我們上述基本一致:
func (wp *workerPool) Serve(c net.Conn) bool {
ch := wp.getCh()
if ch == nil {
return false
}
ch.ch <- c
return true
}
從wp里取一個(gè)channel斗锭,然后向該channel發(fā)消息,讓對(duì)應(yīng)的worker執(zhí)行任務(wù)失球。我們這個(gè)具體看看fasthttp是怎么找到空閑worker的:
func (wp *workerPool) getCh() *workerChan {
var ch *workerChan
createWorker := false
wp.lock.Lock()
ready := wp.ready
n := len(ready) - 1
if n < 0 {
if wp.workersCount < wp.MaxWorkersCount {
createWorker = true
wp.workersCount++
}
} else {
ch = ready[n]
ready[n] = nil
wp.ready = ready[:n]
}
wp.lock.Unlock()
if ch == nil {
if !createWorker {
return nil
}
vch := wp.workerChanPool.Get()
if vch == nil {
vch = &workerChan{
ch: make(chan net.Conn, workerChanCap),
}
}
ch = vch.(*workerChan)
go func() {
wp.workerFunc(ch)
wp.workerChanPool.Put(vch)
}()
}
return ch
}
其實(shí)也是岖是,一開始嘗試從ready隊(duì)列里取,如果ready隊(duì)列里沒有实苞,但是當(dāng)前worker數(shù)量還沒有達(dá)到用戶配置的MaxWorkersCount豺撑,那么就新起一個(gè)worker,否則就直接返回nil黔牵。這里新建worker還用到了臨時(shí)對(duì)象池sync.Pool也就是代碼中的wp.workerChanPool聪轿,能在兩次gc之間復(fù)用對(duì)象,減少內(nèi)存分配的開銷猾浦。不過從這里也能看出陆错,fasthttp的workerPool是lazyLoading的,并不是像我們之前的實(shí)現(xiàn)那樣一開始就創(chuàng)建N個(gè)worker金赦。這么做當(dāng)然就是省內(nèi)存啦音瓷,大部分業(yè)務(wù)大時(shí)間服務(wù)器都不會(huì)有這么高的并發(fā)壓力,因此fasthttp作為通用框架夹抗,lazyLoading肯定是一個(gè)正確的選擇外莲!
這里的wp.workerFunc其實(shí)就是我們之前偽代碼中的job函數(shù),在里面監(jiān)聽channel消息兔朦,然后執(zhí)行業(yè)務(wù)邏輯。我們可以具體看看:
func (wp *workerPool) workerFunc(ch *workerChan) {
var c net.Conn
var err error
for c = range ch.ch {
if c == nil {
break
}
if err = wp.WorkerFunc(c); err != nil && err != errHijacked {
// 省略錯(cuò)誤處理
}
if err == errHijacked {
wp.connState(c, StateHijacked)
} else {
c.Close()
wp.connState(c, StateClosed)
}
c = nil
// 把ch放到ready隊(duì)列里
if !wp.release(ch) {
break
}
}
wp.lock.Lock()
wp.workersCount--
wp.lock.Unlock()
}
從上面代碼我們能夠看到磨确,每個(gè)job確實(shí)也是不斷地監(jiān)聽channel沽甥,如果收到消息且不是nil,那就執(zhí)行真正的業(yè)務(wù)邏輯乏奥。成功執(zhí)行完之后(省略掉一些錯(cuò)誤處理分支)摆舟,通過wp.release把channel放到ready隊(duì)列里。正常情況下,workerFunc會(huì)一直執(zhí)行恨诱,直到收到一個(gè)nil或者執(zhí)行出錯(cuò)媳瞪,然后把workerPool的workersCount-1并退出,之后就等著runtime來回收或者釋放goroutine了照宝。
以上就是fasthttp的主要邏輯蛇受,沒有什么特別的設(shè)計(jì),和其它線程池的設(shè)計(jì)幾乎是一模一樣的厕鹃。當(dāng)然fasthttp的workerPool還有些需要注意的性質(zhì)兢仰,從上面可以看出,每次release到ready隊(duì)列時(shí)剂碴,直接放到隊(duì)尾把将,每次取也是從隊(duì)尾取。因此fasthttp的worker隊(duì)列是FILO的忆矛,即先進(jìn)后出察蹲。這會(huì)導(dǎo)致在并發(fā)小的情況下很多先入隊(duì)的worker會(huì)一直空閑。因此fasthttp也支持設(shè)置IdleDuration參數(shù)催训,定期清理空閑的worker減少資源占用洽议。這部分代碼:
func (wp *workerPool) Start() {
if wp.stopCh != nil {
panic("BUG: workerPool already started")
}
wp.stopCh = make(chan struct{})
stopCh := wp.stopCh
go func() {
var scratch []*workerChan
for {
wp.clean(&scratch)
select {
case <-stopCh:
return
default:
time.Sleep(wp.getMaxIdleWorkerDuration())
}
}
}()
}
func (wp *workerPool) clean(scratch *[]*workerChan) {
maxIdleWorkerDuration := wp.getMaxIdleWorkerDuration()
// Clean least recently used workers if they didn't serve connections
// for more than maxIdleWorkerDuration.
currentTime := time.Now()
wp.lock.Lock()
ready := wp.ready
n := len(ready)
i := 0
for i < n && currentTime.Sub(ready[i].lastUseTime) > maxIdleWorkerDuration {
i++
}
*scratch = append((*scratch)[:0], ready[:i]...)
if i > 0 {
m := copy(ready, ready[i:])
for i = m; i < n; i++ {
ready[i] = nil
}
wp.ready = ready[:m]
}
wp.lock.Unlock()
// Notify obsolete workers to stop.
// This notification must be outside the wp.lock, since ch.ch
// may be blocking and may consume a lot of time if many workers
// are located on non-local CPUs.
tmp := *scratch
for i, ch := range tmp {
ch.ch <- nil
tmp[i] = nil
}
}
wp.Start中啟動(dòng)一個(gè)goroutine,定期執(zhí)行wp.clean操作瞳腌。wp.clean其實(shí)就是從頭遍歷ready隊(duì)列绞铃,把空閑時(shí)間超過maxIdleWorkerDuration的都清理掉。這里清理也很簡(jiǎn)單嫂侍,直接向該channel發(fā)送一個(gè)nil就行了儿捧。別忘了之前workFunc中,當(dāng)收到一個(gè)nil之后就直接break出大循環(huán)挑宠,做些收尾工作然后退出函數(shù)菲盾,整個(gè)goroutine也就可以被runtime回收了。
不過這還不算完各淀。我們?cè)倏纯磜p.WorkerFunc吧懒鉴,這是一個(gè)很長(zhǎng)的函數(shù),其實(shí)就是s.serveConn(在初始化時(shí)把s.serveConn賦值給了wp.WorkerFunc)碎浇,以下也是簡(jiǎn)化過的代碼临谱,我們只關(guān)注其hotpath:
func (s *Server) serveConn(c net.Conn) error {
ctx := s.acquireCtx(c)
var connRequestNum int
for {
connRequestNum++
var br *bufio.Reader = acquireReader(ctx)
err = ctx.Request.readLimitBody(br, maxRequestSize)
err = s.Handler(ctx)
var wr *bufio.Writer = acquireWriter(ctx)
err = wr.Flush()
if err != nil {
return
}
if s.MaxRequestNumPerConn {
break;
}
}
// 省略
}
這里有個(gè)比較奇怪的地方,為什么要用一個(gè)無限循環(huán)呢奴璃?難道是接收網(wǎng)絡(luò)包的分組之類的悉默?NoNoNo,不要把概念搞混了苟穆!分組這些都是協(xié)議棧處理的內(nèi)容抄课,到Go這塊直接就是應(yīng)用層了唱星。那為什么要無限循環(huán)呢?在我這篇文章里說過跟磨,只有通過三次握手新建的連接间聊,才用Accept去取。建立好連接后抵拘,后續(xù)數(shù)據(jù)的收發(fā)都是基于該socket對(duì)象哎榴,也即net.Conn對(duì)象。
也就是說仑濒,只有新建的連接才會(huì)從Serve中的acceptConn函數(shù)開始叹话,然后執(zhí)行上述的邏輯。已經(jīng)建立好的連接墩瞳,后續(xù)的請(qǐng)求都在serveConn
中循環(huán)處理驼壶。換句話說,如果一個(gè)HTTP請(qǐng)求是KeepAlive的(HTTP 1.1默認(rèn)行為)喉酌,那么worker就會(huì)一直處理此連接热凹,無限循環(huán)地從該連接上讀取數(shù)據(jù)(也就是下一個(gè)請(qǐng)求),然后進(jìn)行業(yè)務(wù)邏輯泪电。除非遇到connRequestNum >= MaxRequestNumPerConn或者其它錯(cuò)誤了般妙,才會(huì)關(guān)閉該連接,然后把自己設(shè)置為空閑相速。
這里還有個(gè)問題你可能會(huì)疑惑:由于并不知道下一次請(qǐng)求啥時(shí)候會(huì)發(fā)過來碟渺,這里只有一個(gè)ctx.Request.readLimitBody(br, maxRequestSize)
,并沒有看到“不斷嘗試去讀”這種邏輯呢突诬?
這是一個(gè)好問題I慌摹!
其實(shí)這里的答案就是Go提供的一種強(qiáng)大的抽象net.Conn
旺隙。它不僅僅是代表一個(gè)socket绒极,同時(shí)它還被封裝成了netPoller對(duì)象。netPoller是Go runtime的一個(gè)數(shù)據(jù)結(jié)構(gòu)蔬捷,也許你早已知道了linux的epoll垄提,netPoller就是對(duì)epoll的一種封裝。Go把socket注冊(cè)到epoll里周拐,后續(xù)當(dāng)用戶在net.Conn對(duì)象上調(diào)用Read時(shí)铡俐,實(shí)際上是這樣的:
// Read implements io.Reader.
func (fd *FD) Read(p []byte) (int, error) {
if err := fd.readLock(); err != nil {
return 0, err
}
defer fd.readUnlock()
if len(p) == 0 {
// If the caller wanted a zero byte read, return immediately
// without trying (but after acquiring the readLock).
// Otherwise syscall.Read returns 0, nil which looks like
// io.EOF.
// TODO(bradfitz): make it wait for readability? (Issue 15735)
return 0, nil
}
if err := fd.pd.prepareRead(fd.isFile); err != nil {
return 0, err
}
if fd.IsStream && len(p) > maxRW {
p = p[:maxRW]
}
for {
n, err := syscall.Read(fd.Sysfd, p)
if err != nil {
n = 0
if err == syscall.EAGAIN && fd.pd.pollable() {
if err = fd.pd.waitRead(fd.isFile); err == nil {
continue
}
}
// On MacOS we can see EINTR here if the user
// pressed ^Z. See issue #22838.
if runtime.GOOS == "darwin" && err == syscall.EINTR {
continue
}
}
err = fd.eofError(n, err)
return n, err
}
}
也就是先會(huì)進(jìn)行一次syscall.Read,但是如果沒有數(shù)據(jù)妥粟,此時(shí)會(huì)得到一個(gè)錯(cuò)誤syscall.EAGAIN高蜂。這時(shí),會(huì)執(zhí)行fd.pd.waitRead罕容,這個(gè)函數(shù)會(huì)一直阻塞直到epoll通知socket有數(shù)據(jù)就緒备恤。這里的阻塞和syscall的阻塞調(diào)用不一樣,這里的阻塞相當(dāng)于主動(dòng)讓出時(shí)間片(park)锦秒,當(dāng)前線程可以去執(zhí)行別的goroutine露泊,然后等待適當(dāng)?shù)臅r(shí)間(epoll event fire)被runtime喚醒。從用戶的角度來看旅择,這就像是阻塞的惭笑。
那么這是怎么做到的呢?這就涉及到runtime的調(diào)度了生真,具體可以參見這篇文章沉噩。順帶提一句,查看太深入到runtime的代碼一定要用dlv柱蟀、lldb或者gdb川蒙,用IDE會(huì)跳到錯(cuò)誤的位置,因?yàn)楹芏鄏untime的代碼直接和平臺(tái)有關(guān)了长已,不同平臺(tái)對(duì)應(yīng)實(shí)現(xiàn)也不一樣畜眨,然后鏈接器也會(huì)搞一些事情導(dǎo)致符號(hào)表在源代碼層面不能正確跳轉(zhuǎn),所以一定要用單步調(diào)試去看代碼术瓮。
以上便是對(duì)fasthttp源碼結(jié)構(gòu)的一個(gè)剖析康聂,接下來讓我們思考一個(gè)問題吧:
假設(shè)有N個(gè)客戶端都使用長(zhǎng)連接(http keepalive)發(fā)送請(qǐng)求,同時(shí)假設(shè)每個(gè)客戶端每秒發(fā)送M個(gè)請(qǐng)求胞四。那么此時(shí)fasthttp和net/http的性能會(huì)如何呢恬汁?
由于有N個(gè)客戶端,因此fashttp和net/http的Server都會(huì)Accept N次辜伟。標(biāo)準(zhǔn)庫會(huì)啟動(dòng)N個(gè)goroutine氓侧,而對(duì)于fasthttp來說,由于每個(gè)連接都是長(zhǎng)連接游昼,每個(gè)worker會(huì)一直處理該連接直到連接關(guān)閉或者次數(shù)到了限制甘苍,因此ready隊(duì)列一直是空的,所以也會(huì)啟動(dòng)N個(gè)goroutine烘豌。即使N很大载庭,這種case下fasthttp和標(biāo)準(zhǔn)庫所使用的goroutine數(shù)量的持平的。
但是由于fasthttp大量使用了sync.Pool復(fù)用對(duì)象減少內(nèi)存分配的開銷廊佩,而標(biāo)準(zhǔn)庫每個(gè)請(qǐng)求都會(huì)new一個(gè)request和response對(duì)象囚聚。同時(shí)fasthttp中大部分存的是[]byte而標(biāo)準(zhǔn)庫中多是string,因此fasthttp還相比標(biāo)準(zhǔn)庫減少了很多內(nèi)存復(fù)制的開銷标锄。
總體而言顽铸,fasthttp性能在各種場(chǎng)景下應(yīng)該都比標(biāo)準(zhǔn)庫好很多。
當(dāng)然還有個(gè)tips料皇,作為網(wǎng)關(guān)谓松,外網(wǎng)和內(nèi)網(wǎng)的一道門星压,為了防止惡意請(qǐng)求,還是應(yīng)該在response后主動(dòng)close連接鬼譬,也就是說應(yīng)該使用短連接娜膘,這樣才安全。
That's all优质!