用Golang實現百萬級Websocket服務
前言: 本文為國外大佬的一篇文章,因為我最近在研究和學習使用go寫一個訊息伺服器,所以找到這篇文章,於是將它翻譯過來,希望能夠幫到其他的同學。這是我的處女作翻譯作品。希望大神能夠幫助指導修正。以後可能每週都會有一篇國外技術文章的翻譯,有興趣的同學可以加QQ群共同討論(511247400)。
這篇文章我們討論一下怎麼使用go開發一個高負載的Websocket
服務
如果你很熟悉websocket,但是不瞭解Go, 我希望你先掌握一些Go的知識
1.介紹
要定義我們的故事背景,應該說下我們為什麼需要這樣的一個服務。
我們有一個狀態系統,使用者郵箱儲存就是其中之一,這裡有一些方法去跟蹤系統狀態的變化和系統事件。大多是通過定期輪詢或系統通知來改變狀態。
兩種方法各有利弊,但是提到email,使用者越快接受到郵件越好。
郵件輪詢涉及到大約 50000
個 HTTP
請求每秒,60%
返回了304
狀態, 這意味著郵箱裡沒有發生任何變化。
因此, 我們要減少伺服器的負載去快速的傳送email
到使用者,這讓我決定重新創造一個輪子去寫一個釋出者
和訂閱者
服務,一方面它將接受到狀態的通知,另一方面,也將這樣的通知釋出到其他使用者。
之前
現在:
第一個方案展示的就我們之前說的第一個,瀏覽器定期輪詢Api並且詢問郵箱儲存服務的改變。
第二個方案描述了一個新的架構,瀏覽器和通知API建立一個Websocket
連線,這是服務的客戶端。收到一個新的郵件的時候,伺服器傳送一個通知到釋出者
服務(1),釋出者釋出(2),api確定收到通知的連線,併傳送到使用者的瀏覽器(3)。
所以,今天我們來討論這個API和Websocket服務,展望未來,我將告訴你這個服務將又有300萬線上連線。
2. 通常解決方法
讓我們看下如何在沒有任何優化的情況下使用普通GO功能實現伺服器的某些功能。
在我們繼續使用 net/http
之前, 我們來看下怎麼傳送和接受資料,在Websocket協議之上(例如:json 物件)我們稱之為資料包。
讓我們開始實現這個channel
結構體,它將包含Websocket傳送和接受數包的邏輯,通過Websocket連線。
2.1 channel struct
// 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 channel 實現
我想,你應該注意到兩個讀寫的goroutines, 每個goroutines擁有一個堆記憶體,並且初始大小為2K到8K大小,這取決於作業系統和Go的版本。
關於上面我們提到的300萬線上連線,我們就需要24GB的記憶體(按每個goroutine 4Kb 堆記憶體計算),而且沒有為channel結構體分配記憶體。
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減少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()
}
}
我們遍歷傳出的資料包通道 c.send
, 並將它們寫入緩衝區,我們細心的讀者已經猜到,我們的300萬連線還有另外的4kb和12GB記憶體。
2.3 HTTP
我們已經有了一個簡單的 channel
實現, 那麼現在我們需要一個 websockt
連線和它一起工作,因為我們還在通常的解決方案之下,所以我們按照響應的方式去做。
注意: 如果你不知道Websocket是怎麼工作的, 應該提到切換到Websocket協議,意思是呼叫特殊的HTTP升級機制,成功處理升級請求後。伺服器和客戶端使用
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)
//...
})
請注意 http.ResponseWriter
為 bufio.Reader
和 bufio.Writer
進行記憶體分配(都帶4KB的緩衝區),使用者 *http.Request
的初始化和進一步的響應寫入。
無論使用什麼Websocket
庫, 成功響應升級請求之後,呼叫 responseWriter.Hijack()
後, 伺服器與TCP連線同時接收 I/O
快取區。
提示 :在某些情況下,go:linkname可用於通過呼叫 net / http.putBufio {Reader,Writer} 將緩衝區返回到net / http內的sync.Pool。
從而,我們300萬的線上連線又需要額外24GB的記憶體, 所以,我們的應用程式就需要72GB記憶體,並且什麼也沒做。
3. 優化
讓我們回顧一下我們在介紹部分中討論的內容,並記住使用者連線的行為方式,切換到 Websocket
之後,客戶端傳送資料包和相應的時間或者換句話說事件訂閱,然後(不考慮 ping/pong
的技術細節),客戶端可能在連線的生命週期內沒有傳送任何資料
連線的生命週期可能是幾秒到幾天
因此,我們的 Channel.reader()
和 Channel.writer()
大部分時間都是在等待接收或傳送資料的處理,每個單獨的等待都有4KB的快取區
現在我們已經清楚哪些能夠做的更好,哪些不能。
3.1. Netpoll
你還記得 Channel.reader()
的實現是:等待新的資料進入,通過鎖定 bufio.Reader.Read()
內的conn.Read()
呼叫? 如果連線中有資料,Go 執行時會喚醒 goroutine
去允許讀取下一條資料包,之後,這個goroutine
再次被鎖定,並等待新的資料。讓我們看看Go執行時是怎麼知道 goroutine
必須被喚醒。
如果我們檢視 conn.Read()
實現,我們將看到 net.netFD.Read()
被呼叫
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 ” 說沒有資料在socket連線裡並且不要鎖定讀取空的Websocket連線。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
實現的,為什麼不對我們的連線使用相同的方法 ?我們可以分配一個讀緩衝區,並在真正需要時啟動讀取 goroutine
, 當socket
中有真正可讀的資料時。
在 github.com/golang/go 這裡, 存在匯出
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 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)
}
Channel.writer()
更容易, 因為我們只能在傳送資料包時執行goroutine並分配緩衝區:
func (ch *Channel) Send(p Packet) {
if c.noWriterYet() {
go ch.writer()
}
ch.send <- p
}
請注意,我沒有處理作業系統在
write()
系統呼叫上返回EAGAIN
的情況。這種情況,我們依靠Go執行時,因為這種服務實際上很少見。 然而,如果你需要,它可以以相同的方式處理。
從 ch.send
讀出資料包之後,writer
將完成它,並釋放 goroutine
堆疊和傳送快取區。
完美,我們儲存了48GB的記憶體,擺脫了兩個連續執行的goroutine內堆疊 I/O
緩衝區。
3.3 控制資源
大量的連線並不只是涉及高記憶體消耗,當開發服務時,我們經歷了反覆的競爭條件和死鎖之後通常會出現所謂的自我DDoS--應用程式客戶端試圖連線到伺服器從而進一步破壞它的情況。
例如:如果出於什麼原因導致我們無法處理 ping/pong
訊息,但空閒的處理程式繼續關閉這樣的連線(假如這個連結埠,因此沒有提供資料)。客戶端似乎每N秒去嘗試充實連線而不是等待事件。
如果鎖定或過載的伺服器剛剛停止接受新連線,並且它之前的平衡器(例如,nginx)將請求傳遞給下一個伺服器例項,那將是很好的。
此外,伺服器負載如何,如果所有客戶突然想以任何理由向我們傳送資料包,先前儲存的48 GB將再次使用,我們實際上會回到每個連線的goroutine和緩衝區的初始狀態。
goroutine 池
我們可以使用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
}
}
現在我們的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)
})
})
所以現在我們不僅在socket
中可讀資料出現時讀取資料包,而且也是第一次有機會接受空閒的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()
, 我們想寫對一個有N個goroutines
池, 在其中一個重複使用的goroutines
。我們可以保證同時處理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請求並且在他的頭部只需要切換到Websocket
協議, 這些知識以及http.Request
中儲存的內容提示,為了優化,在處理HTTP請求並放棄標準的net / http伺服器時,我們可能會拒絕不必要的分配和複製。
例如: 這個
http.Request
包含一個具相同名稱標頭型別的欄位,通過將資料從連線複製到值字串,無條件地填充所有請求標頭,想象一下,在該欄位內可以保留多少額外資料,例如對於大型Cookie標頭。
但是該怎麼返回呢?
WebSocket 實現
我們的伺服器優化時存在的所有庫都允許我們僅為標準的net / http伺服器進行升級, 而且,(兩個)庫都不能使用所有上述讀寫優化,使這些優化工作,我們必須有一個相當低階的API來處理WebSocket。重用緩衝區,我們需要procotol函式看起來像這樣:
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)
//...
}
簡而言之,是時候建立自己的庫了。
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/op
BenchmarkUpgradeTCP 973 ns/op 0 B/op 0 allocs/op
切換到 ws
和零拷貝升級儲存了我們另外24GB的記憶體,在 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. 總結
過早優化是程式設計中所有邪惡(或至少大部分)的根源。唐納德克努特
當然,上述優化是相對應的,並非在所有情況下。例如,如果自由資源(記憶體,CPU)與線上連線數之間的比率相當高,則優化可能沒有意義。但是,通過了解改進的位置和內容,您可以從中受益匪淺。
感謝您的關注!
5. 參考
- https://github.com/mailru/easygo
- https://github.com/gobwas/ws
- https://github.com/gobwas/ws-examples
- https://github.com/gobwas/httphead
- Russian version of this article
原文連結:https://medium.freecodecamp.org/million-websockets-and-go-cc58418460bb