WinFormsでフォーム・アプリを1つだけ表示:staticとMutex

WinFormsでフォームを1つだけ表示するstaticインスタンスと、Mutexでアプリの多重起動を防ぐ方法を.NET 10で検証。Global\とLocal\の範囲、既存ウィンドウを前面へ、macOSでの実測まで。

2026年11月改訂: この記事は2021年に韓国語で書いた記事を、日本語向けに書き直したものです。例を .NET 10 でコンパイルし直しました。WinForms と Win32 API を使う部分は Windows 専用のため、Windows向けに.NET 10でコンパイル済み、このマシン(Apple Silicon の Mac)では実行していません。 一方、Mutex で多重起動を検知する部分はクロスプラットフォームなので、コンソール版を macOS で実際に2つのプロセスとして動かし、その出力をそのまま載せています。ただし macOS の名前付き Mutex は Windows と範囲のルールが違います。 2021年版の C# の例(this.Forms.Form2)はコンパイルできなかったので差し替えました。

WinForms で「ボタンを押すたびに同じウィンドウがまた開く」「アプリを2回起動すると2つ立ち上がる」を防ぎたい人向けの記事です。2021年版は前者(フォーム1つ)だけを扱っていましたが、検索で来る人の多くは後者(プロセス1つ、いわゆる多重起動防止)を探しているので、両方をまとめました。結論は、フォームなら static フィールド+IsDisposed の確認、プロセスなら名前付き Mutex(または VB の WindowsFormsApplicationBase)です。

判断の図。問い:何を1つにしたいか。動いているアプリ内のウィンドウ1枚なら、static フィールドに持ち、IsDisposed が true なら作り直す。プロセス全体なら次の問い:Visual Basic のアプリケーションモデルを使ってよいか。はいなら WindowsFormsApplicationBase の IsSingleInstance = true。いいえなら名前付き Mutex と、最初のインスタンスへの独自の通知。
まず「何を1つにしたいか」を決める。アプリ内のウィンドウ1枚なのか、プロセスそのものなのかで、使う道具が違う。

2021年版の C# の例がコンパイルできなかった理由

2021年版は VB.NET の My.Forms.Form2.Show() を紹介し、C# 版として this.Forms.Form2.Show() を載せていました。.NET 10 の WinForms プロジェクトにそのまま入れると、こう失敗します。

Program.cs(20,33): error CS1002: ; が必要です
Program.cs(20,33): error CS1010: 定数の 新しい行です
Program.cs(20,33): error CS1012: 文字リテラルに文字が多すぎます
Program.cs(20,51): error CS1002: ; が必要です
Program.cs(23,57): error CS1002: ; が必要です
…

各行の末尾が ' で始まる VB 式のコメントになっていて、C# では文字リテラルとして読まれたためです。コメントを // に直しても、本当のエラーが残ります。

Program.cs(20,14): error CS1061: 'Form1' に 'Forms' の定義が含まれておらず、型 'Form1' の最初の引数を受け付けるアクセス可能な拡張メソッド 'Forms' が見つかりませんでした。using ディレクティブまたはアセンブリ参照が不足していないことを確認してください

My.Forms(既定のインスタンス)は Visual Basic のコンパイラーが用意してくれる機能で、C# には相当するものがありません。C# では自分で書くことになり、それが2021年版の後半の static プロパティの方法です。

フォームを1つだけ:static インスタンスと IsDisposed

2021年版の static プロパティのコードはコンパイルできましたが、nullable の警告が1つ出ました。

Forms.cs(7,26): warning CS8618: null 非許容の フィールド '_instance' には、コンストラクターの終了時に null 以外の値が入っていなければなりません。'required' 修飾子を追加するか、フィールド を Null 許容として宣言することを検討してください。

フィールドを Form2? で宣言すれば消えます。今書くなら「表示する」ところまで1つのメソッドにまとめた方がミスが減ります(警告0件でコンパイル)。

public sealed class ToolWindow : Form
{
    static ToolWindow? s_current;

    public static ToolWindow ShowSingle(IWin32Window? owner = null)
    {
        if (s_current is null || s_current.IsDisposed)
        {
            s_current = new ToolWindow();
            s_current.Show(owner);
        }
        else
        {
            if (s_current.WindowState == FormWindowState.Minimized)
                s_current.WindowState = FormWindowState.Normal;
            s_current.Activate();
        }
        return s_current;
    }

