C#で実現するWindowsサービスの毎正時実行:タイマー設計とQuartz.NET/Hangfire比較、ドリフト対策まで完全解説

「毎正時きっちり(7:00、8:00…)に実行し、前回処理が2分伸びても次回は8:02ではなく8:00で走らせたい」。Windowsサービスでよくあるこの要件は、タイマーの使い方とジョブ実行の分離で解決できます。この記事ではC#の実装パターン、設計上の注意、運用の勘所まで、実務でそのまま使える形で徹底解説します。

目次

問題の背景と要件

標準的なタイマー実装では「ジョブ終了後に次回のタイマーをセット」しがちです。この設計だと処理が長引くほど次回が遅延し、正時基準の運用(7:00、8:00…)を満たせません。求められているのは次の2点です。

  • 毎正時(HH:00:00)で発火させる。
  • 前回処理に時間がかかっても、次回は正時に戻す(遅延を持ち越さない)。

結論(要約)

アプローチは大きく3通りです。選定の基準とともに整理します。

主なアプローチポイントメリット注意点
① タイマーは正時で固定、処理は別スレッドSystem.Timers.TimerやSystem.Threading.Timerを使い、タイマーは正時の刻みだけを担当。ジョブ本体はTask.Runなどで非同期実行。タイマーは止めない/遅らせない。処理が長引いても次の正時で必ず発火。遅延を持ち越さない。並列を許容しないなら重複起動の排他制御が必要。微小ドリフトの補正も検討。
② 毎回「次の正時」までの残り時間を再計算AutoReset = falseで都度次の正時までのIntervalを計算して再起動。
ジョブ完了を待たずに次の正時で再セットする設計にする。
ドリフトを累積させにくい。実装が比較的シンプル。ジョブが極端に長いと負の間隔計算に注意。1時間超のジョブは①やスケジューラが安全。
③ 専用スケジューラ(Quartz.NET / Hangfire / Windows タスク スケジューラ)cron式や「毎時HH:00」をフレームワークに委譲。リトライ・重複防止・履歴など運用機能が豊富。堅牢性・監視性・可観測性が高い。分散ロックなども実装済み。外部ライブラリの導入・学習コスト、ランタイム依存が増える。

最低限押さえる設計ポイント

  1. 「ジョブ完了を待って次回を決める」設計をやめる。次回は常に正時で決まっているべき(壁時計スケジュール)。
  2. 並列実行の方針を決める。許容しないならミューテックス/SemaphoreSlimで排他。許容するならスレッドプールで並列化。
  3. 1時間超のジョブや障害時の再実行に備え、ログ・監視・リトライ方針を用意。
  4. 時刻の正確性(NTP、サマータイム、タイムゾーン差、スリープ復帰)に備え、ドリフト補正または毎回再計算を取り入れる。

実装パターン①:タイマーは正時で固定、処理は別スレッド

もっとも扱いやすい定番。初回だけ「次の正時までの残り時間」を待ち、以降は正時ごとに発火し続けます。ジョブ本体は別スレッドで実行し、タイマーは止めません。

ServiceBase版(.NET Framework 〜 .NET 4.x)のサンプル

using System;
using System.ServiceProcess;
using System.Timers;
using System.Threading;
using System.Threading.Tasks;

