WindowsサービスのTimerでSMS重複送信を防ぐ方法|C#再入防止・排他制御・冪等化

WindowsサービスでTimerを使って定期的にSMSを送ると、送信処理が間隔より長いときに同じ処理が重なり、二重送信が起きがちです。本記事では「なぜ重複するのか」をTimerの仕組みから整理し、再入防止・排他制御・設計面の冪等化まで、現場で使える対策を具体的なC#コード付きで解説します。

目次

現象:3秒ごと送信のつもりが「重複送信」になる

たとえばWindowsサービスで次のように「3秒間隔」でSMS送信処理を回しているとします。

  • 3秒ごとにDBから送信対象を取得
  • SMS APIを呼び出して送信
  • 送信済みフラグを更新

ところが、SMS APIの応答遅延、ネットワーク混雑、プロバイダ側のスロットリング、リトライ、DBのロック待ちなどで1回の送信処理が3秒を超えることがあります。すると次のタイマーイベントが発火し、前回の処理が終わる前に次が走ってしまい、同じユーザー/同じメッセージが二重に送られる――これが今回の問題です。

原因:Timerは「前回処理の完了」を待ってくれない

多くのTimer(System.Timers.Timer、System.Threading.Timerなど)は、基本的に「一定間隔でコールバック(イベント)を発火する」仕組みです。重要なのは、Timer自身は前回の処理が完了したかどうかを管理しない点です。

Windowsサービスでよく使われるTimerの特徴を、ざっくり整理すると次の通りです。

Timerの種類主な用途コールバックが走るスレッド処理が長いときの挙動
System.Timers.Timerサービス/サーバー系ThreadPool(既定)完了を待たず、次の発火が重なり得る
System.Threading.Timer低レベル・軽量ThreadPool完了を待たず、複数のコールバックが並行実行され得る
System.Windows.Forms.TimerWinForms UIUIスレッドUIスレッドが忙しいと遅延する(サービスには不向き)

つまり「3秒ごとにイベントが発火する」だけで、イベント内で実行するSMS送信の完了を保証してくれません。処理が遅れれば遅れるほど、同時に複数の送信処理が走りやすくなり、結果として重複送信・競合更新・リソース枯渇が発生します。

Timerが処理完了前に発火すると何が起きるか

「前回の処理が終わる前に次が始まる」状況を、時間軸でイメージすると分かりやすいです。

時刻Timer発火処理の状態起こり得る問題
t=0秒1回目SMS送信開始—
t=3秒2回目1回目がまだ実行中(例:API待ち)2本目が同じ対象を取得して二重送信
t=6秒3回目1回目・2回目が混在DB更新競合、ThreadPool圧迫、例外多発

特にSMS送信は「外部API」「ネットワーク」「課金」「リトライ」が絡むため、二重送信がそのままユーザー体験の悪化やコスト増に直結します。したがって、Timerのコールバックをそのまま実処理に直結させず、必ず再入(同時実行)を防ぐ仕組みを入れるのが定石です。

まずは結論:同時に1本しか実行されないように“再入防止”する

対策の中心は、「Timerが何回発火しても、実際の送信処理は同時に1本だけ」にすることです。よく使われる選択肢は次の通りです。

  • 実行中フラグ(ただし“原子性”に注意)
  • lock による排他
  • SemaphoreSlim による排他(非同期と相性が良い)
  • Timerを止めて、処理完了後に再開(AutoReset=false)

注意:単純なboolフラグは“そのまま”だと競合する

ネット上でよく見る次のパターンは、発想としては正しいのですが、厳密には競合が残る点に注意が必要です。

private static bool isTaskRunning = false;

private void TimerElapsed(object sender, ElapsedEventArgs e)
{
    if (isTaskRunning) return;

    isTaskRunning = true;
    try
    {
        SendSmsMessages();
    }
    finally
    {
        isTaskRunning = false;
    }
}

問題は「if (isTaskRunning) return;」と「isTaskRunning = true;」が分離していることです。タイミングが悪いと、2本のスレッドが同時にfalseを観測して、両方がtrueにして突入する可能性があります。現場では、低頻度でも発生すると困るので、チェックとセットを原子的に行う方法に寄せるのがおすすめです。

堅い実装例:Interlockedで再入を確実に止める

最小の変更で堅牢性を上げるなら、Interlocked.CompareExchange(またはInterlocked.Exchange)で「実行中フラグ」を原子的に扱う方法が分かりやすいです。

