基本的にシングルスレッドでしか使わないライブラリを、速度を落とさずにスレッドセーフにするために、ロックフリーなメモリープールを自作した話を紹介する。この記事はC# その2 Advent Calendar 2020の18日目の記事である。
前回に続き、今回もDynaJsonの話である。DynaJsonについては前回を参照してほしい。実はDynaJsonはバージョン2.1.0まではスレッドセーフではなかった。私がデスクトップクライアントでしか使わないので、速度優先でそうしていた。しかし、テストをマルチスレッドで動かせないのがつらいので、バージョン2.2.0でスレッドセーフにした。
スレッドセーフにすると遅くなるのは、処理を呼び出すたびに、その処理に必要なデータを保持するインスタンスを生成する必要があるからだ。スレッドセーフでなくてよいなら、静的クラスで実装するか、一つインスタンスを生成して静的メンバーに代入して使い回すことで、呼び出し時のインスタンス生成を避けられる。
DynaJsonをスレッドセーフにする際には、速度低下を抑えるためにメモリープールを使ってインスタンスの生成を避けるようにした。プログラム起動時にメモリープールにインスタンスを一つ生成して、通常の呼び出しではこれを使い回し、マルチスレッドで再入が生じたときに初めて追加のインスタンスを生成する。これならシングルスレッドで使っている限りはインスタンスが生成されない。
メモリープールについては、標準の実装にMemoryPool<T>があるが、DynaJsonがサポートしている.NET Standard 2.0にはMemoryPool<T>がないので自作した。メモリープールの処理は、インスタンス生成よりずっと速くないと意味がない。ロックはすごく遅いので実装に使うことはできず、ロックフリーにする必要がある。
ロックフリーなデータ構造は、C#ではたいていInterlocked.CompareExchangeを用いて実装する。これを使ったロックフリーなスタックの実装がこちらの記事で紹介されている。このスタックの実装を参考にして実装したのが、以下の簡素なメモリープールである。
正直なところ、メモリの返却のたびにインスタンスが生成される残念な実装である。それでも、生成されるインスタンスが16バイトと小さいことから、このメモリープールによって、全体としてのインスタンスの生成コストは下がっている。
以下のグラフは、{"X":1234.5,"Y":5.6789,"Name":"Sakura"}
という小さなJSONのパーズに掛かる時間を、メモリープール無し(Without)と有り(With)で測定したものである。メモリープールによってレイテンシーが203 ns小さくなっている。