最近和春暉、劉丁討論定時(shí)器的問題最筒,又仔細(xì)看了下 go timer 兩個(gè)版本的實(shí)現(xiàn),再結(jié)合 epoll 事件驅(qū)動(dòng)蔚叨,對(duì)比 Nginx, 實(shí)現(xiàn)方式如出一轍床蜘。只不過 go 的是無(wú)阻塞順序編程,Nginx 異步回調(diào)蔑水。
高并發(fā)
老生常談了邢锯,什么是“高并發(fā)編程”呢?核心只有兩個(gè)搀别,epoll 和 NonBlock. Nginx 和 go 實(shí)現(xiàn)方式太像了丹擎,go runtime 庫(kù)所提供的接口都是無(wú)阻塞的,用 epoll 來(lái)實(shí)現(xiàn)事件驅(qū)動(dòng)歇父,效率非常高蒂培,后面的定時(shí)器就是典型案例。先舉一個(gè) openresty 的例子榜苫,這本書蠻不錯(cuò)护戳,以后好好研究下:
location /sleep_1 {
default_type 'text/plain';
content_by_lua_block {
ngx.sleep(0.01)
ngx.say("ok")
}
}
location /sleep_2 {
default_type 'text/plain';
content_by_lua_block {
function sleep(n)
os.execute("sleep " .. n)
end
sleep(0.01)
ngx.say("ok")
}
}
上面的配置,很好理解垂睬,兩個(gè) location 都是 sleep(0.01) 秒操作媳荒,但是區(qū)別在哪呢抗悍?先看壓測(cè)
? nginx git:(master) ab -c 10 -n 20 http://127.0.0.1/sleep_1
...
Requests per second: 860.33 [#/sec] (mean)
...
? nginx git:(master) ab -c 10 -n 20 http://127.0.0.1/sleep_2
...
Requests per second: 56.87 [#/sec] (mean)
...
性能差距 10 倍,原因就在于 sleep1 使用 openresty 提供的非阻塞 sleep 操作肺樟,執(zhí)行的時(shí)候會(huì)導(dǎo)致協(xié)程切換檐春,出讓 cpu, 但是 sleep2 調(diào)用了系統(tǒng)函數(shù),這是阻塞的么伯,cpu 空轉(zhuǎn)瓢省,openresty lua 開發(fā)的坑也很多。
阻塞 Block 是高并發(fā)的敵人寺庄,go 同理耀石,很多人認(rèn)為 goroutine 很歷害,但是一遇到 cgo, 或是需要系統(tǒng)調(diào)用就會(huì)出問題硬爆,阻塞操作占用了大量的 m, 系統(tǒng)線程猛增欣舵,這時(shí) gc 又會(huì)出來(lái)?yè)v亂。借用 qyuhen 老師的一句話缀磕,每寫一行代碼缘圈,都得知道背后發(fā)生了什么。為了搞清楚 go 背后的原理袜蚕,最近 春暉 在翻譯 go-internal糟把,有興趣的可以看看,需要有匯編和 go runtime 知識(shí)牲剃。
無(wú)阻塞操作
平時(shí)使用最多的 Read, Write 操作遣疯,go 都做了無(wú)阻塞封裝。Netpoll Accept 連接時(shí)凿傅,先設(shè)置成 SetNonBlock 模式缠犀,再使用 epoll et 邊緣觸發(fā)方式注冊(cè)到 netpoll 中。對(duì)于 Read 操作聪舒,如果當(dāng)前有數(shù)據(jù)辨液,那么讀出后返回。如果沒有箱残,那么調(diào)用 waitRead 將當(dāng)前 goroutine 掛起室梅,讓出 process, 等待網(wǎng)絡(luò)消息或是超時(shí)回調(diào)喚醒
// Read implements io.Reader.
func (fd *FD) Read(p []byte) (int, error) {
......
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
}
}
......
}
err = fd.eofError(n, err)
return n, err
}
}
waitRead 函數(shù)最終會(huì)調(diào)用 netpollblock 函數(shù),并 gopark 在這里疚宇,runtime 釋放當(dāng)前 goroutine 所使用的 process
func netpollblock(pd *pollDesc, mode int32, waitio bool) bool {
gpp := &pd.rg
if mode == 'w' {
gpp = &pd.wg
}
......
if waitio || netpollcheckerr(pd, mode) == 0 {
gopark(netpollblockcommit, unsafe.Pointer(gpp), "IO wait", traceEvGoBlockNet, 5)
}
// be careful to not lose concurrent READY notification
old := atomic.Xchguintptr(gpp, 0)
if old > pdWait {
throw("runtime: corrupted polldesc")
}
return old == pdReady
}
那么 Read 函數(shù)是如何繼續(xù)亡鼠?goroutine 如何喚醒的呢?
- SetReadDeadline 超時(shí)到時(shí)敷待,定時(shí)器喚醒
- Netpoll 收到了消息间涵,觸發(fā)epollin 消息喚醒
SetDeadline,每次對(duì) conn 讀寫前設(shè)置榜揖,并且只對(duì)下一次讀寫生效勾哩。先來(lái)看一下 go 如何實(shí)現(xiàn)
//go:linkname poll_runtime_pollSetDeadline internal/poll.runtime_pollSetDeadline
func poll_runtime_pollSetDeadline(pd *pollDesc, d int64, mode int) {
......
pd.seq++ // invalidate current timers 用來(lái)檢測(cè)當(dāng)前定時(shí)任務(wù)是否過期
// Reset current timers. 刪除老的任務(wù)
if pd.rt.f != nil {
deltimer(&pd.rt)
pd.rt.f = nil
}
if pd.wt.f != nil {
deltimer(&pd.wt)
pd.wt.f = nil
}
// Setup new timers.
if d != 0 && d <= nanotime() {
d = -1
}
if mode == 'r' || mode == 'r'+'w' {
pd.rd = d
}
if mode == 'w' || mode == 'r'+'w' {
pd.wd = d
}
if pd.rd > 0 && pd.rd == pd.wd {
pd.rt.f = netpollDeadline
pd.rt.when = pd.rd
// Copy current seq into the timer arg.
// Timer func will check the seq against current descriptor seq,
// if they differ the descriptor was reused or timers were reset.
pd.rt.arg = pd
pd.rt.seq = pd.seq
addtimer(&pd.rt)
} else {
if pd.rd > 0 {
pd.rt.f = netpollReadDeadline
pd.rt.when = pd.rd
pd.rt.arg = pd
pd.rt.seq = pd.seq
addtimer(&pd.rt)
}
if pd.wd > 0 {
pd.wt.f = netpollWriteDeadline
pd.wt.when = pd.wd
pd.wt.arg = pd
pd.wt.seq = pd.seq
addtimer(&pd.wt)
}
}
......
}
上面是 SetDeadline 核心部份代碼抗蠢,比較容易理解
- 生成 seq 號(hào),這個(gè)序列號(hào)用來(lái)判斷當(dāng)前定時(shí)器是否過期
- 查看是否設(shè)置了 pd.rt.f 定時(shí)器回調(diào)函數(shù)思劳,刪除上一次的任務(wù)
- 設(shè)置定時(shí)時(shí)間
- 將讀|寫任務(wù)加入定時(shí)器迅矛,任務(wù)到期后回調(diào)函數(shù) netpollDeadline
func netpolldeadlineimpl(pd *pollDesc, seq uintptr, read, write bool) {
lock(&pd.lock)
// Seq arg is seq when the timer was set.
// If it's stale, ignore the timer event.
if seq != pd.seq {
// The descriptor was reused or timers were reset.
unlock(&pd.lock)
return
}
var rg *g
if read {
if pd.rd <= 0 || pd.rt.f == nil {
throw("runtime: inconsistent read deadline")
}
pd.rd = -1
atomicstorep(unsafe.Pointer(&pd.rt.f), nil) // full memory barrier between store to rd and load of rg in netpollunblock
rg = netpollunblock(pd, 'r', false)
}
var wg *g
if write {
if pd.wd <= 0 || pd.wt.f == nil && !read {
throw("runtime: inconsistent write deadline")
}
pd.wd = -1
atomicstorep(unsafe.Pointer(&pd.wt.f), nil) // full memory barrier between store to wd and load of wg in netpollunblock
wg = netpollunblock(pd, 'w', false)
}
unlock(&pd.lock)
if rg != nil {
netpollgoready(rg, 0)
}
if wg != nil {
netpollgoready(wg, 0)
}
}
最終的定時(shí)任務(wù)到期,執(zhí)行回調(diào) netpollDeadline潜叛,執(zhí)功能也很簡(jiǎn)單:
- 判斷 seq 是否是最新的秽褒,對(duì)于長(zhǎng)連接存在多次讀寫交互,正常情況網(wǎng)絡(luò) socket 不會(huì)超時(shí)威兜,那么定時(shí)器觸發(fā)后什么也不做
- 根據(jù)讀|寫事件销斟,獲取要喚醒的 goroutine
- netpollgoready 喚醒 goroutine, 所謂的喚醒只是標(biāo)記成 可運(yùn)行 狀態(tài),具體執(zhí)行時(shí)間由 go runtime 決定
這是超時(shí)的情況椒舵,對(duì)于正常收到數(shù)據(jù)也很簡(jiǎn)單蚂踊,findrunnable 調(diào)用 netpoll 獲取收到事件的 goroutine, 標(biāo)記成 runnable 可運(yùn)行狀態(tài),具體執(zhí)行時(shí)間由 go runtime 決定笔宿。代碼參考 proc.go findrunnable 函數(shù)犁钟。
定時(shí)器
市面上流行的高效定時(shí)器有三種,go 使用的堆結(jié)構(gòu)泼橘、linux kernel 使用的時(shí)間輪涝动、nginx 紅黑樹。
go 在1.10前使用一個(gè)全局的四叉小頂堆結(jié)構(gòu)侥加,在面對(duì)大量連接時(shí),定時(shí)器性能非常差粪躬,所以很多人實(shí)現(xiàn)了用戶層的定時(shí)器庫(kù)担败,很多公司還做過分享。但是 1.10 引入了 runtime 層的 64 個(gè)定時(shí)器镰官,也就是 64 個(gè)四叉小頂堆定時(shí)器提前,性能提升不少。
相比二叉泳唠,更遍平一些狈网,增加刪除都是 O(log4N) 級(jí)別,查詢是O(1)笨腥,但是為了維護(hù)堆結(jié)構(gòu)也要額外操作 O(log4N)
時(shí)間輪有很多變種拓哺,內(nèi)核使用了多級(jí) time wheel, 沒看過內(nèi)核代碼,舉一個(gè)單輪的例子吧脖母,圖片來(lái)自csdn這篇文章
一個(gè)輪有 N 個(gè)刻度士鸥,每個(gè)刻度是一個(gè) t 嘀嗒時(shí)間,假如 N = 60, t = 1s, 那么就是生活中的秒針谆级。時(shí)間輪初始化 N 個(gè)槽烤礁,每個(gè)槽是一個(gè)鏈表讼积,在某一時(shí)刻加入一個(gè)時(shí)間為 T 的超時(shí)事件,cycle = T / t, n = T % t, 其中 cycle 是輪數(shù)脚仔,n 代表當(dāng)前事件插入 current 時(shí)刻后的第 n 個(gè)槽勤众。當(dāng)時(shí)間流逝,指針指向下一個(gè)時(shí)刻鲤脏,遍歷槽內(nèi)鏈表们颜,cycle - 1, 如果為 0 那么回調(diào)當(dāng)前超時(shí)任務(wù)函數(shù),否則繼續(xù)檢查下一個(gè)任務(wù)凑兰。插入刪除超時(shí)任務(wù)時(shí)間復(fù)雜度 O(1)掌桩,查詢是 O(n),但由于己經(jīng)分成多個(gè)槽姑食,所以效率肯定好于 O(n)波岛,多級(jí)時(shí)間輪計(jì)算更復(fù)雜。
紅黑樹有兩篇文章不錯(cuò)音半,nginx紅黑樹詳解, nginx學(xué)習(xí)9-ngx_rbtree_t则拷,整體感覺效率和小頂堆差不多。Nginx 使用紅黑樹做定時(shí)器曹鸠,舉一個(gè)最熟悉的場(chǎng)景煌茬,接收到連接后,如果長(zhǎng)時(shí)間沒收到 http header, 那么 Nginx 會(huì)關(guān)閉這個(gè)連接彻桃。
epoll 驅(qū)動(dòng)
Nginx 啟動(dòng) N 個(gè) worker, 并將 worker 和 cpu 進(jìn)行綁定坛善,每個(gè) worker 有自己的 epoll 和 定時(shí)器,由于沒有進(jìn)程邻眷、線程切換開銷眠屎,性能非常好。最近在看 Nginx 也引入了線程池肆饶,用于處理文件并發(fā)處理的情況改衩,不過線上并沒有用。
Epoll 開發(fā)多注意觸發(fā)模式驯镊,默認(rèn)是 LT 即水平觸發(fā)葫督,只要有數(shù)據(jù)可讀|寫,epoll_wait 返回時(shí)就一直攜帶 FD板惑。而大部分服務(wù)都使用 ET 邊緣觸發(fā)模式橄镜,即從無(wú)數(shù)據(jù)到有數(shù)據(jù),從不可寫到可寫冯乘,狀態(tài)變化才會(huì)觸發(fā) epoll, 如果一次沒有讀完蛉鹿,內(nèi)核里還有待讀數(shù)據(jù),那么 epoll 是不會(huì)觸發(fā)的往湿。所以 ET 使用的正確姿勢(shì)妖异,抱住 FD惋戏,一直讀|寫,直到遇到 EAGAIN他膳、EWOULDBLOCK 錯(cuò)誤响逢。
Nginx 在這里也做了優(yōu)化,如果有大段數(shù)據(jù)要讀取或是發(fā)送棕孙,他會(huì)分多次調(diào)用的舔亭,防止當(dāng)前 worker 其它任務(wù)餓死。上周公司同事將 1.9G 文件寫到一個(gè) git, 恰巧這個(gè)工程是服務(wù)配置庫(kù)蟀俊,每次上線都要拉取配置庫(kù)的壓縮包钦铺,直接將 Nginx 壓跨了。
小結(jié)
高并發(fā)的要點(diǎn)就是無(wú)阻塞 NonBlock, 看 openresty 文檔肢预,官方 lua 實(shí)現(xiàn)的都是無(wú)阻塞的矛洞,有時(shí)間讀讀。
前幾天某個(gè)大牛又提起所謂的 tcp 粘包烫映、拆包問題沼本,明明就是用戶協(xié)義解析問題,非要發(fā)明新名詞锭沟。thrift protobuf 反序列化就是個(gè)好例子抽兆。