日時

【C#】DateTime・Stopwatch・Timerで実装するミリ秒単位の時間管理ハンドブック

C#でミリ秒を扱うなら、現在の時刻のミリ秒部分取得はDateTime.Now.Millisecond、経過時間はStopwatch.ElapsedMilliseconds、時間差はTimeSpan.TotalMillisecondsが基本です。

高精度が必要な周期処理にはSystem.Threading.Timerを使い、UIならDispatcherTimerが便利です。

目次から探す
  1. DateTimeで取得する現在時刻のミリ秒
  2. TimeSpanで表す時間差とミリ秒換算
  3. Stopwatchで高精度な経過時間計測
  4. System.Threading.Timerで周期処理
  5. UI向けタイマーコンポーネント
  6. 高精度タイマーの選定基準
  7. 非同期プログラミングとミリ秒待機
  8. マルチスレッド環境での時間同期
  9. ミリ秒を含む日時の文字列フォーマット
  10. ロギングでのミリ秒精度保持
  11. 単体テストでの時間依存コードの検証
  12. パフォーマンスチューニング
  13. ベンチマークで精度を測定
  14. よくある落とし穴
  15. クロスプラットフォームでの違い
  16. ミリ秒データのシリアライズ
  17. イベント駆動アーキテクチャへの応用
  18. 外部ライブラリの活用例
  19. セキュリティとタイムスタンプ整合性
  20. まとめ

DateTimeで取得する現在時刻のミリ秒

C#で現在時刻のミリ秒部分を取得する際に最も基本的な方法は、DateTime構造体のMillisecondプロパティを利用することです。

DateTimeは日付と時刻を表す標準的な型であり、ミリ秒単位の情報も保持しています。

ここではDateTimeのミリ秒取得方法や表示方法、UTCとの違い、そしてDateTimeOffsetとの比較について詳しく解説します。

Millisecondプロパティの基本

DateTimeMillisecondプロパティは、現在の時刻のミリ秒部分(0~999)を整数値で返します。

これは秒の小数点以下の部分を表しており、1秒を1000分割した単位です。

以下のサンプルコードは、現在の時刻からミリ秒を取得して表示する例です。

using System;
class Program
{
    static void Main()
    {
        DateTime now = DateTime.Now;  // 現在のローカル時刻を取得
        int milliseconds = now.Millisecond;  // ミリ秒部分を取得
        Console.WriteLine($"現在のミリ秒: {milliseconds}");
    }
}
現在のミリ秒: 801

このコードを実行すると、例えば「現在のミリ秒: 123」のように、0から999の範囲でミリ秒が表示されます。

Millisecondはあくまで時刻の一部であり、秒全体の経過時間ではない点に注意してください。

HH:mm:ss.fffでの表示

ミリ秒を含む時刻を文字列として表示したい場合は、DateTimeToStringメソッドにカスタムフォーマットを指定します。

特にfffはミリ秒を3桁で表す書式指定子です。

以下の例では、時刻を「時:分:秒.ミリ秒」の形式で表示しています。

using System;
class Program
{
    static void Main()
    {
        DateTime now = DateTime.Now;
        string formattedTime = now.ToString("HH:mm:ss.fff");  // ミリ秒を含むフォーマット
        Console.WriteLine($"現在時刻(ミリ秒付き): {formattedTime}");
    }
}
現在時刻(ミリ秒付き): 14:35:27.123

このようにHH:mm:ss.fffを使うと、時刻の秒以下にミリ秒が3桁で表示されます。

fffはミリ秒をゼロ埋めして3桁で表現するため、例えばミリ秒が5の場合は「005」と表示されます。

ローカルタイムとUTCの差異

DateTimeにはローカル時刻DateTime.Nowと協定世界時(UTC、DateTime.UtcNow)の2種類の現在時刻取得方法があります。

ミリ秒の取得方法は同じですが、時刻の基準が異なるため注意が必要です。

  • DateTime.Nowはコンピューターのローカルタイムゾーンに基づく現在時刻を返します。夏時間(サマータイム)やタイムゾーンの影響を受けます
  • DateTime.UtcNowは世界標準時(UTC)を返し、タイムゾーンの影響を受けません

以下のコードは両者の違いを示しています。

using System;
class Program
{
    static void Main()
    {
        DateTime localTime = DateTime.Now;
        DateTime utcTime = DateTime.UtcNow;
        Console.WriteLine($"ローカル時刻: {localTime:HH:mm:ss.fff}");
        Console.WriteLine($"UTC時刻: {utcTime:HH:mm:ss.fff}");
    }
}
ローカル時刻: 10:10:47.817
UTC時刻: 01:10:47.821

この例では、ローカル時刻とUTC時刻の時刻部分は異なりますが、ミリ秒部分は同じです。

ミリ秒は時刻の細かい単位であり、どちらも同じ瞬間の時刻を表しているためです。

ローカル時刻を使う場合は、タイムゾーンや夏時間の影響で時刻が変わる可能性があることを理解しておくとよいでしょう。

UTC時刻はグローバルな基準として使うのに適しています。

DateTimeOffsetとの比較

DateTimeOffsetDateTimeにタイムゾーンのオフセット情報を含めた構造体です。

これにより、単に時刻を表すだけでなく、その時刻がどのタイムゾーンに属しているかを明示的に管理できます。

DateTimeOffsetMillisecondプロパティを持ち、ミリ秒部分を取得できます。

DateTimeとの主な違いは、DateTimeOffsetがオフセット(UTCとの差)を保持している点です。

以下のコードはDateTimeDateTimeOffsetのミリ秒取得を比較しています。

using System;
class Program
{
    static void Main()
    {
        DateTime dt = DateTime.Now;
        DateTimeOffset dto = DateTimeOffset.Now;
        Console.WriteLine($"DateTimeのミリ秒: {dt.Millisecond}");
        Console.WriteLine($"DateTimeOffsetのミリ秒: {dto.Millisecond}");
        Console.WriteLine($"DateTimeOffsetのオフセット: {dto.Offset}");
    }
}
DateTimeのミリ秒: 123
DateTimeOffsetのミリ秒: 123
DateTimeOffsetのオフセット: +09:00:00

この例では、両者のミリ秒は同じですが、DateTimeOffsetOffsetプロパティでタイムゾーンの差を示しています。

DateTimeはタイムゾーン情報を持たないため、タイムゾーンを意識した日時管理が必要な場合はDateTimeOffsetの利用が推奨されます。

まとめると、現在時刻のミリ秒を取得する基本はDateTimeMillisecondプロパティですが、タイムゾーンやオフセットを考慮する場合はDateTimeOffsetを使うとより正確な時間管理が可能です。

表示の際はHH:mm:ss.fffのフォーマットを活用してミリ秒を含めた時刻をわかりやすく表現できます。

TimeSpanで表す時間差とミリ秒換算

TotalMillisecondsとMillisecondsの違い

TimeSpan構造体は時間の長さや差を表現するために使われます。

ミリ秒に関しては、TotalMillisecondsMillisecondsという2つのプロパティがあり、それぞれ意味が異なります。

  • TotalMillisecondsTimeSpan全体の時間をミリ秒単位で表したdouble型の値です。時間の全体量を小数点以下まで含めて返します
  • MillisecondsTimeSpanの「秒未満のミリ秒部分」を整数で返します。つまり、1秒未満のミリ秒だけを切り出した値で、0~999の範囲です

以下のコードで違いを確認できます。

using System;
class Program
{
    static void Main()
    {
        // 1日1時間1分1秒500ミリ秒のTimeSpanを作成
        TimeSpan timeSpan = new TimeSpan(1, 1, 1, 1, 500);
        Console.WriteLine($"TotalMilliseconds: {timeSpan.TotalMilliseconds}");
        Console.WriteLine($"Milliseconds: {timeSpan.Milliseconds}");
    }
}
TotalMilliseconds: 90061500
Milliseconds: 500

この例では、TotalMillisecondsは1日1時間1分1秒500ミリ秒をすべてミリ秒に換算した約9億ミリ秒を返しています。

一方、Millisecondsは秒未満の500ミリ秒だけを返しています。

TotalMillisecondsは時間の合計を計算したい場合に使い、Millisecondsは秒の小数点以下のミリ秒部分を取得したい場合に使います。

Add/Subtractでミリ秒を操作

TimeSpanは加算や減算が可能で、AddメソッドやSubtractメソッドを使ってミリ秒単位で時間を操作できます。

これにより、既存の時間差にミリ秒を足したり引いたりすることが簡単にできます。

以下はAddで500ミリ秒を加算し、Subtractで200ミリ秒を減算する例です。

using System;
class Program
{
    static void Main()
    {
        TimeSpan original = new TimeSpan(0, 0, 1, 0, 0);  // 1分0秒0ミリ秒
        // 500ミリ秒を加算
        TimeSpan added = original.Add(TimeSpan.FromMilliseconds(500));
        // 200ミリ秒を減算
        TimeSpan subtracted = added.Subtract(TimeSpan.FromMilliseconds(200));
        Console.WriteLine($"元のTimeSpan: {original}");
        Console.WriteLine($"500ミリ秒加算後: {added}");
        Console.WriteLine($"200ミリ秒減算後: {subtracted}");
    }
}
元のTimeSpan: 00:01:00
500ミリ秒加算後: 00:01:00.5000000
200ミリ秒減算後: 00:01:00.3000000

TimeSpan.FromMillisecondsはミリ秒を指定してTimeSpanを生成する便利なメソッドです。

AddSubtractは元のTimeSpanを変更せず、新しいTimeSpanを返すため、元の値は不変です。

負のTimeSpanの扱い

TimeSpanは正の時間差だけでなく、負の時間差も表現できます。

負のTimeSpanは、基準となる時刻より前の時間差や、減算結果が負になる場合に使います。

負のTimeSpanTicks(100ナノ秒単位の最小単位)が負の値を持つことで表現されます。

表示するときはマイナス記号が付いてわかりやすくなります。

以下の例で負のTimeSpanを作成し、加算・減算の結果を確認します。

using System;
class Program
{
    static void Main()
    {
        TimeSpan positive = TimeSpan.FromSeconds(10);
        TimeSpan negative = TimeSpan.FromSeconds(-5);
        Console.WriteLine($"正のTimeSpan: {positive}");
        Console.WriteLine($"負のTimeSpan: {negative}");
        // 正のTimeSpanから負のTimeSpanを減算(実質加算)
        TimeSpan result = positive.Subtract(negative);
        Console.WriteLine($"10秒 - (-5秒) = {result}");
        // 負のTimeSpanに500ミリ秒を加算
        TimeSpan negativeAdded = negative.Add(TimeSpan.FromMilliseconds(500));
        Console.WriteLine($"負のTimeSpanに500ミリ秒加算: {negativeAdded}");
    }
}
正のTimeSpan: 00:00:10
負のTimeSpan: -00:00:05
10秒 - (-5秒) = 00:00:15
負のTimeSpanに500ミリ秒加算: -00:00:04.5000000

