百万のWebSocketとGo

皆さんこんにちは!私の名前はSergey Kamardinで、Mail.Ruの開発者です。

この記事では、Goで高負荷のWebSocketサーバーを開発した方法について説明します。

WebSocketsに精通していてもGoについてほとんど知らない場合は、パフォーマンスの最適化のためのアイデアとテクニックの観点から、この記事がおもしろいと思うことを願っています。

1.はじめに

ストーリーのコンテキストを定義するには、このサーバーが必要な理由についていくつかの言葉を言う必要があります。

Mail.Ruには多くのステートフルシステムがあります。ユーザーのメールストレージもその1つです。システム内の状態の変化、およびシステムイベントについて追跡する方法はいくつかあります。ほとんどの場合、これは定期的なシステムポーリングまたはその状態変化に関するシステム通知のいずれかを介して行われます。

どちらの方法にも長所と短所があります。しかし、メールに関しては、ユーザーが新しいメールを受信する速度が速いほど優れています。

メールポーリングには1秒あたり約50,000のHTTPクエリが含まれ、その60%は304ステータスを返します。つまり、メールボックスに変更はありません。

したがって、サーバーの負荷を軽減し、ユーザーへのメール配信を高速化するために、パブリッシャー/サブスクライバーサーバー(バス、メッセージブローカー、またはイベントとも呼ばれる)を記述することにより、車輪を再発明することを決定しました。チャネル)は、一方では状態の変更に関する通知を受け取り、他方ではそのような通知のサブスクリプションを受け取ります。

以前:

今:

最初のスキームは、それが以前どのようなものだったかを示しています。ブラウザは定期的にAPIをポーリングし、ストレージ(メールボックスサービス)の変更について尋ねました。

2番目のスキームは、新しいアーキテクチャを説明しています。ブラウザは、Busサーバーのクライアントである通知APIとのWebSocket接続を確立します。新しい電子メールを受信すると、ストレージはそれに関する通知をバス(1)に送信し、バスはそのサブスクライバー(2)に送信します。 APIは、受信した通知を送信するための接続を決定し、ユーザーのブラウザーに送信します(3)。

今日は、APIまたはWebSocketサーバーについてお話します。これから先、サーバーには約300万のオンライン接続があります。

2.慣用的な方法

最適化なしでプレーンなGo機能を使用してサーバーの特定の部分を実装する方法を見てみましょう。

net / httpに進む前に、データの送受信方法について説明しましょう。 WebSocketプロトコルの上にあるデータ(JSONオブジェクトなど)は、以降パケットと呼ばれます。

WebSocket接続を介してこのようなパケットを送受信するロジックを含むチャンネル構造の実装を始めましょう。

2.1。チャンネル構造

読み取りと書き込みの2つのゴルーチンの開始に注目してください。各ゴルーチンには、オペレーティングシステムとGoバージョンに応じて2〜8 KBの初期サイズを持つ独自のメモリスタックが必要です。

上記の300万のオンライン接続数については、すべての接続に24 GBのメモリ(4 KBのスタック)が必要です。また、チャネル構造、送信パケットch.send、およびその他の内部フィールドに割り当てられたメモリはありません。

2.2。 I / Oゴルーチン

「リーダー」の実装を見てみましょう。

ここでは、bufio.Readerを使用して、read()syscallの数を減らし、bufバッファーサイズで許可されている数だけ読み取ります。無限ループ内では、新しいデータが来ることが予想されます。新しいデータが来ることを期待してください。後でそれらに戻ります。

これから説明する最適化にとって重要ではないため、着信パケットの解析と処理は無視します。ただし、bufは今注目する価値があります。デフォルトでは4 KBであり、これは接続用にさらに12 GBのメモリを意味します。 「作家」にも同様の状況があります。

発信パケットチャネルc.sendを反復処理し、バッファに書き込みます。これは、熱心な読者がすでに推測しているように、300万の接続に対してさらに4 KBと12 GBのメモリです。

2.3。 HTTP

シンプルなチャンネル実装はすでにありますが、今では動作するWebSocket接続を取得する必要があります。私たちはまだ慣用的な方法の見出しの下にあるので、対応する方法でそれをしましょう。

