750,000MPSを達成したsurgemqの秘密に迫る

  • 56
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

(かきかけ)つい先日、surgemqの速さについての記事「SurgeMQ: MQTT Message Queue @ 750,000 MPS」が製作者本人により書かれ自分の周りのTLではにぎわいを見せました。

現時点ではまだまだ未実装な点が多いsurgemqですがgoでこの速度を達成した、ということは驚異に値します。

nsqdが約10万msg/sec、私のgoのmqttサーバーが4万msg/sec、ということで普通にgoでああいったTCPサーバーを書くと何もしないとSingle Coreで1〜2万に行くのがやっとこなはずなので凄さがわかると思います。

LMAX Disruptor style RingBuffer

surgemqの実装周りの参考にしたのがLMAX DisruptorのRing Bufferだそうです。
Disruptorは高速な汎用QueueをRing Bufferを使って実現しているのですが、surgemqではこのRing Bufferの設計を参考にbufioの代わりとして使えるものを作ったそうです。

LMAX DisruptorはRingBufferにSequenceを持たせたのが特徴です。Producerが書き込みをする際にSequenceの値を得て、該当するBufferに書き込みPublishを行うと読み込みを待っていたConsumerがSequence番号を受け取り、読み込みが行えるという仕組みです。

ring.png

これにより複数のProducer / ConsumerからもRingBufferを並列に扱うことが容易になりました。

実装についてはtrishaのblogに色々と書かれていたりLMAX Disruptorのページに参考リンクがあるので興味が有る方は読んでみると面白いと思います。

詳細に見ていくとLMAX DisruptorはCPUレベルでの最適化ができるようにクラスやBufferにPADDINGをしてより高速に扱えるようにしているようです(あんまりJava力ないので読んだだけ)

◇ ◇ ◇ ◇

surgemqがRingBufferを使うに至った背景としては、surgemqのcpu profileを見ていた時に沢山のメモリコピーが発生していたのを発見したこと。そして以前にDisruptorスタイルのRingBufferを実装して試していた事から思いついたそうです。

RingBufferで速度を稼ぐ

Networkアプリケーションでbufioを使うことは非常によいアイデアです。bufioはシステムコールを減らし、パフォーマンスの向上につながるのでbufioを使うことは非常に良い手法です。

gnatsではflushだけを別goroutineからコールすることで20万MPSを達成しました(ちょっとうろ覚え)。通常の用途であればこれだけでも十分いい仕事をこなしてくれます。しかし、もっと速度を稼ぎたい場合はどうすればいいのでしょうか?

◇ ◇ ◇ ◇

surgemqではRingBuffer(Producer, Consumer)を使い驚異的な速度を達成しました。

surge.png

ひとつのクライアントに対して3つのGoroutine(Processor, Receiver, Sender)を使っているのはGoでのサーバー実装ではよくある構成ですが、Receiver・Senderでbufioの代わりにRingBufferを使っているためfill()でのデータ移動処理を省いた結果、あの速度になっているようです。

bufio VS RingBuffer

一般の用途であればbufioを使うべきです。しかし、抱えている問題を解決するには圧倒的な速度が必要など特殊な事情であればRingBuffer等の別アプローチをとることでgoでも容易に達成することができるかもしれません。

今回説明したRingBufferはProducer / Consumerを使ったDisruptorスタイルです。単順にbufioの変わりにRingBufferを渡せば置き換えできる、というわけではなく、うまく読み出しをしてくれるように書き換える必要が出てきます。

またRingBufferではバッファ領域が足りない場合にエラーを返す実装が多く、サイズがわからないデータを受け取りづらいことも問題に思えます。実装次第でどうにでもなる部分ですが考える事が多くなるので悩ましい部分です。

総括

surgemqはbufferの実装を工夫することで驚異的な速度を達成し、goでのMiddleware実装の可能性を広げてくれました。

基本的なGoアプリケーションの高速化のアプローチとしては、メモリコピーを極力減らす、システムコールは極力別goroutineで行う、GCのインパクトが小さくなるようにつくるというのが大事です。

◇ ◇ ◇ ◇

余談ですが、よくある例としてGoのベンチマークではClientの条件が違うためUnfairなベンチマークが散見されます。ベンチマーク系の記事を見るときはClient実装はどうなっているか?ということも確認しておきましょう。