public class HourlyService : ServiceBase
{
private readonly Timer _timer = new Timer();
private readonly SemaphoreSlim _singleRunGate = new SemaphoreSlim(1, 1); // 並列禁止の場合
private volatile bool _disposed;


protected override void OnStart(string[] args)
{
    // 初回のみ「次の正時」まで待つ
    var now = DateTime.Now;
    var nextTop = new DateTime(now.Year, now.Month, now.Day, now.Hour, 0, 0, now.Kind).AddHours(1);
    _timer.Interval = Math.Max(1, (nextTop - now).TotalMilliseconds);
    _timer.AutoReset = false;
    _timer.Elapsed += OnTick;
    _timer.Start();
}

private void OnTick(object? sender, ElapsedEventArgs e)
{
    if (_disposed) return;

    // ジョブは別スレッドで開始(タイマーは止めない)
    _ = Task.Run(() => RunJobSafeAsync());

    // 次回以降も必ず「正時」で発火するよう、都度補正する
    var now = DateTime.Now;
    var nextTop = new DateTime(now.Year, now.Month, now.Day, now.Hour, 0, 0, now.Kind).AddHours(1);
    _timer.Interval = Math.Max(1, (nextTop - now).TotalMilliseconds);
    _timer.Start();
}

private async Task RunJobSafeAsync()
{
    // 並列禁止ならゲートを使う(不要ならこのブロックを削除)
    if (!await _singleRunGate.WaitAsync(0))
    {
        // 既に実行中ならスキップ(またはキューイング)
        return;
    }

    try
    {
        await DoWorkAsync();
    }
    catch (Exception ex)
    {
        // 例外は必ず握りつぶさずログに残す
        // 例:EventLog.WriteEntry("HourlyService", ex.ToString(), EventLogEntryType.Error);
    }
    finally
    {
        _singleRunGate.Release();
    }
}

private Task DoWorkAsync()
{
    // 本処理(I/O、DB、APIコールなど)
    // 例:return ProcessSomethingAsync();
    return Task.CompletedTask;
}

protected override void OnStop()
{
    _disposed = true;
    _timer.Stop();
    _timer.Dispose();
    _singleRunGate.Dispose();
}


} 
  • ポイント:タイマーの発火とは独立に次の正時に向けて常に再セットするため、処理が長引いても次回は必ず正時で発火します。
  • 並列禁止:同時起動を避けたい場合はSemaphoreSlimなどで排他。許容ならゲートを外せばOK。
  • ドリフト補正:毎回次の正時までの残り時間を再計算するため、OSスケジューラの遅延やスリープ復帰による誤差を累積しません。

.NET 6+ Worker Service版(推奨)

.NET 6以降はWindowsサービスへ容易にホストできるBackgroundServiceが便利です。PeriodicTimerではなく、毎回「次の正時」までTask.Delayする形がシンプルで正確です。

using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

public sealed class HourlyJobService : BackgroundService
{
private readonly ILogger _logger;
private readonly SemaphoreSlim _gate = new SemaphoreSlim(1, 1); // 並列禁止なら使用


public HourlyJobService(ILogger<HourlyJobService> logger) => _logger = logger;

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
    while (!stoppingToken.IsCancellationRequested)
    {
        var now = DateTime.Now;
        var nextTop = new DateTime(now.Year, now.Month, now.Day, now.Hour, 0, 0, now.Kind).AddHours(1);
        var delay = nextTop - now;
        if (delay < TimeSpan.FromMilliseconds(1)) delay = TimeSpan.FromMilliseconds(1);

        try
        {
            await Task.Delay(delay, stoppingToken); // 次の正時まで待機
        }
        catch (TaskCanceledException) { break; }

        _ = Task.Run(() => RunJobSafeAsync(stoppingToken), stoppingToken);
    }
}

private async Task RunJobSafeAsync(CancellationToken ct)
{
    if (!await _gate.WaitAsync(0, ct))
    {
        _logger.LogWarning("前回処理がまだ実行中のためスキップしました。");
        return;
    }

    try
    {
        _logger.LogInformation("ジョブ開始: {Timestamp}", DateTimeOffset.Now);
        await DoWorkAsync(ct);
        _logger.LogInformation("ジョブ終了: {Timestamp}", DateTimeOffset.Now);
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "ジョブ実行で例外が発生しました。");
    }
    finally
    {
        _gate.Release();
    }
}

private Task DoWorkAsync(CancellationToken ct)
{
    // 本処理
    return Task.CompletedTask;
}


} 
// Program.cs(一例)
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

Host.CreateDefaultBuilder(args)
    .UseWindowsService() // Windowsサービスとして動作
    .ConfigureServices(services =&gt;
    {
        services.AddHostedService&lt;HourlyJobService&gt;();
    })
    .Build()
    .Run();

