如果讓我們自己來(lái)實(shí)現(xiàn)一個(gè)http server,大部分同學(xué)都可以寫出以下實(shí)現(xiàn):
import (
"fmt"
"net"
"os"
)
func main() {
tcpAddr, err := net.ResolveTcpAddr("tcp", "localhost:6666")
checkError(err)
fd, err := net.ListenTcp("tcp", tcpAddr)
checkError(err)
for {
conn, err := fd.Accept()
if err != nil {
continue
}
go Handle(conn)
}
}
主要步驟就是:
- 監(jiān)聽一個(gè)端口
- 一個(gè)大循環(huán)不斷地Accept連接
- 每個(gè)連接開一個(gè)goroutine去處理業(yè)務(wù)邏輯
以上的步驟也很直觀,非常好理解。如果你粗略地掃一眼標(biāo)準(zhǔn)庫(kù)httpServer的實(shí)現(xiàn),也能看到大致是一個(gè)路子。以下是標(biāo)準(zhǔn)庫(kù)的關(guān)鍵部分:
for {
rw, e := l.Accept()
if e != nil {
//處理錯(cuò)誤 ...
}
tempDelay = 0
c := srv.newConn(rw)
c.setState(c.rwc, StateNew) // before Serve can return
go c.serve(ctx)
}
看起來(lái)幾乎差不多也是一個(gè)套路,accept一個(gè)連接,然后起一個(gè)goroutine去處理。不過(guò)最近在做流量錄制時(shí)遇到一個(gè)奇怪的現(xiàn)象。
我起了一個(gè)非常簡(jiǎn)單的echo server,然后使用strace查看它的系統(tǒng)調(diào)用。同時(shí)我有一個(gè)client每隔10s curl一次。奇怪的事情來(lái)了,client發(fā)了無(wú)數(shù)次請(qǐng)求,一直到我關(guān)閉了server,strace都顯示server只進(jìn)行過(guò)兩次accept,而且這兩次accept都是在第一個(gè)請(qǐng)求到來(lái)之前。
這里就比較困惑了,前面httpserver的源碼我們也看了,確實(shí)是accept一個(gè)連接,再go 一個(gè)handler,怎么會(huì)出現(xiàn)沒(méi)有accept依然能走到handler呢?
尋找問(wèn)題
我第一時(shí)間想到的是,會(huì)不會(huì)是Go把Accept包了一層,在內(nèi)部復(fù)用了連接,因此我嘗試去讀這塊的源碼實(shí)現(xiàn)。這里還是要吐槽一下這塊兒Go的代碼實(shí)現(xiàn)得還是不怎么好,看著比較亂。同時(shí)vscode的代碼跳轉(zhuǎn)也問(wèn)題很多,經(jīng)常跳到錯(cuò)誤的地方去了,很多platform related的代碼實(shí)現(xiàn)都跳不對(duì)。
這里總結(jié)的第一條經(jīng)驗(yàn)就是,看標(biāo)準(zhǔn)庫(kù)的源碼,一定要用delve進(jìn)行單步,用IDE跳轉(zhuǎn)就廢了。當(dāng)然你也應(yīng)該要理解IDE,畢竟很多代碼都是compiler自動(dòng)生成的,或者有些函數(shù)需要ld的幫助才能找到對(duì)應(yīng)實(shí)現(xiàn)。所以一定要用dlv!
同步與異步
要理解這個(gè)現(xiàn)象的根本原因,我們需要先理解Go提供給我們的同步調(diào)用的抽象。首先,Go標(biāo)準(zhǔn)庫(kù)中所有API都是“同步”的,并不是異步的。那么問(wèn)題就來(lái)了,如果都是同步調(diào)用,那么和多線程還有啥區(qū)別,syscall始終要有一個(gè)線程阻塞在那里等待返回,實(shí)現(xiàn)一個(gè)N*M的goroutine還有啥意義?
上面的“同步”我是打了引號(hào)的,如果真的完全是同步的,那么goroutine確實(shí)就廢了,這說(shuō)明它并沒(méi)有表面上那么簡(jiǎn)單。我們?cè)谶M(jìn)行網(wǎng)絡(luò)IO操作時(shí),代碼都是這樣的:
err := conn.Write(buffer)
data,err := conn.ReadAll()
然而實(shí)際上內(nèi)部實(shí)現(xiàn)做了很多事情,最關(guān)鍵的一點(diǎn)是,最底層的IO操作其實(shí)是異步的。異步就需要等待event fire,但是我的代碼并沒(méi)有這么做???——這便是Go提供的抽象所在。
底層其實(shí)把每個(gè)socket都加入到了一個(gè)全局的epollfd中進(jìn)行監(jiān)聽,當(dāng)我們?cè)趕ocket上發(fā)生讀寫操作時(shí),標(biāo)準(zhǔn)庫(kù)都會(huì)進(jìn)行異步操作,也就是說(shuō)底層其實(shí)立刻就返回了。但是如果事件并沒(méi)有完成,那么它會(huì)配合runtime park當(dāng)前的goroutine,也就是說(shuō)會(huì)把當(dāng)前這個(gè)goroutine掛起。這里要注意,由于是異步操作,因此真正的線程是不阻塞而是立刻返回的,因此scheduler可以調(diào)度當(dāng)前線程執(zhí)行其它goroutine。當(dāng)epoll收到event fire時(shí),scheduler再把該goroutine喚醒,然后讓對(duì)應(yīng)線程去執(zhí)行那個(gè)goroutine。這樣,我們編寫代碼時(shí)就可以以同步的方式,寫出基于異步回調(diào)的高性能的代碼了。
這里的關(guān)鍵是,IO相關(guān)的系統(tǒng)調(diào)用是異步的,操作系統(tǒng)線程不會(huì)阻塞。因此配合scheduler正確的調(diào)度,才能實(shí)現(xiàn)這種抽象。同步的代碼,異步的性能。如果系統(tǒng)調(diào)用會(huì)阻塞,那scheduler也沒(méi)轍。
問(wèn)題所在
回到之前的問(wèn)題,由于底層會(huì)把每個(gè)socket都加入到epoll中進(jìn)行監(jiān)聽,因此主循環(huán)每accept到一個(gè)連接,就會(huì)加入到epoll中。如果你對(duì)網(wǎng)絡(luò)編程的概念不是很熟悉,你可能會(huì)問(wèn),如果把某個(gè)socket加入epoll之后就無(wú)法accept了嗎?如果你問(wèn)出這個(gè)問(wèn)題,至少說(shuō)明你沒(méi)有理解accept的語(yǔ)義。看一個(gè)accept的manpage,一開始是這么說(shuō)的:
The accept() system call is used with connection-based socket types(SOCK_STREAM, SOCK_SEQPACKET). It extracts the first connection request on the queue of pending connections for the listening socket, sockfd, creates a new connected socket, and returns a new file descriptor referring to that socket. The newly created socket is not in the listening state. The original socket sockfd is unaffected by this call.
當(dāng)client和server通過(guò)三次握手建立了連接之后(也就是實(shí)例化了一個(gè)socket結(jié)構(gòu)體),它會(huì)被放到內(nèi)核的一個(gè)緩存隊(duì)列里。accept就是從這個(gè)隊(duì)列的隊(duì)首取出一個(gè)socket,然后返回一個(gè)fd指向該socket。換句話說(shuō),accept消費(fèi)的是三次握手新建的連接!對(duì)于建立好的連接,后續(xù)發(fā)送的數(shù)據(jù),只需要對(duì)該fd調(diào)用read方法即可。當(dāng)然,這里也很復(fù)雜,因?yàn)槟悴恢肋@個(gè)fd對(duì)應(yīng)的socket什么時(shí)候有數(shù)據(jù),然后你要么輪詢地試要么select要么就epoll,反正這里就有很多方法了。
因此你應(yīng)該理解,accept是讀取新連接,而epoll等各種方法實(shí)際上針對(duì)的是已建立的連接,對(duì)其后續(xù)的數(shù)據(jù)流入流出事件進(jìn)行通知。
但是還是很奇怪啊,據(jù)說(shuō)HTTP不是短連接嗎?NoNoNo,這個(gè)觀點(diǎn)已經(jīng)過(guò)時(shí)了。在HTTP/1.0時(shí)代,確實(shí)是短連接。雖然TCP是長(zhǎng)連接,但是基于TCP的HTTP/1.0協(xié)議要求:如果client沒(méi)有明確告之keepalive,server在發(fā)送完response后就要主動(dòng)關(guān)閉連接。那時(shí)keepalive還是一個(gè)試驗(yàn)特性,不過(guò)現(xiàn)在使用的http/1.1協(xié)議中,keepalive已經(jīng)是默認(rèn)行為了。因此大部分http請(qǐng)求也是長(zhǎng)連接,也就是說(shuō)socket并不會(huì)主動(dòng)被server關(guān)掉。
一般來(lái)說(shuō),如果要復(fù)用連接,我們需要保存對(duì)應(yīng)的數(shù)據(jù)結(jié)構(gòu),比如:
cli := newClient(host)
cli.Post()
cli.Get()
cli.Post()
但是,我們大多數(shù)時(shí)候直接使用了靜態(tài)方法,并沒(méi)有保存句柄:
http.Get()
http.Post()
不過(guò)go的http包底層幫我們做了連接池,我們的TCP連接都會(huì)被放到一個(gè)map中,后續(xù)建立連接前先檢查map中有沒(méi)有對(duì)應(yīng)的連接,如果沒(méi)有才會(huì)進(jìn)行新建連接。
// getConn dials and creates a new persistConn to the target as
// specified in the connectMethod. This includes doing a proxy CONNECT
// and/or setting up TLS. If this doesn't return an error, the persistConn
// is ready to write requests to.
func (t *Transport) getConn(treq *transportRequest, cm connectMethod) (*persistConn, error) {
req := treq.Request
trace := treq.trace
ctx := req.Context()
if trace != nil && trace.GetConn != nil {
trace.GetConn(cm.addr())
}
if pc, idleSince := t.getIdleConn(cm); pc != nil {
if trace != nil && trace.GotConn != nil {
trace.GotConn(pc.gotIdleConnTrace(idleSince))
}
// set request canceler to some non-nil function so we
// can detect whether it was cleared between now and when
// we enter roundTrip
t.setReqCanceler(req, func(error) {})
return pc, nil
}
type dialRes struct {
pc *persistConn
err error
}
dialc := make(chan dialRes)
//...后面省略
你可以看到,獲取連接時(shí)會(huì)先getIdleConn,如果沒(méi)有IdleConn再去dial。具體的代碼就不細(xì)講了,因?yàn)閷?shí)現(xiàn)一個(gè)連接池簡(jiǎn)單,但是要實(shí)現(xiàn)一個(gè)高性能的連接池還是挺麻煩的,感興趣可以具體學(xué)習(xí)下為什么有兩個(gè)map。
而具體從conn中通過(guò)epoll或者kqueue讀取數(shù)據(jù)然后執(zhí)行ServeHTTP的邏輯,都在go c.serve(ctx)中感興趣可以看看