這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
翻譯原文連結 轉帖/轉載請註明出處
原文連結@medium.com 發表於2017/08/03
大家好!我的名字叫Sergey Kamardin。我是來自Mail.Ru的一名工程師。這篇文章將講述我們是如何用Go語言開發一個高負荷的WebSocket服務。即使你對WebSockets熟悉但對Go語言知之甚少,我還是希望這篇文章裡講到的效能最佳化的思路和技術對你有所啟發。
1. 介紹
作為全文的鋪墊,我想先講一下我們為什麼要開發這個服務。
Mail.Ru有許多包含狀態的系統。使用者的電子郵件儲存是其中之一。有很多辦法來跟蹤這些狀態的改變。不外乎通過週期性輪詢或者系統通知來得到狀態的變化。這兩種方法都有它們的優缺點。對郵件這個產品來說,讓使用者儘快收到新的郵件是一個考量指標。郵件的輪詢會產生大概每秒5萬個HTTP請求,其中60%的請求會返回304狀態(表示郵箱沒有變化)。因此,為了減少伺服器的負荷並加速郵件的接收,我們決定重寫一個publisher-subscriber服務(這個服務通常也會稱作bus,message broker或者event-channel)。這個服務負責接收狀態更新的通知,然後還處理對這些更新的訂閱。
重寫publisher-subscriber服務之前:
現在:
上面第一個圖為舊的架構。瀏覽器(Browser)會定期輪詢API服務來獲得郵件儲存服務(Storage)的更新。
第二張圖展示的是新的架構。瀏覽器(Browser)和通知API服務(notificcation API)建立一個WebSocket串連。通知API服務會發送相關的訂閱到Bus服務上。當收到新的電子郵件時,儲存服務(Storage)向Bus(1)發送一個通知,Bus又將通知發送給相應的訂閱者(2)。API服務為收到的通知找到相應的串連,然後把通知推送到使用者的瀏覽器(3)。
我們今天就來討論一下這個API服務(也可以叫做WebSocket服務)。在開始之前,我想提一下這個線上服務處理將近3百萬個串連。
2. 慣用的做法(The idiomatic way)
首先,我們看一下不做任何最佳化會如何用Go來實現這個服務的部分功能。在使用net/http
實現具體功能前,讓我們先討論下我們將如何發送和接收資料。這些資料是定義在WebSocket協議之上的(例如JSON對象)。我們在下文中會成他們為packet。
我們先來實現Channel
結構。它包含相應的邏輯來通過WebScoket串連發送和接收packet。
2.1. Channel結構
// Packet represents application level data.type Packet struct { ...}// Channel wraps user connection.type Channel struct { conn net.Conn // WebSocket connection. send chan Packet // Outgoing packets queue.}func NewChannel(conn net.Conn) *Channel { c := &Channel{ conn: conn, send: make(chan Packet, N), } go c.reader() go c.writer() return c}
這裡我要強調的是讀和寫這兩個goroutines。每個goroutine都需要各自的記憶體棧。棧的初始大小由作業系統和Go的版本決定,通常在2KB到8KB之間。我們之前提到有3百萬個線上串連,如果每個goroutine棧需要4KB的話,所有串連就需要24GB的記憶體。這還沒算上給Channel
結構,發送packet用的ch.send
和其它一些內部欄位分配的記憶體空間。
2.2. I/O goroutines
接下來看一下“reader”的實現:
func (c *Channel) reader() { // We make a buffered read to reduce read syscalls. buf := bufio.NewReader(c.conn) for { pkt, _ := readPacket(buf) c.handle(pkt) }}
這裡我們使用了bufio.Reader
。每次都會在buf
大小允許的範圍內盡量讀取多的位元組,從而減少read()
系統調用的次數。在無限迴圈中,我們期望會接收到新的資料。請記住之前這句話:期望接收到新的資料。我們之後會討論到這一點。
我們把packet的解析和處理邏輯都忽略掉了,因為它們和我們要討論的最佳化不相關。不過buf
值得我們的關註:它的預設大小是4KB。這意味著所有串連將消耗掉額外的12 GB記憶體。“writer”也是類似的情況:
func (c *Channel) writer() { // We make buffered write to reduce write syscalls. buf := bufio.NewWriter(c.conn) for pkt := range c.send { _ := writePacket(buf, pkt) buf.Flush() }}
我們在待發送packet的c.send
channel上迴圈將packet寫到緩衝(buffer)裡。細心的讀者肯定已經發現,這又是額外的4KB記憶體。3百萬個串連會佔用12GB的記憶體。
2.3. HTTP
我們已經有了一個簡單的Channel
實現。現在我們需要一個WebSocket串連。因為還在通常做法(Idiomatic Way)的標題下,那麼就先來看看通常是如何?的。
註:如果你不知道WebSocket是怎麼工作的,那麼這裡值得一提的是用戶端是通過一個叫升級(Upgrade)請求的特殊HTTP機制來建立WebSocket的。在成功處理升級請求以後,服務端和用戶端使用TCP串連來交換二進位的WebSocket幀(frames)。這裡有關於幀結構的描述。
import ( "net/http" "some/websocket")http.HandleFunc("/v1/ws", func(w http.ResponseWriter, r *http.Request) { conn, _ := websocket.Upgrade(r, w) ch := NewChannel(conn) //...})
請注意這裡的http.ResponseWriter
結構包含bufio.Reader
和bufio.Writer
(各自分別包含4KB的緩衝)。它們用於\*http.Request
初始化和返回結果。
不管是哪個WebSocket,在成功回應一個升級請求之後,服務端在調用responseWriter.Hijack()
之後會接收到一個I/O緩衝和對應的TCP串連。
註:有時候我們可以通過net/http.putBufio{Reader,Writer}
調用把緩衝釋放回net/http
裡的sync.Pool
。
這樣,這3百萬個串連又需要額外的24 GB記憶體。
所以,為了這個什麼都不乾的程式,我們已經佔用了72 GB的記憶體!
3. 最佳化
我們來回顧一下前面介紹的使用者串連的工作流程。在建立WebSocket之後,用戶端會發送提取訂閱相關事件(我們這裡忽略類似ping/pong
的請求)。接下來,在整個串連的生命週期裡,用戶端可能就不會發送任何其它資料了。
串連的生命週期可能會持續幾秒鐘到幾天。
所以在大部分時間裡,Channel.reader()
和Channel.writer()
都在等待接收和發送資料。與它們一起等待的是各自分配的4 KB的I/O緩衝。
現在,我們發現有些地方是可以做進一步最佳化的,對吧?
3.1. Netpoll
你還記得Channel.reader()
的實現使用了bufio.Reader.Read()
嗎?bufio.Reader.Read()
又會調用conn.Read()
。這個調用會被阻塞以等待接收串連上的新資料。如果串連上有新的資料,Go的運行環境(runtime)就會喚醒相應的goroutine讓它去讀取下一個packet。之後,goroutine會被再次阻塞來等待新的資料。我們來研究下Go的運行環境是怎麼知道goroutine需要被喚醒的。
如果我們看一下conn.Read()
的實現,就會看到它調用了net.netFD.Read()
:
// net/fd_unix.gofunc (fd *netFD) Read(p []byte) (n int, err error) { //... for { n, err = syscall.Read(fd.sysfd, p) if err != nil { n = 0 if err == syscall.EAGAIN { if err = fd.pd.waitRead(); err == nil { continue } } } //... break } //...}
Go使用了sockets的非阻塞模式。EAGAIN表示socket裡沒有資料了但不會阻塞在空的socket上,OS會把控制權返回給使用者進程。
這裡它首先對串連檔案描述符進行read()
系統調用。如果read()
返回的是EAGAIN
錯誤,運行環境就是調用pollDesc.waitRead()
:
// net/fd_poll_runtime.gofunc (pd *pollDesc) waitRead() error { return pd.wait('r')}func (pd *pollDesc) wait(mode int) error { res := runtime_pollWait(pd.runtimeCtx, mode) //...}
如果繼續深挖,我們可以看到netpoll的實現在Linux裡用的是epoll而在BSD裡用的是kqueue。我們的這些串連為什麼不採用類似的方式呢?只有在socket上有可讀資料時,才分配緩衝空間並啟用讀資料的goroutine。
在github.com/golang/go上,有一個關於開放(exporting)netpoll函數的問題。
3.2. 幹掉goroutines
假設我們用Go語言實現了netpoll。我們現在可以避免建立Channel.reader()
的goroutine,取而代之的是從訂閱串連裡收到新資料的事件。
ch := NewChannel(conn)// Make conn to be observed by netpoll instance.poller.Start(conn, netpoll.EventRead, func() { // We spawn goroutine here to prevent poller wait loop // to become locked during receiving packet from ch. go ch.Receive()})// Receive reads a packet from conn and handles it somehow.func (ch *Channel) Receive() { buf := bufio.NewReader(ch.conn) pkt := readPacket(buf) c.handle(pkt)}
Channel.writer()
相對容易一點,因為我們只需在發送packet的時候建立goroutine並分配緩衝。
func (ch *Channel) Send(p Packet) { if c.noWriterYet() { go ch.writer() } ch.send <- p}
注意,這裡我們沒有處理write()
系統調用時返回的EAGAIN
。我們依賴Go運行環境去處理它。這種情況很少發生。如果需要的話我們還是可以像之前那樣來處理。
從ch.send
讀取待發送的packets之後,ch.writer()
會完成它的操作,最後釋放goroutine的棧和用於發送的緩衝。
很不錯!通過避免這兩個連續啟動並執行goroutine所佔用的I/O緩衝和棧記憶體,我們已經節省了48 GB。
3.3. 控制資源
大量的串連不僅僅會造成大量的記憶體消耗。在開發服務端的時候,我們還不停地遇到競爭條件(race conditions)和死結(deadlocks)。隨之而來的是所謂的自我分布式阻斷攻擊(self-DDOS)。在這種情況下,用戶端會悍然地嘗試重新串連服務端而把情況搞得更加糟糕。
舉個例子,如果因為某種原因我們突然無法處理ping/pong
訊息,這些空閑串連就會不斷地被關閉(它們會以為這些串連已經無效因此不會收到資料)。然後用戶端每N秒就會以為失去了串連並嘗試重建立立串連,而不是繼續等待服務端發來的訊息。
在這種情況下,比較好的辦法是讓負載過重的服務端停止接受新的串連,這樣負載平衡器(例如nginx)就可以把請求轉到其它的服務端上去。
撇開服務端的負載不說,如果所有的用戶端突然(很可能是因為某個bug)向服務端發送一個packet,我們之前節省的48 GB記憶體又將會被消耗掉。因為這時我們又會和開始一樣給每個串連建立goroutine並分配緩衝。
Goroutine池
可以用一個goroutine池來限制同時處理packets的數目。下面的代碼是一個簡單的實現:
package gopoolfunc New(size int) *Pool { return &Pool{ work: make(chan func()), sem: make(chan struct{}, size), }}func (p *Pool) Schedule(task func()) error { select { case p.work <- task: case p.sem <- struct{}{}: go p.worker(task) }}func (p *Pool) worker(task func()) { defer func() { <-p.sem } for { task() task = <-p.work }}
我們使用netpoll的代碼就變成下面這樣:
pool := gopool.New(128)poller.Start(conn, netpoll.EventRead, func() { // We will block poller wait loop when // all pool workers are busy. pool.Schedule(func() { ch.Receive() })})
現在我們不僅要等可讀的資料出現在socket上才能讀packet,還必須等到從池裡擷取到閒置goroutine。
同樣的,我們修改下Send()
的代碼:
pool := gopool.New(128)func (ch *Channel) Send(p Packet) { if c.noWriterYet() { pool.Schedule(ch.writer) } ch.send <- p}
這裡我們沒有調用go ch.writer()
,而是想重複利用池裡goroutine來發送資料。 所以,如果一個池有N
個goroutines的話,我們可以保證有N
個請求被同時處理。而N + 1
個請求不會分配N + 1
個緩衝。goroutine池允許我們限制對新串連的Accept()
和Upgrade()
,這樣就避免了大部分DDoS的情況。
3.4. 零拷貝升級(Zero-copy upgrade)
之前已經提到,用戶端通過HTTP升級(Upgrade)請求切換到WebSocket協議。下面顯示的是一個升級請求:
GET /ws HTTP/1.1Host: mail.ruConnection: UpgradeSec-Websocket-Key: A3xNe7sEB9HixkmBhVrYaA==Sec-Websocket-Version: 13Upgrade: websocketHTTP/1.1 101 Switching ProtocolsConnection: UpgradeSec-Websocket-Accept: ksu0wXWG+YmkVx+KQR2agP0cQn4=Upgrade: websocket
我們接收HTTP請求和它的頭部只是為了切換到WebSocket協議,而http.Request
裡儲存了所有頭部的資料。從這裡可以得到啟發,如果是為了最佳化,我們可以放棄使用標準的net/http
服務並在處理HTTP請求的時候避免無用的記憶體配置和拷貝。
舉個例子,http.Request
包含了一個叫做Header的欄位。標準net/http
服務會將請求裡的所有頭部資料全部無條件地拷貝到Header欄位裡。你可以想象這個欄位會儲存許多冗餘的資料,例如一個包含很長cookie的頭部。
我們如何來最佳化呢?
WebSocket實現
不幸的是,在我們最佳化服務端的時候所有能找到的庫只支援對標準net/http
服務做升級。而且沒有一個庫允許我們實現上面提到的讀和寫的最佳化。為了使這些最佳化成為可能,我們必須有一套底層的API來操作WebSocket。為了重用緩衝,我們需要類似下面這樣的協議函數:
func ReadFrame(io.Reader) (Frame, error)func WriteFrame(io.Writer, Frame) error
如果我們有一個包含這樣API的庫,我們就按照下面的方式從串連上讀取packets:
// getReadBuf, putReadBuf are intended to// reuse *bufio.Reader (with sync.Pool for example).func getReadBuf(io.Reader) *bufio.Readerfunc putReadBuf(*bufio.Reader)// readPacket must be called when data could be read from conn.func readPacket(conn io.Reader) error { buf := getReadBuf() defer putReadBuf(buf) buf.Reset(conn) frame, _ := ReadFrame(buf) parsePacket(frame.Payload) //...}
簡而言之,我們需要自己寫一個庫。
github.com/gobwas/ws
ws
庫的主要設計思想是不將協議的操作邏輯暴露給使用者。所有讀寫函數都接受通用的io.Reader
和io.Writer
介面。因此它可以隨意搭配是否使用緩衝以及其它I/O的庫。
除了標準庫net/http
裡的升級請求,ws
還支援零拷貝升級。它能夠處理升級請求並切換到WebSocket模式而不產生任何記憶體配置或者拷貝。ws.Upgrade()
接受io.ReadWriter
(net.Conn
實現了這個介面)。換句話說,我們可以使用標準的net.Listen()
函數然後把從ln.Accept()
收到的串連馬上交給ws.Upgrade()
去處理。庫也允許拷貝任何請求資料來滿足將來應用的需求(舉個例子,拷貝Cookie
來驗證一個session)。
下面是處理升級請求的效能測試:標準net/http
庫的實現和使用零拷貝升級的net.Listen()
:
BenchmarkUpgradeHTTP 5156 ns/op 8576 B/op 9 allocs/opBenchmarkUpgradeTCP 973 ns/op 0 B/op 0 allocs/op
使用ws
以及零拷貝升級為我們節省了24 GB的空間。這些空間原本被用做net/http
裡處理請求的I/O緩衝。
3.5. 回顧
讓我們來回顧一下之前提到過的最佳化:
- 一個包含緩衝的讀goroutine會佔用很多記憶體。方案: netpoll(epoll, kqueue);重用緩衝。
- 一個包含緩衝的寫goroutine會佔用很多記憶體。方案: 在需要的時候建立goroutine;重用緩衝。
- 存在大量串連請求的時候,netpoll不能很好的限制串連數。方案: 重用goroutines並且限制它們的數目。
net/http
對升級到WebSocket請求的處理不是最高效的。方案: 在TCP串連上實現零拷貝升級。
下面是服務端的大致實現代碼:
import ( "net" "github.com/gobwas/ws")ln, _ := net.Listen("tcp", ":8080")for { // Try to accept incoming connection inside free pool worker. // If there no free workers for 1ms, do not accept anything and try later. // This will help us to prevent many self-ddos or out of resource limit cases. err := pool.ScheduleTimeout(time.Millisecond, func() { conn := ln.Accept() _ = ws.Upgrade(conn) // Wrap WebSocket connection with our Channel struct. // This will help us to handle/send our app's packets. ch := NewChannel(conn) // Wait for incoming bytes from connection. poller.Start(conn, netpoll.EventRead, func() { // Do not cross the resource limits. pool.Schedule(func() { // Read and handle incoming packet(s). ch.Recevie() }) }) }) if err != nil { time.Sleep(time.Millisecond) }}
4. 結論
在程式設計時,過早最佳化是萬惡之源。Donald Knuth
上面的最佳化是有意義的,但不是所有情況都適用。舉個例子,如果空閑資源(記憶體,CPU)與線上串連數之間的比例很高的話,最佳化就沒有太多意義。當然,知道什麼地方可以最佳化以及如何最佳化總是有協助的。
謝謝你的關注!
5. 引用
- https://github.com/mailru/easygo
- https://github.com/gobwas/ws
- https://github.com/gobwas/ws-...
- https://github.com/gobwas/htt...