Windowsアプリの多重起動防止 ── 名前付きMutexと二重起動時のアクティブ化

· · 多重起動防止, Mutex, Windows, .NET, C#, SetForegroundWindow, Remote Desktop, 名前付きパイプ, Windows開発, 技術相談

「業務アプリを誤ってもう一度起動してしまい、同じファイルを2つのウィンドウで編集して片方の変更が消えた」「常駐ツールが二重に立ち上がって、同じイベントを二重処理していた」。デスクトップアプリの多重起動は地味なテーマですが、実務では意外と事故の原因になります。対策の骨格自体はシンプルで、名前付き Mutex で「自分は最初のインスタンスか」を判定するだけです。

ところが、このシンプルな実装には周辺の落とし穴が付きまといます。リモートデスクトップ環境で防止が効かなくなる名前空間の罠、他の同期オブジェクトとは違う Mutex 特有の解放ルール、そして「見つかった既存インスタンスをどう前面に出すか」という Win32 のフォアグラウンドウィンドウ制限が絡む設計課題です。この記事では、名前付き Mutex による多重起動検出の基本から、実務で必要になる周辺の設計判断までを一通り整理します。

1. まず結論

  • 多重起動検出の基本形は new Mutex(true, name, out bool createdNew) です。同名の Mutex が既に存在していても例外は発生せず、createdNewfalse になるだけなので、この値で分岐します。1
  • 名前にプレフィックスを付けないと、既定で Local\(セッション限定)の名前空間に作られます。 リモートデスクトップで同じユーザーが複数セッションを持つ環境では、セッションごとに別の Mutex として扱われ、多重起動防止が効きません。セッションをまたいで1つに絞りたいなら Global\ プレフィックスが必須です。23
  • Mutex取得したスレッドと同じスレッドでしか解放できません。別スレッドから ReleaseMutex を呼ぶと ApplicationException になります。所有プロセスが解放せずに終了した場合は次に取得したスレッドに AbandonedMutexException が送出されますが、これは待機自体は成功しているという合図で、握りつぶさずに状態の整合性を確認してから使うのが正しい扱いです。45
  • 「既に起動している」と分かった後の本題は、既存インスタンスのウィンドウを前面に出すことです。SetForegroundWindow はフォアグラウンドプロセス以外からの呼び出しを OS が制限しており、単純に呼ぶだけでは失敗し、タスクバーボタンの点滅に留まります。6 今起動した2つ目のプロセスが持つ「フォアグラウンドを設定できる権限」を AllowSetForegroundWindow で既存インスタンスに明け渡す設計が定石です。7
  • 起動引数(開くべきファイルパスなど)を既存インスタンスへ渡すには、名前付きパイプで転送するのが定石です。手段の比較は「Windowsのプロセス間通信をどう選ぶか」に譲り、この記事では「検出 → 通知 → アクティブ化」の設計に絞ります。
  • コンソールアプリやサービスは、この記事の設計をそのまま持ち込む対象ではありません。サービスは SCM がそもそも同名サービスを1つしか起動しないため考え方が異なります(第8章)。

2. 名前付きMutexによる検出の基本

Mutex はカーネルオブジェクトで、名前を付けて作成すると、その名前を知っている他のプロセスから同じオブジェクトを参照できます。多重起動検出には、この「名前の共有」だけを利用します。

using var mutex = new Mutex(initiallyOwned: true, name: MutexName, out bool createdNew);
if (!createdNew)
{
    // 既に起動している
    return;
}
// createdNew が true のときだけ、このスレッドが Mutex を所有している

ここで押さえておきたいのは次の2点です。

  • 同名の Mutex が既に存在しても例外にはなりません。 createdNewfalse が入り、既存のオブジェクトへの参照が返るだけです。分岐は必ず createdNew で行います。1
  • initiallyOwned: true は「新規作成できたとき」だけ有効です。 既に存在していた場合(createdNew == false)、このスレッドは自動的には所有者になりません。多重起動検出ではこの副作用は問題になりませんが、「2つ目のプロセスが誤って ReleaseMutex を呼んでしまう」事故を防ぐためにも、createdNewfalse の分岐では Mutex に一切触らず即座に処理を終えるのが安全です。1

名前は他社アプリと衝突しないよう、製品固有の GUID を埋め込んだ文字列にするのが定石です("KomuraSoft.MyApp.SingleInstance.{3F1E2B10-...}" のように)。パイプ名の占有(スクワッティング)と同様、名前付きカーネルオブジェクトの名前空間はマシン上の他のプロセスからも見えるため、推測されにくい名前にしておく意味があります。

