非同期APIでは、該当の処理をバックグラウンドで実行するためにスレッドを使う。と同時に、バッチ処理やサーバプログラムなどではフォアグラウンドに相当するスレッドが複数個同時に実行される。となると、性能評価はフォアグラウンドのスレッド数とバックグラウンドのスレッド数の二次元でなされねばならない。その結果が以下である。
前回の記事で説明した非同期APIは、裏でタスクキューとスレッドプールを持ち、バックグラウンドで処理を行うものだ。非同期APIのユースケースは二つに分類することができる。フォアグラウンドの方が重い処理を行う場合と、バックグラウンドの方が重い処理を行う場合だ。
バッチ処理などではフォアグラウンドが比較的重い処理になるので、問題を分割して、フォアグラウンドをマルチスレッド化するのが理想だ。その上で、I/O系の処理の待ち時間を減らすために非同期APIを用いることもある。ただし、フォアグラウンドをマルチスレッド化している時点で、ある程度の並列性を確保し、マルチコアのユーティリティは高いはずなので、非同期APIを使うメリットはあまり大きくない。
RPCのサーバやユーザインタラクションのあるアプリケーションプログラムでは、フォアグラウンドの処理は比較的軽く、相対的にI/O系の待ち時間の比重が高まる。しかし、それらのユースケースではレイテンシに強い制約があるので、フォアグラウンドのスレッドをブロックさせないようにしたい。非同期APIはその要求に応えるのにうってつけだ。多数のクライアントの要求を同時に処理するサーバでも、イベント駆動で複数の操作を同時に受け付けるアプリケーションプログラムでも、フォアグラウンドがマルチスレッドであるのは一般的だ。
すなわち、典型的な非同期APIのユースケースでは、フォアグラウンドはマルチスレッドで軽い処理のみを行うと同時に、バックグラウンドでデータベース操作などの重い処理を行い、フォアグラウンドがバックグラウンドの終了を待ち合わせた上で結果を処理することになる。
以上のことを踏まえて、性能評価のシナリオを考える。ハッシュデータベースに合計100万個のレコードを格納する。キーと値はそれぞれ8バイトのユニークな文字列とする。指定したスレッド数のフォアグラウンドを起動し、各々は100万をスレッド数で割った回数のループを行う。ループの中では、同期APIまたは非同期APIのSetメソッドで1つのレコードを格納する。非同期APIの場合、ループ1000回毎にバックグラウンドの待ち合わせを行い、結果の成否を調べる。以下ようなコマンドで測定を行う。
# フォアグラウンドスレッド数2、同期API tkrzw_dbm_tran async casket.tkh --params num_buckets=2000000 \ --iter 500000 --threads 2 --wait_freq 1000 --set_only --async 0 # フォアグラウンドスレッド数4、非同期APIワーカスレッド数2 tkrzw_dbm_tran async casket.tkh --params num_buckets=2000000 \ --iter 250000 --threads 4 --wait_freq 1000 --set_only --async 2
フォアグラウンドのスレッド数は1、2、4、8と変えて調査する。それぞれで、同期APIおよび非同期APIで調査し、非同期APIのワーカスレッド数は1、2、4、8と変えて調査する。結果は以下の通りで、単位はQPS(秒間スループット)である。
フォアグラウンド | 同期API | 非同期1 | 非同期2 | 非同期4 | 非同期8 |
1スレッド | 1,123,859 | 506,638 | 825,015 | 372,547 | 334,232 |
2スレッド | 1,439,215 | 458,677 | 865,065 | 1,055,652 | 368,198 |
4スレッド | 2,050,378 | 411,535 | 838,889 | 1,057,120 | 1,106,758 |
8スレッド | 2,008,129 | 440,947 | 815,878 | 1,068,916 | 1,074,913 |
まず言えるのは、スループットを考えるなら、同期APIを使ってフォアグラウンドのスレッド数を上げるのが最善だということだ。理論上、CPUのコア数までスレッド数を増やすと得があるはずだ。今回の検証環境である私のノートPCは4コア8スレッドなので、4から8の間くらいが上限になるという予想と合致した結果になった。
非同期APIは同期APIに比べてスループットは半減する。スレッド間のデータの授受や待ち合わせのためのオーバーヘッドが増えるので当然だ。興味深いのは、非同期APIの最適なワーカスレッドの数が、フォアグラウンドのスレッド数に応じて変わるということだ。フォアグラウンドが1スレッドの場合、ワーカスレッドは2スレッドが最善だ。フォアグラウンドが2スレッドの場合、ワーカスレッドは4スレッドが最善だ。フォアグラウンドが4スレッド以上の場合、ワーカスレッドは8スレッドが最善だ。これだけで結論を出すのは尚早だが、帰納的に考えるなら、フォアグラウンドのスレッド数が多いほどバックグラウンドのスレッド数も多い方が良いということになるとの予想が立つ。おそらく、タスクキューに入れるタスクを作る処理に律速要素があり、それはフォアグラウンドのスレッド数を増やすと並列処理によって高速化されるというのが理由だろう。
2021年現在、CPUのコア数は8コア以上が普通なので、フォアグラウンドのスレッド数も8以上になるのが普通だ。サーバプログラムの場合はスレッドプールの数を8以上にするだろうし、対話型のアプリケーションが使うスレッド数の上限はもっと多いだろう。となると、非同期APIを使うなら、ワーカスレッド数も8以上にするのが良いだろう。とりあえず10とか16とか適当な値でいいと思う。スレッド数が多すぎると無駄にメモリを食うが、時間コストはあまり変わらないので、コア数よりちょっと多めに設定するのが良さげだ。もちろん、実行環境やデータベースの規模や内容やアクセスパターンによって実際に最適な設定は変わるので、自分のユースケースで性能測定をするのが確実である。
まとめ。Tkrzwの同期APIの非同期APIのスループットを計測した。非同期APIのスループットはフォアグラウンドのスレッド数とバックグラウンドのスレッド数に応じて劇的に変わる。バッチ処理などでスループットを追求するならそもそも非同期APIを使わずにフォアグラウンドのスレッド数を増やすべきで、できれば同期APIを用いた方がよい。一方、サーバや対話型のプログラムでは、個々のリクエストやイベントを処理するスレッドをフォアグラウンドとみなすなら、そのスレッド数が多いことは前提となる。個々のスレッドの中でさらに並列処理をしてレイテンシを低減したい場合には、非同期APIを使うのは有効な手段だ。その際には、フォアグラウンドのスレッド数とワーカスレッドのスレッド数の双方を、CPUのコア数よりちょっと多めに設定すると良いだろう。