負のTimeSpanは計算上も自然に扱えます。

例えば、ある時刻から過去の時刻を引いた結果が負のTimeSpanになることがあります。

ミリ秒単位の操作も同様に可能で、加算・減算の結果が負になることも問題ありません。

ただし、負のTimeSpanを使う際は、表示やロジックでマイナスの意味を正しく理解して扱うことが重要です。

例えば、経過時間としては負の値は通常使わず、差分の方向を示すために使うことが多いです。

Stopwatchで高精度な経過時間計測

クラス概要と生成コスト

Stopwatchクラスは、処理の経過時間を高精度で計測するためのクラスです。

System.Diagnostics名前空間に属し、内部的には高精度なパフォーマンスカウンターを利用しているため、ミリ秒以下の精度で時間を測定できます。

生成コストは非常に低く、new Stopwatch()でインスタンスを作成してもパフォーマンスに大きな影響はありません。

ただし、頻繁に大量のインスタンスを生成するとガベージコレクションの負荷が増えるため、使い回しが推奨されます。

以下は基本的な生成例です。

using System;
using System.Diagnostics;
class Program
{
    static void Main()
    {
        Stopwatch sw = new Stopwatch();
        Console.WriteLine("Stopwatchインスタンスを生成しました。");
    }
}

Start・Stop・Restartの使い分け

Stopwatchは計測の開始・停止・再開を簡単に制御できます。

主に使うメソッドは以下の3つです。

  • Start():計測を開始または再開します。停止中の計測を続けて行いたい場合に使います
  • Stop():計測を一時停止します。計測結果は保持されます
  • Restart():計測をリセットしてから開始します。新たに計測を始めたいときに使います

例えば、処理の一部を計測し、途中で停止して後で再開したい場合はStartStopを使い分けます。

計測を完全にリセットして新たに計測したい場合はRestartが便利です。

以下のコードはこれらの使い分け例です。

using System;
using System.Diagnostics;
using System.Threading;
class Program
{
    static void Main()
    {
        Stopwatch sw = new Stopwatch();
        sw.Start();
        Thread.Sleep(500);  // 500ミリ秒待機
        sw.Stop();
        Console.WriteLine($"1回目の計測: {sw.ElapsedMilliseconds} ms");
        Thread.Sleep(300);  // 計測外の待機
        sw.Start();  // 再開
        Thread.Sleep(200);
        sw.Stop();
        Console.WriteLine($"2回目の計測(累積): {sw.ElapsedMilliseconds} ms");
        sw.Restart();  // リセットして再スタート
        Thread.Sleep(100);
        sw.Stop();
        Console.WriteLine($"リスタート後の計測: {sw.ElapsedMilliseconds} ms");
    }
}
1回目の計測: 515 ms
2回目の計測(累積): 718 ms
リスタート後の計測: 108 ms

このようにStartは停止中の計測を続けるため、累積時間が計測されます。

Restartは計測時間をリセットして新たに計測を始めます。

ElapsedMillisecondsとElapsedTicks

Stopwatchは経過時間を2つの単位で取得できます。

  • ElapsedMilliseconds:経過時間をミリ秒単位で返すlong型のプロパティです。整数値で扱いやすく、一般的な計測に適しています
  • ElapsedTicks:経過時間を内部のタイマー単位(ティック)で返すlong型のプロパティです。ティックはシステムの高精度カウンターの最小単位で、環境によって異なります

Ticksからミリ秒への変換

ElapsedTicksは環境依存の単位なので、そのままではミリ秒に換算できません。

Stopwatch.Frequencyプロパティを使うことで、1秒あたりのティック数がわかります。

これを利用してミリ秒に変換できます。

計算式は以下の通りです。

ミリ秒 = (ElapsedTicks × 1000) ÷ Frequency

以下のコードで変換例を示します。

using System;
using System.Diagnostics;
using System.Threading;
class Program
{
    static void Main()
    {
        Stopwatch sw = Stopwatch.StartNew();
        Thread.Sleep(123);
        sw.Stop();
        long ticks = sw.ElapsedTicks;
        long frequency = Stopwatch.Frequency;
        double milliseconds = (double)ticks * 1000 / frequency;
        Console.WriteLine($"ElapsedTicks: {ticks}");
        Console.WriteLine($"Stopwatch.Frequency: {frequency}");
        Console.WriteLine($"計算によるミリ秒: {milliseconds:F3}");
        Console.WriteLine($"ElapsedMilliseconds: {sw.ElapsedMilliseconds}");
    }
}
ElapsedTicks: 1357439
Stopwatch.Frequency: 10000000
計算によるミリ秒: 135.744
ElapsedMilliseconds: 135

このようにElapsedTicksFrequencyで割ってミリ秒に換算すると、ElapsedMillisecondsよりも高精度な値が得られます。

IsHighResolutionの確認方法

Stopwatchが高精度タイマーを使用しているかどうかは、IsHighResolutionプロパティで確認できます。

trueの場合は高精度なパフォーマンスカウンターを利用しており、より正確な計測が可能です。

以下のコードで確認できます。

using System;
using System.Diagnostics;
class Program
{
    static void Main()
    {
        Console.WriteLine($"高精度タイマー使用中: {Stopwatch.IsHighResolution}");
        Console.WriteLine($"タイマー周波数: {Stopwatch.Frequency} ticks/秒");
    }
}
高精度タイマー使用中: True
タイマー周波数: 10000000 ticks/秒

多くのWindows環境ではIsHighResolutiontrueであり、1秒あたり1000万ティック(100ナノ秒単位)で計測しています。

環境によってはfalseになることもあるため、精度が重要な場合はこの値をチェックするとよいでしょう。

インスタンス再利用でGC負荷を減らす

Stopwatchは軽量ですが、頻繁にインスタンスを生成するとガベージコレクション(GC)の負荷が増えます。

特にループ内や高頻度の計測で大量に生成するとパフォーマンスに影響が出ることがあります。

そのため、Stopwatchインスタンスは使い回すことが推奨されます。

例えばクラスのフィールドとして保持し、必要に応じてRestartResetを使って再利用します。

using System;
using System.Diagnostics;
using System.Threading;
class TimerExample
{
    private Stopwatch stopwatch = new Stopwatch();
    public void MeasureAction(Action action)
    {
        stopwatch.Restart();
        action();
        stopwatch.Stop();
        Console.WriteLine($"処理時間: {stopwatch.ElapsedMilliseconds} ms");
    }
}
class Program
{
    static void Main()
    {
        TimerExample example = new TimerExample();
        example.MeasureAction(() => System.Threading.Thread.Sleep(200));
        example.MeasureAction(() => System.Threading.Thread.Sleep(300));
    }
}
処理時間: 208 ms
処理時間: 309 ms

このようにインスタンスを再利用することで、GCの発生を抑えつつ効率的に計測できます。

ログ出力への組み込み例

Stopwatchは処理時間のログ出力に非常に便利です。

特にパフォーマンスのボトルネックを特定したい場合や、処理時間を記録して分析したい場合に役立ちます。

以下は、処理の開始時刻と終了時刻、経過時間をログに出力する例です。

using System;
using System.Diagnostics;
using System.Threading;
class Program
{
    static void Main()
    {
        Stopwatch sw = new Stopwatch();
        Console.WriteLine($"処理開始: {DateTime.Now:HH:mm:ss.fff}");
        sw.Start();
        // 処理例
        Thread.Sleep(450);
        sw.Stop();
        Console.WriteLine($"処理終了: {DateTime.Now:HH:mm:ss.fff}");
        Console.WriteLine($"経過時間: {sw.ElapsedMilliseconds} ms");
    }
}
処理開始: 14:50:10.123
処理終了: 14:50:10.573
経過時間: 450 ms

このようにDateTimeで時刻を記録しつつ、Stopwatchで正確な経過時間を計測してログに残すと、後から詳細なパフォーマンス分析が可能になります。

ログのフォーマットは用途に応じてカスタマイズしてください。

System.Threading.Timerで周期処理

コールバックのシグネチャ

System.Threading.Timerは指定した間隔でメソッドを繰り返し実行するためのタイマーです。

タイマーが起動すると、指定したコールバックメソッドがスレッドプールのスレッド上で呼び出されます。

コールバックメソッドのシグネチャは以下のようにTimerCallbackデリゲートで定義されています。

void TimerCallback(object state)
  • stateパラメータは、タイマー生成時に渡した任意のオブジェクトを受け取ります。これにより、コールバック内で必要な情報を参照できます
  • 戻り値はなく、非同期に呼び出されるため、処理はできるだけ短時間で終わらせることが推奨されます

以下はコールバックの例です。

using System;
using System.Threading;
class Program
{
    static void TimerMethod(object state)
    {
        Console.WriteLine($"タイマーコールバック: {DateTime.Now:HH:mm:ss.fff}");
    }
    static void Main()
    {
        Timer timer = new Timer(TimerMethod, null, 0, 1000);
        Thread.Sleep(3500);
        timer.Dispose();
    }
}

この例では、1秒ごとにTimerMethodが呼ばれ、現在時刻が表示されます。

dueTimeとperiodの設定パターン

TimerのコンストラクタやChangeメソッドで指定するdueTimeperiodは、タイマーの動作開始までの遅延時間と繰り返し間隔をミリ秒単位で設定します。

  • dueTime:最初のコールバックが呼ばれるまでの遅延時間(ミリ秒)。0で即時開始、Timeout.Infiniteで開始しない
  • period:コールバックの繰り返し間隔(ミリ秒)。Timeout.Infiniteで繰り返しなし(1回だけ実行)

代表的な設定パターンは以下の通りです。

dueTimeperiod動作内容
01000即時開始、1秒ごとに繰り返し
50020000.5秒後に開始、2秒ごとに繰り返し
Timeout.Infinite1000開始しない(手動で開始可能)
0Timeout.Infinite即時1回だけ実行

以下はdueTimeperiodを変えてタイマーを設定する例です。

using System;
using System.Threading;
class Program
{
    static void TimerMethod(object state)
    {
        Console.WriteLine($"タイマー発火: {DateTime.Now:HH:mm:ss.fff}");
    }
    static void Main()
    {
        // 0.5秒後に開始し、2秒ごとに繰り返す
        Timer timer = new Timer(TimerMethod, null, 500, 2000);
        Thread.Sleep(7000);
        timer.Dispose();
    }
}

Disposeでリークを防止

Timerはアンマネージリソースを内部で使用しているため、使い終わったら必ずDisposeメソッドを呼んでリソースを解放する必要があります。