3. Global\ と Local\ ── セッションをまたぐと何が起きるか

Windows のカーネルオブジェクト名前空間は、セッションごとに独立した名前空間と、システム全体で共有されるグローバル名前空間に分かれています。名前付きの Mutex を作るとき、プレフィックスを指定しなければ既定で呼び出し元のセッション名前空間(Local\)に作られ、Global\ を付けたときだけ全セッション共通の名前空間に作られます。3 .NET の Mutex クラスもこの挙動をそのまま踏襲しており、ドキュメントには「プレフィックスを指定しない名前付き Mutex は既定で Local\ を持つ」と明記されています。2

これが問題になるのは、次のような場面です。

  • 業務用の管理端末に、コンソールから直接ログインしたユーザーが、リモートデスクトップでも自分自身にもう1セッション接続している(運用ではよくあります)。
  • 同じユーザーが RDP 接続を切断・再接続を繰り返し、そのたびに新しいセッション ID が割り当てられる。

Local\ のまま(プレフィックスなし)で Mutex を作ると、これらは別セッション扱いになり、それぞれのセッションで多重起動防止が独立して働きます。つまり「同じユーザーなのに2つ目のセッションからは普通に起動できてしまう」という、多重起動防止が意図どおりに効かない不具合になります。逆に言えば、単一セッションのみを想定した一般的なデスクトップ利用では Local\(既定)のままでも実害はありません。

複数ユーザーが同居する端末の設計一般については「Windowsユーザープロファイル入門」でも扱っていますが、この記事のテーマに絞って言えば注意が必要です。Global\ はマシン上の全ユーザー・全セッションが共有する単一の名前空間であり、「1ユーザーにつき1インスタンス」を自動的には意味しません。 Global\KomuraSoft.MyApp.SingleInstance のように名前を固定したまま Global\ を付けるだけだと、ユーザー A が起動中はユーザー B の起動も同じ Mutex に阻まれてしまい、実質的に「マシン全体で1インスタンス」(第5章の表の3行目)になります。「1ユーザーにつき1インスタンス、ただしそのユーザーの複数セッションはまとめる」を実現したいなら、Global\ に加えて、名前にユーザー固有の識別子(SID など)を埋め込む必要があります。 逆にマシン全体で1インスタンスに絞りたい場合(全ユーザー共有が意図どおり)は、SIDを含めない Global\ のままで構いません。

4. Mutexの解放ルール ── 所有スレッドとAbandonedMutexException

Mutex には、SemaphoreAutoResetEvent などの他の同期オブジェクトにはない制約があります。取得したスレッドと同じスレッドでしか解放できないという、スレッド ID を強制するルールです。2 別スレッドから ReleaseMutex を呼ぶと ApplicationException(「呼び出し元のスレッドがミューテックスを所有していません」)が送出されます。4

.NET の async/await を使ったコードでは、await の後に処理が別のスレッドプールスレッドで再開されることがあります。「Mutex を取得した直後に非同期処理を挟み、その続きで解放する」ようなコードを書くと、この制約に静かに違反する可能性があるので注意してください。多重起動検出用途では取得も解放も同期的な短いコードに閉じるのが安全です。

もう1つの注意点が放棄(abandoned)です。Mutex を所有していたスレッドが、ReleaseMutex を呼ばずに終了する(プロセスがクラッシュした、未処理例外で落ちた、など)と、その Mutex は放棄された状態になります。次にこの Mutex を取得したスレッドには AbandonedMutexException が送出されますが、これは待機自体は成功しており、呼び出し元は既に Mutex の所有権を得ていることを示す例外です。5 多重起動検出だけの用途では通常発生しません(取得後に解放せず持ち続けたままプロセス終了まで使うため)が、同じ Mutex を他の排他制御にも流用している場合は、保護対象の状態が壊れている可能性を踏まえて次のように扱います。

try
{
    if (mutex.WaitOne(TimeSpan.FromSeconds(5)))
    {
        // 通常どおりの処理
    }
}
catch (AbandonedMutexException)
{
    // 待機は成功しており、このスレッドは既に所有権を得ている。
    // 保護対象の状態を検査してから使うか、安全に初期化し直す
}

