Windows環境のC#でミリ秒精度のタイマー制御を比較検証してみた(Task.Delay vs Stopwatch)

C# / Avalonia

はじめに:50msのタイマー設定なのに、なぜ通信が不安定になるのか?

「50msごとにコマンドを送信する」——そんなシンプルな通信仕様のプログラムを実装していたときのことです。

ターゲットの機器(UARTデバイス)に対して一定間隔で10バイトのリクエストを流し込む処理を組んでいたのですが、どうも通信のタイミングが微妙にバラついたり、ごく稀に取りこぼしや応答遅延が発生したりする現象に直面しました。

C#でよく使われる System.Windows.Forms.Timer や Task.Delay(50) をそのまま使えば問題ないだろうと考えていたのですが、ふと疑問が湧きました。

  • 「Windowsのタイマーって、本当に設定したミリ秒通りに動いているんだっけ?」
  • 「UARTのボーレート38400bps(1バイト送信に約0.26ms)に対して、タイマー側がガタついていたら意味がないのでは?」

制御系やシリアル通信の現場において、この数ミリ秒のズレやタイマーの不安定さはトラブルの元になります。そこで今回は、「本当に正確な周期で処理を実行するためには、どの実装方法が一番安定するのか?」をハッキリさせるため、C#で3つのアプローチを実際にコードを書いて比較検証してみることにしました。

原因はOSにあり!Windowsタイマーの「15.6msの壁」とは

そもそも、なぜC#(Windows環境)の標準タイマーは設定通りに動かないのでしょうか。

その最大の原因は、Windowsの標準的なOSタイマーの分解能(ティック周期)が「約15.6ミリ秒(64Hz)」に固定されている点にあります。

  • System.Windows.Forms.Timer や System.Threading.DispatcherTimer、Task.Delay などの標準的な機能は、内部でこのOSのタイマーに依存しています。
  • そのため、コード上で 50ms と指定しても、OSが時間を認識・通知する単位が約15.6ms刻みであるため、実際には 約47ms になったり 約62ms になったりと、大きなジッター(時間的な揺らぎ)が発生してしまいます。

50msという数値は15.6msの綺麗な倍数ではないため、この影響をモロに受けてしまうのです。

C#で3つのタイマー制御方法を検証する(比較コード)

この「15.6msの壁」を突破し、50ms周期の精度をどこまで高められるか。以下の 3つの方法 を実装して比較するコンソールアプリを作成しました。

  1. 標準の Task.Delay(OS規定)
  2. timeBeginPeriod(1) + Task.Delay(OSタイマー分解能の変更)
  3. Stopwatch + 専用バックグラウンドスレッド(高精度な独自制御)

検証用C#コード

C#

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;

class Program
{
    [DllImport("winmm.dll")]
    public static extern uint timeBeginPeriod(uint uPeriod);
    [DllImport("winmm.dll")]
    public static extern uint timeEndPeriod(uint uPeriod);

    static async Task Main(string[] args)
    {
        int targetIntervalMs = 50;
        int count = 100; // 100回測定

        Console.WriteLine($"=== 50ms周期の精度比較テスト (各回 {count} 回実行) ===\n");

        // ① 標準 Task.Delay (デフォルト分解能)
        await RunTestAsync("1. 標準 Task.Delay (OS規定)", async (onTick) => {
            for (int i = 0; i < count; i++) {
                var sw = Stopwatch.StartNew();
                await Task.Delay(targetIntervalMs);
                sw.Stop();
                onTick(sw.ElapsedMilliseconds);
            }
        });

        // ② timeBeginPeriod(1) + Task.Delay
        timeBeginPeriod(1);
        await RunTestAsync("2. timeBeginPeriod(1) + Task.Delay", async (onTick) => {
            for (int i = 0; i < count; i++) {
                var sw = Stopwatch.StartNew();
                await Task.Delay(targetIntervalMs);
                sw.Stop();
                onTick(sw.ElapsedMilliseconds);
            }
        });
        timeEndPeriod(1);

        // ③ Stopwatch + 専用バックグラウンドスレッド (ドリフト補正付き)
        RunTestThread("3. Stopwatch + 専用スレッド (高精度)", targetIntervalMs, count);

        Console.WriteLine("\n検証完了。何かキーを押すと終了します。");
        Console.ReadKey();
    }

