WinForms(C#)で「今夜0:00になったら別アプリを起動したい」「ラベルに残り時間をカウントダウン表示したい」—そんなときはTimerの性質を理解して、指定時刻と現在時刻の差(TimeSpan)を毎回計算するのが近道です。実装例と、より確実に動かす設計(タスク スケジューラ活用)まで解説します。
WinFormsのTimerで「指定時刻に実行」を実現する考え方
Windows FormsのTimerは「指定した時刻になったら一度だけ鳴るアラーム」ではなく、一定間隔でTick(イベント)を繰り返す仕組みです。つまり、0:00を狙ってIntervalを工夫するよりも、毎回「指定時刻 − 現在時刻」を計算し、残り時間が0以下になった瞬間に起動処理を走らせるのが、最も分かりやすく、保守もしやすい実装になります。
| やりたいこと | Timerでの実現方法(基本方針) |
|---|---|
| 今夜0:00に別プログラムを起動 | 「次に到来する0:00」をDateTimeで作り、Tickのたびに残りTimeSpanを計算し、0以下で起動してTimer停止 |
| フォームのラベルにカウントダウン表示 | Tickのたびに残りTimeSpanを分解して、label.Textに「日・時・分・秒」を整形して表示 |
この考え方を押さえると、0:00に限らず「毎日12:00」「指定した日時」などにも同じパターンで拡張できます。
「今夜0:00」をDateTimeで正しく作る(翌日にまたがる罠)
質問でよくあるのが、22:50の時点で「今夜0:00」=(今日の0:00)と勘違いしてしまうケースです。22:50から見た「次の0:00」は、日付をまたいだ明日の00:00です。ここを間違えると、残り時間がマイナスになり、起動処理が即時に走ってしまいます。
「次に到来する指定時刻」を作る汎用パターンは次の通りです(0:00に限らず使えます)。
private static DateTime GetNextOccurrence(DateTime now, TimeSpan targetTime)
{
// 例:targetTime = 00:00:00 なら「次の0:00」
var todayTarget = now.Date + targetTime;
// すでに過ぎていたら翌日に繰り越す(== now も “次回” にしたいなら < にする)
return (todayTarget <= now) ? todayTarget.AddDays(1) : todayTarget;
}
今夜0:00(次の0:00)なら、次のように呼び出します。
var target = GetNextOccurrence(DateTime.Now, TimeSpan.Zero);
運用上の誤解を減らすなら、画面のどこかに「ターゲット日時」も表示しておくのがおすすめです(例:2026/01/03 00:00)。
最小構成:System.Windows.Forms.Timer 1本でカウントダウンと起動
「フォーム上のラベルを更新したい」場合、まずはSystem.Windows.Forms.Timerを使うのが分かりやすいです。TickはUIスレッド上で動くため、label.Textを直接更新してもスレッド例外が起きません(逆に言うと、UIが重いとTickが遅れます)。
以下は、フォーム起動時に「次の0:00」を目標にし、1秒ごとにカウントダウン表示し、0以下になった瞬間に外部プログラムを起動する例です。
using System;
using System.Diagnostics;
using System.IO;
using System.Windows.Forms;
public partial class Form1 : Form
{
// ローカルではなくフィールドで保持する(GCで止まる、参照が切れる等を防ぐ)
private readonly Timer _uiTimer = new Timer();
private DateTime _targetDateTime;
private bool _launched;
public Form1()
{
InitializeComponent();
}
private void Form1_Load(object sender, EventArgs e)
{
// 「次の0:00」をターゲットにする
_targetDateTime = GetNextOccurrence(DateTime.Now, TimeSpan.Zero);
_uiTimer.Interval = 1000; // 1秒ごと(表示用なので厳密でなくてOK)
_uiTimer.Tick += UiTimer_Tick;
_uiTimer.Start();
// 起動直後にも表示を更新しておく
UpdateCountdownLabel();
}
private void UiTimer_Tick(object sender, EventArgs e)
{
var remaining = _targetDateTime - DateTime.Now;
// 0以下になったら起動(多重起動防止のガードも入れる)
if (!_launched && remaining <= TimeSpan.Zero)
{
_launched = true;
_uiTimer.Stop();
TryLaunchExternalProgram();
// 表示上は0に固定する
labelCountdown.Text = "0日 00:00:00";
return;
}
UpdateCountdownLabel();
}
private void UpdateCountdownLabel()
{
var remaining = _targetDateTime - DateTime.Now;
if (remaining < TimeSpan.Zero)
remaining = TimeSpan.Zero;
labelCountdown.Text = FormatRemaining(remaining);
}
private static string FormatRemaining(TimeSpan remaining)
{
// 例:2日 03:04:05
return $"{remaining.Days}日 {remaining.Hours:00}:{remaining.Minutes:00}:{remaining.Seconds:00}";
}
private void TryLaunchExternalProgram()
{
// 実行したいプログラムのパス(例:環境に合わせて変更)
var exePath = @"C:\Tools\BatchRunner\BatchRunner.exe";
var args = "--mode nightly";
try
{
if (!File.Exists(exePath))
{
MessageBox.Show($"起動対象が見つかりません: {exePath}", "エラー",
MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
var psi = new ProcessStartInfo
{
FileName = exePath,
Arguments = args,
WorkingDirectory = Path.GetDirectoryName(exePath) ?? "",
UseShellExecute = true, // .NET(Core/5+)でも安定させやすい
};
Process.Start(psi);
}
catch (Exception ex)
{
MessageBox.Show($"起動に失敗しました: {ex.Message}", "エラー",
MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private static DateTime GetNextOccurrence(DateTime now, TimeSpan targetTime)
{
var todayTarget = now.Date + targetTime;
return (todayTarget <= now) ? todayTarget.AddDays(1) : todayTarget;
}
}
ここまでが「最短で動く」構成です。重要なのは、Tickのたびに残り時間を計算し直している点です。これにより、Tickが多少遅れても、残り時間は常に現在時刻基準で再計算されます。
補足:上の例では簡潔さを優先してTimerを明示的にDisposeしていません。アプリ終了と同時にフォームが破棄される運用なら実害は少ないですが、フォームを閉じてもプロセスが残る設計や複数フォームで使い回す設計なら、FormClosedなどでStop()・Dispose()しておくと安全です。
Timerをフィールドで保持する理由(ローカル変数の落とし穴)
WinFormsでTimerを次のようにローカル変数で作ってしまうと、状況によってはTimerが参照されなくなり、ガベージコレクションで回収されてTickが止まることがあります。
// NG例(フォームがtimerを保持していない)
private void StartTimer()
{
var timer = new Timer();
timer.Interval = 1000;
timer.Tick += (s, e) => { /* ... */ };
timer.Start();
}
安定して動かすために、
- フォームのフィールドとして保持する
- または、Designerで配置して
componentsコンテナに紐付ける
のいずれかにして、「Timerの生存期間=フォームの生存期間」に揃えるのが定石です。
カウントダウン表示を見やすくするコツ
残り時間の表示は、使う人にとって「ぱっと見で分かる」ことが大切です。特に「日数」が絡むと、Hoursだけでは足りないため、Daysも合わせて表示するのが安全です。
| 表示例 | 向いているケース | 実装のポイント |
|---|---|---|
| 0日 01:09:12 | 数時間以内の起動が多い | 日数+HH:mm:ssの固定フォーマットで読みやすい |
| 残り 1時間 9分 12秒 | 非エンジニア向けの画面 | 単位を日本語にすると直感的(ただし表示幅が伸びやすい) |
| 2026/01/03 00:00まで 01:09:12 | ターゲット日時の誤解が起きやすい | 「いつまで」が明示され、日付またぎの勘違いを減らせる |
運用で事故が多いのは「ターゲットが今日なのか明日なのか」が曖昧なケースです。ラベルの横に小さくでも良いので、ターゲット日時を併記すると、問い合わせが減ります。
起動が遅れる原因と対策(UIスレッド・Tick遅延)
System.Windows.Forms.TimerはUIスレッドのメッセージループに依存するため、フォームが重い処理をしているとTickが遅れます。結果として、0:00ぴったりの起動を期待していると「数秒〜数十秒遅れた」と感じることがあります。
遅延の典型パターンと対策をまとめます。
| 症状 | よくある原因 | 対策 |
|---|---|---|
| カウントダウンが飛ぶ/カクつく | UIスレッドが別処理で詰まっている | 重い処理はTask.Run等でバックグラウンドへ、UIは軽く保つ |
| 0:00の起動が数秒遅れる | Tickが0:00ちょうどに来ない/UIが固まっていた | 「起動用」を別系統に分ける(後述)/またはタスクスケジューラに任せる |
| 何度も起動してしまう | 起動後にTimerを止めていない/ガードがない | Stop()+boolフラグで多重起動を防止 |
ポイントは、Timerそのものを「正確な時計」にしようとしないことです。Timerはあくまで「確認のきっかけ」。確認のたびにDateTime.Nowで時刻を見て判断する、という役割分担が一番堅いです。
より正確に起動する:UI更新用と起動用で役割を分ける
「ラベルの更新はUIスレッドで」「起動処理はなるべく正確に」という要求が同時にあるなら、Timerを2系統に分けるのが定石です。
- UI更新:
System.Windows.Forms.Timer(1秒間隔など) - 起動:
System.Threading.TimerやTask.Delay(単発で実行し、終わったら破棄)
ただし注意点があります。単発タイマーは「残り時間」を最初に一度だけ計算して待つため、実行までの間にPCの時刻が変更された場合(手動変更、NTP補正、サマータイム切り替えなど)に、意図した“壁時計”の0:00とズレる可能性があります。
一方、Tickごとに再計算する方式は、時刻変更が入っても追従しやすい、というメリットがあります。要件に応じて選びましょう。
それでも「UIが固まっても起動だけは外したくない」なら、次のような二段構えが実用的です(Timer型名の衝突を避けるため、WinForms側は別名にしています)。
using System;
using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Windows.Forms;
using WinFormsTimer = System.Windows.Forms.Timer;
public partial class Form1 : Form
{
private readonly WinFormsTimer _uiTimer = new WinFormsTimer();
private System.Threading.Timer? _launchTimer;
private DateTime _targetDateTime;
private bool _launched;
private void Form1_Load(object sender, EventArgs e)
{
_targetDateTime = GetNextOccurrence(DateTime.Now, TimeSpan.Zero);
// UI更新(軽く、短く)
_uiTimer.Interval = 1000;
_uiTimer.Tick += (s, ev) => UpdateCountdownLabel();
_uiTimer.Start();
UpdateCountdownLabel();
// 起動(単発)
var due = _targetDateTime - DateTime.Now;
if (due < TimeSpan.Zero) due = TimeSpan.Zero;
_launchTimer = new System.Threading.Timer(_ =>
{
if (_launched) return;
_launched = true;
Exception? error = null;
try
{
LaunchExternalProgram();
}
catch (Exception ex)
{
error = ex;
}
_launchTimer?.Dispose();
_launchTimer = null;
BeginInvoke(new Action(() =>
{
_uiTimer.Stop();
labelCountdown.Text = "0日 00:00:00";
if (error != null)
{
MessageBox.Show($"起動に失敗しました: {error.Message}", "エラー",
MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}));
},
null,
dueTime: due,
period: Timeout.InfiniteTimeSpan); // 単発
}
protected override void OnFormClosing(FormClosingEventArgs e)
{
_launchTimer?.Dispose();
_launchTimer = null;
base.OnFormClosing(e);
}
private void UpdateCountdownLabel()
{
var remaining = _targetDateTime - DateTime.Now;
if (remaining < TimeSpan.Zero) remaining = TimeSpan.Zero;
labelCountdown.Text = FormatRemaining(remaining);
}
private static string FormatRemaining(TimeSpan remaining)
=> $"{remaining.Days}日 {remaining.Hours:00}:{remaining.Minutes:00}:{remaining.Seconds:00}";
private static DateTime GetNextOccurrence(DateTime now, TimeSpan targetTime)
{
var todayTarget = now.Date + targetTime;
return (todayTarget <= now) ? todayTarget.AddDays(1) : todayTarget;
}
private static void LaunchExternalProgram()
{
var exePath = @"C:\Tools\BatchRunner\BatchRunner.exe";
var args = "--mode nightly";
if (!File.Exists(exePath))
throw new FileNotFoundException("起動対象が見つかりません", exePath);
var psi = new ProcessStartInfo
{
FileName = exePath,
Arguments = args,
WorkingDirectory = Path.GetDirectoryName(exePath) ?? "",
UseShellExecute = true,
};
Process.Start(psi);
}
}
この構成だと、カウントダウン表示はUIタイマーで継続しつつ、起動はスレッドプール側の単発タイマーで狙えます。UIを触る必要がある部分だけBeginInvokeで戻すのがポイントです。
毎日実行に拡張する場合の考え方(0:00で毎晩起動など)
「今夜だけ」ではなく「毎晩0:00に実行したい」場合は、起動後にターゲットを更新して次回分に回すだけです。Tick再計算方式なら実装がシンプルで、時刻変更にも追従しやすいです。
// 起動後に次回の0:00を再設定する例
_launched = false;
_targetDateTime = GetNextOccurrence(DateTime.Now, TimeSpan.Zero);
_uiTimer.Start();
ただし、PCがスリープしている・アプリが終了しているといった状況でも確実に動かしたいなら、ここで無理にWinForms側に寄せず、タスク スケジューラ運用に切り替える方が堅牢です。
要件別:どのTimerを選ぶべきか(比較表)
| 選択肢 | 長所 | 短所 | 向いている用途 |
|---|---|---|---|
System.Windows.Forms.Timer | UIスレッドでTickするのでラベル更新が安全/実装が単純 | UIが重いと遅れる/高精度ではない | カウントダウン表示、軽い定期処理 |
System.Timers.Timer | バックグラウンドで動きやすい/比較的安定 | UI更新にはInvokeが必要/多重実行対策が必要な場合あり | サーバー寄りの定期処理、UIとは分離した監視 |
System.Threading.Timer | 単発待ちが簡単/UIと分離できる | UIは触れない/時刻変更への追従は自前で考える | 「決まった時間に1回だけ何かする」 |
Task.Delay(async/await) | コードが読みやすい/キャンセルしやすい | アプリが動いている前提/時刻変更やスリープ復帰などの考慮が必要 | フォーム内で完結する予約実行、テスト用途 |
最も堅い方法:タスク スケジューラで「指定時刻起動」をOSに任せる
要件が「アプリが起動していなくても0:00に必ず開始したい」なら、WinForms側で頑張るよりも、Windows標準のタスク スケジューラに任せるのが一番堅いです。フォームアプリは「状況表示(カウントダウン)」に専念し、実際の起動はOSが責任を持つ、という分担にできます。
タスク スケジューラを使うメリットは次の通りです。
- アプリが落ちていても、起動していなくても、指定時刻に開始できる
- ログオン状態や権限、実行ユーザーをタスク側で制御できる
- 「PCをスリープから復帰して実行」など、OS側の機能を活用できる
設定手順(Windows 10/11の一般的な流れ)は以下です。
- スタートメニューで「タスク スケジューラ」を検索して起動
- 右ペインから「基本タスクの作成」(または「タスクの作成」)
- トリガー:毎日/開始時刻:00:00
- 操作:プログラムの開始
- 「プログラム/スクリプト」に起動したいexeのパスを指定
- 必要なら「引数の追加」「開始(オプション)」に作業フォルダを指定
特にハマりやすいのが「開始(オプション)」です。相対パスで設定ファイルを読むプログラムだと、作業フォルダが違うだけで失敗します。exeと同じフォルダを開始ディレクトリにするのが無難です。
| 設定項目 | おすすめ | 理由 |
|---|---|---|
| 開始(オプション) | exeのフォルダ | 相対パスのファイル参照で事故りにくい |
| 「ユーザーがログオンしているかどうかにかかわらず実行する」 | 必要に応じてON | 夜間バッチなどでログオン不要にできる(資格情報が必要な場合あり) |
| 「コンピューターをスリープ解除してこのタスクを実行する」 | 必要ならON | スリープ中でも0:00に実行させたい場合に有効 |
| 「タスクを停止する(実行時間)」 | 安全のため設定 | 暴走時の保険になる(夜間運用で重要) |
タスク スケジューラ運用に寄せるなら、WinFormsアプリ側は次のように割り切れます。
- 表示用に「次の実行予定」や「残り時間」を出す
- “実行そのもの”はタスクが担保するので、フォームアプリが落ちても致命傷にならない
設計の選び方:要件から逆算するチェックリスト
「Timerで頑張る」か「タスク スケジューラに任せる」かは、要件で決めるのが正解です。迷ったら次の表で判断すると早いです。
| 要件 | おすすめ | 理由 |
|---|---|---|
| アプリが起動している間だけ動けばよい | WinForms Timer方式 | 実装が簡単で、画面更新も自然にできる |
| 必ず0:00に開始したい(アプリが落ちても困る) | タスク スケジューラ | OSが実行を保証しやすい |
| 表示はWinFormsで、起動はなるべく正確に | UI更新+起動を分離 | UI負荷の影響を減らせる |
| PC時刻の変更にも追従したい | Tickごと再計算方式 | 壁時計基準の判定に強い |
よくある質問(FAQ)
0:00ぴったりに起動しません。ミリ秒単位で合わせられますか?
結論として、WinForms上のTimerだけで“常にミリ秒精度”を保証するのは現実的ではありません。OSのスケジューリング、UIスレッドの混雑、他プロセス負荷などの影響を受けるためです。必要な精度が「数秒以内」ならTick方式で十分なことが多いですが、厳密に保証したい場合はタスク スケジューラなどOS側の仕組みを使うのが安全です。
TimerのIntervalを「残りミリ秒」にすれば一発で起動できませんか?
「残り時間」を一度だけ計算してIntervalに入れる方法は、理屈上は可能です。ただし、WinForms TimerのIntervalはint(ミリ秒)で上限がありますし、途中でUIが詰まれば遅れます。また、実行までにPC時刻が変わると壁時計基準の0:00からズレます。
そのため、UI表示も兼ねる場合は短い間隔でTickし、都度再計算する方がトラブルが少ないです。
日付をまたぐ指定(翌日12:00など)も同じ方法でいけますか?
いけます。ポイントは「TimeSpanだけで管理しない」ことです。日付込みのDateTime(またはDateTimeOffset)でターゲットを持ち、target - DateTime.Nowで残りを出すのが安全です。特に毎日運用するなら、起動後に次回ターゲットを作り直す実装にすると拡張しやすくなります。
外部プログラム起動で失敗します。原因の切り分けは?
次を順に確認すると原因が絞れます。
- exeパスは存在するか(相対パスではなく絶対パス推奨)
- 作業フォルダ(WorkingDirectory)が必要ではないか
- 管理者権限が必要な処理ではないか(タスク側の「最上位の特権で実行」等)
- 引数にスペースが含まれる場合、意図どおり渡っているか
WinForms側で起動するなら、ProcessStartInfoでWorkingDirectoryを明示し、例外をMessageBoxやログに出すだけでもデバッグがかなり楽になります。
まとめ:WinFormsで指定時刻起動+カウントダウンを失敗しないコツ
- Timerは「指定時刻に鳴る」のではなく「一定間隔で確認する」ものとして使う
- Tickごとに
target - DateTime.Nowで残りを計算し、0以下で起動して停止する - Timerはフォームのフィールドとして保持し、多重起動を防ぐガードを入れる
- UI更新は
System.Windows.Forms.Timer、起動は別タイマーに分離すると安定しやすい - “確実に0:00に起動”が必要なら、タスク スケジューラに任せるのが最も堅い

コメント