Disposeを呼ばないと、タイマーが動作し続けてメモリリークや予期しない動作の原因になります。

Disposeはタイマーの動作を停止し、内部リソースを解放します。

Dispose後にタイマーは再利用できません。

以下はDisposeの使い方例です。

using System;
using System.Threading;
class Program
{
    static void TimerMethod(object state)
    {
        Console.WriteLine("タイマーコールバック");
    }
    static void Main()
    {
        Timer timer = new Timer(TimerMethod, null, 0, 1000);
        Thread.Sleep(3000);
        timer.Dispose();  // タイマー停止とリソース解放
        Console.WriteLine("タイマーを破棄しました。");
    }
}

スレッドプールとの関係

System.Threading.Timerのコールバックはスレッドプールのスレッド上で実行されます。

つまり、タイマー処理は専用スレッドではなく、共有されるスレッドプールのスレッドを利用します。

このため、以下の点に注意が必要です。

  • コールバック処理はできるだけ短時間で終わらせること。長時間の処理はスレッドプールの枯渇を招き、他の非同期処理に影響を与えます
  • コールバックが重複して呼ばれる可能性があるため、スレッドセーフな実装が必要です。特に処理が遅い場合は、前回の処理が終わる前に次のコールバックが呼ばれることがあります
  • UIスレッドでの処理が必要な場合は、SynchronizationContextDispatcherを使ってスレッドを切り替える必要があります

以下はスレッドプール上で動作することを示す例です。

using System;
using System.Threading;
class Program
{
    static void TimerMethod(object state)
    {
        Console.WriteLine($"スレッドID: {Thread.CurrentThread.ManagedThreadId} - {DateTime.Now:HH:mm:ss.fff}");
    }
    static void Main()
    {
        Timer timer = new Timer(TimerMethod, null, 0, 1000);
        Thread.Sleep(3500);
        timer.Dispose();
    }
}

ドリフトを抑える再設定テクニック

System.Threading.Timerは指定したperiodで繰り返しコールバックを呼びますが、処理時間やスレッドスケジューリングの影響で呼び出し間隔にズレ(ドリフト)が生じることがあります。

長時間の繰り返しではこのズレが累積し、タイミングがずれてしまうことがあります。

ドリフトを抑えるためには、コールバック内で次の呼び出し時刻を計算し、Changeメソッドでタイマーを再設定する方法が有効です。

これにより、理想的な周期に近いタイミングで処理を実行できます。

以下はドリフト抑制の例です。

using System;
using System.Threading;
class Program
{
    static Timer timer;
    static DateTime nextTick;
    static void TimerMethod(object state)
    {
        Console.WriteLine($"実行時刻: {DateTime.Now:HH:mm:ss.fff}");
        // 次の実行時刻を計算(1000ミリ秒後)
        nextTick = nextTick.AddMilliseconds(1000);
        // 現在時刻との差を計算し、遅延時間を調整
        int delay = (int)(nextTick - DateTime.Now).TotalMilliseconds;
        if (delay < 0) delay = 0;
        // タイマーを再設定
        timer.Change(delay, Timeout.Infinite);
    }
    static void Main()
    {
        nextTick = DateTime.Now.AddMilliseconds(1000);
        timer = new Timer(TimerMethod, null, 1000, Timeout.Infinite);
        Thread.Sleep(5500);
        timer.Dispose();
    }
}

この例では、毎回次の実行時刻を計算し、タイマーを単発モードTimeout.Infiniteで再設定しています。

これにより、処理時間の遅延やスケジューリングの影響を補正し、ドリフトを最小限に抑えられます。

UI向けタイマーコンポーネント

Windows Forms Timer

Windows Formsアプリケーションで使われるSystem.Windows.Forms.Timerは、UIスレッド上で動作するタイマーです。

UIの更新や定期的な処理を簡単に実装できます。

IntervalとTickイベント

Timerの動作間隔はIntervalプロパティでミリ秒単位に設定します。

例えば、Interval = 1000とすると1秒ごとにイベントが発生します。

TickイベントはIntervalの間隔で発生し、ここに処理を記述します。

TickイベントはUIスレッドで実行されるため、UIの直接操作が可能です。

以下は基本的な使い方の例です。

using System;
using System.Windows.Forms;
class Program : Form
{
    private Timer timer;
    private int count = 0;
    public Program()
    {
        timer = new Timer();
        timer.Interval = 1000;  // 1秒ごと
        timer.Tick += Timer_Tick;
        timer.Start();
    }
    private void Timer_Tick(object sender, EventArgs e)
    {
        count++;
        this.Text = $"カウント: {count}";
    }
    [STAThread]
    static void Main()
    {
        Application.EnableVisualStyles();
        Application.Run(new Program());
    }
}

この例では、1秒ごとにTickイベントが発生し、フォームのタイトルバーにカウントが表示されます。

フリーズ回避のTips

System.Windows.Forms.TimerはUIスレッドで動作するため、Tickイベント内で重い処理を行うとUIがフリーズします。

フリーズを防ぐためのポイントは以下の通りです。

  • Tickイベント内で長時間処理をしない。重い処理は別スレッドで実行します
  • 処理が重い場合はTimerを一時停止し、処理完了後に再開します
  • 非同期プログラミングasync/awaitを活用し、UIスレッドをブロックしない

以下はTick内で非同期処理を呼び出す例です。

private async void Timer_Tick(object sender, EventArgs e)
{
    timer.Stop();
    await Task.Run(() =>
    {
        // 重い処理のシミュレーション
        System.Threading.Thread.Sleep(2000);
    });
    timer.Start();
}

このようにすることで、UIの応答性を保ちながら定期処理が可能です。

WPF DispatcherTimer

WPFアプリケーションではSystem.Windows.Threading.DispatcherTimerが使われます。

こちらもUIスレッドのDispatcherに紐づいて動作し、UIの更新に適しています。

DispatcherPriorityの選択

DispatcherTimerDispatcherの優先度を指定できます。

DispatcherPriorityはタイマーイベントの実行タイミングを制御し、UIの描画や入力処理とのバランスを調整します。

主な優先度は以下の通りです。

優先度説明
DispatcherPriority.Normal通常の優先度。UIイベントと同等の扱い。
DispatcherPriority.Render描画直前に実行。描画処理の直前に処理したい場合に使用。
DispatcherPriority.BackgroundUIの処理が終わった後に実行。負荷を抑えたい場合に適切。

以下はDispatcherTimerの基本例です。

using System;
using System.Windows;
using System.Windows.Threading;
class Program : Application
{
    private DispatcherTimer timer;
    private int count = 0;
    protected override void OnStartup(StartupEventArgs e)
    {
        base.OnStartup(e);
        timer = new DispatcherTimer(DispatcherPriority.Normal);
        timer.Interval = TimeSpan.FromSeconds(1);
        timer.Tick += Timer_Tick;
        timer.Start();
        MainWindow = new Window();
        MainWindow.Show();
    }
    private void Timer_Tick(object sender, EventArgs e)
    {
        count++;
        MainWindow.Title = $"カウント: {count}";
    }
    [STAThread]
    static void Main()
    {
        new Program().Run();
    }
}

DispatcherPriorityを適切に設定することで、UIのパフォーマンスや応答性を調整できます。

UWP DispatcherTimerの特徴

UWP(Universal Windows Platform)でもDispatcherTimerが利用されますが、WPF版と似ているものの、いくつか特徴があります。

  • UWPのDispatcherTimerWindows.UI.Xaml名前空間にあり、UIスレッドのCoreDispatcherに紐づいています
  • イベントはUIスレッドで発生し、UIの更新に適しています
  • IntervalTimeSpanで指定し、Tickイベントで処理を行います
  • UWPのDispatcherTimerは省電力を考慮しており、バックグラウンドに回るとタイマーの精度が落ちることがあります

以下はUWPでの基本的な使い方例です。

using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
public sealed partial class MainPage : Page
{
    private DispatcherTimer timer;
    private int count = 0;
    public MainPage()
    {
        this.InitializeComponent();
        timer = new DispatcherTimer();
        timer.Interval = TimeSpan.FromSeconds(1);
        timer.Tick += Timer_Tick;
        timer.Start();
    }
    private void Timer_Tick(object sender, object e)
    {
        count++;
        this.TitleTextBlock.Text = $"カウント: {count}";
    }
}

UWPアプリでは、アプリのライフサイクルに応じてタイマーの開始・停止を適切に管理することが重要です。

バックグラウンド状態ではタイマーの動作が制限されるため、必要に応じてCoreDispatcherRunAsyncなどを使って処理を調整します。

高精度タイマーの選定基準

精度と消費電力のトレードオフ

高精度タイマーを選ぶ際には、計測の精度と消費電力のバランスを考慮する必要があります。

一般に、より高い精度を求めるとCPUやハードウェアの負荷が増え、結果として消費電力が上がる傾向にあります。

例えば、Stopwatchのような高精度タイマーはCPUの高周波カウンターを利用しており、頻繁にタイマーをポーリングしたり割り込みを発生させるため、消費電力が増加します。

一方、System.Threading.TimerやUI向けのタイマーは精度がやや低い代わりに、CPUのスリープ状態を維持しやすく省電力です。

モバイルデバイスやバッテリー駆動の環境では、タイマーの精度を必要最低限に抑え、消費電力を優先する設計が求められます。

逆に、リアルタイム処理や高精度なパフォーマンス計測が必要な場合は、多少の消費電力増加を許容して高精度タイマーを選択します。

以下のポイントを参考にしてください。

  • 高精度タイマーStopwatch、高解像度パフォーマンスカウンター。精度は数マイクロ秒単位だが消費電力は高め
  • 中精度タイマーSystem.Threading.Timer。ミリ秒単位の精度で消費電力は中程度
  • 低精度タイマー:UIタイマーWindows.Forms.TimerDispatcherTimer。精度は数十ミリ秒単位で消費電力は低いでしょう

プラットフォーム依存の最小周期

タイマーの最小周期(最短で設定可能な間隔)はプラットフォームやOSの実装に依存します。

Windows、Linux、macOS、モバイルOSなどで異なり、同じコードでも実際のタイマー精度や最小間隔が変わることがあります。

Windowsでは、System.Threading.Timerの最小周期は約15.6ミリ秒(既定のシステムタイマー解像度)ですが、システム設定やAPI呼び出しで解像度を上げることも可能です。

Stopwatchは高精度カウンターを利用するため、ナノ秒単位の計測も理論上可能です。

Linuxではtimerfdclock_nanosleepなどのAPIが使われ、ミリ秒以下の精度もサポートされますが、スケジューラの影響で実際の精度は変動します。

モバイルOSでは省電力のためにタイマー解像度が粗く設定されていることが多く、数十ミリ秒単位が最小周期となる場合があります。

