如果讓我們自己來實(shí)現(xiàn)一個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)聽一個端口
- 一個大循環(huán)不斷地Accept連接
- 每個連接開一個goroutine去處理業(yè)務(wù)邏輯
以上的步驟也很直觀猾浦,非常好理解紧卒。如果你粗略地掃一眼標(biāo)準(zhǔn)庫httpServer的實(shí)現(xiàn)肮塞,也能看到大致是一個路子。以下是標(biāo)準(zhǔn)庫的關(guān)鍵部分:
for {
rw, e := l.Accept()
if e != nil {
//處理錯誤 ...
}
tempDelay = 0
c := srv.newConn(rw)
c.setState(c.rwc, StateNew) // before Serve can return
go c.serve(ctx)
}
看起來幾乎差不多也是一個套路,accept一個連接嗽桩,然后起一個goroutine去處理兔乞。不過最近在做流量錄制時遇到一個奇怪的現(xiàn)象汇鞭。
我起了一個非常簡單的echo server,然后使用strace查看它的系統(tǒng)調(diào)用庸追。同時我有一個client每隔10s curl一次霍骄。奇怪的事情來了,client發(fā)了無數(shù)次請求淡溯,一直到我關(guān)閉了server读整,strace都顯示server只進(jìn)行過兩次accept,而且這兩次accept都是在第一個請求到來之前咱娶。
這里就比較困惑了米间,前面httpserver的源碼我們也看了煎楣,確實(shí)是accept一個連接,再go 一個handler车伞,怎么會出現(xiàn)沒有accept依然能走到handler呢择懂?
尋找問題
我第一時間想到的是,會不會是Go把Accept包了一層另玖,在內(nèi)部復(fù)用了連接困曙,因此我嘗試去讀這塊的源碼實(shí)現(xiàn)。這里還是要吐槽一下這塊兒Go的代碼實(shí)現(xiàn)得還是不怎么好谦去,看著比較亂慷丽。同時vscode的代碼跳轉(zhuǎn)也問題很多,經(jīng)常跳到錯誤的地方去了鳄哭,很多platform related的代碼實(shí)現(xiàn)都跳不對要糊。
這里總結(jié)的第一條經(jīng)驗(yàn)就是,看標(biāo)準(zhǔn)庫的源碼妆丘,一定要用delve進(jìn)行單步锄俄,用IDE跳轉(zhuǎn)就廢了。當(dāng)然你也應(yīng)該要理解IDE勺拣,畢竟很多代碼都是compiler自動生成的奶赠,或者有些函數(shù)需要ld的幫助才能找到對應(yīng)實(shí)現(xiàn)。所以一定要用dlv药有!
同步與異步
要理解這個現(xiàn)象的根本原因毅戈,我們需要先理解Go提供給我們的同步調(diào)用的抽象。首先愤惰,Go標(biāo)準(zhǔn)庫中所有API都是“同步”的苇经,并不是異步的。那么問題就來了宦言,如果都是同步調(diào)用扇单,那么和多線程還有啥區(qū)別,syscall始終要有一個線程阻塞在那里等待返回蜡励,實(shí)現(xiàn)一個N*M的goroutine還有啥意義令花?
上面的“同步”我是打了引號的,如果真的完全是同步的凉倚,那么goroutine確實(shí)就廢了兼都,這說明它并沒有表面上那么簡單。我們在進(jìn)行網(wǎng)絡(luò)IO操作時稽寒,代碼都是這樣的:
err := conn.Write(buffer)
data,err := conn.ReadAll()
然而實(shí)際上內(nèi)部實(shí)現(xiàn)做了很多事情扮碧,最關(guān)鍵的一點(diǎn)是,最底層的IO操作其實(shí)是異步的。異步就需要等待event fire慎王,但是我的代碼并沒有這么做膀就痢?——這便是Go提供的抽象所在赖淤。
底層其實(shí)把每個socket都加入到了一個全局的epollfd中進(jìn)行監(jiān)聽蜀漆,當(dāng)我們在socket上發(fā)生讀寫操作時,標(biāo)準(zhǔn)庫都會進(jìn)行異步操作咱旱,也就是說底層其實(shí)立刻就返回了确丢。但是如果事件并沒有完成,那么它會配合runtime park當(dāng)前的goroutine吐限,也就是說會把當(dāng)前這個goroutine掛起鲜侥。這里要注意,由于是異步操作诸典,因此真正的線程是不阻塞而是立刻返回的描函,因此scheduler可以調(diào)度當(dāng)前線程執(zhí)行其它goroutine。當(dāng)epoll收到event fire時狐粱,scheduler再把該goroutine喚醒舀寓,然后讓對應(yīng)線程去執(zhí)行那個goroutine。這樣脑奠,我們編寫代碼時就可以以同步的方式基公,寫出基于異步回調(diào)的高性能的代碼了。
這里的關(guān)鍵是宋欺,IO相關(guān)的系統(tǒng)調(diào)用是異步的,操作系統(tǒng)線程不會阻塞胰伍。因此配合scheduler正確的調(diào)度齿诞,才能實(shí)現(xiàn)這種抽象。同步的代碼骂租,異步的性能祷杈。如果系統(tǒng)調(diào)用會阻塞,那scheduler也沒轍渗饮。
問題所在
回到之前的問題但汞,由于底層會把每個socket都加入到epoll中進(jìn)行監(jiān)聽,因此主循環(huán)每accept到一個連接互站,就會加入到epoll中私蕾。如果你對網(wǎng)絡(luò)編程的概念不是很熟悉,你可能會問胡桃,如果把某個socket加入epoll之后就無法accept了嗎踩叭?如果你問出這個問題,至少說明你沒有理解accept的語義∪荼矗看一個accept的manpage自脯,一開始是這么說的:
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通過三次握手建立了連接之后(也就是實(shí)例化了一個socket結(jié)構(gòu)體),它會被放到內(nèi)核的一個緩存隊(duì)列里斤富。accept就是從這個隊(duì)列的隊(duì)首取出一個socket膏潮,然后返回一個fd指向該socket。換句話說满力,accept消費(fèi)的是三次握手新建的連接焕参!對于建立好的連接,后續(xù)發(fā)送的數(shù)據(jù)脚囊,只需要對該fd調(diào)用read方法即可龟糕。當(dāng)然,這里也很復(fù)雜悔耘,因?yàn)槟悴恢肋@個fd對應(yīng)的socket什么時候有數(shù)據(jù)讲岁,然后你要么輪詢地試要么select要么就epoll,反正這里就有很多方法了衬以。
因此你應(yīng)該理解缓艳,accept是讀取新連接,而epoll等各種方法實(shí)際上針對的是已建立的連接看峻,對其后續(xù)的數(shù)據(jù)流入流出事件進(jìn)行通知阶淘。
但是還是很奇怪啊,據(jù)說HTTP不是短連接嗎互妓?NoNoNo溪窒,這個觀點(diǎn)已經(jīng)過時了。在HTTP/1.0時代冯勉,確實(shí)是短連接澈蚌。雖然TCP是長連接,但是基于TCP的HTTP/1.0協(xié)議要求:如果client沒有明確告之keepalive灼狰,server在發(fā)送完response后就要主動關(guān)閉連接宛瞄。那時keepalive還是一個試驗(yàn)特性,不過現(xiàn)在使用的http/1.1協(xié)議中交胚,keepalive已經(jīng)是默認(rèn)行為了份汗。因此大部分http請求也是長連接,也就是說socket并不會主動被server關(guān)掉蝴簇。
一般來說杯活,如果要復(fù)用連接,我們需要保存對應(yīng)的數(shù)據(jù)結(jié)構(gòu)军熏,比如:
cli := newClient(host)
cli.Post()
cli.Get()
cli.Post()
但是轩猩,我們大多數(shù)時候直接使用了靜態(tài)方法,并沒有保存句柄:
http.Get()
http.Post()
不過go的http包底層幫我們做了連接池,我們的TCP連接都會被放到一個map中均践,后續(xù)建立連接前先檢查map中有沒有對應(yīng)的連接晤锹,如果沒有才會進(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)
//...后面省略
你可以看到彤委,獲取連接時會先getIdleConn鞭铆,如果沒有IdleConn再去dial。具體的代碼就不細(xì)講了焦影,因?yàn)閷?shí)現(xiàn)一個連接池簡單车遂,但是要實(shí)現(xiàn)一個高性能的連接池還是挺麻煩的,感興趣可以具體學(xué)習(xí)下為什么有兩個map斯辰。
而具體從conn中通過epoll或者kqueue讀取數(shù)據(jù)然后執(zhí)行ServeHTTP的邏輯舶担,都在go c.serve(ctx)
中感興趣可以看看