「ほぼぴったり00秒」を目指すドリフト対策

  • 発火がわずかに遅れても、次の発火は正時で再計算する(上記コードはこの方式)。
  • 「00秒ちょうど」へのこだわりが強い場合は、DateTime.Nowのミリ秒を丸め、遅延が閾値(例:2秒)を超えたら即時実行に切り替えるなどの調整も有効。
  • OSスリープ復帰、NTP時刻補正、VMスナップショット復元などの要因で数秒~十数秒のズレは現実的に起こり得ます。監視アラートは「数分遅延」で閾値を切るのが実務的です。

実装パターン②:毎回「次の正時」までの残り時間を再設定

タイマーのAutoResetを無効にし、ジョブの開始と同時に次の正時を再セットしておく方式。ジョブ終了を待たないのがポイントです。

using System;
using System.Threading;
using System.Threading.Tasks;

public sealed class HourlyTimer
{
private Timer? _timer;
private readonly object _lock = new object();
private volatile bool _running;


public void Start()
{
    // 初回だけ次の正時まで待機
    var due = GetDelayToNextTopOfHour(DateTime.Now);
    _timer = new Timer(Callback, null, due, Timeout.InfiniteTimeSpan);
}

private void Callback(object? _)
{
    // 次の正時を先に再設定(ジョブの長さに依らず正時で発火させる)
    var due = GetDelayToNextTopOfHour(DateTime.Now);
    _timer?.Change(due, Timeout.InfiniteTimeSpan);

    // 重複防止(不要なら外す)
    lock (_lock)
    {
        if (_running) return;
        _running = true;
    }

    _ = Task.Run(async () =>
    {
        try { await DoWorkAsync(); }
        finally
        {
            lock (_lock) { _running = false; }
        }
    });
}

private static TimeSpan GetDelayToNextTopOfHour(DateTime now)
{
    var next = new DateTime(now.Year, now.Month, now.Day, now.Hour, 0, 0, now.Kind).AddHours(1);
    var delay = next - now;
    if (delay < TimeSpan.FromMilliseconds(1)) delay = TimeSpan.FromMilliseconds(1);
    return delay;
}

private Task DoWorkAsync() => Task.CompletedTask;


} 
  • メリット:毎回再計算するため、ドリフトが蓄積しない。
  • 注意:ジョブが1時間を超える場合は、開始時点で次の正時を先に再設定しておけば問題は起きにくいが、モニタリングとスロットリングは必須。

実装パターン③:スケジューラを使う(Quartz.NET / Hangfire / Windows Task Scheduler)

大規模運用や分散環境、詳細なリトライ・履歴・メトリクスが必要ならスケジューラを導入しましょう。

Quartz.NET(cron式で毎正時)

using Quartz;
using Quartz.Impl;

public class TopOfHourJob : IJob
{
public async Task Execute(IJobExecutionContext context)
{
// 本処理
await Task.CompletedTask;
}
}

public static async Task ConfigureQuartzAsync()
{
var scheduler = await new StdSchedulerFactory().GetScheduler();
await scheduler.Start();


var job = JobBuilder.Create<TopOfHourJob>()
    .WithIdentity("TopOfHourJob")
    .Build();

// 秒 分 時 日 月 曜日
var trigger = TriggerBuilder.Create()
    .WithIdentity("TopOfHourTrigger")
    .WithCronSchedule("0 0 * * * ?") // 毎時00分00秒
    .Build();

await scheduler.ScheduleJob(job, trigger);


} 
  • 特長:ジョブの重複防止、ミスファイア(見逃し)時の扱い、永続化(DB JobStore)などが用意されています。
  • 分散対応:DBを共有すればクラスタでリーダー選出して1台だけが実行できます。

Hangfire(Dashboardで可視化、永続化が簡単)

using Hangfire;

public static void ConfigureHangfire()
{
GlobalConfiguration.Configuration
.UseSqlServerStorage("DefaultConnection");


RecurringJob.AddOrUpdate(
    recurringJobId: "TopOfHour",
    methodCall: () => DoWork(),
    cronExpression: "0 * * * *" // 毎時00分(分ベースのcron)
);


}

public static void DoWork()
{
// 本処理
} 

時刻の落とし穴:タイムゾーン・DST・NTP補正

「正時」はローカルタイムを前提にすることが多いため、サマータイム(DST)で存在しない時刻や重複する時刻が発生します。厳密に扱うならDateTimeOffsetとTimeZoneInfoでローカル時間の次の正時を算出してからUTCに変換して待つのが安全です。