このため、クロスプラットフォーム開発では、最小周期や精度の違いを考慮し、必要に応じてプラットフォームごとにタイマーの選択や設定を調整することが重要です。

システムクロック補正の影響

システムクロックはNTP(Network Time Protocol)などの時刻同期サービスによって定期的に補正されます。

この補正はシステム時刻を正確に保つために必要ですが、タイマーの計測結果に影響を与えることがあります。

DateTime.NowDateTime.UtcNowはシステムクロックに依存しているため、補正が行われると時刻がジャンプしたり逆戻りすることがあります。

これにより、ミリ秒単位の連続した計測や時間差の計算に誤差が生じる可能性があります。

一方、Stopwatchはシステムクロックではなく、高精度パフォーマンスカウンターを利用しているため、NTP補正の影響を受けません。

これにより、連続した経過時間の計測に安定した精度を提供します。

システムクロック補正の影響を避けたい場合は、Stopwatchやハードウェアタイマーを使うことが推奨されます。

逆に、絶対的な現在時刻を扱う場合はシステムクロックを使い、補正による変動を考慮した設計が必要です。

まとめると、タイマー選定時にはシステムクロックの補正が計測に与える影響を理解し、用途に応じて適切なタイマーを選ぶことが重要です。

非同期プログラミングとミリ秒待機

Task.Delayの使い方

非同期プログラミングにおいて、一定時間の待機を行う際に最も一般的に使われるのがTask.Delayメソッドです。

Task.Delayは指定したミリ秒数だけ非同期に待機し、その間スレッドをブロックしません。

これによりUIスレッドやスレッドプールのスレッドを効率的に利用できます。

Task.Delayasyncメソッド内でawaitと組み合わせて使うことが多いです。

以下は1秒(1000ミリ秒)待機する例です。

using System;
using System.Threading.Tasks;
class Program
{
    static async Task Main()
    {
        Console.WriteLine("待機開始");
        await Task.Delay(1000);  // 1000ミリ秒(1秒)非同期待機
        Console.WriteLine("待機終了");
    }
}
待機開始
待機終了

このコードは1秒間待機した後に「待機終了」と表示しますが、待機中はスレッドを占有しないため、他の処理が並行して実行可能です。

UIアプリケーションで使うと、UIのフリーズを防ぎつつ遅延処理ができます。

Task.Delayはキャンセル可能なオーバーロードもあり、CancellationTokenを渡すことで待機を途中でキャンセルできます。

WaitHandle.WaitOneとの比較

WaitHandle.WaitOneはスレッドをブロックして待機する同期的な方法です。

ManualResetEventAutoResetEventなどのWaitHandle派生クラスで使われ、指定した時間だけスレッドを停止させます。

以下はWaitOneで1000ミリ秒待機する例です。

using System;
using System.Threading;
class Program
{
    static void Main()
    {
        using (ManualResetEvent mre = new ManualResetEvent(false))
        {
            Console.WriteLine("待機開始");
            mre.WaitOne(1000);  // 1000ミリ秒間スレッドをブロック
            Console.WriteLine("待機終了");
        }
    }
}
待機開始
待機終了

WaitOneはスレッドを完全にブロックするため、UIスレッドやスレッドプールのスレッドで使うとアプリケーションの応答性が低下します。

対してTask.Delayは非同期で待機し、スレッドを解放するため、非同期プログラミングに適しています。

まとめると、

特徴Task.DelayWaitHandle.WaitOne
待機方法非同期(スレッド非ブロック)同期(スレッドブロック)
使用環境非同期メソッド、UIスレッド同期処理、スレッド制御
キャンセル対応ありCancellationTokenなし(別途制御が必要)
応答性への影響低い高い(フリーズの原因になる)

CancellationTokenでキャンセル可能にする

Task.DelayCancellationTokenを受け取るオーバーロードがあり、待機中にキャンセル要求があった場合は例外をスローして待機を中断できます。

これにより、柔軟に待機処理をキャンセル可能にできます。

以下はキャンセル可能なTask.Delayの例です。

using System;
using System.Threading;
using System.Threading.Tasks;
class Program
{
    static async Task Main()
    {
        using CancellationTokenSource cts = new CancellationTokenSource();
        Task delayTask = Task.Delay(5000, cts.Token);  // 5秒待機(キャンセル可能)
        // 2秒後にキャンセルを要求
        Task cancelTask = Task.Run(async () =>
        {
            await Task.Delay(2000);
            cts.Cancel();
            Console.WriteLine("キャンセル要求を送信");
        });
        try
        {
            Console.WriteLine("待機開始");
            await delayTask;
            Console.WriteLine("待機終了");
        }
        catch (TaskCanceledException)
        {
            Console.WriteLine("待機がキャンセルされました");
        }
    }
}
待機開始
キャンセル要求を送信
待機がキャンセルされました

この例では、5秒の待機を開始した後、2秒でキャンセルを要求しています。

Task.DelayはキャンセルされるとTaskCanceledExceptionをスローし、catchブロックで処理できます。

CancellationTokenを使うことで、ユーザー操作や外部イベントに応じて待機を中断できるため、応答性の高い非同期処理が実現できます。

マルチスレッド環境での時間同期

lockとタイムスタンプの競合回避

マルチスレッド環境で複数のスレッドが同じタイムスタンプや時間関連の共有リソースを扱う場合、競合状態が発生しやすくなります。

例えば、複数スレッドが同時に時刻を更新したり、時間差を計算して共有変数に書き込むと、データの不整合や予期しない動作が起こる可能性があります。

このような競合を防ぐために、C#ではlock文を使って排他制御を行います。

lockは指定したオブジェクトをロックし、同時に複数スレッドがそのコードブロックに入ることを防ぎます。

以下はタイムスタンプの更新処理をlockで保護する例です。

using System;
using System.Threading;
class Program
{
    private static readonly object syncObj = new object();
    private static DateTime lastTimestamp = DateTime.MinValue;
    static void UpdateTimestamp()
    {
        lock (syncObj)
        {
            DateTime now = DateTime.Now;
            if (now > lastTimestamp)
            {
                lastTimestamp = now;
                Console.WriteLine($"タイムスタンプ更新: {lastTimestamp:HH:mm:ss.fff}");
            }
            else
            {
                Console.WriteLine("古いタイムスタンプのため更新しません");
            }
        }
    }
    static void Main()
    {
        Thread t1 = new Thread(UpdateTimestamp);
        Thread t2 = new Thread(UpdateTimestamp);
        t1.Start();
        t2.Start();
        t1.Join();
        t2.Join();
    }
}
タイムスタンプ更新: 14:30:15.123
古いタイムスタンプのため更新しません

この例では、syncObjをロックすることで、同時に複数スレッドがlastTimestampを更新することを防いでいます。

lockを使わないと、両スレッドが同時に読み書きして不整合が起きる恐れがあります。

Thread.Sleepの誤解と注意点

Thread.Sleepは指定した時間だけスレッドを停止させるメソッドですが、マルチスレッド環境での使い方には注意が必要です。

  • Thread.Sleepはスレッドをブロックするため、UIスレッドやスレッドプールのスレッドで使うとアプリケーションの応答性が低下します
  • 指定した時間は「最低限の待機時間」であり、実際のスリープ時間はOSのスケジューラや負荷状況により長くなることがあります。つまり、正確なミリ秒単位の待機は保証されません
  • Thread.Sleep(0)は現在のスレッドの実行権を放棄し、他のスレッドにCPUを譲るために使われますが、必ずしもすぐに再スケジュールされるわけではありません

以下はThread.Sleepの誤解を示す例です。

using System;
using System.Diagnostics;
using System.Threading;
class Program
{
    static void Main()
    {
        Stopwatch sw = Stopwatch.StartNew();
        Thread.Sleep(10);  // 10ミリ秒スリープ
        sw.Stop();
        Console.WriteLine($"実際のスリープ時間: {sw.ElapsedMilliseconds} ms");
    }
}
実際のスリープ時間: 21 ms

このように、指定した10ミリ秒より長くスリープすることが多いです。

正確な時間制御が必要な場合はThread.Sleepは適していません。

ConcurrentQueueとタイムアウト処理

マルチスレッド環境でスレッド間通信やデータ共有に使われるConcurrentQueue<T>は、スレッドセーフなキュー実装です。

複数スレッドから同時に安全にエンキュー・デキューが可能です。

ただし、ConcurrentQueue自体には待機やタイムアウト機能がありません。

データが空の場合に待機したい場合は、SemaphoreSlimManualResetEventSlimなどの同期オブジェクトと組み合わせて使う必要があります。

以下はConcurrentQueueSemaphoreSlimを使い、データが入るまで最大指定時間待機する例です。

using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
class Program
{
    private static ConcurrentQueue<int> queue = new ConcurrentQueue<int>();
    private static SemaphoreSlim semaphore = new SemaphoreSlim(0);
    static async Task Producer()
    {
        for (int i = 0; i < 5; i++)
        {
            queue.Enqueue(i);
            semaphore.Release();
            Console.WriteLine($"Enqueued: {i}");
            await Task.Delay(500);
        }
    }
    static async Task Consumer()
    {
        while (true)
        {
            // 最大1000ミリ秒待機してデータがなければタイムアウト
            if (await semaphore.WaitAsync(1000))
            {
                if (queue.TryDequeue(out int item))
                {
                    Console.WriteLine($"Dequeued: {item}");
                }
            }
            else
            {
                Console.WriteLine("タイムアウト: データなし");
                break;
            }
        }
    }
    static async Task Main()
    {
        Task producer = Producer();
        Task consumer = Consumer();
        await Task.WhenAll(producer, consumer);
    }
}
Enqueued: 0
Dequeued: 0
Enqueued: 1
Dequeued: 1
Enqueued: 2
Dequeued: 2
Enqueued: 3
Dequeued: 3
Enqueued: 4
Dequeued: 4
タイムアウト: データなし

この例では、Producerがデータをキューに追加し、ConsumerSemaphoreSlimで待機してデータが来るまで最大1秒待ちます。

データがなければタイムアウトして処理を終了します。

このようにConcurrentQueue単体では待機やタイムアウトができないため、別の同期機構と組み合わせて時間制御を行うことが重要です。

ミリ秒を含む日時の文字列フォーマット

カスタム書式指定子fffの利用

C#のDateTimeDateTimeOffsetで日時を文字列に変換する際、ミリ秒を含めたい場合はカスタム書式指定子のfffを使います。

fffはミリ秒を3桁の数字で表現し、ゼロ埋めされます。

例えば、ミリ秒が5の場合は005と表示されます。

基本的な使い方はToStringメソッドにフォーマット文字列を渡す方法です。

以下の例では、時刻を「時:分:秒.ミリ秒」の形式で表示しています。