「例外を無視するか、状態を検査して継続するか、諦めて異常終了するか」の切り分けは、当ブログの「想定外例外で終了すべきか継続すべきかの判断表」の考え方がそのまま当てはまります。AbandonedMutexException は「壊れた可能性がある範囲」を明示的に教えてくれる例外なので、握りつぶさずにその範囲(保護対象のデータ)だけを検査するのが基本線です。

なお、正常終了する経路では finally で明示的に ReleaseMutex を呼んでおくべきです。所有スレッドがプロセス終了で消えるとき、Mutex は「放棄された」ものとして扱われるため、明示的に解放しておかないと次回起動時に無用な AbandonedMutexException を発生させることになります。

5. 判断表 ── どのスコープでMutexを使うか

多重起動を「どの単位」で防ぎたいかによって、名前空間とパイプ側の設計は変わります。

単位 想定シナリオ Mutex名前空間 起動引数の受け渡し 注意点
セッション単位(既定) RDPを使わない、または「セッションごとに1つ」でよい通常のデスクトップ利用 プレフィックスなし(=Local\) CurrentUserOnlyに加え、パイプ名にセッションIDを含める RDP併用環境では、別セッションからの起動を防げない。同じユーザーが複数セッションを持つ場合、パイプ名にセッションIDを含めないと固定名の衝突が起きる(名前付きパイプはMutexのセッション名前空間の対象外のため)
ユーザー単位(セッション横断) 同じユーザーがコンソールとRDPを行き来する、RDP接続を繰り返す運用 Global\ + ユーザーSIDを名前に含める+MutexSecurityでACLを明示 CurrentUserOnly(ユーザーSIDで判定するためセッションをまたいでも機能する) 実務で最も必要とされるケース。SIDを含めずにGlobal\だけにすると、意図せずマシン単位(次の行)の挙動になる。SIDはユーザーの秘密情報ではないため、共有PC/RDS環境では他ユーザーによる名前スクワッティングも想定してACLで保護する
マシン単位(全ユーザー横断) ライセンス上マシンに1インスタンスまで、共有リソースを全ユーザーで排他したい Global\(ユーザー固有情報は含めない)+MutexSecurityでACLを明示 CurrentUserOnly を外し、PipeSecurity で許可ユーザーを明示 複数ユーザーが同時ログオンするリモートデスクトップサービス環境では、業務上望ましくない挙動になりがちなので要件と一致するか要確認。別ユーザーの起動引数をそのまま最初のユーザーのウィンドウへ転送しないこと。ファイルパスの漏えいや、意図しないユーザーのセッションで他人のドキュメントが開く事故につながるため、他ユーザーからの要求は拒否するか、UIを持たないブローカー経由に設計を変えるべき

補足すると、名前付きパイプは Mutex のような Local\/Global\ セッション名前空間の対象ではなく、既定でセッションをまたいで到達可能です。つまり CurrentUserOnly を付けなくても、パイプ名さえ一致していれば別セッションの既存インスタンスへ接続自体は届きます。CurrentUserOnly はここでは到達性の話ではなく認可の話で、「誰の接続を許可するか」を現在のユーザー(かつ同じ昇格レベル)に絞り込むアクセス制御です。付けなければ既定のセキュリティ記述子により Everyone にも読み取りアクセスが許可されるため、意図しない他ユーザーからの接続まで届いてしまいます。Global\ + SID の Mutex で「ユーザー単位・セッション横断」を実現する設計では、通知用パイプにも CurrentUserOnly を付け、Mutex と同じ「対象ユーザーのみ」に接続元を絞り込んでおくのが妥当です。幸い PipeOptions.CurrentUserOnly はセッション ID ではなくユーザーの SID(および昇格レベル)で判定するため、上の表の2行目(ユーザー単位・セッション横断)であればそのまま組み合わせて使えます。

もう一点、マシン単位(表の3行目)で固定名の Global\ Mutex を使う場合は名前の占有(スクワッティング)にも注意してください。名前付きMutexを使ってアプリを単一インスタンスに限定しようとする場合、悪意あるユーザーが先回りして同じ名前のMutexを作成し、アプリの起動を妨害できてしまいます。8 MutexSecurity(MutexAcl.Create)で作成時にACLを明示しておくと、自分が先に作成できた場合に、他ユーザーがそのMutexを乗っ取ったり不正に保持し続けたりするのを防げます。ただし、これは「後から邪魔されない」ための対策であって、「先に占有される」こと自体は防げない点に注意してください。ACLは自分がMutexを新規作成したときに初めて適用されるものなので、悪意あるユーザーが自分より先に同じ名前でMutexを作成していた場合、こちらの呼び出しは(ACLをいくら用意していても)相手が設定した既存オブジェクトを開きにいくだけになり、相手のACLに従うしかありません。名前そのものが推測困難であること自体には価値がありますが、ローカルコード実行権を持つ悪意あるユーザーを完全に締め出したいなら、Mutexの名前占有だけに頼らず、ユーザーごとに保護されたディレクトリ配下のロックファイルなど、別の排他制御と組み合わせる設計を検討してください。9

