以前から「例外は重い」「throw は使いすぎないほうがいい」という話をよく見かけます。
そこで今回は、C# の例外生成と throw がどれくらいのコストなのか、BenchmarkDotNet を使ってざっくり確認してみました。
学習した内容を、自分なりに整理してまとめていこうと思います!
計測結果は実行環境にかなり依存します。なので、絶対値そのものよりも「どの操作がどれだけ重いのか」という相対比較を見るのが大事です。
先に結論
-
new Exception()だけならかなり軽いです。 - 本当に重いのは、
throwしたあとに走るスタックトレースの捕捉と例外処理の流れです。 - つまり、例外は「作ること」より「投げること」のほうがコストが大きいです。
- 例外がほとんど起きないなら、
try/catchを置いているだけで致命的に遅くなるわけではありません。 - ただし、ループの中や想定内の分岐に例外を使うと一気に重くなります。
検証方法
今回は BenchmarkDotNet を使って、次のようなパターンを比べました。
- 何も起こらない通常リターン
- 例外を
throwしてcatchする処理 -
new Exception()だけ行う処理 - 事前に作った例外を
throwする処理 -
int.Parseを例外込みで回す処理と、int.TryParseの比較
[MemoryDiagnoser] も付けて、時間だけでなく割り当ても見ています。
まずは普通の処理と比べる
最初に、例外を使わない通常処理と、throw して catch する処理を比べました。
| Method | Mean | Ratio | Allocated |
|---|---|---|---|
| NormalReturn | 0.32 ns | 1 | - |
| ThrowCatchShallow | 1,112.17 ns | 3,833 | 320 B |
見ての通り、普通に return するだけならほぼゼロです。
一方で、例外を投げると 1 回あたり約 1.1 マイクロ秒かかりました。
この時点で「例外は遅い」という話自体は、やっぱり本当なんだなと思いました。
遅さの正体
ここで気になったのが、どこがそんなに重いのかです。
結論からいうと、重いのは new ではありません。
本当に効いているのは、throw した瞬間に走るスタックトレースの捕捉です。
| Method | Mean | Ratio | Allocated |
|---|---|---|---|
| NewExceptionOnly | 3.86 ns | 1 | 120 B |
| ThrowPreallocated | 1,031.13 ns | 279 | 200 B |
| ThrowNew | 1,129.44 ns | 305 | 320 B |
new Exception() だけならかなり軽いです。
事前に作っておいた例外を throw しても、結局 1,000 ns 前後かかっています。
なので、感覚としては「例外オブジェクトを作るのが重い」というより、「例外を投げる行為そのものが重い」と捉えたほうが正しそうです。
スタックが深いともっと重い
次に、再帰で深く潜ったところから例外を投げるケースも見ました。
| Depth | ThrowFromDepth (throwあり) | RecurseNoThrow (throwなし) | Allocated (throwあり) |
|---|---|---|---|
| 1 | 1,457 ns | 1.09 ns | 488 B |
| 5 | 2,071 ns | 4.13 ns | 784 B |
| 20 | 4,956 ns | 15.64 ns | 2,400 B |
| 100 | 18,966 ns | 81.88 ns | 8,624 B |
ここはかなり分かりやすくて、深さが増えるほどコストも増えました。
深さ 100 まで行くと、例外ありの処理は 18,966 ns まで伸びています。
逆に言うと、例外は「どこで投げるか」がかなり大事です。
深いスタックの中で頻繁に投げるような作りは、やっぱり避けたいですね。
失敗処理は Try 系が強い
実務で分かりやすいのが、int.Parse と int.TryParse の比較です。
不正入力の割合を変えて、1,000 件まとめて処理してみました。
| InvalidPercent | ParseWithException | TryParseNoException | 倍率 |
|---|---|---|---|
| 0% | 5.99 μs | 5.48 μs | 1.1× |
| 50% | 651.90 μs | 4.79 μs | 137× |
| 100% | 1,323.19 μs | 3.79 μs | 352× |
ここでのポイントは、失敗が増えるほど例外方式がどんどん不利になることです。
逆に、失敗がほとんど起きないなら差はかなり小さいです。
なので、try/catch を書いてあること自体を過剰に怖がる必要はないけれど、想定内の失敗を例外で表現するのはやっぱり向いていません。
戻り値で返すほうが自然な場面
同じ失敗を、bool の戻り値で返すケースも比べました。
| Method | Mean | Ratio | Allocated |
|---|---|---|---|
| ReturnBoolFailure | 0.79 ns | 1 | - |
| ThrowFailure | 1,170.46 ns | 1,484 | 328 B |
この結果を見ると、失敗が想定内なら bool や Try~ 系で返したほうが素直です。
例外は「本来起きない」「起きたら中断したい」ようなケースに寄せたほうが、設計としても分かりやすいです。
再スローもそれなりに重い
最後に、再スローの違いも軽く見ました。
| Method | Mean | Ratio | Allocated |
|---|---|---|---|
| CatchWhenFilterTrue | 1.156 μs | 1.00 | 320 B |
| CatchWhenFilterFalse | 1.198 μs | 1.04 | 320 B |
RethrowThrow (throw;) |
2.284 μs | 1.98 | 512 B |
RethrowThrowEx (throw ex;) |
2.375 μs | 2.05 | 520 B |
| RethrowDispatchInfo | 3.074 μs | 2.66 | 968 B |
ここで覚えておきたいのは、再スローも追加コストが乗るということです。
特に throw ex; はスタックトレースを壊してしまうので、速度以前に避けたほうがいいです。
まとめ
今回の計測で、例外については次のように考えるとよさそうでした。
-
new Exception()自体はかなり軽いです。 - 重いのは
throwしたときの処理です。 - スタックが深いほどコストが増えます。
- 失敗が頻繁に起きるなら、例外より
Try~や戻り値のほうが向いています。 - 例外がほとんど起きないなら、
try/catchを置いておくこと自体はそこまで気にしなくて大丈夫です。
つまり、例外は「遅いから使わない」ではなく、「どこで、どれくらいの頻度で使うか」を意識するのが大事だと感じました。
この記事が皆様のコーディングライフの助けになれれば幸いです!
参考