C#を用いたアプリケーション開発において、処理の実行時間を正確に把握することは、パフォーマンスの最適化やユーザーエクスペリエンスの向上に欠かせません。
特に大規模なシステムや高頻度で実行されるバッチ処理、あるいはクラウドネイティブなマイクロサービスでは、ミリ秒単位、時にはマイクロ秒単位での計測が求められます。
これまでC#における経過時間計測の定番といえばStopwatchクラスでしたが、近年の.NETの進化、特に.NET 8以降ではTimeProviderという強力な抽象化レイヤーが登場し、計測手法のベストプラクティスが変化しています。
本記事では、従来の手法から最新のモダンな設計まで、シチュエーションに応じた最適な経過時間の計測手法を詳しく検討していきます。
経過時間計測の基本:なぜDateTimeでは不十分なのか
C#の初心者にとって、経過時間を測る際に最も直感的な方法はDateTime.Nowを使用することかもしれません。
処理の開始時と終了時に時刻を取得し、その差分をTimeSpanとして算出する手法です。
しかし、この方法は実務的なパフォーマンス計測においては推奨されません。
システムクロックの分解能と精度の限界
DateTime.NowやDateTime.UtcNowは、OSのシステムクロックに依存しています。
Windows環境におけるシステムクロックの解像度は、通常15ミリ秒程度です。
つまり、1ミリ秒や5ミリ秒で終了するような高速な処理を計測しようとしても、結果が0になったり、逆に15ミリ秒と表示されたりするなど、正確な値が得られません。
また、システムクロックは外部からの時刻同期(NTP)やユーザーによる手動設定によって時刻がジャンプする可能性があります。
計測中に時刻が調整されると、経過時間がマイナスになったり、異常に長い時間になったりするリスクを孕んでいます。
そのため、純粋な「経過時間」を測る目的には、システムクロックとは独立した高精度なタイマーが必要となります。
高精度な計測を実現するStopwatchクラスの活用法
C#で古くから利用されている最も信頼性の高いツールがSystem.Diagnostics.Stopwatchクラスです。
このクラスは、基盤となるOSが提供する高解像度パフォーマンスカウンタ(WindowsであればQueryPerformanceCounter)を利用するため、ナノ秒単位の精度での計測が可能です。
基本的なStopwatchの使用パターン
最も一般的な使い方は、インスタンスを生成してStart()とStop()を呼び出す方法です。
// Stopwatchのインスタンスを作成して計測開始
var sw = System.Diagnostics.Stopwatch.StartNew();
// 計測対象の処理
DoSomething();
// 計測停止
sw.Stop();
Console.WriteLine($"実行時間: {sw.ElapsedMilliseconds} ms");
Console.WriteLine($"詳細な実行時間: {sw.Elapsed}");
実行時間: 124 ms
詳細な実行時間: 00:00:00.1245678
アロケーションを抑えるモダンな計測手法
Stopwatchクラスをインスタンス化(new Stopwatch())すると、わずかではありますがヒープメモリを消費します。
高頻度なループ内やホットパス(頻繁に実行されるコード)で計測を行う場合、このアロケーションがGC(ガベージコレクション)に負荷を与える可能性があります。
これを回避するために、.NET core以降では静的メソッドを用いた計測が推奨される場面が増えています。
Stopwatch.GetTimestamp()を使用すると、インスタンスを作らずに現在のタイムスタンプ(long型)を取得できます。
// 開始時のタイムスタンプを取得
long startTime = System.Diagnostics.Stopwatch.GetTimestamp();
// 計測対象の処理
DoHeavyWork();
// 経過時間を計算
TimeSpan elapsed = System.Diagnostics.Stopwatch.GetElapsedTime(startTime);
Console.WriteLine($"経過時間: {elapsed.TotalMilliseconds} ms");
経過時間: 45.231 ms
このStopwatch.GetElapsedTimeメソッドは、インスタンスを生成しないためメモリ効率が非常に良く、高パフォーマンスが要求されるライブラリ開発などで重宝されます。
.NET 8以降の標準:TimeProviderによる設計の進化
現代のC#開発において、Stopwatchを直接使うことには一つの大きな欠点があります。
それは「テストの難しさ」です。
時間が関わる処理は決定論的なテストが書きにくく、Thread.Sleep()を使ってテストを待機させるなどの非効率なコードが散見されがちでした。
この問題を解決するために導入されたのがSystem.TimeProviderです。
これは時刻取得や経過時間計測を抽象化するための抽象クラスであり、依存性の注入(DI)との相性が抜群です。
TimeProviderを用いた経過時間計測の実装例
実機コードではTimeProvider.Systemを使用し、ユニットテストではモック(偽物)のTimeProviderを注入することで、時間を自由自在に操作できるようになります。
public class PerformanceMonitor
{
private readonly TimeProvider _timeProvider;
// コンストラクタでTimeProviderを受け取る(DI)
public PerformanceMonitor(TimeProvider timeProvider)
{
_timeProvider = timeProvider;
}
public void ExecuteWithLogging()
{
// 計測開始
long start = _timeProvider.GetTimestamp();
// ダミー処理
Thread.Sleep(100);
// 経過時間を取得
TimeSpan elapsed = _timeProvider.GetElapsedTime(start);
Console.WriteLine($"処理時間: {elapsed.TotalMilliseconds} ms");
}
}
TimeProviderを使うメリット
TimeProviderを利用する最大のメリットは、「時間の流れを制御できる」点にあります。
例えば、1時間かかる処理の経過時間計測をテストしたい場合、実際に1時間待つ必要はありません。
テスト用のFakeTimeProviderを使用して、内部時計を一瞬で1時間進めることができるからです。
テスト容易性を劇的に向上させるFakeTimeProvider
Microsoftが提供するライブラリ(Microsoft.Extensions.TimeProvider.Testing)を使用すると、経過時間のテストが驚くほど簡単になります。
// テストコードの例
var fakeProvider = new FakeTimeProvider();
var monitor = new PerformanceMonitor(fakeProvider);
// 初期状態から時間を手動で進める
long start = fakeProvider.GetTimestamp();
fakeProvider.Advance(TimeSpan.FromSeconds(5)); // 5秒進める
TimeSpan elapsed = fakeProvider.GetElapsedTime(start);
// elapsedは正確に5秒となる
このように、「実時間を待たずに時間の経過をシミュレートできる」ことは、CI/CDパイプラインの高速化において極めて重要な要素です。
シチュエーション別の最適な手法の選定基準
C#には複数の計測手法があるため、どれを使うべきか迷うこともあるでしょう。
以下の表に、用途別の推奨手法をまとめました。
| 手法 | 精度 | 主な用途 | テスト容易性 |
|---|---|---|---|
DateTime.UtcNow | 低い | ログへの時刻記録(大まかな目安) | 低い |
Stopwatchクラス | 非常に高い | ローカルでの詳細なプロファイリング | 低い |
Stopwatch.GetTimestamp | 非常に高い | 超高頻度なループ内の計測(低アロケーション) | 低い |
TimeProvider | 高い | 実務アプリケーションの標準設計 | 非常に高い |
基本的には、「DIが可能な環境ならTimeProvider、局所的な最適化ならStopwatch」という使い分けが2026年現在のスタンダードです。
経過時間計測における注意点とベストプラクティス
手法を選んだ後も、正確な計測結果を得るためにはいくつか注意すべき点があります。
1. 初回実行のオーバーヘッド(JITコンパイル)
.NETアプリケーションは実行時にマシンコードにコンパイル(JITコンパイル)されます。
そのため、「最初の1回目の実行」はコンパイル作業が含まれるため遅くなる傾向があります。
正確なパフォーマンスを測る場合は、数回の「ウォームアップ」実行を行った後に計測を開始するか、BenchmarkDotNetのような専用のベンチマークライブラリを使用することを検討してください。
2. 非同期処理(async/await)の計測
非同期メソッドの経過時間を測る場合は、awaitの前後に計測コードを配置します。
long start = Stopwatch.GetTimestamp();
await DoAsyncWork();
TimeSpan elapsed = Stopwatch.GetElapsedTime(start);
ただし、非同期処理の場合は「スレッドが待機している時間」も経過時間に含まれます。
CPUが実際に動いていた時間(CPU Time)を知りたい場合は、OSレベルのカウンタが必要になることに留意してください。
3. 分解能の確認
環境によっては、高解像度タイマーがサポートされていない場合があります(極めて稀な古いハードウェアや特定の仮想化環境)。
Stopwatch.IsHighResolutionプロパティを確認することで、その環境で高精度な計測が可能かどうかを判定できます。
if (Stopwatch.IsHighResolution)
{
Console.WriteLine("高精度タイマーが利用可能です。");
}
else
{
Console.WriteLine("システムクロックの精度に依存します。");
}
BenchmarkDotNetによる本格的な性能評価
もし、あなたが書いているコードがライブラリのコア部分であり、数ナノ秒の差を競うような最適化を行っているのであれば、自前でStopwatchを書くのではなく、BenchmarkDotNetの使用を強くお勧めします。
BenchmarkDotNetは、JITの最適化、GCの発生回数、CPUの分岐予測の影響などを考慮した上で、統計的に有意な計測結果を出力してくれる業界標準のツールです。
[MemoryDiagnoser]
public class MyBenchmark
{
[Benchmark]
public void TestMethod()
{
// 計測したい処理
}
}
このように属性を付与するだけで、信頼性の高いデータを取得できます。
まとめ
C#での経過時間計測は、単純なDateTimeの比較から、高精度なStopwatch、そしてテスト容易性に優れたTimeProviderへと進化を遂げてきました。
現代的なC#開発においては、以下の3点を意識して実装を使い分けるのがベストプラクティスです。
- 保守性とテストを重視するなら:
TimeProviderをDI(依存性の注入)経由で使用する。 - パフォーマンスと低アロケーションを極めるなら:
Stopwatch.GetTimestamp()とStopwatch.GetElapsedTime()を組み合わせる。 - 厳密な性能比較を行うなら:自前実装を避け、
BenchmarkDotNetを活用する。
特に、TimeProviderの導入は、ユニットテストの品質を劇的に向上させるため、新規プロジェクトでは積極的に採用すべき手法です。
適切な計測手法を選択し、正確なデータに基づいてアプリケーションのパフォーマンスを改善していきましょう。
