Nginx 學習筆記3 高并發與go

最近和春暉、劉丁討論定時器的問題,又仔細看了下 go timer 兩個版本的實現,再結合 epoll 事件驅動,對比 Nginx, 實現方式如出一轍。只不過 go 的是無阻塞順序編程,Nginx 異步回調。

高并發

老生常談了,什么是“高并發編程”呢?核心只有兩個,epollNonBlock. Nginx 和 go 實現方式太像了,go runtime 庫所提供的接口都是無阻塞的,用 epoll 來實現事件驅動,效率非常高,后面的定時器就是典型案例。先舉一個 openresty 的例子,這本書蠻不錯,以后好好研究下:

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")
    }
}

上面的配置,很好理解,兩個 location 都是 sleep(0.01) 秒操作,但是區別在哪呢?先看壓測

?  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 操作,執行的時候會導致協程切換,出讓 cpu, 但是 sleep2 調用了系統函數,這是阻塞的,cpu 空轉,openresty lua 開發的坑也很多。

阻塞 Block 是高并發的敵人,go 同理,很多人認為 goroutine 很歷害,但是一遇到 cgo, 或是需要系統調用就會出問題,阻塞操作占用了大量的 m, 系統線程猛增,這時 gc 又會出來搗亂。借用 qyuhen 老師的一句話,每寫一行代碼,都得知道背后發生了什么。為了搞清楚 go 背后的原理,最近 春暉 在翻譯 go-internal,有興趣的可以看看,需要有匯編和 go runtime 知識。

無阻塞操作

平時使用最多的 Read, Write 操作,go 都做了無阻塞封裝。Netpoll Accept 連接時,先設置成 SetNonBlock 模式,再使用 epoll et 邊緣觸發方式注冊到 netpoll 中。對于 Read 操作,如果當前有數據,那么讀出后返回。如果沒有,那么調用 waitRead 將當前 goroutine 掛起,讓出 process, 等待網絡消息或是超時回調喚醒

// 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 函數最終會調用 netpollblock 函數,并 gopark 在這里,runtime 釋放當前 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 函數是如何繼續?goroutine 如何喚醒的呢?

  1. SetReadDeadline 超時到時,定時器喚醒
  2. Netpoll 收到了消息,觸發epollin 消息喚醒

SetDeadline,每次對 conn 讀寫前設置,并且只對下一次讀寫生效。先來看一下 go 如何實現

//go:linkname poll_runtime_pollSetDeadline internal/poll.runtime_pollSetDeadline
func poll_runtime_pollSetDeadline(pd *pollDesc, d int64, mode int) {
      ......
    pd.seq++ // invalidate current timers 用來檢測當前定時任務是否過期
    // Reset current timers. 刪除老的任務
    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 核心部份代碼,比較容易理解

  1. 生成 seq 號,這個序列號用來判斷當前定時器是否過期
  2. 查看是否設置了 pd.rt.f 定時器回調函數,刪除上一次的任務
  3. 設置定時時間
  4. 將讀|寫任務加入定時器,任務到期后回調函數 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)
    }
}

最終的定時任務到期,執行回調 netpollDeadline,執功能也很簡單:

  1. 判斷 seq 是否是最新的,對于長連接存在多次讀寫交互,正常情況網絡 socket 不會超時,那么定時器觸發后什么也不做
  2. 根據讀|寫事件,獲取要喚醒的 goroutine
  3. netpollgoready 喚醒 goroutine, 所謂的喚醒只是標記成 可運行 狀態,具體執行時間由 go runtime 決定

這是超時的情況,對于正常收到數據也很簡單,findrunnable 調用 netpoll 獲取收到事件的 goroutine, 標記成 runnable 可運行狀態,具體執行時間由 go runtime 決定。代碼參考 proc.go findrunnable 函數。

定時器

市面上流行的高效定時器有三種,go 使用的堆結構、linux kernel 使用的時間輪、nginx 紅黑樹。
go 在1.10前使用一個全局的四叉小頂堆結構,在面對大量連接時,定時器性能非常差,所以很多人實現了用戶層的定時器庫,很多公司還做過分享。但是 1.10 引入了 runtime 層的 64 個定時器,也就是 64 個四叉小頂堆定時器,性能提升不少。


golang timer

相比二叉,更遍平一些,增加刪除都是 O(log4N) 級別,查詢是O(1),但是為了維護堆結構也要額外操作 O(log4N)

時間輪有很多變種,內核使用了多級 time wheel, 沒看過內核代碼,舉一個單輪的例子吧,圖片來自csdn這篇文章

時間輪

一個輪有 N 個刻度,每個刻度是一個 t 嘀嗒時間,假如 N = 60, t = 1s, 那么就是生活中的秒針。時間輪初始化 N 個槽,每個槽是一個鏈表,在某一時刻加入一個時間為 T 的超時事件,cycle = T / t, n = T % t, 其中 cycle 是輪數,n 代表當前事件插入 current 時刻后的第 n 個槽。當時間流逝,指針指向下一個時刻,遍歷槽內鏈表,cycle - 1, 如果為 0 那么回調當前超時任務函數,否則繼續檢查下一個任務。插入刪除超時任務時間復雜度 O(1),查詢是 O(n),但由于己經分成多個槽,所以效率肯定好于 O(n),多級時間輪計算更復雜。

紅黑樹有兩篇文章不錯,nginx紅黑樹詳解, nginx學習9-ngx_rbtree_t,整體感覺效率和小頂堆差不多。Nginx 使用紅黑樹做定時器,舉一個最熟悉的場景,接收到連接后,如果長時間沒收到 http header, 那么 Nginx 會關閉這個連接。

epoll 驅動

Nginx 啟動 N 個 worker, 并將 worker 和 cpu 進行綁定,每個 worker 有自己的 epoll 和 定時器,由于沒有進程、線程切換開銷,性能非常好。最近在看 Nginx 也引入了線程池,用于處理文件并發處理的情況,不過線上并沒有用。

Epoll 開發多注意觸發模式,默認是 LT 即水平觸發,只要有數據可讀|寫,epoll_wait 返回時就一直攜帶 FD。而大部分服務都使用 ET 邊緣觸發模式,即從無數據到有數據,從不可寫到可寫,狀態變化才會觸發 epoll, 如果一次沒有讀完,內核里還有待讀數據,那么 epoll 是不會觸發的。所以 ET 使用的正確姿勢,抱住 FD,一直讀|寫,直到遇到 EAGAIN、EWOULDBLOCK 錯誤。

Nginx 在這里也做了優化,如果有大段數據要讀取或是發送,他會分多次調用的,防止當前 worker 其它任務餓死。上周公司同事將 1.9G 文件寫到一個 git, 恰巧這個工程是服務配置庫,每次上線都要拉取配置庫的壓縮包,直接將 Nginx 壓跨了。

小結

高并發的要點就是無阻塞 NonBlock, 看 openresty 文檔,官方 lua 實現的都是無阻塞的,有時間讀讀。

前幾天某個大牛又提起所謂的 tcp 粘包、拆包問題,明明就是用戶協義解析問題,非要發明新名詞。thrift protobuf 反序列化就是個好例子。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容