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.Timer | WinForms UI | UIスレッド | 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 / ワーカーループ)
- それでも起きる二重送信に備え、送信キュー・状態管理・一意制約・冪等キーで設計的に潰す
- ログ、リトライ、レート制限まで含めると、長期運用の安定度が大きく上がる

コメント