    ToolWindow() { Text = "Tool"; }
}
  • 肝は IsDisposed の確認です。 Show で開いたフォームは閉じると破棄され、破棄済みのインスタンスに Show すると ObjectDisposedException になります。2021年版の説明どおりです。
  • 2021年版はこのプロパティを「スレッドセーフではない」と注意していましたが、WinForms のフォームはそもそも UI スレッドでしか作ったり触ったりできません。lock を足すより、UI スレッドからだけ呼ぶのが正しい対処です。
  • 2021年版の、TextBox1 の Modifiers を Public にして外から読む方法は動きますが、必要な値だけをプロパティで公開した方が、画面のレイアウトを変えたときに壊れにくくなります。

「閉じずに隠す」の落とし穴

2021年版の最後の方法は、static readonly のインスタンスを作り、閉じる操作を常に取り消して隠すものでした。

protected override void OnFormClosing(FormClosingEventArgs e)
{
    e.Cancel = true;
    this.Hide();
}

コンパイルはできます(警告0件)が、問題が3つあります。

  1. アプリが終了しなくなることがあります。 Microsoft Learn の Application.Exit のページによると、Exit は開いているすべてのフォームで FormClosing を発生させ、1つでも取り消されると何もせずに戻ります。このフォームが存在する限り Application.Exit() は無視され、Windows のシャットダウンやサインアウト(CloseReason.WindowsShutDown)も妨げます。
  2. base.OnFormClosing(e) を呼んでいないので、外から登録した FormClosing イベントハンドラーが動きません。
  3. static readonly フィールドの初期化のタイミングでフォームが作られます。 型に最初に触れたときなので、それが ApplicationConfiguration.Initialize()(高DPIモードの設定など)より前だったり、UI スレッド以外だったりすると、わかりにくい不具合になります。高DPIモードを最初のウィンドウより前に設定する必要がある理由はモーダル・モードレスフォームで説明しています。

ユーザーが閉じたときだけ隠すように直したものです。

protected override void OnFormClosing(FormClosingEventArgs e)
{
    if (e.CloseReason == CloseReason.UserClosing)
    {
        e.Cancel = true;
        Hide();
    }
    base.OnFormClosing(e);
}

FormClosing で e.Cancel を使う別の例(入力チェック)はOK・キャンセルボタンにあります。

アプリを1つだけ:名前付き Mutex と既存ウィンドウの前面化

プロセス単位の多重起動は名前付き Mutex で防ぎます。最初のプロセスは Mutex を新しく作り(createdNew == true)、2つ目は既存のものを開くことになるので false を受け取ります。

フローチャート。起動すると new Mutex(true, 名前, out createdNew) を作る。createdNew が true なら Application.Run を呼び、Run が戻るまで using 宣言で Mutex を生かしておく。false なら AllowSetForegroundWindow を呼び、登録したメッセージ WM_SHOWME を送って終了する。点線の矢印は、そのメッセージが最初のインスタンスに届き、メインウィンドウを元のサイズに戻してアクティブにすることを示す。
この記事で使う Mutex による起動の流れ。Application.Run まで進むのは最初のプロセスだけで、後から起動したものは最初のプロセスに前面に出るよう頼んで終了する。
static partial class Program
{
    const string MutexName = @"Local\TLab.SingleDemo.{6F1C2A43-8E0B-4D7A-9C55-2B7F0E9A1D10}";
    internal static readonly int WM_SHOWME = RegisterWindowMessage("TLab.SingleDemo.ShowMe");

    [STAThread]
    static void Main()
    {
        using var mutex = new Mutex(initiallyOwned: true, MutexName, out bool createdNew);
        if (!createdNew)
        {
            AllowSetForegroundWindow(ASFW_ANY);                  // 最初のインスタンスに前面化を許可
            PostMessage(HWND_BROADCAST, WM_SHOWME, 0, 0);
            return;
        }

        ApplicationConfiguration.Initialize();
        Application.Run(new MainForm());
        // using のおかげで Run が終わるまで Mutex が生きている
    }

    static readonly nint HWND_BROADCAST = 0xffff;
    const int ASFW_ANY = -1;

    [LibraryImport("user32.dll", EntryPoint = "RegisterWindowMessageW", StringMarshalling = StringMarshalling.Utf16)]
    private static partial int RegisterWindowMessage(string name);

    [LibraryImport("user32.dll")]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static partial bool PostMessage(nint hWnd, int msg, nint wParam, nint lParam);

    [LibraryImport("user32.dll")]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static partial bool AllowSetForegroundWindow(int processId);
}

public class MainForm : Form
{
    protected override void WndProc(ref Message m)
    {
        if (m.Msg == Program.WM_SHOWME)
        {
            if (WindowState == FormWindowState.Minimized) WindowState = FormWindowState.Normal;
            Activate();
            return;
        }
        base.WndProc(ref m);
    }
}