using System;
class Program
{
    static void Main()
    {
        DateTime now = DateTime.Now;
        string formatted = now.ToString("HH:mm:ss.fff");
        Console.WriteLine($"現在時刻(ミリ秒付き): {formatted}");
    }
}
現在時刻(ミリ秒付き): 10:25:15.156

fffはミリ秒を3桁で表示しますが、fffを使うとそれぞれ2桁、1桁のミリ秒表示になります。

ただし、一般的には3桁のfffが使われることが多いです。

また、日時全体をフォーマットする場合もfffを組み込むことでミリ秒を含められます。

DateTime now = DateTime.Now;
string formatted = now.ToString("yyyy-MM-dd HH:mm:ss.fff");
Console.WriteLine(formatted);
2025-05-07 10:25:19.182

ISO 8601形式への変換

ISO 8601は日時の国際標準フォーマットで、ミリ秒を含む場合は小数点以下に3桁のミリ秒を付加します。

C#ではDateTimeDateTimeOffsetToString("o")またはToString("O")でISO 8601形式の文字列を簡単に取得できます。

このフォーマットは以下のような形式です。

2024-06-15T14:45:30.1230000+09:00

ミリ秒は小数点以下7桁(100ナノ秒単位)まで表示されます。

以下はサンプルコードです。

using System;
class Program
{
    static void Main()
    {
        DateTimeOffset now = DateTimeOffset.Now;
        string iso8601 = now.ToString("o");
        Console.WriteLine($"ISO 8601形式: {iso8601}");
    }
}
ISO 8601形式: 2025-05-07T10:25:26.7947294+09:00

DateTimeの場合も同様にToString("o")でISO 8601形式に変換できますが、DateTimeOffsetはタイムゾーンオフセットを含むため、より正確な時刻情報を表現できます。

ISO 8601形式はシステム間の日時データ交換やログ記録に広く使われており、ミリ秒単位の精度を保持したまま日時を表現できるため便利です。

CultureInfoによる表記差異

日時の文字列フォーマットはCultureInfoによって表記が変わることがあります。

特に日付や時刻の区切り文字、曜日の表記、24時間制か12時間制かなどが影響を受けますが、ミリ秒部分の表記は通常fffの指定に従い固定的です。

以下の例では、CultureInfoを指定して日時をフォーマットし、文化圏による違いを確認します。

using System;
using System.Globalization;
class Program
{
    static void Main()
    {
        DateTime now = DateTime.Now;
        string jp = now.ToString("yyyy/MM/dd HH:mm:ss.fff", new CultureInfo("ja-JP"));
        string us = now.ToString("MM/dd/yyyy hh:mm:ss.fff tt", new CultureInfo("en-US"));
        string fr = now.ToString("dd.MM.yyyy HH:mm:ss.fff", new CultureInfo("fr-FR"));
        Console.WriteLine($"日本 (ja-JP): {jp}");
        Console.WriteLine($"アメリカ (en-US): {us}");
        Console.WriteLine($"フランス (fr-FR): {fr}");
    }
}
日本 (ja-JP): 2025/05/07 10:25:05.733
アメリカ (en-US): 05/07/2025 10:25:05.733 AM
フランス (fr-FR): 07.05.2025 10:25:05.733

この例では、日付の区切り文字や時刻の12時間制・24時間制の違いが見られますが、ミリ秒の部分はどの文化圏でも3桁の数字で一貫して表示されています。

なお、CultureInfoを指定しない場合はシステムのロケール設定に依存するため、異なる環境で実行すると表示が変わる可能性があります。

ミリ秒を含む日時を一貫して扱いたい場合は、明示的にフォーマット文字列とCultureInfoを指定することが望ましいです。

ロギングでのミリ秒精度保持

Serilogの出力フォーマット設定

Serilogは柔軟なログフォーマット設定が可能な.NET向けのロギングライブラリです。

ミリ秒単位のタイムスタンプをログに含めるには、出力テンプレートに{Timestamp:HH:mm:ss.fff}のようにカスタム書式を指定します。

以下はコンソールにミリ秒付きのタイムスタンプを出力する基本的な設定例です。

using System;
using Serilog;
class Program
{
    static void Main()
    {
        Log.Logger = new LoggerConfiguration()
            .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss.fff}] [{Level}] {Message}{NewLine}{Exception}")
            .CreateLogger();
        Log.Information("ミリ秒付きログのテスト");
        Log.CloseAndFlush();
    }
}
[14:50:30.123] [Information] ミリ秒付きログのテスト

この例では、TimestampのフォーマットにHH:mm:ss.fffを指定し、時刻の秒以下3桁のミリ秒を表示しています。

outputTemplateはログの見た目を自由にカスタマイズできるため、必要に応じて日付やタイムゾーン情報も追加可能です。

ファイル出力の場合も同様にoutputTemplateを指定してミリ秒を含めることができます。

Log.Logger = new LoggerConfiguration()
    .WriteTo.File("log.txt", outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff}] [{Level}] {Message}{NewLine}{Exception}")
    .CreateLogger();

NLogとLog4Netの設定例

NLogとLog4Netも.NETで広く使われるロギングフレームワークで、ミリ秒精度のタイムスタンプをログに含める設定が可能です。

NLogの設定例

NLogではnlog.configファイルやコードでレイアウトを指定します。

ミリ秒を含めるには${longdate}${date:format=HH\:mm\:ss.fff}を使います。

nlog.configの例:

<nlog>
  <targets>
    <target xsi:type="File" name="file" fileName="log.txt"
            layout="${longdate} [${level}] ${message}" />
  </targets>
  <rules>
    <logger name="*" minlevel="Info" writeTo="file" />
  </rules>
</nlog>

${longdate}yyyy-MM-dd HH:mm:ss.fff形式でミリ秒を含みます。

コードで設定する場合:

var config = new NLog.Config.LoggingConfiguration();
var logfile = new NLog.Targets.FileTarget("logfile")
{
    FileName = "log.txt",
    Layout = "${longdate} [${level}] ${message}"
};
config.AddRule(NLog.LogLevel.Info, NLog.LogLevel.Fatal, logfile);
NLog.LogManager.Configuration = config;

Log4Netの設定例

Log4Netではlog4net.configConversionPattern%date{HH:mm:ss,fff}を指定してミリ秒を含めます。

<appender name="FileAppender" type="log4net.Appender.FileAppender">
  <file value="log.txt" />
  <appendToFile value="true" />
  <layout type="log4net.Layout.PatternLayout">
    <conversionPattern value="%date{yyyy-MM-dd HH:mm:ss,fff} [%level] %message%newline" />
  </layout>
</appender>

この設定でログファイルにミリ秒付きのタイムスタンプが記録されます。

ファイルローテーションとタイムスタンプ

ログファイルのローテーション(分割)は、ファイルサイズや日時で行うことが多いですが、ミリ秒精度のタイムスタンプを保持しつつ管理するには注意が必要です。

  • 日時ベースのローテーションでは、ファイル名に日付や時刻を含めることが一般的です。ミリ秒単位まで含めることは稀ですが、必要に応じてyyyyMMdd_HHmmssfffのようにフォーマット可能です。ただし、ファイル名が長くなりすぎるため注意が必要です
  • サイズベースのローテーションでは、ファイルサイズが一定を超えたら新しいファイルに切り替えます。この場合、タイムスタンプはログの中身に含まれるため、ファイル名にミリ秒を含める必要はありません

Serilogのファイルローテーション例(RollingFileターゲット):

Log.Logger = new LoggerConfiguration()
    .WriteTo.File(
        path: "log-.txt",
        rollingInterval: RollingInterval.Day,
        outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff}] [{Level}] {Message}{NewLine}{Exception}")
    .CreateLogger();

この設定では、日単位でファイルがローテーションされ、ログの各行にミリ秒付きタイムスタンプが記録されます。

NLogやLog4Netでも同様にローテーション機能があり、設定ファイルで日時やサイズに応じたローテーションを指定できます。

ログの中身にミリ秒を含めることで、ファイル分割後も高精度な時刻情報を保持できます。

まとめると、ミリ秒精度のタイムスタンプをログに含めるには、各ロギングフレームワークの出力フォーマット設定で適切な書式を指定し、ファイルローテーションはファイル名の管理とログ内容の両方で考慮することが重要です。

単体テストでの時間依存コードの検証

FakeTimerパターン

時間に依存するコードの単体テストは、実際の時間経過に依存するとテストが遅くなったり再現性が低くなったりします。

これを解決するために「FakeTimerパターン」がよく使われます。

FakeTimerパターンとは、時間を取得する部分を抽象化し、テスト時に任意の時間を返す偽のタイマー(FakeTimer)を注入する方法です。

まず、時間取得をインターフェースで抽象化します。

public interface ITimeProvider
{
    DateTime Now { get; }
}

本番コードではシステム時刻を返す実装を用意します。

public class SystemTimeProvider : ITimeProvider
{
    public DateTime Now => DateTime.Now;
}

テストコードでは任意の時刻を返すFakeTimerを実装します。

public class FakeTimeProvider : ITimeProvider
{
    private DateTime _fakeNow;
    public FakeTimeProvider(DateTime initialTime)
    {
        _fakeNow = initialTime;
    }
    public DateTime Now => _fakeNow;
    public void Advance(TimeSpan timeSpan)
    {
        _fakeNow = _fakeNow.Add(timeSpan);
    }
}

これにより、テスト中に時間を自由に操作でき、時間依存のロジックを高速かつ再現性高く検証できます。

Stopwatchのモック化手法

Stopwatchは経過時間を計測するためのクラスですが、直接モック化が難しいため、テスト可能な設計にするにはラップ(Wrapper)やインターフェース化が有効です。

例えば、IStopwatchインターフェースを定義します。

public interface IStopwatch
{
    void Start();
    void Stop();
    void Reset();
    TimeSpan Elapsed { get; }
}

実装クラスでStopwatchをラップします。

public class RealStopwatch : IStopwatch
{
    private Stopwatch _stopwatch = new Stopwatch();
    public void Start() => _stopwatch.Start();
    public void Stop() => _stopwatch.Stop();
    public void Reset() => _stopwatch.Reset();
    public TimeSpan Elapsed => _stopwatch.Elapsed;
}

テスト時はFakeStopwatchを用意し、任意の経過時間を返せるようにします。

public class FakeStopwatch : IStopwatch
{
    private TimeSpan _elapsed;
    public void Start() { /* 何もしない */ }
    public void Stop() { /* 何もしない */ }
    public void Reset() => _elapsed = TimeSpan.Zero;
    public TimeSpan Elapsed => _elapsed;
    public void SetElapsed(TimeSpan elapsed)
    {
        _elapsed = elapsed;
    }
}

これにより、テストコードでFakeStopwatchSetElapsedを使って任意の経過時間を設定し、時間依存の処理を検証できます。