注:WebSocketの仕組みがわからない場合、クライアントはUpgradeと呼ばれる特別なHTTPメカニズムを使用してWebSocketプロトコルに切り替えることに注意してください。アップグレード要求が正常に処理された後、サーバーとクライアントはTCP接続を使用してバイナリWebSocketフレームを交換します。接続内のフレーム構造について説明します。

http.ResponseWriterは、* http.Requestの初期化と追加の応答書き込みのために、bufio.Readerおよびbufio.Writer(両方とも4 KBのバッファーを使用)のメモリ割り当てを行うことに注意してください。

使用するWebSocketライブラリに関係なく、アップグレード要求への正常な応答の後、サーバーはresponseWriter.Hijack()呼び出しの後にTCP接続とともにI / Oバッファーを受信します。

ヒント:場合によっては、go:linknameを使用して、net / http.putBufio {Reader、Writer}を呼び出して、net / http内のsync.Poolにバッファーを返すことができます。

したがって、300万の接続にさらに24 GBのメモリが必要です。

したがって、まだ何も実行していないアプリケーション用に合計72 GBのメモリが必要です。

3.最適化

導入部で話した内容を確認し、ユーザー接続の動作を思い出してみましょう。 WebSocketに切り替えた後、クライアントは関連イベントを含むパケットを送信します。つまり、イベントをサブスクライブします。その後、(ping / pongなどの技術的なメッセージを考慮に入れずに)クライアントは、接続の存続期間中何も送信しない場合があります。

接続の寿命は数秒から数日続く場合があります。

そのため、ほとんどの場合、Channel.reader()およびChannel.writer()は、受信または送信のためのデータの処理を待機しています。それらに加えて、それぞれ4 KBのI / Oバッファーが待機しています。

今では、特定のことをよりうまく行えるこ​​とは明らかです。

3.1。ネットポール

bufio.Reader.Read()内のconn.Read()呼び出しでロックされることで新しいデータが来ることを期待していたChannel.reader()実装を覚えていますか?接続にデータがあった場合、Goランタイムはゴルーチンを「起動」し、次のパケットを読み取れるようにしました。その後、ゴルーチンは新しいデータを期待している間に再びロックされました。 Goランタイムがゴルーチンを「ウェイクアップ」する必要があることを理解する方法を見てみましょう。

conn.Read()実装を見ると、その中にnet.netFD.Read()呼び出しがあります:

Goは、非ブロックモードでソケットを使用します。 EAGAINは、ソケットにデータがなく、空のソケットからの読み取り時にロックされないため、OSが制御を返します。

接続ファイル記述子からread()syscallが表示されます。 readがEAGAINエラーを返した場合、ランタイムはpollDesc.waitRead()呼び出しを行います。

さらに掘り下げると、netpollはLinuxではepoll、BSDではkqueueを使用して実装されていることがわかります。接続に同じアプローチを使用しないのはなぜですか?読み取りバッファを割り当てて、本当に必要な場合にのみ読み取りゴルーチンを開始できます。実際に読み取り可能なデータがソケットにある場合です。

github.com/golang/goには、netpoll関数をエクスポートする問題があります。

3.2。ゴルーチンを取り除く

Goのnetpoll実装があるとします。これで、内部バッファーでChannel.reader()ゴルーチンを開始することを避け、接続内の読み取り可能なデータのイベントをサブスクライブできます。

Channel.writer()の方が簡単です。これは、パケットを送信するときにのみゴルーチンを実行してバッファーを割り当てることができるためです。

オペレーティングシステムがwrite()システムコールでEAGAINを返す場合は処理しないことに注意してください。このような場合、Goランタイムに依存します。これは、この種のサーバーでは実際にまれだからです。それでも、必要に応じて同じ方法で処理できます。

ch.send(1つまたは複数)から発信パケットを読み取った後、ライターは操作を終了し、ゴルーチンスタックと送信バッファーを解放します。

パーフェクト!連続して実行される2つのgoroutine内のスタックとI / Oバッファーを取り除くことにより、48 GBを節約しました。

3.3。リソースの管理

多数の接続には、大量のメモリ消費だけではありません。サーバーの開発時に、競合状態とデッドロックが繰り返され、いわゆるセルフDDoSが頻繁に発生しました。これは、アプリケーションクライアントがサーバーへの接続を試行錯誤し、サーバーをさらに切断する状況です。

たとえば、何らかの理由で突然ping / pongメッセージを処理できなかったが、アイドル接続のハンドラーがそのような接続を閉じ続けた場合(接続が切断されたためデータが提供されなかった場合)、クライアントはNごとに接続を失ったように見えました秒待ってから、イベントを待つ代わりに再接続を試みました。