このコードは net10.0-windows で警告0件でコンパイルできました(LibraryImport のため <AllowUnsafeBlocks>true</AllowUnsafeBlocks> が必要)。実行はしていないので、以下はドキュメントに基づく説明です。

  • 名前の範囲。 Microsoft Learn の「Kernel object namespaces」によると、Local\(接頭辞なしも同じ)はログオンセッションごと、Global\ は全セッション共通です。高速ユーザー切り替えやリモートデスクトップで2人が同時にサインインしているとき、Local\ なら各自1つずつ、Global\ ならマシン全体で1つです。名前には GUID を入れて、他のアプリとぶつからないようにします。
  • 前面に出す。 Microsoft Learn の SetForegroundWindow のページには、前面ウィンドウを変えられるプロセスの条件が書かれています。バックグラウンドにいる1つ目のプロセスが自分で前に出ようとしても、タスクバーのボタンが点滅するだけで終わることがあります。そこで、ユーザーが今起動した(=前面にいる)2つ目のプロセスが AllowSetForegroundWindow で権利を渡してからメッセージを送ります。HWND_BROADCAST はすべてのトップレベルウィンドウに届くので、メッセージ名も一意にします。
  • Mutex を最後まで生かす。 using var で Main の最後まで参照を保ちます。Release ビルドでローカル変数が早めに回収され、Mutex が消えてしまうという問題はよく知られています。macOS で GC.Collect() を繰り返して再現を試みました(既定の設定と DOTNET_TieredCompilation=0 の両方)が、2つ目のプロセスは常に createdNew=False を受け取り、再現しませんでした。再現するかどうかにかかわらず、using か GC.KeepAlive(mutex) で意図を明示しておくのが安全です。

macOS で2つのプロセスを実際に動かす

Mutex 自体はクロスプラットフォームなので、WinForms の部分を除いたコンソール版を macOS で動かしました(Release ビルド、.NET 10.0.14)。

var sw = Stopwatch.StartNew();
var mutex = new Mutex(initiallyOwned: true, name, out bool createdNew);
double ms = sw.Elapsed.TotalMilliseconds;
Console.WriteLine($"{tag} {DateTime.Now:HH:mm:ss.fff} name={name} createdNew={createdNew} ({ms:F2} ms)");
if (!createdNew) { Console.WriteLine($"{tag} another instance is running -> exit"); return; }
Thread.Sleep(holdMs);
mutex.ReleaseMutex();
mutex.Dispose();

1つ目のプロセスに2秒間 Mutex を持たせ、0.7秒後に2つ目を起動、1つ目が終わった後に3つ目を起動しました。

[pid 63731] 17:18:56.883 name=Local\TLab.MutexDemo createdNew=True (1.59 ms)
[pid 63739] 17:18:57.586 name=Local\TLab.MutexDemo createdNew=False (1.48 ms)
[pid 63739] another instance is running -> exit
[pid 63731] 17:18:59.088 exit
-- after first exited:
[pid 63751] 17:18:59.126 name=Local\TLab.MutexDemo createdNew=True (1.84 ms)

ここまでは Windows で期待する動作と同じです。違ったのは範囲です。2つ目のプロセスを新しい Unix セッション(setsid)で起動して比べました。

名前 同じターミナルセッション 新しいセッションで2つ目を起動
Local\TLab.S createdNew=False createdNew=True(お互いが見えない)
TLab.S(接頭辞なし) — createdNew=True
Global\TLab.S — createdNew=False
Local\TLab.X の後に Global\TLab.X createdNew=True(別の Mutex) —

macOS の .NET は名前付き Mutex を $TMPDIR/.dotnet/shm/ の下のファイルで実装しており、Local\ の名前は session<セッションID> フォルダー、Global\ は global フォルダーにできていました。つまり macOS の Local\ は「ログオンセッション」ではなくプロセスの Unix セッション(たいていターミナルのウィンドウ1つ)単位なので、ターミナルを2つ開いてそれぞれで起動すると両方動きます。また $TMPDIR はユーザーごとに違うので、Global\ も事実上ユーザー単位です。1つ目のプロセスを kill -9 で強制終了した直後でも、次のプロセスは createdNew=True を受け取り、残ったファイルに邪魔されることはありませんでした。

.NET 10 には名前付き待機ハンドル用の NamedWaitHandleOptions も入っていました。既定値を出力すると CurrentUserOnly=True, CurrentSessionOnly=True で、CurrentSessionOnly = false で開くとセッションが違っても2つ目が検知されました。

