4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【C#】例外の生成って本当に遅いの? BenchmarkDotNetで実測してみた

4
Posted at

以前から「例外は重い」「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.Parseint.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; はスタックトレースを壊してしまうので、速度以前に避けたほうがいいです。

まとめ

今回の計測で、例外については次のように考えるとよさそうでした。

  1. new Exception() 自体はかなり軽いです。
  2. 重いのは throw したときの処理です。
  3. スタックが深いほどコストが増えます。
  4. 失敗が頻繁に起きるなら、例外より Try~ や戻り値のほうが向いています。
  5. 例外がほとんど起きないなら、try/catch を置いておくこと自体はそこまで気にしなくて大丈夫です。

つまり、例外は「遅いから使わない」ではなく、「どこで、どれくらいの頻度で使うか」を意識するのが大事だと感じました。

この記事が皆様のコーディングライフの助けになれれば幸いです!

参考

4
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?