もう一点、CurrentUserOnly には見落としやすい制約があります。Windows では、ユーザーアカウントだけでなく昇格レベル(管理者として実行しているかどうか)まで一致していないと接続を許可しません。10 以前は「Mutex の名前はユーザーの SID だけで組み立てているため、検出自体は昇格の有無によらず動く」と考えがちですが、ACLを明示した Mutex を使う場合はここも昇格の影響を受けます。 Windowsは既定で、高い整合性レベル(管理者として実行)のプロセスが作成したオブジェクトに高い整合性レベルのラベルを付与し、より低い整合性レベルのプロセスからの書き込み系アクセスを拒否します。.NETの Mutex/MutexAcl.Create は内部的に SYNCHRONIZEMUTEX_MODIFY_STATE に加えて DELETE/READ_CONTROL/WRITE_DAC/WRITE_OWNER(STANDARD_RIGHTS_REQUIRED)まで要求するため、「管理者として実行」で1つ目を起動し、通常権限で2つ目を起動した場合、Mutexの生成・オープン呼び出し自体が UnauthorizedAccessException を送出することがあります。つまり第7章で書いた「通知パイプだけがACLで弾かれ、多重起動防止自体は成功する」という前提が崩れ、検出のより手前で例外が飛ぶ可能性があるということです。この経路も「別インスタンスが既に動いている、あるいは昇格レベルが違うため通常の手段では判定できない」ことを示すシグナルとして扱い、Mutex/MutexAcl.Create の呼び出し自体を try/catch (UnauthorizedAccessException) で囲み、例外時は起動を諦めて静かに終了する(あるいは第7章の「通知は届かなくてもよい」と同様、多重起動防止としては安全側に倒す)実装にしておくのが妥当です。昇格レベルをまたいだ通知まで必要な場合は、CurrentUserOnly を使わず PipeSecurity でユーザー SID ベースの ACL を明示的に組む設計に切り替えてください。

6. 既存インスタンスを前面に出す ── SetForegroundWindowの制限

Mutex で「既に起動している」と分かった後、多くのアプリは既存インスタンスのウィンドウを前面に出したいはずです。ここで単純に既存プロセスに対して SetForegroundWindow を呼んでも、多くの場合は失敗します。

Windows は、どのプロセスがフォアグラウンドウィンドウを設定できるかを厳しく制限しています。公式ドキュメントによれば、呼び出し元プロセスが次のいずれかに該当しない限り、SetForegroundWindow はウィンドウを実際には前面化せず、タスクバーボタンを点滅させるだけに留まります。6

  • 呼び出し元プロセス自身が現在のフォアグラウンドプロセスである
  • 呼び出し元プロセスがフォアグラウンドプロセスによって起動された
  • 呼び出し元プロセスが直近の入力イベントを受け取った
  • 現在フォアグラウンドウィンドウが存在しない
  • フォアグラウンドプロセスまたは呼び出し元プロセスがデバッグ中である

多重起動検出のシナリオでは、既存インスタンス(バックグラウンドで動いている1つ目のプロセス)はこの条件をまず満たしません。一方、今ユーザーがダブルクリックして起動した2つ目のプロセスは、直近の入力イベントを受け取ってすぐの状態であることが多く、フォアグラウンドを設定できる権限を持っています。この非対称性を利用するのが実務での定石です。

AllowSetForegroundWindow は、フォアグラウンドを設定できるプロセスが、その権限を他のプロセスへ譲渡するための API です。7 2つ目のプロセスが自分の持つ権限を ASFW_ANY(すべてのプロセスに許可)で明け渡してから、名前付きパイプで既存インスタンスへアクティブ化を要求すれば、既存インスタンス側の SetForegroundWindow 呼び出しが成功するようになります。

[DllImport("user32.dll")]
private static extern bool AllowSetForegroundWindow(int dwProcessId);

