WinForms(C#)で指定時刻にプログラム起動する方法|0時起動とカウントダウンをTimerで実装

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.TimerUIスレッドで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の一般的な流れ)は以下です。

  1. スタートメニューで「タスク スケジューラ」を検索して起動
  2. 右ペインから「基本タスクの作成」(または「タスクの作成」)
  3. トリガー:毎日/開始時刻:00:00
  4. 操作:プログラムの開始
  5. 「プログラム/スクリプト」に起動したいexeのパスを指定
  6. 必要なら「引数の追加」「開始(オプション)」に作業フォルダを指定

特にハマりやすいのが「開始(オプション)」です。相対パスで設定ファイルを読むプログラムだと、作業フォルダが違うだけで失敗します。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に起動”が必要なら、タスク スケジューラに任せるのが最も堅い

この記事を書いた人

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

コメント

コメントする

目次