如何使用Go建開發高負載WebSocket伺服器

來源:互聯網
上載者:User
這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。

嗨,大家好! 我的名字是Sergey Kamardin,我是Mail.Ru的工程師。

介紹

首先介紹我們的故事的上下文,應該介紹幾點我們為什麼需要這個伺服器。

Mail.Ru有很多有狀態的系統。 使用者電子郵件儲存是其中之一。 跟蹤系統中的狀態變化和系統事件有幾種方法。 這主要是通過定期系統輪詢或關於其狀態變化的系統通知。


兩種方式都有利弊。 但是當涉及郵件時,使用者收到新郵件的速度越快越好。

郵件輪詢涉及每秒大約50,000個HTTP查詢,其中60%返回304狀態,這意味著郵箱沒有變化。

因此,為了減少伺服器上的負載並加快郵件傳遞給使用者,決定通過編寫發布-訂閱伺服器,一方面將接收有關狀態更改的通知,另一方面則會收到這種通知的訂閱。

先前


現在


第一個方案顯示了以前的樣子。 瀏覽器定期輪詢API,並查詢有關Storage(郵箱服務)的更改。

第二個方案描述了新架構。 瀏覽器與通知API建立WebSocket串連,通知API是Bus伺服器的用戶端。收到新的電子郵件後,Storage會向Bus(1)發送一條通知,由Bus發送到訂閱者。 API確定串連以發送接收到的通知,並將其發送到使用者的瀏覽器(3)。

所以今天我們將討論API或WebSocket伺服器。 我們的伺服器將有大約300萬個線上串連。

實現方式

讓我們看看如何使用Go函數實現伺服器的某些部分,而無需任何最佳化。

在進行net/http ,我們來談談我們如何發送和接收資料。 站在WebSocket協議(例如JSON對象) 之上的資料在下文中將被稱為分組 。

我們開始實現包含通過WebSocket串連發送和接收這些資料包的Channel結構。

channel 結構

// Packet represents applicationleveldata.

type Packet struct {

...

}

// Channel wrapsuserconnection.

type Channel struct {

conn net.Conn    // WebSocketconnection.

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()

returnc

}

注意這裡有reader和writer連個goroutines。 每個goroutine都需要自己的記憶體棧, 根據作業系統和Go版本可能具有2到8 KB的初始大小。

在300萬個線上串連的時候,我們將需要24 GB的記憶體 (堆棧為4 KB)用於維持所有串連。 這還沒有計算為Channel結構分配的記憶體,傳出的資料包ch.send和其他內部欄位消耗的記憶體。

I/O goroutines

我們來看看“reader”的實現:

func (c *Channel) reader() {

// We make a bufferedreadtoreducereadsyscalls.

buf := bufio.NewReader(c.conn)

for{

pkt, _ := readPacket(buf)

c.handle(pkt)

}

}

這裡我們使用bufio.Reader來減少read() syscalls的數量,並讀取與buf緩衝區大小一樣的數量。 在無限迴圈中,我們期待新資料的到來。 請記住: 預計新資料將會來臨。 我們稍後會回來。

我們將離開傳入資料包的解析和處理,因為對我們將要討論的最佳化不重要。 但是, buf現在值得我們注意:預設情況下,它是4 KB,這意味著我們需要另外12 GB記憶體。 “writer”有類似的情況:

func (c *Channel) writer() {

// We make buffered writetoreduce write syscalls.

buf := bufio.NewWriter(c.conn)

forpkt := range c.send {

_ := writePacket(buf, pkt)

buf.Flush()

}

}

我們遍曆c.send ,並將它們寫入緩衝區。細心讀者已經猜到的,我們的300萬個串連還將消耗12 GB的記憶體。

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)

//...

})

請注意, http.ResponseWriter為bufio.Reader和bufio.Writer (使用4 KB緩衝區)進行記憶體配置,用於*http.Request初始化和進一步的響應寫入。

無論使用什麼WebSocket庫,在成功響應升級請求後, 伺服器在responseWriter.Hijack()調用之後,連同TCP串連一起接收 I/O緩衝區。

提示:在某些情況下, go:linkname 可用於 通過調用 net/http.putBufio{Reader,Writer} 將緩衝區返回到 net/http 內 的 sync.Pool 。

因此,我們需要另外24 GB的記憶體來維持300萬個連結。

所以,我們的程式即使什麼都沒做,也需要72G記憶體。

最佳化

我們來回顧介紹部分中談到的內容,並記住使用者串連的行為。 切換到WebSocket之後,用戶端發送一個包含相關事件的資料包,換句話說就是訂閱事件。 然後(不考慮諸如ping/pong等技術資訊),用戶端可能在整個串連壽命中不發送任何其他資訊。

串連壽命可能是幾秒到幾天。

所以在最多的時候,我們的Channel.reader()和Channel.writer()正在等待接收或發送資料的處理。 每個都有4 KB的I/O緩衝區。

現在很明顯,某些事情可以做得更好,不是嗎?

Netpoll

你還記得bufio.Reader.Read()內部,Channel.reader()實現了在沒有新資料的時候conn.read()會被鎖。如果串連中有資料,Go運行時“喚醒”我們的goroutine並允許它讀取下一個資料包。 之後,goroutine再次鎖定,期待新的資料。 讓我們看看Go運行時如何理解goroutine必須被“喚醒”。 如果我們看看conn.Read()實現 ,我們將在其中看到net.netFD.Read()調用 :