時間固定によるテスト再現性向上

時間依存コードのテストでは、実行時の現在時刻や経過時間が変動するとテスト結果が不安定になります。

これを防ぐために、テスト中は時間を固定し、常に同じ時刻や経過時間を返すようにします。

FakeTimerやFakeStopwatchを使い、テスト開始時に固定の日時や経過時間をセットします。

こうすることで、テストの再現性が向上し、失敗時の原因解析が容易になります。

例えば、以下のように固定時刻を使ったテストが可能です。

var fixedTime = new DateTime(2024, 6, 15, 12, 0, 0);
var fakeTimeProvider = new FakeTimeProvider(fixedTime);
var service = new SomeService(fakeTimeProvider);
var result = service.DoSomething();
Assert.AreEqual(expectedValue, result);

また、FakeStopwatchで固定の経過時間を設定して処理を検証することもできます。

このように時間を固定することで、外部環境や実行タイミングに依存しない安定した単体テストが実現します。

パフォーマンスチューニング

JIT最適化とインライン展開

C#の実行時にはJIT(Just-In-Time)コンパイラがコードをネイティブコードに変換します。

JITはパフォーマンス向上のために様々な最適化を行い、その中でも「インライン展開」は重要な技術です。

インライン展開とは、メソッド呼び出しを展開して呼び出しオーバーヘッドを削減し、CPUの命令キャッシュ効率を高める手法です。

ミリ秒単位の時間管理やタイムスタンプ生成のコードでは、頻繁に呼ばれる小さなメソッドが多いため、JITのインライン展開が効果的に働くとパフォーマンスが向上します。

例えば、DateTime.Nowの取得やStopwatch.ElapsedMillisecondsの取得は非常に短いメソッドであり、JITはこれらをインライン化して高速化します。

ただし、インライン展開はメソッドのサイズや複雑さに依存し、大きなメソッドや複雑なロジックはインライン化されにくいです。

パフォーマンスを意識する場合は、頻繁に呼ばれる小さな処理をメソッドに分割しすぎず、適切に設計することが重要です。

また、MethodImplOptions.AggressiveInlining属性を付与することで、JITにインライン展開を促すことも可能です。

using System.Runtime.CompilerServices;
public class TimerUtils
{
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static long GetCurrentMilliseconds()
    {
        return DateTime.Now.Ticks / TimeSpan.TicksPerMillisecond;
    }
}

このように小さなメソッドに対してインライン展開を促すことで、呼び出しコストを減らし、ミリ秒単位の時間取得を高速化できます。

Span<T>でのタイムスタンプ生成

Span<T>はC# 7.2以降で導入された軽量なメモリスライス型で、配列や文字列の一部を効率的に扱えます。

タイムスタンプの文字列生成やフォーマット処理でSpan<char>を活用すると、ヒープ割り当てを減らし、GC負荷を抑えられます。

例えば、DateTimeのミリ秒を含む文字列を生成する際、string.FormatToStringを多用すると文字列の割り当てが多発しますが、Span<char>を使うとスタック上のバッファに直接書き込めるため高速です。

以下はSpan<char>を使ってDateTimeの時刻をHH:mm:ss.fff形式で生成する例です。

using System;
class Program
{
    static void Main()
    {
        DateTime now = DateTime.Now;
        Span<char> buffer = stackalloc char[12]; // HH:mm:ss.fff は12文字
        bool success = now.TryFormat(buffer, out int charsWritten, "HH:mm:ss.fff");
        if (success)
        {
            string result = new string(buffer.Slice(0, charsWritten));
            Console.WriteLine($"現在時刻: {result}");
        }
    }
}
現在時刻: 10:25:50.949

この方法は文字列の中間生成を減らし、パフォーマンスを向上させるため、ログ出力や高頻度のタイムスタンプ生成に適しています。

不要なDateTime構築の削減

DateTime構造体は値型で軽量ですが、頻繁に新しいインスタンスを生成するとパフォーマンスに影響を与えることがあります。

特にループ内や高頻度処理でDateTime.NowDateTime.UtcNowを何度も呼び出す場合、システムコールが多発し、オーバーヘッドが増えます。

パフォーマンスチューニングの一環として、不要なDateTimeの構築を減らす工夫が重要です。

例えば、同じ処理内で複数回現在時刻を使う場合は、一度取得して変数に保持し使い回す方法があります。

DateTime now = DateTime.Now;
for (int i = 0; i < 1000; i++)
{
    // nowを使って処理
    Console.WriteLine(now.ToString("HH:mm:ss.fff"));
}

また、Stopwatchを使って経過時間を計測し、DateTimeの呼び出し回数を減らす設計も有効です。

さらに、DateTimeの生成を伴う文字列フォーマットも最小限に抑え、必要な場合のみ行うようにします。

例えば、ログ出力で大量の日時文字列を生成する場合は、Span<char>やキャッシュを活用して文字列割り当てを減らすことが推奨されます。

これらの工夫により、ミリ秒単位の時間管理を行う際のパフォーマンスを向上させ、アプリケーション全体の効率化につなげられます。

ベンチマークで精度を測定

BenchmarkDotNetの基本設定

BenchmarkDotNetは.NET向けの高精度ベンチマークフレームワークで、コードの実行時間やパフォーマンスを正確に測定できます。

ミリ秒単位の時間管理や処理の精度を評価する際に非常に有用です。

基本的な使い方は、ベンチマーク対象のメソッドに[Benchmark]属性を付け、ベンチマーククラスを作成します。

BenchmarkRunner.Run<T>()で実行すると詳細な結果が得られます。

以下は簡単なサンプルです。

using System;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Threading;
public class TimerBenchmark
{
    [Benchmark]
    public void Sleep100Milliseconds()
    {
        Thread.Sleep(100);
    }
}
class Program
{
    static void Main()
    {
        var summary = BenchmarkRunner.Run<TimerBenchmark>();
    }
}

このコードを実行すると、Sleep100Millisecondsメソッドの実行時間が詳細にレポートされます。

BenchmarkDotNetはウォームアップ、複数回の繰り返し実行、JIT最適化の影響排除などを自動で行い、信頼性の高い測定結果を提供します。

MinIterationTimeと外れ値処理

ベンチマークの精度を高めるために、BenchmarkDotNetではMinIterationTimeという設定が利用できます。

これは1回のイテレーション(繰り返し実行)にかける最小時間を指定し、短すぎる処理の誤差を減らすためのものです。

例えば、非常に高速な処理を測定する場合、1回の実行時間が数ナノ秒程度だとノイズや外れ値の影響が大きくなります。

MinIterationTimeを設定して、1イテレーションあたりの実行時間を数ミリ秒以上にすることで、より安定した結果が得られます。

設定例:

[SimpleJob(iterationTimeMilliseconds: 100)]
public class TimerBenchmark
{
    [Benchmark]
    public void Sleep100Milliseconds()
    {
        Thread.Sleep(100);
    }
}

また、BenchmarkDotNetは外れ値(アウトライヤー)を自動的に検出し、統計処理から除外します。

これにより、突発的な遅延やシステム負荷による異常値の影響を抑え、平均値や中央値の信頼性を高めています。

メモリ計測とオーバーヘッド考慮

BenchmarkDotNetは実行時間だけでなく、メモリ使用量も計測可能です。

[MemoryDiagnoser]属性をベンチマーククラスに付けると、割り当てられたヒープサイズやGCの発生回数などがレポートに含まれます。

[MemoryDiagnoser]
public class TimerBenchmark
{
    [Benchmark]
    public void Sleep100Milliseconds()
    {
        Thread.Sleep(100);
    }
}

メモリ計測は、ミリ秒単位の時間計測においても重要です。

例えば、頻繁にDateTimeや文字列を生成する処理はメモリ割り当てが多くなり、GCが発生するとパフォーマンスに影響を与えます。

また、ベンチマーク自体のオーバーヘッドも考慮する必要があります。

BenchmarkDotNetはオーバーヘッドを最小化する設計ですが、非常に短い処理を測定する場合はオーバーヘッドが相対的に大きくなります。

そのため、処理時間が極端に短い場合は複数回の繰り返しやMinIterationTimeの設定でオーバーヘッドの影響を減らす工夫が必要です。

まとめると、BenchmarkDotNetを使う際は実行時間だけでなくメモリ使用量も計測し、MinIterationTimeや外れ値処理を活用して正確で信頼性の高いベンチマーク結果を得ることが重要です。

よくある落とし穴

サマータイム変更によるズレ

サマータイム(夏時間)の開始や終了に伴う時計の調整は、日時処理でよく問題となる落とし穴の一つです。

DateTime.Nowなどローカルタイムを使っている場合、サマータイムの切り替え時に1時間の前後移動が発生し、時間のズレや重複が起こります。

例えば、サマータイム終了時に時計が1時間戻ると、同じ時刻が2回存在するため、時間差の計算やログのタイムスタンプが混乱することがあります。

逆に開始時は1時間飛ばされるため、存在しない時刻を扱うことになります。

using System;
class Program
{
    static void Main()
    {
        // サマータイム終了直前の時刻(例)
        DateTime beforeDstEnd = new DateTime(2024, 10, 27, 1, 30, 0);
        Console.WriteLine($"サマータイム終了前: {beforeDstEnd} (IsDaylightSavingTime: {beforeDstEnd.IsDaylightSavingTime()})");
        // 1時間後(サマータイム終了後)
        DateTime afterDstEnd = beforeDstEnd.AddHours(1);
        Console.WriteLine($"サマータイム終了後: {afterDstEnd} (IsDaylightSavingTime: {afterDstEnd.IsDaylightSavingTime()})");
    }
}
サマータイム終了前: 2024/10/27 1:30:00 (IsDaylightSavingTime: True)
サマータイム終了後: 2024/10/27 2:30:00 (IsDaylightSavingTime: False)

このように、同じ「2時台」の時刻が2回存在するため、単純な日時比較や差分計算で誤差が生じやすいです。

対策としては、ローカル時刻ではなくDateTimeOffsetDateTime.UtcNowを使い、UTC基準で時間を管理することが推奨されます。

スリープ・レジューム後の誤差

PCやデバイスがスリープ状態に入った後、再開(レジューム)するとシステムクロックやタイマーの動作に誤差が生じることがあります。

特にStopwatchSystem.Threading.Timerなどの高精度タイマーは、スリープ中はカウントを停止するか、OSのタイマー精度に依存するため、経過時間の計測にズレが発生します。

例えば、Stopwatchはスリープ中の時間を計測しないため、スリープ前後での経過時間が実際の経過時間と異なることがあります。