using System;
using System.Linq;

static TimeSpan DelayToNextTopOfHourLocalTz()
{
var tz = TimeZoneInfo.Local;
var nowUtc = DateTimeOffset.UtcNow;
var localNow = TimeZoneInfo.ConvertTime(nowUtc, tz);


var localNext = new DateTimeOffset(localNow.Year, localNow.Month, localNow.Day,
                                   localNow.Hour, 0, 0, localNow.Offset).AddHours(1);

// 無効時間(DST飛び)なら次の有効時刻へ
if (tz.IsInvalidTime(localNext.DateTime))
    localNext = localNext.AddHours(1);

// 曖昧時間(DST戻り)なら標準時側(大きいオフセット)を採用
if (tz.IsAmbiguousTime(localNext.DateTime))
{
    var offsets = tz.GetAmbiguousTimeOffsets(localNext.DateTime);
    var chosen = offsets.Max(); // 好みでMin()でも可
    localNext = new DateTimeOffset(localNext.DateTime, chosen);
}

var nextUtc = TimeZoneInfo.ConvertTime(localNext, TimeZoneInfo.Utc);
var delay = nextUtc - nowUtc;
if (delay < TimeSpan.FromMilliseconds(1)) delay = TimeSpan.FromMilliseconds(1);
return delay;


} 
  • NTP補正:時刻が大きく巻き戻る/進むと待機時間が狂うことがあります。毎回再計算する方式(本稿①・②)なら致命的な影響を避けやすいです。
  • サーバ時刻の健全性監視:イベントログやメトリクスに観測開始時刻と想定スケジュール時刻を残すと解析が簡単。

重複起動の制御と分散ロック

並列禁止で、かつサービスを複数台で冗長化している場合は分散ロックが必要です。

方式概要長所注意点
DBテーブルロック方式専用テーブルにJobNameをユニーク制約でINSERTし、成功したプロセスのみ実行。実装容易、可視化しやすい。リーク対策(タイムアウト・心拍)必須。
SQL Server sp_getapplockアプリケーションロックを取得できたプロセスが実行。高信頼、再起動時の解放が自動。DB依存。権限設定が必要。
Redis分散ロックSET NX EX(Redlock等)で排他。高速、分散シナリオに適合。ネットワーク分断時の安全性に配慮。
Quartz.NETクラスタJobStore(DB)を共有し、クラスタリングで1台のみ実行。出来合いで堅牢。Quartz導入が前提。

遅延・停止時のポリシー(キャッチアップかスキップか)

サービス停止や長時間実行で正時を逃した場合、キャッチアップ(溜まった分を順次実行)するか、スキップするかを事前に決めます。

  • バッチ集計:スキップせずキャッチアップが無難。実行対象の時刻をパラメータ化し、7:00分を実行するなら「targetHour = 7」で集計する。
  • 送信・通知:ユーザ体験を損ねるならスキップ(閾値超の遅延は破棄)を選ぶ。
// 例:スケジュール基準時刻をe.SignalTimeや丸めた現在時刻から決める
DateTime ScheduleSlot(DateTime when)
{
    return new DateTime(when.Year, when.Month, when.Day, when.Hour, 0, 0, when.Kind);
}

可観測性:ログ・メトリクス・アラート

観測項目説明例
予定開始時刻本来のスロット(例:2025-11-05 08:00)slot=2025-11-05T08:00:00+09:00
実際の開始時刻ジョブの実観測開始時間start=2025-11-05T08:00:02.135+09:00
遅延秒数実開始 − 予定開始delayMs=2135
実行時間終了 − 開始durationMs=84231
ステータスSuccess / Fail / Skippedstatus=Success

遅延秒数の95パーセンタイル、エラー率、連続失敗回数などにしきい値を設けてアラート化すると保守が安定します。