[pid 64747] options(CurrentUserOnly=true, CurrentSessionOnly=false) createdNew=True
[pid 64749] options(CurrentUserOnly=true, CurrentSessionOnly=false) createdNew=False

新しいプロセスでの new Mutex(...) 1回の時間は、20回の計測で1.28〜2.49ms(初回呼び出しの JIT を含む)でした。起動時間を気にするほどではありません。

もう1つの方法:WindowsFormsApplicationBase

C# のアプリでも Microsoft.VisualBasic.ApplicationServices.WindowsFormsApplicationBase を使えます。Microsoft.VisualBasic.Forms.dll は Windows Desktop のランタイムに含まれているので、追加のパッケージは不要です(net10.0-windows で警告0件)。

using Microsoft.VisualBasic.ApplicationServices;

sealed class SingleInstanceApp : WindowsFormsApplicationBase
{
    public SingleInstanceApp()
    {
        IsSingleInstance = true;
        EnableVisualStyles = true;
        HighDpiMode = HighDpiMode.PerMonitorV2;
    }

    protected override void OnCreateMainForm() => MainForm = new MainForm();

    protected override void OnStartupNextInstance(StartupNextInstanceEventArgs e)
    {
        e.BringToForeground = true;                 // 既定値は true
        base.OnStartupNextInstance(e);
        // e.CommandLine に2回目の起動の引数が入っている
    }
}
// Main で:new SingleInstanceApp().Run(args);

利点は、2回目の起動のコマンドライン引数を1つ目のインスタンスに渡し、前面に出すところまでやってくれることです。ファイルをダブルクリックするたびに既存のウィンドウで開きたい、というときに向いています。dotnet/winforms の SingleInstanceHelpers.vb を見ると、.NET ではこれが PipeOptions.CurrentUserOnly 付きの名前付きパイプで実装され、引数は DataContractSerializer でシリアライズして送られています。そのため範囲は「ユーザー単位」で、Mutex のように Global\ と Local\ を選ぶことはできません。範囲を自分で決めたいなら Mutex を使います。

やりたいこと 方法
アプリ内でウィンドウ1つ static フィールド+IsDisposed の確認+Activate()
閉じたら隠す CloseReason.UserClosing のときだけ e.Cancel+Hide()、base を呼ぶ
セッション(ユーザー)ごとにプロセス1つ Local\+GUID の名前の Mutex
マシン全体でプロセス1つ Global\+GUID の名前の Mutex
2回目の起動の引数を渡す WindowsFormsApplicationBase+StartupNextInstance

確認環境:.NET SDK 10.0.403/ランタイム 10.0.14、Microsoft.WindowsDesktop.App.Ref 10.0.14、C# 14、macOS(Apple Silicon の Mac)。WinForms と Win32 のコードは dotnet build -p:EnableWindowsTargeting=true でコンパイルのみ行い、Mutex のコンソール版だけを macOS で実行しました。2021年版には画像はありませんでした。

よくある質問

C# でも My.Forms.Form2.Show() のように書ける?

書けません。My.Forms は Visual Basic 専用の機能です。2021年版の C# の例 this.Forms.Form2.Show() は .NET 10 でコンパイルできませんでした(VB 式のコメントで CS1002・CS1010・CS1012、コメントを直しても 'Form1' に 'Forms' の定義がないという CS1061)。C# では static フィールドにインスタンスを持ち、IsDisposed を確認して作り直します。

Mutex の名前には Global\ と Local\ のどちらを付ける?

Microsoft Learn の「Kernel object namespaces」によると、Local\(または接頭辞なし)はログオンセッションごと、Global\ は全セッション共通です。ユーザーのセッションごとに1つなら Local\、リモートデスクトップも含めマシン全体で1つなら Global\ です。macOS で実測すると挙動が違い、Local\ と接頭辞なしの名前は別のターミナルセッションから見えず、Global\ は見えました。

2回目の起動時に、動いているウィンドウを前面に出すには?

2つ目のプロセスが AllowSetForegroundWindow を呼び、RegisterWindowMessage で登録したメッセージを PostMessage(HWND_BROADCAST, …) で送って終了します。1つ目のプロセスは WndProc でそのメッセージを受け、最小化を解除して Activate() します。Windows は SetForegroundWindow を呼べるプロセスを制限している(Microsoft Learn「SetForegroundWindow」)ので、前面にいる2つ目のプロセスが権利を渡すのがポイントです。

  • #C#
  • #WinForms
  • #Mutex
  • #多重起動防止
  • #シングルトン

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