ロックまたはオーバーロードされたサーバーが新しい接続の受け入れを停止し、その前のバランサー(たとえば、nginx)が次のサーバーインスタンスに要求を渡した場合、それは素晴らしいことです。

さらに、サーバーの負荷に関係なく、すべてのクライアントが何らかの理由で(おそらくバグの原因により)突然パケットを送信したい場合、以前に保存した48 GBが再び使用されます。実際には初期状態に戻ります。各接続ごとのゴルーチンとバッファの

ゴルーチンプール

goroutineプールを使用して、同時に処理されるパケットの数を制限できます。これは、このようなプールの単純な実装のようです。

これで、netpollを使用したコードは次のようになります。

そのため、ソケット内の読み取り可能なデータの出現時だけでなく、プール内の無料ゴルーチンを使用する最初の機会にもパケットを読み取ります。

同様に、Send()を変更します。

go ch.writer()の代わりに、再利用されたゴルーチンの1つで書き込みます。したがって、N個のgoroutineのプールでは、N個の要求と到着したN + 1が同時に処理されるため、読み取り用にN + 1バッファーが割り当てられないことを保証できます。 goroutineプールにより、新しい接続のAccept()およびUpgrade()を制限し、DDoSのほとんどの状況を回避することもできます。

3.4。ゼロコピーアップグレード

WebSocketプロトコルから少し逸脱しましょう。既に述べたように、クライアントはHTTPアップグレード要求を使用してWebSocketプロトコルに切り替えます。これは次のようになります。

つまり、この場合、WebSocketプロトコルに切り替えるためにのみHTTPリクエストとそのヘッダーが必要です。この知識とhttp.Request内に保存されている内容は、最適化のために、HTTP要求を処理し、標準のnet / httpサーバーを放棄するときに、不必要な割り当てとコピーをおそらく拒否することを示唆しています。

たとえば、http.Requestには、接続から値文字列にデータをコピーすることにより、無条件にすべての要求ヘッダーが入力される同名ヘッダータイプのフィールドが含まれます。大きいサイズのCookieヘッダーなど、このフィールド内にどれだけの余分なデータを保持できるか想像してください。

しかし、見返りに何をすべきですか?

WebSocketの実装

残念ながら、サーバーの最適化時に存在していたすべてのライブラリにより、標準のnet / httpサーバーのみのアップグレードが可能になりました。さらに、どちらの(2つの)ライブラリも、上記の読み取りおよび書き込みの最適化をすべて使用することを可能にしませんでした。これらの最適化が機能するためには、WebSocketを操作するためのかなり低レベルのAPIが必要です。バッファを再利用するには、次のようなprocotol関数が必要です。

func ReadFrame(io.Reader)(フレーム、エラー)
func WriteFrame(io.Writer、Frame)エラー

このような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()からすぐに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バッファーに割り当てられたスペースです。

3.5。概要

先ほどお伝えした最適化を構成しましょう。

  • 内部にバッファを備えた読み取りゴルーチンは高価です。解決策:netpoll(epoll、kqueue);バッファを再利用します。
  • 内部にバッファがある書き込みゴルーチンは高価です。解決策:必要に応じてゴルーチンを開始します。バッファを再利用します。
  • 接続の嵐で、netpollは機能しません。解決策:ゴルーチンの数を制限して再利用します。
  • net / httpは、WebSocketへのアップグレードを処理する最速の方法ではありません。解決策:ベアTCP接続でゼロコピーアップグレードを使用します。

サーバーコードは次のようになります。

4.結論

時期尚早な最適化は、プログラミングにおけるすべての悪(または少なくともその大部分)の根源です。ドナルド・クヌース

もちろん、上記の最適化は関連していますが、すべての場合に当てはまるわけではありません。たとえば、空きリソース(メモリ、CPU)とオンライン接続の数の比率がかなり高い場合、おそらく最適化する意味はありません。ただし、どこで何を改善するかを知ることで多くのメリットを得ることができます。

ご清聴ありがとうございました!

5.参照

  • https://github.com/mailru/easygo
  • https://github.com/gobwas/ws
  • https://github.com/gobwas/ws-examples
  • https://github.com/gobwas/httphead
  • この記事のロシア語版