using System;
using System.Diagnostics;
using System.Threading;
class Program
{
    static void Main()
    {
        Stopwatch sw = Stopwatch.StartNew();
        Console.WriteLine("計測開始");
        Thread.Sleep(2000);  // 2秒待機
        // ここでPCがスリープし、数分後に復帰したと仮定
        sw.Stop();
        Console.WriteLine($"計測された経過時間: {sw.ElapsedMilliseconds} ms");
    }
}

スリープ中の時間は計測されないため、実際の経過時間より短く表示されることがあります。

これにより、時間管理やタイムアウト処理に誤差が生じるため、スリープ・レジュームを考慮した設計が必要です。

対策としては、DateTime.UtcNowなどのシステムクロックを併用し、スリープ中の時間も含めて計測する方法があります。

32ビット環境のTickカウンタオーバーフロー

StopwatchEnvironment.TickCountなどのタイマーは、内部でティックカウンタを使って時間を計測しています。

32ビット環境では、このカウンタが一定時間経過後にオーバーフロー(巻き戻り)することがあります。

特にEnvironment.TickCountは32ビット符号付き整数でミリ秒単位のカウンタを保持し、約49.7日(2^31ミリ秒)で負の値に巻き戻ります。

これにより、時間差の計算で負の値が返るなどの問題が発生します。

using System;
class Program
{
    static void Main()
    {
        int tick1 = int.MaxValue - 10;
        int tick2 = int.MinValue + 10;
        int diff = tick2 - tick1;
        Console.WriteLine($"tick1: {tick1}");
        Console.WriteLine($"tick2: {tick2}");
        Console.WriteLine($"差分: {diff}");
    }
}
tick1: 2147483637
tick2: -2147483638
差分: -4294967275

このように、単純な引き算では正しい経過時間が得られません。

対策としては、Environment.TickCount64(64ビット版)を使うか、StopwatchElapsedTicksElapsedMillisecondsを利用してオーバーフローを回避します。

また、32ビット環境で長時間稼働するアプリケーションは、タイマーのオーバーフローを考慮したロジックを実装する必要があります。

例えば、差分計算時に符号なし整数として扱うか、オーバーフローを検出して補正する方法があります。

これらの落とし穴は時間管理の精度や信頼性に大きく影響するため、設計段階で十分に理解し対策を講じることが重要です。

クロスプラットフォームでの違い

WindowsとLinuxのStopwatch精度

Stopwatchクラスは高精度な経過時間計測を提供しますが、その内部実装や精度はプラットフォームによって異なります。

特にWindowsとLinuxでは、利用されるハードウェアカウンターやOSのタイマーAPIが異なるため、計測精度や解像度に差が生じます。

Windowsの場合

Windows環境では、Stopwatchは主にQueryPerformanceCounter(QPC)APIを利用して高精度なタイマーを実現しています。

QPCはCPUの高精度パフォーマンスカウンターを参照し、ナノ秒単位に近い精度を持ちます。

多くのWindowsマシンでStopwatch.IsHighResolutiontrueとなり、Stopwatch.Frequencyは数MHzから数GHzの範囲で高い周波数を示します。

このため、Windows上のStopwatchは非常に高精度かつ安定した計測が可能です。

Linuxの場合

Linux環境では、Stopwatchclock_gettimeシステムコールを利用して時間を取得します。

特にCLOCK_MONOTONICCLOCK_MONOTONIC_RAWが使われ、これらはシステムの単調増加クロックを参照します。

Linuxのタイマー精度はカーネルやハードウェアに依存し、一般的にはWindowsのQPCほど高精度ではない場合があります。

Stopwatch.IsHighResolutionはLinuxでもtrueになることが多いですが、Stopwatch.FrequencyはWindowsより低い値になることがあります。

また、LinuxではCPUの省電力機能やクロックの変動が影響し、計測の安定性に差が出ることもあります。

項目WindowsLinux
内部APIQueryPerformanceCounter (QPC)clock_gettime (CLOCK_MONOTONIC)
精度非常に高い高いがWindowsよりやや劣る
IsHighResolutiontruetrue(多くの場合)
周波数 (Frequency)数MHz~数GHz数MHz程度
安定性高い環境により変動あり

この違いを理解し、クロスプラットフォームでの時間計測を行う際は、環境ごとの特性を考慮して設計することが重要です。

Monoと.NET Coreの差異

.NETの実装には主にMonoと.NET Core(および.NET 5以降の統合版)があり、これらのランタイムでも時間計測の挙動に違いがあります。

Mono

Monoはクロスプラットフォーム対応のオープンソース実装で、LinuxやmacOS、Windowsなどで動作します。

MonoのStopwatchはプラットフォームのネイティブAPIを呼び出す形で実装されており、Linuxではclock_gettime、WindowsではQPCを利用します。

ただし、Monoのバージョンやビルド設定によっては、Stopwatchの精度やIsHighResolutionの値が異なることがあります。

古いMonoでは高精度タイマーがサポートされていない場合もあり、計測精度が低下することがあります。

.NET Core / .NET 5以降

.NET Coreおよび.NET 5以降の統合版は、Microsoftが公式に開発しているクロスプラットフォーム対応のランタイムです。

これらはMonoよりも一貫した高精度タイマーのサポートを提供し、Stopwatchの実装も最適化されています。

特にLinux環境でも高精度なclock_gettimeを利用し、Windows同様に高精度な計測が可能です。

また、IsHighResolutionはほぼ常にtrueとなり、Frequencyも安定しています。

項目Mono.NET Core / .NET 5+
タイマー実装プラットフォーム依存(API呼び出し)同上、より最適化・一貫性あり
高精度タイマー対応バージョンにより異なる高精度タイマーを標準サポート
IsHighResolutionまれにfalseになることがあるほぼ常にtrue
精度・安定性環境・バージョン依存高精度かつ安定

クロスプラットフォームでの時間計測を行う際は、使用するランタイムのバージョンや環境を確認し、必要に応じて動作検証やフォールバック処理を実装することが望ましいです。

ミリ秒データのシリアライズ

JSONの日付フォーマット

C#で日時データをJSON形式にシリアライズする際、ミリ秒を含めた正確な日時表現が重要です。

標準的な.NETのJSONシリアライザーSystem.Text.JsonNewtonsoft.Jsonは、デフォルトでISO 8601形式の文字列として日時をシリアライズします。

この形式はミリ秒を含むことができ、yyyy-MM-ddTHH:mm:ss.fffZのように表現されます。

例えば、DateTimeオブジェクトをSystem.Text.Jsonでシリアライズする例です。

using System;
using System.Text.Json;
class Program
{
    static void Main()
    {
        DateTime now = DateTime.UtcNow;
        string json = JsonSerializer.Serialize(now);
        Console.WriteLine(json);
    }
}
"2024-06-15T05:30:45.123Z"

この出力はUTC時刻でミリ秒まで含まれたISO 8601形式の文字列です。

Newtonsoft.Jsonでも同様にミリ秒を含むISO 8601形式でシリアライズされます。

ただし、カスタムフォーマットを指定したい場合は、JsonSerializerOptionsConvertersにカスタムコンバーターを追加したり、JsonConverter<DateTime>を実装して制御できます。

また、DateTimeKindプロパティUtcLocalUnspecifiedによってシリアライズ結果が変わるため、UTCで統一することが推奨されます。

Unix時間ミリ秒表現との相互変換

JSONで日時を数値として扱いたい場合、Unix時間(エポックタイム)をミリ秒単位で表現することが一般的です。

Unix時間は1970年1月1日00:00:00 UTCからの経過時間を秒またはミリ秒で表します。

C#でDateTimeDateTimeOffsetをUnix時間ミリ秒に変換する方法は以下の通りです。

using System;
class Program
{
    static void Main()
    {
        DateTimeOffset now = DateTimeOffset.UtcNow;
        // Unix時間ミリ秒に変換
        long unixMillis = now.ToUnixTimeMilliseconds();
        Console.WriteLine($"Unix時間(ミリ秒): {unixMillis}");
        // Unix時間ミリ秒からDateTimeOffsetに変換
        DateTimeOffset dto = DateTimeOffset.FromUnixTimeMilliseconds(unixMillis);
        Console.WriteLine($"復元した日時: {dto:yyyy-MM-dd HH:mm:ss.fff}");
    }
}
Unix時間(ミリ秒): 1746581409424
復元した日時: 2025-05-07 01:30:09.424

この方法で、日時を数値としてJSONにシリアライズし、ミリ秒単位の精度を保ったままデータの送受信が可能です。

JSONシリアライズ時にUnix時間ミリ秒を使う場合は、カスタムコンバーターを実装してDateTimeDateTimeOffsetのプロパティを数値として扱うことが多いです。

Newtonsoft.JsonではJsonConverterを継承し、ReadJsonWriteJsonをオーバーライドして実装します。

まとめると、ミリ秒を含む日時データのシリアライズは、ISO 8601形式の文字列かUnix時間ミリ秒の数値で表現するのが一般的であり、用途に応じて使い分けることが重要です。

イベント駆動アーキテクチャへの応用

リアルタイムデータ処理のタイムスタンプ管理

イベント駆動アーキテクチャでは、リアルタイムに発生するデータやイベントを効率的に処理することが求められます。

この際、各イベントに正確なタイムスタンプを付与し、時間軸に沿った管理を行うことが重要です。

ミリ秒単位の精度でタイムスタンプを管理することで、イベントの発生順序の判定や遅延検知、パフォーマンス分析が可能になります。

C#では、DateTime.UtcNowDateTimeOffset.UtcNowを使ってUTC基準のタイムスタンプを取得し、イベントデータに付加します。

Stopwatchを併用して処理時間の計測や遅延の検出も行えます。

例えば、イベント受信時にタイムスタンプを付与し、ログやストリーム処理に活用するコード例です。

using System;
public class EventData
{
    public string Payload { get; set; }
    public DateTimeOffset Timestamp { get; set; }
}
public class EventProcessor
{
    public void OnEventReceived(string payload)
    {
        var eventData = new EventData
        {
            Payload = payload,
            Timestamp = DateTimeOffset.UtcNow  // UTCでミリ秒精度のタイムスタンプを付与
        };
        ProcessEvent(eventData);
    }
    private void ProcessEvent(EventData eventData)
    {
        Console.WriteLine($"[{eventData.Timestamp:O}] イベント処理: {eventData.Payload}");
    }
}

このようにタイムスタンプを一貫してUTCで管理することで、分散システム間での時刻のズレを最小限に抑え、正確なイベント順序の把握が可能になります。

スロットリングとバッチ処理の設計

大量のイベントが短時間に発生する場合、システムの負荷を抑えるためにスロットリング(処理制限)やバッチ処理を設計することが一般的です。

ミリ秒単位の時間管理は、これらの制御において重要な役割を果たします。

スロットリング

スロットリングは、一定時間内に処理するイベント数を制限し、過負荷を防ぐ仕組みです。