private const int ASFW_ANY = -1;

// 2つ目のプロセス(自分)がフォアグラウンド設定権限を持っている前提で、
// その権限を無条件で明け渡す。既存インスタンス側の
// SetForegroundWindow 呼び出しをこれで成功させる
AllowSetForegroundWindow(ASFW_ANY);

これでも救えないケース(タスクスケジューラ経由の起動など、2つ目のプロセス自身にもフォアグラウンド権限がない場合)も残ります。その場合はタスクバーの点滅で通知するだけに留め、無理に前面化しようとしないのが妥当な設計です。ユーザーへの通知手段としてはトースト通知や、ウィンドウの WindowStateMinimized から Normal に戻すところまでは常に行い、最終的な前面化は「できれば行う」程度の位置づけにしておくと破綻しません。

7. 起動引数を既存インスタンスへ渡す ── 名前付きパイプ

多重起動検出だけでなく、「開くべきファイルを引数付きで起動したら、既存インスタンスがそのファイルを開く」という要件もよくあります。この用途で WM_COPYDATA(ウィンドウメッセージでデータを送る古典的な手段)も選択肢としては存在しますが、ウィンドウハンドルの取得やメッセージのマーシャリングの面倒さに対して得るものが少なく、新規設計では名前付きパイプを使うのが素直です。

設計はシンプルで、Mutex によって「既に起動している」と分かった2つ目のプロセスが、既存インスタンスが待ち受けている名前付きパイプへ接続し、コマンドライン引数を JSON などでシリアライズして送るだけです。パイプ名の占有(スクワッティング)対策や CurrentUserOnly によるアクセス制御など、名前付きパイプ自体の実装上の注意点は「Windowsのプロセス間通信をどう選ぶか」の名前付きパイプの節にまとめてあるので、そちらを踏襲してください。多重起動検出という文脈に特有の注意点は次の1点だけです。

  • 通知の送信に失敗しても、多重起動防止としては成功として扱ってよい。 既存インスタンスが終了処理中でパイプサーバーを閉じていたなど、タイミングの問題で通知が届かないことはあります。この場合でも「2つ目のプロセスを起動させない」という主目的は達成できているので、通知の失敗を理由にエラーダイアログを出す必要はありません。

8. コンソールアプリ・サービスとの違い

この記事の設計は、ウィンドウを持つデスクトップアプリを前提にしています。コンソールアプリやバッチツールでは「多重起動を防ぐ」より「多重実行されても安全に共存できるようにする」設計のほうが現実的なことが多く、これは実質的にファイル連携の排他制御の話に帰着します。

Windows サービスはさらに事情が異なります。サービスコントロールマネージャー(SCM)は、同じサービス名のサービスをそもそも同時に2つ起動しないため、この記事の Mutex による検出は基本的に不要です。「UI アプリ + 常駐サービス」のような構成では、UI 側だけこの記事の設計を使い、サービス側の多重起動・多重実行に関する考え方はまた別の話になります。サービスの作り方や設計の勘所は別記事に譲ります。

9. 実装例 ── Mutexによる検出とアクティブ化要求

第2〜7章の内容をまとめた実務的な最小構成です。WPF を想定していますが、WinForms でも Application.Current.DispatcherControl.Invoke に置き換えるだけでほぼそのまま使えます。

using System.IO.Pipes;
using System.Runtime.InteropServices;
using System.Security.AccessControl;
using System.Security.Principal;
using System.Text.Json;

public static class Program
{
    // 製品固有のGUIDを埋め込み、他アプリと名前が衝突しないようにする。
    // 「ユーザー単位・セッション横断」で1インスタンスに絞りたいので、Global\に加えて
    // ユーザーのSIDを名前へ埋め込む。SIDを含めずGlobal\だけにすると、全ユーザーが
    // 同じMutexを共有してしまい「マシン全体で1インスタンス」に変わる(第3章)
    private static readonly string MutexName =
        $@"Global\KomuraSoft.MyApp.SingleInstance.{{3F1E2B10-9C2E-4B7E-8B1E-8B4F2D6A5C10}}.{WindowsIdentity.GetCurrent().User}";
    // 通知用パイプもMutexと同じスコープ(ユーザー単位)に揃える。SIDを含めず
    // 固定名のままだと、別ユーザーが同じ名前でサーバーを立てたときに
    // 衝突・混線し得る(第5章、CurrentUserOnlyはACLを絞るだけで名前は分けない)
    private static readonly string PipeName =
        $"KomuraSoft.MyApp.Activate.{{3F1E2B10-9C2E-4B7E-8B1E-8B4F2D6A5C10}}.{WindowsIdentity.GetCurrent().User}";