using System;
using System.Diagnostics;
using System.Threading;
using System.Timers;

public class SmsService
{
    private readonly Timer _timer;
    private int _isRunning = 0; // 0:停止中, 1:実行中

    public SmsService()
    {
        _timer = new Timer(3000);
        _timer.AutoReset = true;
        _timer.Elapsed += TimerElapsed;
    }

    public void Start() => _timer.Start();
    public void Stop() => _timer.Stop();

    private void TimerElapsed(object sender, ElapsedEventArgs e)
    {
        // すでに実行中なら即return(原子的に判定)
        if (Interlocked.CompareExchange(ref _isRunning, 1, 0) != 0)
        {
            return;
        }

        try
        {
            SendSmsMessages();
        }
        catch (Exception ex)
        {
            // 例外は握りつぶさずログへ(サービスが落ちるのを防ぐ)
            Debug.WriteLine(ex);
        }
        finally
        {
            Interlocked.Exchange(ref _isRunning, 0);
        }
    }

    private void SendSmsMessages()
    {
        // ここでDBから未送信を取得して送る、など
    }
}

ポイント

  • 「実行中チェック」と「実行開始」を1命令で行うので、並行実行が起きにくい
  • finallyで必ずフラグを戻す(例外時に戻らないと永久停止になる)
  • TimerイベントはThreadPool上で動くため、例外がプロセス全体に影響しないようにログ化する

非同期送信ならSemaphoreSlimが扱いやすい

SMS送信がHTTP通信であれば、async/awaitで非同期化するのが一般的です。非同期と相性が良い再入防止として、SemaphoreSlim(1,1)を使うパターンがあります。

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

public class SmsServiceAsync
{
    private readonly Timer _timer;
    private readonly SemaphoreSlim _gate = new SemaphoreSlim(1, 1);

    public SmsServiceAsync()
    {
        _timer = new Timer(3000);
        _timer.AutoReset = true;
        _timer.Elapsed += TimerElapsedAsync;
    }

    public void Start() => _timer.Start();
    public void Stop() => _timer.Stop();

    private async void TimerElapsedAsync(object sender, ElapsedEventArgs e)
    {
        // すでに実行中ならスキップ(待たずに戻す)
        if (!await _gate.WaitAsync(0).ConfigureAwait(false))
        {
            return;
        }

        try
        {
            await SendSmsMessagesAsync().ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            // TODO: logger.Error(ex);
        }
        finally
        {
            _gate.Release();
        }
    }

    private Task SendSmsMessagesAsync()
    {
        // HTTP送信などを非同期で実装
        return Task.CompletedTask;
    }
}

この方式は「実行中なら次のtickを捨てる」動きになります。もし捨てたくない(遅れた分も必ず処理したい)場合は、後述する「ワーカーループ型」や「キュー化」を検討してください。

排他制御の選び方を比較する

方法強み注意点向いているケース
boolフラグ(単純)実装が最短原子性が弱く、まれに競合が残る試作、単発ツール(本番は非推奨)
Interlocked軽量で堅い処理をスキップする設計になりやすい短い周期でポーリングするサービス
lock直感的ロック中に重い処理をすると待ちが増える同期処理中心・共有状態が多い
SemaphoreSlim非同期と相性が良いasync voidイベントの例外管理が必要HTTP/DBなどI/O主体で非同期化したい

Timerを一旦止めて、処理完了後に再開する(AutoReset=false)

「そもそもTimer側で次の発火を抑えたい」なら、System.Timers.TimerのAutoResetをfalseにして、処理が終わってから手動で再開する方法も有効です。これは“固定レート”ではなく“固定ディレイ”になりますが、重複実行のリスクは下がります。

using System;
using System.Timers;

public class SmsServiceFixedDelay
{
    private readonly Timer _timer;

    public SmsServiceFixedDelay()
    {
        _timer = new Timer(3000);
        _timer.AutoReset = false; // 1回発火したら止まる
        _timer.Elapsed += TimerElapsed;
    }

    public void Start() => _timer.Start();
    public void Stop() => _timer.Stop();

    private void TimerElapsed(object sender, ElapsedEventArgs e)
    {
        try
        {
            SendSmsMessages();
        }
        catch (Exception ex)
        {
            // TODO: ログ
        }
        finally
        {
            // 処理が終わったら次の3秒待ちを開始
            _timer.Start();
        }
    }

    private void SendSmsMessages()
    {
        // 実処理
    }
}

この方式のメリット

  • Timerイベントの重なりが起きにくい
  • 実装がシンプルで読みやすい

