豪鬼メモ

一瞬千撃

ネイティブ非同期APIとゴルーチンの性能比較

Tkrzwの非同期APIをGoインターフェイスでもサポートした。しかし、性能測定してみた結果、Go自体が備えているゴルーチンが強力すぎるので、ネイティブ側の非同期APIは要らなかったかもという結論に至った話。
f:id:fridaynight:20210807162538p:plain


データベースの操作はファイルアクセスを伴うので、いくらDBMライブラリが早くても、それなりの遅延はある。よって、遅延しそうな処理をバックグラウンドで処理させながら、別の処理を並行して進める非同期処理のテクニックが広く用いられている。それを手軽に行えるようにするために、Tkrzwは非同期APIを備えている。C++はもちろん、C、JavaPythonRuby、そしてGoのインターフェイスでも非同期APIがサポートされている。

// レコードの格納処理を開始する。
future := async.Set("hello", "world", true)

// 何か違うことをする。
fmt.Println("Hey, Bro. What's up?")

// 格納処理の結果を調べて何かする。
status := future.Get()
status.OrDie()

非同期APIを呼ぶと、裏ではネイティブスレッドのスレッドプールがバックグラウンドで並列に処理を行ってくれるのだが、利用者にはその詳細は完全に隠蔽される。非同期APIはFutureクラスのインスタンスを返すが、そのGetメソッドを呼ぶと処理の終了待ちをした上で、結果を取り出すことができる。

さて、Go言語では、ゴルーチンとチャンネルを利用すると、どんな処理でも非常に簡単に非同期化できる。上述の処理を同期APIを使いつつ、ゴルーチンとチャンネルを使って非同期化してみる。

// レコードを格納して結果をチャンネルに入れる関数
set := func(dbm *tkrzw.DBM, key string, value string, done chan<- *tkrzw.Status) {
  done <- dbm.Set(key, value, true)
}

// レコードの格納処理を開始する。
done := make(chan *tkrzw.Status)
go set(dbm, "hello", "world", done)

// 何か違うことをする。
fmt.Println("Hey, Bro. What's up?")

// 格納処理の結果を調べて何かする。
status := <-done
status.OrDie()

ということで、Goの世界では、本質的に非同期APIが不要である。クエリ毎にゴルーチンやチェンネルを生成したら遅くなっちゃうんじゃないかと心配をするわけだが、ところがどっこい、それらがめちゃ早いというのがGoの凄いところだ。

典型的なユースケースを考えてみよう。新しいレコードを格納する10000回のクエリを非同期的に発行して、その結果を調べるというバッチ処理を行う。バッチは10000回実行し、合計1億個のレコードを格納する。この処理を、ゴルーチンと非同期APIでそれぞれ実装し、その性能を比較する。使い方の対比が美しいので、実際のコード全文を載せる。

package main

import (
  "fmt"
  "github.com/estraier/tkrzw-go"
  "time"
)

// データベースにレコードを格納する関数。ゴルーチンとして利用。
func set(dbm *tkrzw.DBM, key string, value string, done chan<- *tkrzw.Status) {
  done <- dbm.Set(key, value, true)
}

func main() {
  // データベースを準備する
  numBatches := 10000
  numRecords := 10000
  dbm := tkrzw.NewDBM()
  path := "casket.tkh"
  params := fmt.Sprintf("truncate=true,num_buckets=%d", numBatches * numRecords * 2)

  // ゴルーチンによるテスト
  fmt.Println("Setting with goroutines and channels ...")
  dbm.Open(path, true, params).OrDie()
  startTime := time.Now()
  for batchID := 0; batchID < numBatches; batchID++ {
    dones := make([]chan *tkrzw.Status, 0, numRecords)
    for i := 0; i < numRecords; i++ {
      key := fmt.Sprintf("%08d", batchID * numBatches + i)
      done := make(chan *tkrzw.Status)
      go set(dbm, key, key, done)
      dones = append(dones, done)
    }
    for _, done := range dones {
      status := <-done
      status.OrDie()
    }
  }
  endTime := time.Now()
  elapsed := endTime.Sub(startTime).Seconds()
  fmt.Printf("time=%.3f, qps=%.0f\n",
    elapsed, float64(numBatches * numRecords) / elapsed)
  dbm.Close().OrDie()

  // Tkrzwの非同期APIによるテスト
  fmt.Println("Setting with the asynchrnouns API ...")
  dbm.Open(path, true, params).OrDie()
  async := tkrzw.NewAsyncDBM(dbm, 1)
  startTime = time.Now()
  for batchID := 0; batchID < numBatches; batchID++ {
    futures := make([]*tkrzw.Future, 0, numRecords)
    for i := 0; i < numRecords; i++ {
      key := fmt.Sprintf("%08d", batchID * numBatches + i)
      future := async.Set(key, key, true)
      futures = append(futures, future)
    }
    for _, future := range futures {
      status := future.Get()
      status.OrDie()
    }
  }
  endTime = time.Now()
  elapsed = endTime.Sub(startTime).Seconds()
  fmt.Printf("time=%.3f, qps=%.0f\n",
    elapsed, float64(numBatches * numRecords) / elapsed)
  async.Destruct()

  // データベースを閉じる
  dbm.Close().OrDie()
}

結果発表。単位はスループットのQPS(クエリ毎秒)。

ゴルーチン 504,123
非同期API 473,199

ということで、ゴルーチンの勝利だ。同期APIだと70万QPSくらい出るのだが、非同期化してそれなりのオーバーヘッドを伴っても50万QPSを叩き出すことが確かめられた。毎回のクエリでチャンネルの作成とゴルーチンの起動をしているのに、スループットがそんなに落ちない。ゴルーチンの生成はスレッドの生成ではなく、Goの処理系内部のスレッドプールが実行するタスクを生成しているに過ぎない。むしろ、素直にゴルーチンの偉大さを認めた上で、ネイティブのスレッドプールを使った非同期APIがそれに匹敵する性能を出していることに喜ぶべきかもしれない。

ゴルーチンもまたネイティブのスレッドプールの上に構築されているので、それに加えてライブラリ側でスレッドを立てるという行為がそもそも筋悪なのかもしれない。さらに、非同期APIの場合、処理開始と終了待ちの2回、Goとネイティブの間のインターフェイスを通ることになるので、そのオーバーヘッドが大きいということだろう。呼び出す処理がもっと重いものならばインターフェイスのオーバーヘッドの割合は小さくなるのだが、同じことはゴルーチンにも言えることなので、やはりゴルーチンの優位は変わらない。

なお、上記の実験では非同期APIのワーカスレッドは1つにしていた。これを2つ以上にすると、スループットはむしろ落ちる。ボトルネックがデータベース内部の処理ではなく、インターフェイスにあるので、データベースに多くのCPUリソースを割り当てても仕方がないのだ。


まとめ。Go言語のゴルーチンの効率は凄まじく、ネイティブ側でいろいろ工夫してもなかなか追いつけない。並列処理ならGoという評判に間違いはない。

スループットから考えると、Go言語でTkrzwの非同期APIを積極的に使う理由はない。しかし、各クエリ毎にゴルーチン用の関数を書くのが面倒だという場合には、ほぼ同じスループットが出ながらより簡素な書き方ができる非同期APIを使うのも一興だろう。データベース処理に割り当てられるCPUリソースを隔離できるというのも利点と言えば利点かも。