    [STAThread]
    private static void Main(string[] args)
    {
        // 現在のユーザーにのみフルコントロールを許可するACLを明示しておくと、
        // 自分が先に作成できた場合に他ユーザーからの乗っ取り・妨害を防げる
        // (NuGetパッケージ System.Threading.AccessControl が必要)。
        // ただしこれは「先に占有される」こと自体への対策にはならない(第5章)
        var mutexSecurity = new MutexSecurity();
        mutexSecurity.AddAccessRule(new MutexAccessRule(
            WindowsIdentity.GetCurrent().User!, MutexRights.FullControl, AccessControlType.Allow));

        bool createdNew;
        Mutex mutex;
        try
        {
            // createdNew が true のときだけ、この呼び出しでMutexを取得できている
            mutex = MutexAcl.Create(
                initiallyOwned: true, name: MutexName, createdNew: out createdNew, mutexSecurity: mutexSecurity);
        }
        catch (UnauthorizedAccessException)
        {
            // 同じユーザーでも昇格レベルが異なると、既存Mutexのオープン自体が
            // 拒否されることがある(第5章)。「昇格レベル違いの別インスタンスが
            // 既にいる」とみなし、通知は諦めて安全側(起動しない)に倒す
            return;
        }
        using var _ = mutex;

        if (!createdNew)
        {
            // 既に起動している。既存インスタンスへ通知して自分は終了する
            NotifyRunningInstanceAsync(args).GetAwaiter().GetResult();
            return;
        }

        try
        {
            // App.xamlのStartupUriは削除しておくこと。残したままだと、
            // ここで手動生成したMainWindowに加えてStartupUri側のウィンドウも
            // 自動生成・表示されてしまい(app.MainWindowもそちらに上書きされる)、
            // ウィンドウが2つ開いた挙句アクティブ化要求が届かないほうを向く事故になる
            var app = new App();
            app.InitializeComponent();
            var mainWindow = new MainWindow();
            app.MainWindow = mainWindow;
            mainWindow.Show();

            // MainWindowの生成・表示が終わってからパイプサーバーを起動する。
            // 逆順だと、ウィンドウがまだ無い状態でアクティブ化要求が届き、
            // ActivateMainWindow呼び出しが失敗し得る(例外は下のcatch-allで
            // 握りつぶされ、通知が単に消えるだけになる)。この間に届いた要求は
            // 「サーバー未起動→接続失敗」としてNotifyRunningInstanceAsync側の
            // ベストエフォート設計(第7章)にそのまま乗せる
            StartActivationServer();

            app.Run();
        }
        finally
        {
            // 所有スレッド(このスレッド)から明示的に解放してから終了する。
            // 解放せずに終了すると、次回起動時にAbandonedMutexExceptionの
            // 原因になる(第4章)
            mutex.ReleaseMutex();
        }
    }

    private const int ASFW_ANY = -1;

    [DllImport("user32.dll")]
    private static extern bool AllowSetForegroundWindow(int dwProcessId);

