6月からDMM.comラボでミドルウェアを作るエンジニアインターンをしている@kawasin73です。
DMM.comラボではluaで実装されたKVS(キーバリューストア)を利用しています。
これは、TCPの上で独自プロトコルで通信しており、URIのPathがKeyとなり最長共通接頭辞検索をするKVSで、社内でluaの皮を被ったC言語で実装されたものが運用されています。
この度、このKVSをGo言語で再実装することになり、設計は既存のミドルウェアを踏襲した形で DMM.com ラボの方が行い、実装は僕がすることになりました。
Go言語の実装手法(goroutine や channel等)については僕が学びながらそれについて都度相談するというスタイルで行なっています。
その開発記を連載しています。
第2回の今回は、TCPサーバーを実装する時にパフォーマンス上気をつけるべきことと、開発の手順をまとめていきます。
第1回 GoでTCPサーバー上で独自プロトコルKey-Value Storeを実装する(知識編)
第2回 GolangでハイパフォーマンスなTCPサーバーを実装する(下準備編)
第3回 Go言語でTCPのEchoサーバーを実直に実装する
第4回 Go言語でシグナルハンドリングをするTCPのEchoサーバーを実装する
第5回 Go言語でGraceful Shutdown可能なTCPのechoサーバーを実装する(その1)
第6回 Go言語でGraceful Shutdown可能なTCPのechoサーバーを実装する(その2)
第7回 GolangでTCPサーバーに再起動とGraceful Shutdownを実装する
パフォーマンスを高めるために必要な知識
僕が実装を始めるにあたり、メンターの方からパフォーマンスに気を使うようにアドバイスをいただきました。
ハイパフォーマンスなKVSを実装することはより早くレスポンスを返せるだけでなく、
- CPUリソースをフルに使うことができ、サーバーの台数を減らし、コストを下げることができる。
- データベースはWebAppサーバーなどと違い、スケールアウトが難しくスケールアップで対応する必要があり、ダウンタイムが発生する可能性がある。パフォーマンスを上げることでスケールアップの必要なタイミングを遅らせることができ、運用の負担を減らすことができる。
といったメリットがあります。これらのメリットを享受するためには、最初からパフォーマンスに気を使った実装を行うことが必要です。
では、教えていただいたことと、そのキーワードをもとに調べたことをまとめていきます。
コンテキストスイッチ
現代のメジャーなOSは、1つのCPUの上で複数のプロセスやスレッドの処理を少しずつ進めていくことで、マルチタスクを実現しています。
プロセスやスレッドの処理が切り替わって別の処理が始まることを「コンテキストスイッチ」と呼びます。
ハイパフォーマンスなサーバーを実装するときには、このコンテキストスイッチをなるべく起こさないことが重要になります。
コンテキストスイッチが起こり、他のプロセスにCPUを奪われるとその時間分サーバーは動くことができなくなります。
なるべくコンテキストスイッチを起こさず、ギリギリまでCPUを保持してサーバーのためにCPUを動かし続けることが重要です。
また、コンテキストスイッチ自体にもオーバーヘッドがあり、パフォーマンス劣化に繋がります。
コンテキストスイッチは、基本的にシステムコールを呼んだときに起こります。(一部コンテキストスイッチを起こさないシステムコールもあります)
そのため、システムコールを不必要に呼ばないことが大切です。
グリーンスレッド
スレッドには大きく分けて2つの種類があります。
- ネイティブスレッド
- グリーンスレッド
です。
ネイティブスレッドはOSが提供するスレッドで、グリーンスレッドはOSではなく言語や仮想マシンが提供するスレッドです。
ネイティブスレッドは、同時に数千や数万のスレッドを立ち上げることは難しいですが、グリーンスレッドは軽量でメモリの限りスレッドを立ち上げることができます。
GO言語の特徴の一つであるgoroutineは、グリーンスレッドです。
一方、似たものに コルーチン(co-routine) があります。これは、処理を途中で中断し、その後再開できるものです。
グリーンスレッドとコルーチンの違いは、スケジューラがあるかどうかです。
グリーンスレッドにはスケジューラがあり、コルーチンにはスケジューラがありません。
それぞれ代表的なものには以下のような言語があります。
- コルーチン : C(libdill), lua など
- グリーンスレッド : Golang, Erlang
ブロッキングI/O と ノンブロッキングI/O
ブロッキングI/O
とノンブロッキングI/O
については、この記事がわかりやすいです。
ノンブロッキングI/Oと非同期I/Oの違いを理解する
ブロッキングI/O
ファイルへの書き込み読み込みや、ネットワークからのデータの読み取り書き込みなどは、比較的時間がかかる処理です。
ここでは、ネットワークからの読み取りを例にとります。
ネットワークの読み取りはカーネル側で行いますが、その読み取りが完了するまでユーザー側が待つのがブロッキングI/O
です。
読み取りが完了するまでユーザー側は停止し処理をブロックしてしまいます。
ノンブロッキングI/O
ブロックしてしまうと、サーバー処理のパフォーマンスは劣化してしまいます。
そこで、カーネル側で読み取りの準備が完了していないときに、完了するまで待つのではなく、即座にエラー(EAGAIN
)を返してもらうのが、ノンブロッキングI/O
です。
ノンブロッキングI/O
では、ユーザー側はEAGAIN
を受け取るとepoll
などのKernel Eventを使います。
Kernel Eventを使うと、カーネル側で読み取りの準備が完了するとユーザー側に通知が飛んできて、ユーザー側で読み込みを再開します。
これにより、カーネル側で読み込みの準備をしている間に別のスレッドの処理を進めて、CPUを効率的に使うことができます。(I/Oの多重化)
Go言語ではどうなるの?
goroutineの嬉しいところは、ノンブロッキングな処理も同期的に書くことができることです。
あるgoroutineが処理を待っているときには、別のgoroutineがスケジューリングされて動くため、CPUを効率的に使うことができます。
詳しくは、意外と知らないgoroutineのスケジューラーの挙動 #golangが参考になります。
Go言語のReadの実装では、EAGAIN
を受け取ると、即座にKernel Eventに登録して(fd.pd.waitRead()
)、I/Oの多重化を行なっています。
Readができるようになると、goroutineは中断されたところから再開されるため、同期的に(ブロッキングI/Oの時と同じように)コードが表現されています。
func (fd *netFD) Read(p []byte) (n int, err error) {
if err := fd.readLock(); err != nil {
return 0, err
}
defer fd.readUnlock()
if len(p) == 0 {
// If the caller wanted a zero byte read, return immediately
// without trying. (But after acquiring the readLock.) Otherwise
// syscall.Read returns 0, nil and eofError turns that into
// io.EOF.
// TODO(bradfitz): make it wait for readability? (Issue 15735)
return 0, nil
}
if err := fd.pd.prepareRead(); err != nil {
return 0, err
}
if fd.isStream && len(p) > 1<<30 {
p = p[:1<<30]
}
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
}
}
}
err = fd.eofError(n, err)
break
}
if _, ok := err.(syscall.Errno); ok {
err = os.NewSyscallError("read", err)
}
return
}
ストリームパーサー
今回実装するKVSは、独自の可変長バイナリプロトコルによって生成されたバイト列をTCPの上でやりとりします。
そのため、受け取ったバイト列をパースする、Protocol Parser
を実装する必要があります。
今回はProtocol Parser
をストリームパーサーとして実装します。
サーバーでTCPから一度にReadするバイト列は、リクエストの一部だけしかなかったり、1つ以上のリクエストが連続して含まれていたりします。
つまり、一度にパーサーに渡すバイト列で1つのリクエストの途中までしかわからないことがあります。
独自プロトコルで生成されたバイト列は、先頭から TYPE
, RID
, KEYLEN
, KEY
, VALLEN
, VAL
の6つの部分に分かれています。
例えば、1回のReadで得られたバイト列で、途中のKEYLEN
までパースできたが、KEY
の途中からのバイト列が足りないとします。
次にReadされたバイト列を足し合わせて再度パースし直すという処理をしていると、パース済みのTYPE
, RID
, KEYLEN
分のパース処理を2回行うことになり、CPUリソースの無駄になります。
ストリームパーサーでは、パーサーの内部に、部分的にパースが完了したTYPE
, RID
, KEYLEN
の情報とパースできなかったKEY
の一部のバイト列を保持して、次にReadされたバイト列を受け取ってから途中からパースしていきます。
ストリームパーサーには以下のメリットがあります。
- パースの計算処理を無駄なく行うことができる
- また途中でプロトコル上のエラーが検出できたら即座にエラーを返すことができ、長い不正リクエストによる処理の遅延などを防げる
開発の順番
今回開発するKVSは外側から、以下のようなモジュールに分かれています。
- 制御レイヤー : シグナル処理を行って、サーバーの再起動などを制御する
- ネットワークレイヤー : 非ブロッキングイベント駆動 TCP サーバ
- プロトコルレイヤー : 独自可変長バイナリプロトコル のストリームパーサ
- リクエストハンドラー : リクエストに対応する処理を行うレイヤー
- データレイヤー : インメモリーKVS
今回は、これらのレイヤーを外側から開発していこうということになりました。
それにより以下のようなメリットがあります。
- 常に動くものが得られ、アジャイルな開発ができる。
- ベンチを都度取りながら開発を進めることができ、パフォーマンスを気にした開発をしやすい。
- レイヤーごとに実装することで、開発のマイルストーンを小さな単位で置きやすい。進捗の進み具合がわかりやすい。
- レイヤーごとに実装を完了させてインターフェースを確定させていくので、手戻りが発生しにくい。
- サーバーやTCPは普遍的なものであり、普遍的なものから実装することで初めて触れるGo言語の特性を学びながら実装を進めることができる。
手順
全体的な流れはこのような順番にしました。
- TCPの echo サーバー
- goroutine (accept をGoroutine化)
- goroutine (recv send をGoroutine化)
- Configのロード
- Signal処理を実装, Graceful Shutdownに対応
- keepalive の実装
- echoサーバーのベンチマークをとりパフォーマンスチェック
- ログ, PIDファイル, デーモン化
- Request Protocol Parser (Stream Parser として実装)(バイナリ -> 内部的な構造体)
- Response Protocol Parser (Streamでなくて大丈夫。決まったHelloとかを返す)(レスポンスデータ -> バイナリ)
- Request Handlers を実装
- Parserを含めたベンチマークをとりパフォーマンスチェック
- In memory Storage を実装
- Test
- 最終的なパフォーマンスチェック
この中では、以下のタイミングでそれぞれベンチマークをとり、パフォーマンスチェックをします。
- echoサーバーの後
- Parserの実装が終わった後
- ストレージを実装して全てが完成した後
特に、echoサーバーのパフォーマンスはその言語における最大値になるので、なるべく高いパフォーマンスを出せるようにチューニングする必要があります。
そこからParserなど実装を進めていくにつれ、全体的なパフォーマンスは落ちていきます。
最後に
ハイパフォーマンスなTCPサーバーを作るために必要な知識と、全体的な開発手順を解説しました。
次回は、Go言語でGraceful Shutdownに対応したTCPのechoサーバーを実装する部分について解説していきます。