例えば、1秒間に最大100件のイベントのみ処理し、それ以上は遅延させるなどの制御が考えられます。

C#では、StopwatchDateTimeOffsetを使って時間を計測し、処理開始時刻や経過時間を管理します。

以下は簡単なスロットリングの例です。

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading.Tasks;
public class ThrottledProcessor
{
    private readonly int maxEventsPerSecond;
    private readonly Queue<DateTimeOffset> eventTimestamps = new Queue<DateTimeOffset>();
    public ThrottledProcessor(int maxEventsPerSecond)
    {
        this.maxEventsPerSecond = maxEventsPerSecond;
    }
    public async Task<bool> TryProcessEventAsync(string payload)
    {
        var now = DateTimeOffset.UtcNow;
        // 古いタイムスタンプを削除
        while (eventTimestamps.Count > 0 && (now - eventTimestamps.Peek()).TotalSeconds >= 1)
        {
            eventTimestamps.Dequeue();
        }
        if (eventTimestamps.Count >= maxEventsPerSecond)
        {
            // スロットリング発動、処理を遅延または拒否
            return false;
        }
        eventTimestamps.Enqueue(now);
        // 実際の処理(例:非同期での処理)
        await Task.Run(() => Console.WriteLine($"[{now:O}] 処理: {payload}"));
        return true;
    }
}

この例では、1秒間に処理可能なイベント数を制限し、超過した場合は処理を拒否しています。

ミリ秒単位のタイムスタンプで正確に時間窓を管理しています。

バッチ処理

バッチ処理は、一定時間または一定件数のイベントをまとめて処理する方法です。

これにより、処理効率が向上し、リソースの使用を最適化できます。

バッチの開始・終了タイミングをミリ秒単位で管理し、例えば100ミリ秒ごとにバッチを確定する設計が考えられます。

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
public class BatchProcessor
{
    private readonly List<string> batch = new List<string>();
    private readonly object lockObj = new object();
    private readonly int batchIntervalMs;
    private Timer timer;
    public BatchProcessor(int batchIntervalMs)
    {
        this.batchIntervalMs = batchIntervalMs;
        timer = new Timer(ProcessBatch, null, batchIntervalMs, batchIntervalMs);
    }
    public void AddEvent(string payload)
    {
        lock (lockObj)
        {
            batch.Add(payload);
        }
    }
    private void ProcessBatch(object state)
    {
        List<string> toProcess;
        lock (lockObj)
        {
            if (batch.Count == 0) return;
            toProcess = new List<string>(batch);
            batch.Clear();
        }
        var timestamp = DateTimeOffset.UtcNow;
        Console.WriteLine($"[{timestamp:O}] バッチ処理開始 件数: {toProcess.Count}");
        // バッチ処理の実装例
        foreach (var item in toProcess)
        {
            Console.WriteLine($"  処理: {item}");
        }
    }
}

このコードは指定したミリ秒間隔でバッチ処理を行い、ミリ秒単位のタイムスタンプで処理開始時刻を記録しています。

リアルタイムデータ処理におけるミリ秒単位の時間管理は、イベントの正確な順序付けや負荷制御に不可欠です。

スロットリングやバッチ処理と組み合わせることで、効率的かつ安定したイベント駆動システムを構築できます。

外部ライブラリの活用例

PollyのTimeoutPolicyで処理時間を制限

Pollyは.NET向けの堅牢なリトライやフォールバック、タイムアウトなどのポリシーを簡単に実装できるライブラリです。

ミリ秒単位で処理時間を制限したい場合、TimeoutPolicyを使うことで指定した時間内に処理が完了しなければ例外をスローし、タイムアウトを検知できます。

Pollyのインストール

Pollyは、Nugetからインストールする必要があります。

「Polly」と検索してインストールするようにしてください。

dotnet add package Polly

以下はPollyのTimeoutPolicyを使って、処理時間を500ミリ秒に制限する例です。

using System;
using System.Threading;
using System.Threading.Tasks;
using Polly;
using Polly.Timeout;
class Program
{
    static async Task Main()
    {
        // 500ミリ秒のタイムアウトポリシーを作成
        var timeoutPolicy = Policy.TimeoutAsync(0.5, TimeoutStrategy.Pessimistic);
        try
        {
            await timeoutPolicy.ExecuteAsync(async ct =>
            {
                Console.WriteLine("処理開始");
                // 1秒間の遅延(タイムアウトを超える)
                await Task.Delay(1000, ct);
                Console.WriteLine("処理完了");
            }, CancellationToken.None);
        }
        catch (TimeoutRejectedException)
        {
            Console.WriteLine("処理がタイムアウトしました");
        }
    }
}
処理開始
処理がタイムアウトしました

この例では、TimeoutAsyncに0.5秒(500ミリ秒)を指定し、1秒の遅延処理がタイムアウトとなって例外が発生します。

TimeoutStrategy.Pessimisticは強制的にキャンセルを試みる方式で、処理が長引く場合に有効です。

TimeoutPolicyを使うことで、ミリ秒単位の時間制限を簡潔に実装でき、外部API呼び出しや重い処理の制御に役立ちます。

Rx.NETのObservable.Intervalでストリーム生成

Rx.NET(Reactive Extensions)はイベントや非同期データのストリームを扱うためのライブラリで、Observable.Intervalは指定したミリ秒間隔で連続的に値を発行するストリームを生成します。

これを使うと、ミリ秒単位の周期的なイベント処理やタイマー処理を簡単に実装できます。

以下は1秒(1000ミリ秒)ごとにカウントアップするObservable.Intervalの例です。

using System;
using System.Reactive.Linq;
using System.Threading;
class Program
{
    static void Main()
    {
        var observable = Observable.Interval(TimeSpan.FromMilliseconds(1000));
        var subscription = observable.Subscribe(x =>
        {
            Console.WriteLine($"イベント発生: {x} - {DateTime.Now:HH:mm:ss.fff}");
        });
        // 5秒間待機してから購読解除
        Thread.Sleep(5000);
        subscription.Dispose();
    }
}
イベント発生: 0 - 14:55:30.123
イベント発生: 1 - 14:55:31.123
イベント発生: 2 - 14:55:32.123
イベント発生: 3 - 14:55:33.123
イベント発生: 4 - 14:55:34.123

Observable.Intervalは内部でタイマーを使い、指定した間隔で連続的に値を発行します。

これにより、複雑なタイマー管理やスレッド制御を意識せずに、リアクティブなイベントストリームを構築できます。

また、ThrottleBufferなどの演算子と組み合わせることで、スロットリングやバッチ処理も簡単に実装可能です。

PollyのTimeoutPolicyとRx.NETのObservable.Intervalは、ミリ秒単位の時間管理や制御を外部ライブラリで効率的に実装する強力なツールです。

用途に応じて使い分けることで、堅牢で拡張性の高いアプリケーション設計が可能になります。

セキュリティとタイムスタンプ整合性

Syslogとの時刻同期

セキュリティログや監査ログの信頼性を確保するためには、ログに記録されるタイムスタンプの正確性と整合性が非常に重要です。

特にSyslogサーバーを利用する環境では、複数の機器やサーバーから送信されるログの時刻を統一し、一貫した時系列で管理する必要があります。

Syslogはネットワーク経由でログを集約する仕組みですが、各送信元のシステムクロックがずれていると、ログの時刻がバラバラになり、インシデント解析やフォレンジック調査の妨げになります。

したがって、Syslogサーバーとクライアント間で時刻同期を行うことが必須です。

一般的には、NTP(Network Time Protocol)を用いて各機器のシステムクロックを正確に合わせます。

Syslogメッセージのタイムスタンプは、送信元のシステムクロックに依存するため、NTP同期が正しく機能していることが前提となります。

また、Syslogサーバー側でも受信時刻を記録することが多く、送信元タイムスタンプと受信時刻の差異を監視することで、時刻のずれや不正なログ改ざんの検知に役立てられます。

以下のポイントが重要です。

  • NTP同期の徹底:すべてのログ送信元とSyslogサーバーでNTPを設定し、時刻を正確に保ちます
  • タイムスタンプの検証:Syslogサーバーで送信元タイムスタンプと受信時刻の差を監視し、異常があればアラートを発生させます
  • UTC基準の利用:タイムゾーンの違いによる混乱を避けるため、UTCでタイムスタンプを統一します

これにより、ログの時刻整合性が保たれ、セキュリティ監査やインシデント対応の信頼性が向上します。

NTP障害検知とアラート設計

NTPはネットワーク上で時刻を同期するためのプロトコルですが、NTPサーバーの障害やネットワーク障害により同期が失われるリスクがあります。

時刻同期が崩れると、システム全体のタイムスタンプがずれ、ログの信頼性やセキュリティ機能に重大な影響を及ぼします。

そのため、NTPの正常動作を監視し、障害を早期に検知して対応する仕組みが必要です。

NTP障害検知の方法

  • NTPクライアントの状態監視

各サーバーや機器のNTPクライアントの同期状態を定期的にチェックします。

Windowsではw32tm /query /statusコマンド、Linuxではntpq -pchronyc trackingコマンドで状態を確認可能です。

  • 時刻ずれの監視

システムクロックとNTPサーバーの時刻差(オフセット)を監視し、許容範囲を超えた場合にアラートを発生させます。

例えば、数百ミリ秒以上のずれは問題視されます。

  • ログ監視

NTP関連のログにエラーや警告が記録されていないか監視し、異常を検知します。

アラート設計のポイント

  • 閾値設定

時刻ずれの許容範囲を明確に設定し、超過時に通知します。

閾値はシステムの要件に応じて調整します。

  • 多段階アラート

軽微なずれは警告レベル、重大なずれや同期喪失は緊急レベルのアラートに分けることで対応の優先度を明確化。

  • 自動復旧の検討

NTPサービスの再起動や代替NTPサーバーへの切り替えなど、自動復旧機能を組み込むと運用負荷を軽減できます。

  • 監査ログとの連携

NTP障害発生時のログを監査ログに記録し、後からの解析や証跡として活用します。

NTP障害はシステム全体の時刻整合性を崩し、セキュリティリスクを高めるため、障害検知とアラート設計は必須です。

適切な監視と迅速な対応体制を整えることで、タイムスタンプの信頼性を維持し、セキュリティインシデントの早期発見・対応につなげられます。

まとめ

C#でミリ秒単位の時間管理を行う際は、DateTimeStopwatchTimerなどの基本クラスを適切に使い分けることが重要です。

クロスプラットフォームや非同期処理、パフォーマンス最適化、ログの精度保持、セキュリティ面での時刻整合性など、多角的な視点から設計・実装を検討する必要があります。

外部ライブラリやベンチマークツールを活用し、正確かつ効率的な時間管理を実現しましょう。

関連記事

Back to top button