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)です。
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つあります。
- アプリが終了しなくなることがあります。 Microsoft Learn の
Application.Exitのページによると、Exitは開いているすべてのフォームでFormClosingを発生させ、1つでも取り消されると何もせずに戻ります。このフォームが存在する限りApplication.Exit()は無視され、Windows のシャットダウンやサインアウト(CloseReason.WindowsShutDown)も妨げます。 base.OnFormClosing(e)を呼んでいないので、外から登録したFormClosingイベントハンドラーが動きません。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 を受け取ります。
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つ目のプロセスが権利を渡すのがポイントです。