用 Golang 實現百萬級 Websocket 服務

bean發表於2018-08-23

用Golang實現百萬級Websocket服務

file

前言: 本文為國外大佬的一篇文章,因為我最近在研究和學習使用go寫一個訊息伺服器,所以找到這篇文章,於是將它翻譯過來,希望能夠幫到其他的同學。這是我的處女作翻譯作品。希望大神能夠幫助指導修正。以後可能每週都會有一篇國外技術文章的翻譯,有興趣的同學可以加QQ群共同討論(511247400)。

這篇文章我們討論一下怎麼使用go開發一個高負載的Websocket服務

如果你很熟悉websocket,但是不瞭解Go, 我希望你先掌握一些Go的知識

1.介紹

要定義我們的故事背景,應該說下我們為什麼需要這樣的一個服務。

我們有一個狀態系統,使用者郵箱儲存就是其中之一,這裡有一些方法去跟蹤系統狀態的變化和系統事件。大多是通過定期輪詢或系統通知來改變狀態。

兩種方法各有利弊,但是提到email,使用者越快接受到郵件越好。

郵件輪詢涉及到大約 50000HTTP 請求每秒,60%返回了304狀態, 這意味著郵箱裡沒有發生任何變化。

因此, 我們要減少伺服器的負載去快速的傳送email到使用者,這讓我決定重新創造一個輪子去寫一個釋出者訂閱者服務,一方面它將接受到狀態的通知,另一方面,也將這樣的通知釋出到其他使用者。

之前
file

現在:
file

第一個方案展示的就我們之前說的第一個,瀏覽器定期輪詢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.ResponseWriterbufio.Readerbufio.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 中的 epollBSD 中的 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.Readerio.Writer介面, 這使得可以使用或不使用緩衝或任何其他 I / O 包裝。

除了從標準 net/http 升級,ws支援零拷貝升級, 處理升級請求並切換到WebSocket而不進行記憶體分配或複製。ws.Upgrade()接受 io.ReadWriter (net.Conn實現這個介面), 換句話說,我們可以標準的net.Listen 並將接收到的連線從 ln.Accept()立即轉移到 ws.Upgrade(). 該庫可以複製任何請求資料,以備將來在應用程式中使用(例如: CookieSession 驗證)

下面是升級請求處理的基準:標準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://medium.freecodecamp.org/million-websockets-and-go-cc58418460bb

相關文章