この方式の注意点

  • 送信処理が5秒かかった場合、次の開始は「5秒+3秒後」になる(全体が遅れる)
  • 「厳密に3秒ごと」を要件にする場合は別設計が必要

「3秒ごと」をどう定義するか:固定レートと固定ディレイ

実務で揉めやすいのが「3秒ごと」の解釈です。Timerは固定レート(一定時刻ごとに発火)に見えますが、処理が長いと破綻します。要件として次のどちらが必要かを決めると、設計がブレにくくなります。

考え方イメージメリットデメリット
固定レートt=0,3,6,9…で開始したい時刻基準で揃う処理が長いと並行実行が発生しやすい
固定ディレイ処理完了から3秒後に次を開始並行実行しにくい処理が長いと周期が伸びる

SMS送信は外部要因で遅延するので、固定レートにこだわると重複や過負荷の温床になりがちです。多くの場合は固定ディレイ+キュー化の方が安定します。

Timerよりシンプル:1本のワーカーループで順番に処理する

「重複送信を確実に避けたい」「非同期でI/O待ちが多い」なら、Timerイベントよりも1本のループで処理を直列化する方が読みやすく、事故が減ります。Windowsサービスでも、内部的にTaskを立ち上げてループさせればOKです。

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

public class SmsWorkerLoop
{
    private readonly TimeSpan _interval = TimeSpan.FromSeconds(3);
    private CancellationTokenSource _cts;
    private Task _worker;

    public void Start()
    {
        _cts = new CancellationTokenSource();
        _worker = Task.Run(() => RunAsync(_cts.Token));
    }

    public async Task StopAsync()
    {
        _cts.Cancel();
        try
        {
            await _worker.ConfigureAwait(false);
        }
        catch (OperationCanceledException)
        {
            // 正常停止
        }
    }

    private async Task RunAsync(CancellationToken token)
    {
        while (!token.IsCancellationRequested)
        {
            var sw = Stopwatch.StartNew();
            try
            {
                await SendSmsMessagesAsync(token).ConfigureAwait(false);
            }
            catch (Exception ex)
            {
                // TODO: ログ
                Debug.WriteLine(ex);
            }

            // 「処理にかかった時間」を差し引いて待つ(固定レートに近づける)
            var delay = _interval - sw.Elapsed;
            if (delay > TimeSpan.Zero)
            {
                await Task.Delay(delay, token).ConfigureAwait(false);
            }
            // 処理が3秒超なら、待たずに次へ(ただし直列なので重複しない)
        }
    }

    private Task SendSmsMessagesAsync(CancellationToken token)
    {
        // 未送信を取得し、順に送る
        return Task.CompletedTask;
    }
}

この方式なら「前回が終わる前に次が走る」状況は原理的に起こりません。Timerイベントの落とし穴(再入、Disposeタイミング、例外伝播など)も減ります。

.NET 6以降ならPeriodicTimerで書くとさらに見通しが良い

.NET 6以降を使える環境なら、System.Threading.PeriodicTimerを使うと「一定間隔のループ」を自然に書けます。WaitForNextTickAsyncを待っている間に次のtickが来た場合はまとめられるため、重複実行を作りにくいのが利点です。

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

public class SmsPeriodicTimerWorker
{
    private CancellationTokenSource _cts;
    private Task _worker;

    public void Start()
    {
        _cts = new CancellationTokenSource();
        _worker = Task.Run(() => RunAsync(_cts.Token));
    }

    public async Task StopAsync()
    {
        _cts.Cancel();
        try { await _worker.ConfigureAwait(false); }
        catch (OperationCanceledException) { }
    }

    private async Task RunAsync(CancellationToken token)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(3));

        while (await timer.WaitForNextTickAsync(token).ConfigureAwait(false))
        {
            try
            {
                await SendSmsMessagesAsync(token).ConfigureAwait(false);
            }
            catch (Exception ex)
            {
                // TODO: ログ
            }
        }
    }

    private Task SendSmsMessagesAsync(CancellationToken token) => Task.CompletedTask;
}

Timerイベント型よりも「読みやすい直列処理」になりやすいので、長期運用の保守性を重視する場合は検討価値があります。

二重送信を“設計”で防ぐ:冪等化とキュー化が最終防衛線

再入防止は重要ですが、現場ではそれでも二重送信が起きるケースがあります。たとえば次のような状況です。

  • サービスが途中でクラッシュし、再起動後に同じ未送信データを再処理した
  • 複数台でサービスを冗長化し、別インスタンスが同じレコードを拾った
  • SMS APIがタイムアウトし、実際は送れていたのにリトライしてしまった