    static async Task RunTestAsync(string title, Func<Action<long>, Task> testAction)
    {
        Console.WriteLine($"[{title}] 実行中...");
        List<long> intervals = new List<long>();
        await Task.Delay(100); // ウォームアップ

        await testAction(ms => intervals.Add(ms));
        PrintStats(intervals);
    }

    static void RunTestThread(string title, int intervalMs, int count)
    {
        Console.WriteLine($"[{title}] 実行中...");
        List<long> diffs = new List<long>();

        using (var signal = new ManualResetEventSlim(false))
        {
            var thread = new Thread(() => {
                var watch = Stopwatch.StartNew();
                long nextTick = intervalMs;
                long lastTime = 0;

                Thread.Sleep(10);
                watch.Restart();
                nextTick = intervalMs;
                lastTime = 0;

                for (int i = 0; i < count; i++)
                {
                    while (watch.ElapsedMilliseconds < nextTick)
                    {
                        Thread.SpinWait(20); // CPU負荷を抑える微小待機
                    }

                    long now = watch.ElapsedMilliseconds;
                    diffs.Add(now - lastTime);
                    lastTime = now;
                    nextTick += intervalMs;
                }
                signal.Set();
            });

            thread.IsBackground = true;
            thread.Priority = ThreadPriority.Highest;
            thread.Start();
            signal.Wait();
        }

        PrintStats(diffs);
    }

    static void PrintStats(List<long> data)
    {
        if (data.Count == 0) return;
        double avg = data.Average();
        long min = data.Min();
        long max = data.Max();
        long jitter = max - min;

        Console.WriteLine($"  平均間隔: {avg:F1} ms (Min: {min} ms, Max: {max} ms) [最大ブレ(ジッター): {jitter} ms]");
        var grouped = data.GroupBy(x => x).OrderBy(g => g.Key);
        string dist = string.Join(", ", grouped.Select(g => $"{g.Key}ms({g.Count()})"));
        Console.WriteLine($"  分布: {{ {dist} }}\n");
    }
}

各方式の特徴と結果の傾向

  1. 標準の Task.Delay
    • 分布が大きく散らばり、平均も50msからズレやすくなります。Windowsのデフォルト分解能の制約がそのまま現れる形です。
  2. timeBeginPeriod(1) + Task.Delay
    • 49ms〜52msあたりに集まるようになり大きく改善しますが、OSのバックグラウンドタスクなどの影響で、たまに大きな遅延(スパイク)が混ざることがあります。
  3. Stopwatch + 専用バックグラウンドスレッド
    • 累積誤差(ドリフト)の補正が入るため、ほぼ正確に50ms(±1ms以内)を維持し、長期間稼働させても極めて安定した周期を保ちます。

検証結果のまとめ:シリアル通信や制御系で選ぶべきベストプラクティス

今回の検証を通して、用途に応じた選び方が見えてきました。

  • UIの定期更新や、数ミリ秒のズレが許容される一般的な処理
    • → 方法1(標準の Task.Delay / Timer) で十分です。実装も手軽で余計な負荷がかかりません。
  • 少し精度を上げたいが、コードをシンプルに保ちたい場合
    • → 方法2(timeBeginPeriod(1) の活用) が手軽な選択肢になります。
  • UARTなどのシリアル通信、ハードウェア制御、取りこぼしが許されないポーリング処理
    • → 方法3(Stopwatch + 専用バックグラウンドスレッド) が最も確実で安定したベストプラクティスです。

「たかが数ミリ秒のズレ」と侮っていると、通信プロトコルのタイムアウトやデータ破損の原因になり得ます。正確な周期制御が求められるアプリを開発する際は、ぜひ参考にしてみてください!

コメント