翻譯原文連接 轉帖/轉載請註明出處html
原文連接@medium.com 發表於2017/08/03nginx
你們好!個人名字叫Sergey Kamardin。我是來自Mail.Ru的一名工程師。這篇文章將講述咱們是如何用Go語言開發一個高負荷的WebSocket服務。即便你對WebSockets熟悉但對Go語言知之甚少,我仍是但願這篇文章裏講到的性能優化的思路和技術對你有所啓發。git
做爲全文的鋪墊,我想先講一下咱們爲何要開發這個服務。github
Mail.Ru有許多包含狀態的系統。用戶的電子郵件存儲是其中之一。有不少辦法來跟蹤這些狀態的改變。不外乎經過按期的輪詢或者系統通知來獲得狀態的變化。這兩種方法都有它們的優缺點。對郵件這個產品來講,讓用戶儘快收到新的郵件是一個考量指標。郵件的輪詢會產生大概每秒5萬個HTTP請求,其中60%的請求會返回304狀態(表示郵箱沒有變化)。所以,爲了減小服務器的負荷並加速郵件的接收,咱們決定重寫一個publisher-subscriber服務(這個服務一般也會稱做bus,message broker或者event-channel)。這個服務負責接收狀態更新的通知,而後還處理對這些更新的訂閱。golang
重寫publisher-subscriber服務以前:web
如今:瀏覽器
上面第一個圖爲舊的架構。瀏覽器(Browser)會按期輪詢API服務來得到郵件存儲服務(Storage)的更新。緩存
第二張圖展現的是新的架構。瀏覽器(Browser)和通知API服務(notificcation API)創建一個WebSocket鏈接。通知API服務會發送相關的訂閱到Bus服務上。當收到新的電子郵件時,存儲服務(Storage)向Bus(1)發送一個通知,Bus又將通知發送給相應的訂閱者(2)。API服務爲收到的通知找到相應的鏈接,而後把通知推送到用戶的瀏覽器(3)。性能優化
咱們今天就來討論一下這個API服務(也能夠叫作WebSocket服務)。在開始以前,我想提一下這個在線服務處理將近3百萬個鏈接。服務器
首先,咱們看一下不作任何優化會如何用Go來實現這個服務的部分功能。在使用net/http
實現具體功能前,讓咱們先討論下咱們將如何發送和接收數據。這些數據是定義在WebSocket協議之上的(例如JSON對象)。咱們在下文中會成他們爲packet。
咱們先來實現Channel
結構。它包含相應的邏輯來經過WebScoket鏈接發送和接收packet。
// 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
和其它一些內部字段分配的內存空間。
接下來看一下「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的內存。
咱們已經有了一個簡單的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的內存!
咱們來回顧一下前面介紹的用戶鏈接的工做流程。在創建WebSocket以後,客戶端會發送請求訂閱相關事件(咱們這裏忽略相似ping/pong
的請求)。接下來,在整個鏈接的生命週期裏,客戶端可能就不會發送任何其它數據了。
鏈接的生命週期可能會持續幾秒鐘到幾天。
因此在大部分時間裏,Channel.reader()
和Channel.writer()
都在等待接收和發送數據。與它們一塊兒等待的是各自分配的4 KB的I/O緩存。
如今,咱們發現有些地方是能夠作進一步優化的,對吧?
你還記得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.go func (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.go func (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函數的問題。
假設咱們用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。
大量的鏈接不只僅會形成大量的內存消耗。在開發服務端的時候,咱們還不停地遇到競爭條件(race conditions)和死鎖(deadlocks)。隨之而來的是所謂的自我分佈式阻斷攻擊(self-DDOS)。在這種狀況下,客戶端會悍然地嘗試從新鏈接服務端而把狀況搞得更加糟糕。
舉個例子,若是由於某種緣由咱們忽然沒法處理ping/pong
消息,這些空閒鏈接就會不斷地被關閉(它們會覺得這些鏈接已經無效所以不會收到數據)。而後客戶端每N秒就會覺得失去了鏈接並嘗試從新創建鏈接,而不是繼續等待服務端發來的消息。
在這種狀況下,比較好的辦法是讓負載太重的服務端中止接受新的鏈接,這樣負載均衡器(例如nginx)就能夠把請求轉到其它的服務端上去。
撇開服務端的負載不說,若是全部的客戶端忽然(極可能是由於某個bug)向服務端發送一個packet,咱們以前節省的48 GB內存又將會被消耗掉。由於這時咱們又會和開始同樣給每一個鏈接建立goroutine並分配緩存。
能夠用一個goroutine池來限制同時處理packets的數目。下面的代碼是一個簡單的實現:
package gopool func 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的狀況。
以前已經提到,客戶端經過HTTP升級(Upgrade)請求切換到WebSocket協議。下面顯示的是一個升級請求:
GET /ws HTTP/1.1 Host: mail.ru Connection: Upgrade Sec-Websocket-Key: A3xNe7sEB9HixkmBhVrYaA== Sec-Websocket-Version: 13 Upgrade: websocket HTTP/1.1 101 Switching Protocols Connection: Upgrade Sec-Websocket-Accept: ksu0wXWG+YmkVx+KQR2agP0cQn4= Upgrade: websocket
咱們接收HTTP請求和它的頭部只是爲了切換到WebSocket協議,而http.Request
裏保存了全部頭部的數據。從這裏能夠獲得啓發,若是是爲了優化,咱們能夠放棄使用標準的net/http
服務並在處理HTTP請求的時候避免無用的內存分配和拷貝。
舉個例子,
http.Request
包含了一個叫作Header的字段。標準net/http
服務會將請求裏的全部頭部數據所有無條件地拷貝到Header字段裏。你能夠想象這個字段會保存許多冗餘的數據,例如一個包含很長cookie的頭部。
咱們如何來優化呢?
不幸的是,在咱們優化服務端的時候全部能找到的庫只支持對標準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.Reader func 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) //... }
簡而言之,咱們須要本身寫一個庫。
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/op BenchmarkUpgradeTCP 973 ns/op 0 B/op 0 allocs/op
使用ws
以及零拷貝升級爲咱們節省了24 GB的空間。這些空間本來被用作net/http
裏處理請求的I/O緩存。
讓咱們來回顧一下以前提到過的優化:
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) } }
在程序設計時,過早優化是萬惡之源。Donald Knuth
上面的優化是有意義的,但不是全部狀況都適用。舉個例子,若是空閒資源(內存,CPU)與在線鏈接數之間的比例很高的話,優化就沒有太多意義。固然,知道什麼地方能夠優化以及如何優化老是有幫助的。
謝謝你的關注!