この手の事故を止めるには、アプリの挙動を冪等(同じ要求を複数回実行しても結果が1回と同じ)に寄せるのが効果的です。

送信キューの状態管理を入れる

代表例は「SMS送信キュー」テーブルを作り、状態遷移で管理する方法です。

状態意味代表的な更新タイミング
Pending送信待ちメッセージ作成時
Processing送信中(ロック取得済み)送信ワーカーが取得した直後
Sent送信完了SMS API成功応答後
Failed失敗(要リトライ)例外発生・失敗応答後

ポイントは「取得した瞬間にPending→Processingへ更新し、他の処理が同じ行を拾えないようにする」ことです。DB側で一意制約(例:(UserId, MessageBodyHash, ScheduledAt)など)を設ける、またはプロバイダがサポートする冪等キー(クライアント参照ID)を使うと、さらに安全です。

複数台運用を見据えるなら“分散ロック”も選択肢

将来的にサービスを2台以上に増やす(冗長化/スケールアウト)と、プロセス内のlockやフラグでは守れません。その場合は「DBでの一意制約」「取得時の状態更新」「分散ロック」を組み合わせます。

守り方概要強み注意点
DB一意制約同一メッセージを保存できないようにする最終的に重複を止められる要件に合う一意キー設計が必要
状態更新でロックPending→Processingを原子的に行うワーカーが複数でも取り合いを防げるタイムアウト回収・失敗時の戻しが必要
分散ロックRedis/DBのロック機構で「実行権」を1台に限定単一実行を全体で保証しやすいロックの期限切れ・死活監視が必要

「重複送信が絶対に許されない」業務では、プロセス内の再入防止だけに頼らず、データ層での冪等性までセットで作ると事故が激減します。

デバッグに役立つ:わざと遅くして重複を再現する

再入問題は環境依存で起きたり起きなかったりするため、修正が効いているかを確認しにくいことがあります。検証では、送信処理に意図的な遅延を入れて再現させると分かりやすいです。

using System.Threading;

private void SendSmsMessages()
{
    // 疑似的に5秒かかる処理を作る(本番では入れない)
    Thread.Sleep(5000);

    // 本来の送信処理
}

この状態でログに「開始」「終了」「スキップ」を出してみると、再入防止が効いているかが一目で分かります。修正後は、スキップが発生しても二重送信が起きないこと、遅延が続いてもThreadPoolが詰まらないことを確認しましょう。

運用で差がつく:ログ・リトライ・レート制限

重複送信対策は、コードだけでなく運用面も重要です。最低限、次の情報はログに残すと原因追跡が楽になります。

  • 送信対象の一意キー(キューID、ユーザーID、電話番号のマスクなど)
  • 送信要求ID(冪等キー)とプロバイダのメッセージID
  • 送信開始・終了時刻、所要時間
  • 失敗時のステータスコード、レスポンス本文(個人情報に注意)

また、SMSプロバイダにはレート制限があることも多いため、3秒ごとに“まとめて送る”設計の場合は、ワーカー内部で送信件数を制御(1回にN件まで、失敗は指数バックオフでリトライ、など)すると安定します。

よくある落とし穴チェックリスト

  • OnStopでTimerを止めても、すでに開始された送信処理は走り続ける:キャンセルトークンで止める設計にする
  • Elapsedイベント内の例外:未処理例外がサービス停止につながらないよう必ず捕捉・記録する
  • 重い処理をThreadPoolに積みすぎる:並行実行が増えるとさらに遅くなり、雪だるま式に詰まる
  • “送ったかどうか”の判定が弱い:DBの状態・プロバイダの応答・冪等キーで多重に守る
  • 「スキップ=失敗」になっていないか:スキップしても次回で未送信を拾える設計にする

まとめ:Timerの重複実行は「再入防止」と「冪等化」で潰す

  • Timerは一定間隔で発火するだけで、前回処理の完了は待たない
  • 最優先は「同時に1本しか送信処理を走らせない」再入防止(Interlocked / SemaphoreSlim / AutoReset=false / ワーカーループ)
  • それでも起きる二重送信に備え、送信キュー・状態管理・一意制約・冪等キーで設計的に潰す
  • ログ、リトライ、レート制限まで含めると、長期運用の安定度が大きく上がる

この記事を書いた人

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

コメント

コメントする

目次