よくある落とし穴と回避策

  • タイマーをジョブ完了時に再設定してしまい、遅延を累積 → 開始時に次の正時を予約する方式にする。
  • AutoReset = trueのままハンドラで重入 → 無効化して再設定、または排他制御。
  • 例外未捕捉でサービスごと落ちる → ハンドラはtry/catchで必ずログ。
  • 時計の飛び(NTP/DST/スリープ)で大幅遅延 → 毎回再計算+監視。必要なら閾値遅延時に即時実行。
  • 多重稼働(クラスタ) → 分散ロックまたはQuartzクラスタ。

検証手順(現実的で効果の高いテスト)

  1. 高速化モード:本番コードの「1時間」を1分に置き換え、正分(mm:00)で発火するか確認。
  2. 遅延注入:わざとDoWorkAsyncにTask.Delay(TimeSpan.FromMinutes(2))を入れ、次回が正分で実行されるか確認。
  3. 複数インスタンス:排他制御が効くか、二重実行が起きないか。
  4. 時刻ジャンプ:テスト環境で時計を前後に動かし、毎回再計算ロジックがズレを吸収するか。

運用の指針(チェックリスト)

  • 目的は正時基準の維持。スケジュールは壁時計(absolute)で管理。
  • 並列方針:禁止 or 許容 を明記し、コードで担保。
  • 1時間超のジョブに対する方針(スキップ/並列許可/分割)を決める。
  • ログとメトリクスで遅延/実行時間を可視化し、アラートを設定。
  • サーバ時刻のヘルス(NTP同期、サマータイム)を定期確認。
  • クラスタ構成なら分散ロックやジョブストアで単一実行を保証。

「簡単に使える」サンプル(最小構成)

とにかく最短で「正時実行」を達成したい場合の最小サンプルです。初回だけ正時まで待機し、その後は毎回「次の正時」を再計算。ジョブは別スレッドで実行、並列禁止。

public class MyService : ServiceBase
{
    private readonly System.Timers.Timer _timer = new System.Timers.Timer();
    private readonly System.Threading.SemaphoreSlim _gate = new(1, 1);


protected override void OnStart(string[] args)
{
    var now = DateTime.Now;
    var next = new DateTime(now.Year, now.Month, now.Day, now.Hour, 0, 0, now.Kind).AddHours(1);
    _timer.Interval = Math.Max(1, (next - now).TotalMilliseconds);
    _timer.AutoReset = false;
    _timer.Elapsed += (_, __) =>
    {
        // 次の正時を先に予約
        var n = DateTime.Now;
        var nt = new DateTime(n.Year, n.Month, n.Day, n.Hour, 0, 0, n.Kind).AddHours(1);
        _timer.Interval = Math.Max(1, (nt - n).TotalMilliseconds);
        _timer.Start();

        // ジョブ本体(並列禁止)
        _ = Task.Run(async () =>
        {
            if (!await _gate.WaitAsync(0)) return;
            try { await DoWorkAsync(); }
            finally { _gate.Release(); }
        });
    };
    _timer.Start();
}

private Task DoWorkAsync() => Task.CompletedTask;
protected override void OnStop() { _timer.Stop(); _timer.Dispose(); _gate.Dispose(); }


} 

補足:Windows タスク スケジューラで代替する場合

サービス常駐が不要で、1時間ごとの独立ジョブで事足りるなら、Windows タスク スケジューラに毎時のトリガーを設定する方法も堅実です。再試行・タスク同時実行ポリシー(前回が実行中なら新規開始しない)などをGUIで指定できます。アプリ側は単発実行のコンソールアプリに簡素化でき、保守が楽になります。

最終まとめ

  • 要件は「遅延を持ち越さず毎正時に実行」。
  • 実装はタイマーとジョブの分離が王道。タイマーは「正時」を刻むだけ。
  • ドリフトや時刻ジャンプに備えて毎回再計算する設計が実務的。
  • 運用要件(重複防止、リトライ、履歴、分散)はQuartz.NET / Hangfireなどのスケジューラが強力。
  • 監視・ログ・分散ロックを整え、失敗しても原因が追える仕組みにしておく。

以上の方針に沿えば、「7:00に2分かかっても次は8:00で実行される」精度と運用性を無理なく両立できます。まずは最小構成で動かし、遅延・実行時間のメトリクスを観測しながら必要に応じてスケジューラや分散ロックへ段階的に強化していくのが現場では最短経路です。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次