    private static async Task NotifyRunningInstanceAsync(string[] args)
    {
        try
        {
            using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
            using var pipe = new NamedPipeClientStream(
                ".", PipeName, PipeDirection.Out, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
            await pipe.ConnectAsync(cts.Token);

            // 自分(今起動した2つ目のプロセス)は直近の入力イベントを受けて
            // 起動したばかりで、フォアグラウンド設定権限を持っていることが多い。
            // その権限を無条件で明け渡し、既存インスタンス側のSetForegroundWindowを
            // 成功させる(第6章)
            AllowSetForegroundWindow(ASFW_ANY);

            byte[] payload = JsonSerializer.SerializeToUtf8Bytes(new ActivateRequest(1, args));
            await pipe.WriteAsync(payload, cts.Token);
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or OperationCanceledException)
        {
            // 既存インスタンスが終了処理中で応答しなかった(IOException)、
            // 昇格レベルが異なりCurrentUserOnlyの認可に失敗した(UnauthorizedAccessException。
            // 第5章補足)など、通知が届かない理由を区別せず、
            // 多重起動防止としての主目的(2つ目を起動させない)は達成できている(第7章)
        }
    }

    private static void StartActivationServer()
    {
        // 1メッセージの上限。同一ユーザーの古いヘルパーや壊れたクライアントが
        // 際限なく送り続けても、サーバー側のメモリを守る
        const int MaxPayloadBytes = 64 * 1024;

        _ = Task.Run(async () =>
        {
            while (true)
            {
                try
                {
                    // コンストラクター自体もtryの中に入れる。maxNumberOfServerInstancesが
                    // 1のため、直前の接続の後始末が終わっていないタイミングなどで
                    // ここがIOExceptionを送出することがあり、tryの外に置くとこの
                    // 1回の失敗でバックグラウンドタスクごと止まってしまう
                    using var pipe = new NamedPipeServerStream(
                        PipeName, PipeDirection.In, 1,
                        PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);

                    await pipe.WaitForConnectionAsync();

                    // maxNumberOfServerInstancesが1なので、この接続が応答しない
                    // クライアントに固定されると、以降の正規の起動要求を一切
                    // 受け付けられなくなる。接続1回分に上限時間を設ける
                    using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
                    using var ms = new MemoryStream();
                    var buffer = new byte[4096];
                    int n;
                    while ((n = await pipe.ReadAsync(buffer, cts.Token)) > 0)
                    {
                        ms.Write(buffer, 0, n);
                        if (ms.Length > MaxPayloadBytes)
                            throw new IOException("ペイロードが上限サイズを超えました。");
                    }

                    var req = JsonSerializer.Deserialize<ActivateRequest>(ms.ToArray());
                    // Version だけでなく Args も検査する。旧バージョンの送信元や
                    // 手作りの不正なペイロードが {"Version":1} のようにArgsを
                    // 欠いたJSONを送ってくると、Argsはnullのままデシリアライズされる
                    if (req is { Version: 1, Args: not null })
                    {
                        // UIの操作はUIスレッドに戻して行う
                        Application.Current.Dispatcher.Invoke(() => ActivateMainWindow(req.Args));
                    }
                }
                catch (Exception)
                {
                    // 接続が切れた、上限時間・上限サイズを超えた、ペイロードが壊れている、
                    // ディスパッチ中に想定外の例外が起きた、など理由を問わず、
                    // この接続1回分の異常として握りつぶす。このタスク自体を死なせると
                    // 以降すべての通知を受け付けられなくなるため、ループは必ず継続する。
                    // ただしパイプの構築自体がawait前に即座に失敗し続けるケース
                    // (単一インスタンス枠を他プロセスが握っている等)でホットスピン
                    // しないよう、次のループ前に必ず一呼吸置く
                    await Task.Delay(TimeSpan.FromSeconds(1));
                }
            }
        });
    }

    [DllImport("user32.dll")]
    private static extern bool SetForegroundWindow(IntPtr hWnd);

    private static void ActivateMainWindow(string[] args)
    {
        var window = Application.Current.MainWindow;
        if (window is null) return;

        if (window.WindowState == System.Windows.WindowState.Minimized)
            window.WindowState = System.Windows.WindowState.Normal;
        window.Show();
        window.Activate();

        // WPFのActivate()は内部でSetForegroundWindowを呼ぶが、制限(第6章)で
        // 失敗することがあるため、AllowSetForegroundWindow済みの状態で明示的にも呼ぶ
        var hwnd = new System.Windows.Interop.WindowInteropHelper(window).Handle;
        SetForegroundWindow(hwnd);

        if (args.Length > 0)
        {
            // args[0] を開くべきファイルパスとして扱う、などアプリ固有の処理
        }
    }

    private sealed record ActivateRequest(int Version, string[] Args);
}

設計判断を3つ補足します。