// net/fd_unix.go

func (fd *netFD)Read(p []byte) (nint, 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 {

returnpd.wait('r')

}

func (pd *pollDesc) wait(modeint) error {

res := runtime_pollWait(pd.runtimeCtx, mode)

//...

}

如果我們深入挖掘 ,我們將看到netpoll是使用Linux中的epoll和BSD中的kqueue來實現的。 為什麼不使用相同的方法來進行串連? 我們可以分配一個讀緩衝區,只有在真正有必要時才使用goroutine:當通訊端中有真實可讀的資料時。

在github.com/golang/go上, 匯出netpoll函數有問題 。

擺脫goroutines

假設我們有Go的netpoll實現 。 現在我們可以避免使用內部緩衝區啟動Channel.reader() goroutine,並在串連中訂閱可讀資料的事件:

ch := NewChannel(conn)

// Make conntobe observedbynetpoll instance.

poller.Start(conn, netpoll.EventRead, func() {

// We spawn goroutine heretoprevent poller wait loop

//tobecome locked during receiving packetfromch.

go Receive(ch)

})

// Receive reads a packetfromconnandhandles 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棧和發送緩衝區。

完美! 通過擺脫兩個連續啟動並執行goroutine中的堆棧和I/O緩衝區,我們節省了48 GB 。

資源控制

大量的串連不僅涉及高記憶體消耗。 在程式開發伺服器時,我們會經曆重複的競爭條件和死結,常常是所謂的自動DDoS,這種情況是當應用程式用戶端肆意嘗試串連到伺服器,從而破壞伺服器。

例如,如果由於某些原因我們突然無法處理ping/pong訊息,但是空閑串連的處理常式會關閉這樣的串連(假設串連斷開,因此沒有提供資料),用戶端會不斷嘗試串連,而不是等待事件。

如果鎖定或超載的伺服器剛剛停止接受新串連,並且負載平衡器(例如,nginx)將請求都傳遞給下一個伺服器執行個體,那壓力將是巨大的。

此外,無論伺服器負載如何,如果所有用戶端突然想要以任何原因發送資料包(大概是由於錯誤原因),則先前節省的48 GB將再次使用,因為我們將實際恢複到初始狀態goroutine和並對每個串連分配緩衝區。

Goroutine池

我們可以使用goroutine池來限制同時處理的資料包數量。 這是一個go routine池的簡單實現:

package gopool

func New(sizeint) *Pool {

return&Pool{

work: make(chan func()),

sem:  make(chan struct{},size),

}

}

func (p *Pool) Schedule(task func()) error {

select{

casep.work<- task:

casep.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 loopwhen

//allpool workers are busy.

pool.Schedule(func() {

Receive(ch)

})

})

所以現在我們讀取資料包可以在池中使用了閒置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打垮。

零拷貝升級

讓我們從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請求和header才能切換到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 intendedto

// reuse *bufio.Reader (withsync.Poolforexample).

func getReadBuf(io.Reader) *bufio.Reader

func putReadBuf(*bufio.Reader)

// readPacket must be calledwhendata could bereadfromconn.

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以驗證會話)。

以下是升級請求處理的基準 :標準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緩衝區分配的空間。

概要

讓我們結合代碼告訴你我們做的最佳化。

讀取內部緩衝區的goroutine是非常昂貴的。 解決方案 :netpoll(epoll,kqueue); 重用緩衝區。

寫入內部緩衝區的goroutine是非常昂貴的。 解決方案 :必要時啟動goroutine; 重用緩衝區。

DDOS,netpoll將無法工作。 解決方案 :重新使用數量限制的goroutines。

net/http不是處理升級到WebSocket的最快方法。 解決方案 :在串連上使用零拷貝升級。

這就是伺服器代碼的樣子:

import (

"net"

"github.com/gobwas/ws"

)

ln, _ := net.Listen("tcp",":8080")

for{

// Trytoaccept incomingconnectioninsidefreepool worker.

// If therenofreeworkersfor1ms, donotaccept anythingandtry later.

// This will help ustoprevent many self-ddosoroutofresource limit cases.

err := pool.ScheduleTimeout(time.Millisecond, func() {

conn := ln.Accept()

_ = ws.Upgrade(conn)

// Wrap WebSocketconnectionwithour Channel struct.

// This will help ustohandle/send our app's packets.

ch := NewChannel(conn)

// Waitforincoming bytesfromconnection.

poller.Start(conn, netpoll.EventRead, func() {

// Donotcrossthe resource limits.

pool.Schedule(func() {

//Readandhandle incoming packet(s).

ch.Recevie()

})

})

})

if err != nil {

time.Sleep(time.Millisecond)

}

}

結論

過早最佳化是萬惡之源。 Donald Knuth

當然,上述最佳化是有意義的,但並非所有情況都如此。 例如,如果可用資源(記憶體,CPU)和線上串連數之間的比例相當高(伺服器很閑),則最佳化可能沒有任何意義。 但是,您可以從哪裡需要改進以及改進內容中受益匪淺。

原文連結

相關文章

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.