最近在研究 Websockets,文章來源於 Sergey Kamardin,是 Mail.Ru 的一名開發人員。
這篇文章是關於我們如何使用 Go 開發高負載 WebSocket 服務的。
如果您熟悉 WebSockets,但對 Go 知之甚少,希望您在性能優化的思想和技術方面仍然對本文感興趣。
1. 簡介
爲了定義我們故事的上下文,應該說幾句關於爲什麼我們需要這個服務器。
Mail.Ru 網站有很多狀態系統。用戶電子郵件存儲就是其中之一。有幾種方法可以跟蹤系統內的狀態變化——以及系統事件。這主要是通過定期系統輪詢或關於其狀態更改的系統通知。
兩種方式各有利弊。但在郵件方面,用戶收到新郵件的速度越快越好。
郵件輪詢涉及每秒約 50,000 個 HTTP 查詢,其中 60% 返回 304 狀態,這意味着郵箱沒有變化。
因此,爲了減少服務器的負載並加快向用戶發送郵件的速度,決定重新發明輪子,編寫發佈-訂閱服務器(也稱爲總線、消息代理或事件- channel) ,一方面會接收有關狀態更改的通知,另一方面會接收此類通知的訂閱。
之前:
現在:
第一個方案顯示了它之前的樣子。瀏覽器定期輪詢 API 並詢問存儲(郵箱服務)更改。
第二個方案描述了新的架構。瀏覽器與通知 API 建立 WebSocket 連接,通知 API 是總線服務器的客戶端。收到新電子郵件後,Storage 會向 Bus (1) 發送有關它的通知,然後將 Bus 發送給其訂閱者 (2)。API 確定連接以發送收到的通知,並將其發送到用戶的瀏覽器 (3)。
所以今天我們將討論 API 或 WebSocket 服務器。展望未來,我告訴你服務器將有大約 300 萬在線連接。
2. 慣用的方式
讓我們看看我們如何在沒有任何優化的情況下使用普通的 Go 功能來實現我們服務器的某些部分。
在我們繼續之前net/http
,讓我們談談我們將如何發送和接收數據。位於 WebSocket 協議之上的數據(例如 JSON 對象)在下文中將被稱爲數據包。
讓我們開始實現Channel
將包含通過 WebSocket 連接發送和接收此類數據包的邏輯的結構。
2.1. 通道結構
// 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
}
WebSocket 通道實現。
我想提請您注意兩個讀寫 goroutines 的啓動。每個 goroutine 都需要自己的內存堆棧,其初始大小可能爲 2 到 8 KB,具體取決於操作系統和 Go 版本。
對於上面提到的 300 萬個在線連接數,我們將需要24 GB 的內存(加上 4 KB 的堆棧)用於所有連接。Channel
這還沒有爲結構、傳出數據包和其他內部字段分配的內存ch.send
。
2.2. I/O 協程
讓我們看一下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)
}
}
Channel 的讀取 goroutine。
在這裏,我們使用 來bufio.Reader
減少系統調用的數量read()
,並在緩衝區大小允許的範圍內讀取儘可能多的數據buf
。在無限循環中,我們期待新數據的到來。請記住這句話:*期待新數據的到來。*我們稍後會返回給他們。
我們將把傳入數據包的解析和處理放在一邊,因爲它對我們將要討論的優化並不重要。然而,buf
現在值得我們注意:默認情況下,它是 4 KB,這意味着我們的連接還有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()
}
}
Channel 的寫入 goroutine。
我們遍歷傳出數據包通道c.send
並將它們寫入緩衝區。正如我們細心的讀者已經猜到的那樣,這是用於我們 300 萬個連接的另外 4 KB 和12 GB內存。
2.3. HTTP
我們已經有了一個簡單的Channel
實現,現在我們需要獲得一個 WebSocket 連接才能使用。由於我們仍在慣用方式標題下,讓我們以相應的方式進行。
注意:如果你不知道 WebSocket 是如何工作的,應該提到客戶端通過一種稱爲升級的特殊 HTTP 機制切換到 WebSocket 協議。成功處理升級請求後,服務器和客戶端使用 TCP 連接交換二進制 WebSocket 幀。這裏是對連接內部的幀結構的描述。
import (
"net/http"
"some/websocket"
)
http.HandleFunc("/v1/ws", func(w http.ResponseWriter, r *http.Request) {
conn, _ := websocket.Upgrade(r, w)
ch := NewChannel(conn)
//...
})
升級到 WebSocket 的慣用方式。
請注意,爲初始化和進一步的響應http.ResponseWriter
寫入分配內存bufio.Reader
(bufio.Writer
均有 4 KB 緩衝區)*http.Request
。
無論使用什麼 WebSocket 庫,在成功響應升級請求後,服務器都會收到I/O 緩衝區以及調用後的 TCP 連接responseWriter.Hijack()
。
提示:在某些情況下,
go:linkname
可用於通過調用net/http.putBufio{Reader,Writer}
將緩衝區返回到net/http
內的sync.Pool
。
因此,我們需要另外24 GB的內存來支持 300 萬個連接。
因此,還沒有執行任何操作的應用程序總共需要72 GB的內存!
3. 優化
讓我們回顧一下我們在介紹部分討論的內容並記住用戶連接的行爲方式。切換到 WebSocket 後,客戶端發送一個包含相關事件的數據包,或者換句話說,訂閱事件。然後(不考慮諸如連接之類的技術消息ping/pong
),客戶端可能在整個連接生命週期內不發送任何其他內容。
連接壽命可能持續幾秒到幾天。
因此,在大多數情況下,我們Channel.reader()
正在Channel.writer()
等待處理接收或發送的數據。與它們一起等待的是每個 4 KB 的 I/O 緩衝區。
現在很明顯,某些事情可以做得更好,不是嗎?
3.1. Netpoll
您還記得Channel.reader()
實現通過鎖定bufio.Reader.Read()
內的conn.Read()
調用來預期新數據的到來嗎? 如果連接中有數據,Go 運行時會“喚醒”我們的 goroutine 並允許它讀取下一個數據包。 之後,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 在非阻塞模式下使用套接字。EAGAIN 表示套接字中沒有數據並且不會在從空套接字讀取時被鎖定,操作系統將控制權返回給我們。
我們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 用法的內部信息。
如果我們深入挖掘,我們會發現 netpoll在 Linux 中使用epoll實現,在 BSD 中使用 kqueue實現。爲什麼不對我們的連接使用相同的方法?我們可以分配一個讀取緩衝區並僅在真正需要時才啓動讀取 goroutine:當套接字中確實有可讀數據時。
在 github.com/golang/go 上,有導出 netpoll 函數的問題。
3.2. 擺脫協程
假設我們有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 Receive(ch)
})
// 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)
}
使用
netpoll
。
使用更容易,Channel.writer()
因爲我們可以運行 goroutine 並僅在我們要發送數據包時分配緩衝區:
func (ch *Channel) Send(p Packet) {
if c.noWriterYet() {
go ch.writer()
}
ch.send <- p
}
僅在需要時啓動 writer goroutine。
請注意,我們不處理操作系統返回
EAGAIN
系統write()
調用的情況。對於這種情況,我們依靠 Go 運行時,因爲這種服務器實際上很少見。然而,如果需要,它可以以相同的方式處理。
從(一個或多個)讀取傳出數據包後ch.send
,編寫器將完成其操作並釋放 goroutine 堆棧和發送緩衝區。
完美的作品!通過去除兩個連續運行的 goroutine 中的堆棧和 I/O 緩衝區,我們節省了48 GB 。
3.3. 資源控制
大量的連接不僅涉及高內存消耗。在開發服務器時,我們經歷了反覆出現的競爭條件和死鎖,隨後經常出現所謂的自我 DDoS——應用程序客戶端瘋狂地嘗試連接到服務器從而進一步破壞服務器的情況。
例如,如果由於某種原因我們突然無法處理ping/pong
消息,但是空閒連接的處理程序繼續關閉此類連接(假設連接斷開因此沒有提供數據),客戶端似乎每隔 N 秒就失去連接並嘗試再次連接而不是等待事件。
如果鎖定或過載的服務器只是停止接受新連接,並且它之前的平衡器(例如,nginx)將請求傳遞給下一個服務器實例,那就太好了。
此外,無論服務器負載如何,如果所有客戶端出於任何原因突然要向我們發送數據包(可能是由於錯誤原因),之前保存的48 GB將再次使用,因爲我們實際上將回到初始狀態每個連接的 goroutine 和緩衝區。
協程池
我們可以使用 goroutine 池限制同時處理的數據包數量。這就是這種池的簡單實現:
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
}
}
goroutine 池的簡單實現。
現在我們的代碼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() {
Receive(ch)
})
})
在 goroutine 池中處理輪詢器事件。
所以現在我們不僅在套接字中出現可讀數據時讀取數據包,而且在第一次有機會佔用池中的空閒 goroutine 時讀取數據包。
同樣,我們將更改Send()
:
pool := gopool.New(128)
func (ch *Channel) Send(p Packet) {
if c.noWriterYet() {
pool.Schedule(ch.writer)
}
ch.send <- p
}
複用編寫 goroutine。
而不是go ch.writer()
,我們想寫在一個重用的 goroutines 中。因此,對於一個 goroutines 池N
,我們可以保證在N
請求同時處理和到達N + 1
時,我們不會爲N + 1
讀取分配緩衝區。goroutine 池還允許我們限制Accept()
和Upgrade()
新連接並避免大多數 DDoS 情況。
3.4. 零拷貝升級
讓我們稍微偏離一下 WebSocket 協議。如前所述,客戶端使用 HTTP 升級請求切換到 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 升級示例。
也就是說,在我們的例子中,我們只需要 HTTP 請求及其標頭來切換到 WebSocket 協議。這些知識和存儲在其中的內容http.Request
表明,爲了優化,我們可能會在處理 HTTP 請求時拒絕不必要的分配和複製,並放棄標準服務器net/http
。
例如,
http.Request
包含一個具有同名 Header 類型的字段,通過將數據從連接複製到值字符串,該字段無條件地填充所有請求標頭。想象一下這個字段中可以保留多少額外數據,例如用於大型 Cookie 標頭。
但是要得到什麼回報呢?
WebSocket 實現
不幸的是,在我們優化服務器時存在的所有庫都允許我們只對標準net/http
服務器進行升級。此外,這兩個(兩個)庫都無法使用上述所有讀寫優化。爲了使這些優化起作用,我們必須有一個相當低級的 API 來處理 WebSocket。要重用緩衝區,我們需要協議函數如下所示:
func ReadFrame(io.Reader) (Frame, error)
func WriteFrame(io.Writer, Frame) error
如果我們有一個帶有此類 API 的庫,我們可以按如下方式從連接中讀取數據包(數據包寫入看起來是一樣的):
// 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)
//...
}
預期的 WebSocket 實現 API。
簡而言之,是時候創建我們自己的庫了。
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()
immediately 轉移到ws.Upgrade()
. 該庫可以複製任何請求數據以供將來在應用程序中使用(例如,Cookie
驗證會話)。
下面是升級請求處理的基準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——**處理程序處理請求時爲 I/O 緩衝區分配的空間net/http
。
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)
}
}
具有 netpoll、goroutine 池和零拷貝升級的示例 WebSocket 服務器。
4. 結論
過早的優化是編程中萬惡之源(或者至少是萬惡之源)。唐納德高德納
當然,上述優化是相關的,但並非在所有情況下。例如,如果可用資源(內存、CPU)與在線連接數之間的比率相當高,則可能沒有優化的意義。但是,如果知道需要改進的地方和需要改進的地方,您會受益匪淺。
感謝您的關注!