  • Global\ に付け足すユーザー識別子は、名前が変わらないものを選ぶ。 WindowsIdentity.GetCurrent().User が返す SecurityIdentifier はユーザー名と違って改名の影響を受けず、ToString()S-1-5-21-... 形式の文字列になります。11 ユーザー名を直接埋め込むと、アカウント名の変更やドメイン移行で多重起動防止が効かなくなる事故につながります。
  • Mutexの解放とパイプサーバーの停止は、アプリの終了処理の一部として明示的に行う。 上の例では finallyReleaseMutex を呼んでいますが、実アプリではウィンドウを閉じる処理の中でキャンセルトークンを使ってパイプサーバーのループも止めるようにしてください。
  • version フィールドは最初から入れる。 起動引数の形式を将来変える可能性は十分あります。「知らないバージョンは無視する」判定を最初から入れておくと、旧バージョンの実行ファイルが残っている端末でも安全に振る舞えます。

10. まとめ

Windowsアプリの多重起動防止は、new Mutex(true, name, out createdNew) という数行だけを見れば単純です。しかし実務で事故なく機能させるには、Global\/Local\ によるセッション可視性の違い、Mutex 特有のスレッド所有権制約と AbandonedMutexException の扱い、そして SetForegroundWindow の制限を踏まえたアクティブ化設計まで、周辺知識をひととおり押さえておく必要があります。

実装の順序としては、まず「どの単位(セッション・ユーザー・マシン)で多重起動を防ぎたいか」を決め(第5章)、それに合わせて Mutex の名前空間と通知用パイプのスコープを揃える。そのうえで、既存インスタンスの前面化は AllowSetForegroundWindow で権限を委譲する定石を使い、それでも失敗するケースは無理に前面化せず通知に留める、という割り切りを持っておくと実装が破綻しません。要件が「1ユーザー1インスタンスか、マシン全体で1インスタンスか」で迷う場合は、運用環境(RDP併用の有無、複数ユーザーの同時ログオンの有無)を先に確認することをおすすめします。

関連記事

関連する相談領域

合同会社小村ソフトでは、多重起動防止やウィンドウ制御を含む Windows デスクトップアプリの設計・実装、リモートデスクトップ環境特有の不具合の原因調査、既存アプリの設計レビューを扱っています。

参考リンク

  1. Microsoft Learn, Mutex Constructor. 名前付きMutexが既に存在する場合はcreatedNewがfalseになり例外は発生しないこと、initiallyOwnedによる初期所有権はcreatedNewがtrueのときだけ有効なことについて。  2 3

  2. Microsoft Learn, Mutex Class. 名前付きMutexにプレフィックスを指定しない場合は既定でLocal\になること、Global\/Local\によるターミナルサービスセッション間の可視性の違い、Mutexがスレッド単位で所有権を強制する(他の同期オブジェクトと異なる)ことについて。  2 3

  3. Microsoft Learn, Kernel Object Namespaces. セッションごとに独立した名前空間とグローバル名前空間の構造、Global\/Local\プレフィックスによる名前空間の指定方法について。  2

  4. Microsoft Learn, Mutex.ReleaseMutex Method. 所有していないスレッドがReleaseMutexを呼ぶとApplicationExceptionが送出されること、スレッドがMutexを解放せずに終了した場合はMutexが放棄された状態になることについて。  2

  5. Microsoft Learn, AbandonedMutexException Class. 放棄されたMutexを次に取得したスレッドにAbandonedMutexExceptionが送出されること、待機自体は成功しており呼び出し元がMutexの所有権を得ていることについて。  2

  6. Microsoft Learn, SetForegroundWindow function. フォアグラウンドウィンドウを設定できるプロセスの条件と、条件を満たさない場合はタスクバーボタンの点滅に留まることについて。  2

  7. Microsoft Learn, AllowSetForegroundWindow function. フォアグラウンドウィンドウを設定できるプロセスが、その権限を他のプロセスへ譲渡できること、ASFW_ANYを指定すると任意のプロセスに許可できることについて。  2

  8. Microsoft Learn, CreateMutexW function (synchapi.h). 名前付きMutexで単一インスタンスに制限する場合、悪意あるユーザーが先に同じ名前のMutexを作成してアプリの起動を妨害できること、対策としてランダムな名前や1ユーザー1インスタンスならユーザープロファイル配下のロックファイルを使う代替案について。 

  9. Microsoft Learn, Mutexes. 名前付きシステムMutexはOS全体から可視でありグローバルであるため、作成時点からアクセス制御セキュリティで保護することが推奨されること、MutexSecurityによるアクセス制御について。 

  10. Microsoft Learn, PipeOptions Enum. CurrentUserOnlyがWindows上ではユーザーアカウントに加えて昇格レベルまで検証することについて。 

  11. Microsoft Learn, WindowsIdentity.User Property. ユーザーのセキュリティ識別子(SID)を返すプロパティであり、SIDがすべてのWindows NT実装でユーザーまたはグループを一意に識別することについて。 

同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。

このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。

この記事は次のサービスページにつながります。近い入口からご覧ください。

著者プロフィール

記事の著者プロフィールページです。

小村 豪

合同会社小村ソフト 代表

Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る