防止 Windows 應用程式重複啟動 ── 具名 Mutex 與重複執行時的視窗前置

· · 防止重複啟動, Mutex, Windows, .NET, C#, SetForegroundWindow, 遠端桌面, 具名管道, Windows 開發, 技術諮詢

「不小心把業務用應用程式多開了一次,結果在兩個視窗編輯同一個檔案,其中一邊的變更就這樣消失了。」「常駐工具被啟動了兩次,結果同一個事件被處理了兩次。」桌面應用程式重複啟動聽起來是個不起眼的主題,但在實務上意外經常成為事故的原因。對策的骨架本身很單純:用具名 Mutex 判斷「自己是不是第一個執行個體」就可以了。

問題是,這個單純的實作背後藏著不少周邊陷阱:讓防止機制在遠端桌面環境下失效的命名空間陷阱、Mutex 特有的(和其他同步物件不同的)釋放規則,以及「找到既有執行個體之後要怎麼把它帶到最前面」這個牽涉 Win32 前景視窗限制的設計課題。本文會從具名 Mutex 偵測重複啟動的基本做法開始,一次整理到實務上必須處理的周邊設計判斷。

1. 先講結論

  • 偵測重複啟動的基本寫法是 new Mutex(true, name, out bool createdNew)。就算已經存在同名的 Mutex,也不會拋出例外,只是 createdNew 會變成 false,所以要依這個值分支。1
  • 名稱如果沒加前綴,預設會建立在 Local\(工作階段專屬)命名空間裡。 在遠端桌面下,同一使用者擁有多個工作階段的環境中,每個工作階段會被視為各自獨立的 Mutex,防止重複啟動就會失效。想跨工作階段限制成一個,就必須加上 Global\ 前綴。23
  • Mutex 只能由取得它的那個執行緒釋放。從別的執行緒呼叫 ReleaseMutex 會拋出 ApplicationException。如果擁有者的處理程序沒釋放就結束了,下一個取得該 Mutex 的執行緒會收到 AbandonedMutexException,但這其實是在告訴你等待本身成功了,正確做法是先確認狀態的一致性再使用,不要直接忽略。45
  • 一旦確定「已經在執行了」,接下來的重點其實是把既有執行個體的視窗帶到最前面SetForegroundWindow 被 OS 限制,只有前景處理程序以外的呼叫多半會失敗,單純呼叫的話,頂多讓工作列按鈕閃爍。6 標準做法是讓剛啟動的第二個處理程序,用 AllowSetForegroundWindow 把自己擁有的「設定前景視窗的權限」讓給既有執行個體。7
  • 要把啟動參數(例如該打開的檔案路徑)傳給既有執行個體,標準做法是透過具名管道傳送。手段的比較就留給「如何選擇 Windows 處理程序間通訊」,本文只專注在「偵測 → 通知 → 啟用」這個設計上。
  • 主控台應用程式或服務不是本文設計要直接套用的對象。服務的情況不同,因為 SCM 本來就只會啟動一個同名的服務(第 8 章)。

2. 用具名 Mutex 偵測的基本做法

Mutex 是核心物件,只要用名稱建立它,其他知道這個名稱的處理程序就能參照到同一個物件。偵測重複啟動只用到這個「名稱共用」的特性。

using var mutex = new Mutex(initiallyOwned: true, name: MutexName, out bool createdNew);
if (!createdNew)
{
    // 已經在執行了
    return;
}
// 只有 createdNew 為 true 時,這個執行緒才擁有 Mutex

這裡要記住兩個要點。

  • 就算同名的 Mutex 已經存在,也不會拋出例外。 createdNew 只會變成 false,回傳的是既有物件的參照。務必用 createdNew 來分支。1
  • initiallyOwned: true 只有在「成功新建」時才有效。 如果它已經存在(createdNew == false),這個執行緒不會自動變成擁有者。這個副作用對偵測重複啟動來說不成問題,但為了避免「第二個處理程序不小心呼叫了 ReleaseMutex」這種事故,在 createdNewfalse 的分支裡完全不去動 Mutex、立刻結束處理,是比較安全的做法。1

名稱最好嵌入產品專屬的 GUID,避免和其他公司的應用程式衝突(例如 "KomuraSoft.MyApp.SingleInstance.{3F1E2B10-...}")。就像管道名稱佔用(squatting)一樣,具名核心物件的命名空間對機器上其他處理程序也是可見的,所以取一個難以猜到的名稱是有意義的。

3. Global\ 與 Local\ ── 跨工作階段時會發生什麼事

Windows 的核心物件命名空間分成每個工作階段各自獨立的命名空間,以及系統全體共用的全域命名空間。建立具名 Mutex 時如果沒指定前綴,預設會建立在呼叫端所在的工作階段命名空間Local\),只有加上 Global\ 才會建立在所有工作階段共用的命名空間裡。3 .NET 的 Mutex 類別完全遵循這個行為,文件裡也明確寫著「沒指定前綴的具名 Mutex 預設具有 Local\」。2

這會在下列場景造成問題:

  • 業務用的管理主機上,有使用者直接從主控台登入,同時又用遠端桌面連到同一台機器上,替自己開了另一個工作階段(在維運中這很常見)。
  • 同一位使用者反覆斷開又重新連上 RDP,每次重新連線都被分配到新的工作階段 ID。

如果 Mutex 維持 Local\(不加前綴),這些情況會被當成不同的工作階段,防止重複啟動會在每個工作階段裡各自獨立運作。也就是說,會出現「同一位使用者,卻能從第二個工作階段正常啟動」這種防止重複啟動沒有照預期運作的問題。反過來說,如果只設想單一工作階段的一般桌面使用情境,維持 Local\(預設值)也不會有實際危害。

多位使用者共用一台主機的一般性設計,在「Windows 使用者設定檔入門」中也有討論,但就本文主題而言仍需留意。Global\ 是機器上所有使用者、所有工作階段共用的單一命名空間,並不會自動代表「每位使用者一個執行個體」。 如果只是把名稱固定為 Global\KomuraSoft.MyApp.SingleInstance 這樣加上 Global\,那麼當使用者 A 在執行時,使用者 B 的啟動也會被同一個 Mutex 擋下,實質上變成「整台機器一個執行個體」(第 5 章表格的第三列)。如果想要的是「每位使用者一個執行個體,但同一使用者的多個工作階段要合併成一個」,就必須在 Global\ 之外,把使用者專屬識別碼(例如 SID)也嵌入名稱中。 反過來說,如果本來就打算限制整台機器只有一個執行個體(所有使用者共用是刻意設計),那麼維持不含 SID 的 Global\ 就沒問題。

4. Mutex 的釋放規則 ── 擁有執行緒與 AbandonedMutexException

Mutex 有一個 SemaphoreAutoResetEvent 等其他同步物件沒有的限制:它會強制檢查執行緒 ID,只能由取得它的那個執行緒釋放2 從別的執行緒呼叫 ReleaseMutex 會拋出 ApplicationException(「呼叫端執行緒並未擁有此 Mutex」)。4

在使用 .NET async/await 的程式碼中,await 之後的處理有可能在不同的執行緒集區執行緒上恢復執行。如果寫出「剛取得 Mutex 後插入一段非同步處理,接著在那段的後續處理裡釋放」這樣的程式碼,很可能悄悄違反這個限制,請務必留意。對於偵測重複啟動這種用途,把取得和釋放都封閉在同步且簡短的程式碼裡是比較安全的。

另一個要注意的是放棄(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,或「每個工作階段一個」就夠用的一般桌面使用情境 不加前綴(即 Local\ CurrentUserOnly 之外,還要在管道名稱中包含工作階段 ID 在併用 RDP 的環境下無法阻止從別的工作階段啟動。如果同一使用者有多個工作階段,管道名稱如果不含工作階段 ID,會發生固定名稱衝突(因為具名管道不受 Mutex 的工作階段命名空間限制)
使用者層級(跨工作階段) 同一使用者在主控台與 RDP 之間來回,或反覆連上 RDP 的維運情境 Global\ + 在名稱中包含使用者 SID + 用 MutexSecurity 明確設定 ACL CurrentUserOnly(以使用者 SID 判斷,因此跨工作階段也能運作) 實務上最常需要的情境。若只加 Global\ 而不含 SID,會非預期地變成機器層級(下一列)的行為。SID 不是使用者的機密資訊,在共用 PC/RDS 環境下也要考慮被其他使用者搶占名稱的可能性,並以 ACL 保護
機器層級(跨所有使用者) 授權上整台機器只能有一個執行個體,或共用資源需要對所有使用者互斥 Global\(不包含使用者專屬資訊)+ 用 MutexSecurity 明確設定 ACL 移除 CurrentUserOnly,改用 PipeSecurity 明確指定允許的使用者 在多位使用者同時登入的遠端桌面服務環境下,這容易產生業務上不理想的行為,務必確認是否符合需求。不要把其他使用者的啟動參數原封不動轉發給第一位使用者的視窗。 這可能造成檔案路徑外洩,或在非預期的使用者工作階段中打開別人的文件,因此應拒絕來自其他使用者的請求,或改用不帶 UI 的中介(broker)來設計

補充一點,具名管道不像 Mutex 那樣受 Local\/Global\ 工作階段命名空間限制,預設可以跨工作階段連線。也就是說,就算不加 CurrentUserOnly,只要管道名稱一致,連線本身還是能連到不同工作階段裡的既有執行個體。這裡的 CurrentUserOnly 不是可連線性的問題,而是授權問題——它是一種存取控制,把「允許誰的連線」限縮到目前使用者(且相同提升層級)。如果不設定,預設的安全性描述元會讓 Everyone 也擁有讀取存取權,導致非預期的其他使用者的連線也能通過。在用 Global\ + SID 的 Mutex 實現「使用者層級・跨工作階段」的設計裡,也應該在通知管道上加上 CurrentUserOnly,把連線來源限縮到和 Mutex 相同的「僅限目標使用者」。幸好 PipeOptions.CurrentUserOnly 是依使用者的 SID(及提升層級)判斷,而非工作階段 ID,所以能直接和上表第二列(使用者層級・跨工作階段)搭配使用。

還有一點,如果在機器層級(表格第三列)使用固定名稱的 Global\ Mutex,也要留意名稱佔用(squatting)。用具名 Mutex 把應用程式限制成單一執行個體時,惡意使用者可能先搶著建立同名的 Mutex,妨礙應用程式啟動。8MutexSecurityMutexAcl.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_OWNERSTANDARD_RIGHTS_REQUIRED),因此如果以「系統管理員身分」啟動第一個,再以一般權限啟動第二個,Mutex 的建立・開啟呼叫本身就可能拋出 UnauthorizedAccessException 也就是說,第 7 章寫的「只有通知管道被 ACL 擋下,防止重複啟動本身仍成功」這個前提會崩潰,例外可能在更早的偵測階段就飛出來。這條路徑也應被視為「已經有別的執行個體在執行,或提升層級不同以致無法用一般手段判斷」的訊號:把 Mutex/MutexAcl.Create 的呼叫本身用 try/catch (UnauthorizedAccessException) 包起來,遇到例外時放棄啟動並安靜地結束(或者和第 7 章「通知送不到也沒關係」一樣,讓防止重複啟動這件事往安全的方向傾斜),是比較妥當的實作。如果還需要跨提升層級的通知,就不要用 CurrentUserOnly,改成用 PipeSecurity 明確建立以 SID 為基礎的 ACL 這種設計。

6. 把既有執行個體帶到最前面 ── SetForegroundWindow 的限制

透過 Mutex 確認「已經在執行了」之後,多數應用程式應該都想把既有執行個體的視窗帶到最前面。這時單純對既有處理程序呼叫 SetForegroundWindow,大多會失敗。

Windows 嚴格限制哪些處理程序可以設定前景視窗。根據官方文件,除非呼叫端處理程序符合下列其中之一,SetForegroundWindow 實際上不會把視窗帶到最前面,只會讓工作列按鈕閃爍。6

  • 呼叫端處理程序本身就是目前的前景處理程序
  • 呼叫端處理程序是由前景處理程序啟動的
  • 呼叫端處理程序剛收到最近一次的輸入事件
  • 目前沒有前景視窗
  • 前景處理程序或呼叫端處理程序正在被偵測(debug)

在偵測重複啟動的情境下,既有執行個體(在背景執行的第一個處理程序)多半不符合這些條件。反過來,使用者剛剛雙擊啟動的第二個處理程序,通常剛收到輸入事件不久,因此擁有設定前景視窗的權限。實務上的標準做法就是利用這個不對稱性。

AllowSetForegroundWindow 是讓有權設定前景視窗的處理程序,把這個權限讓給其他處理程序的 API。7 只要第二個處理程序用 ASFW_ANY(允許所有處理程序)把自己的權限讓出去,再透過具名管道向既有執行個體要求啟用(activate),既有執行個體那邊的 SetForegroundWindow 呼叫就會成功。

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

private const int ASFW_ANY = -1;

// 假設第二個處理程序(自己)擁有設定前景視窗的權限,
// 無條件把這個權限讓出去。藉此讓既有執行個體那一側的
// SetForegroundWindow 呼叫成功
AllowSetForegroundWindow(ASFW_ANY);

即使這樣做,還是有救不回來的情況(例如透過工作排程器啟動,第二個處理程序自己也沒有前景權限)。這種情況下,比較妥當的設計是只用工作列閃爍來通知,不要硬把視窗拉到最前面。作為使用者通知的手段,可以固定使用吐司通知,並把視窗的 WindowStateMinimized 還原成 Normal;至於最終要不要真的帶到最前面,就當成「能做就做」的附加效果,不必當成一定要成功的步驟,這樣設計才不會出問題。

7. 把啟動參數傳給既有執行個體 ── 具名管道

除了偵測重複啟動之外,「帶著要打開的檔案作為參數啟動,讓既有執行個體來打開這個檔案」也是常見需求。這個用途也可以選擇 WM_COPYDATA(用視窗訊息傳資料的傳統手段),但取得視窗控制代碼、封送訊息的麻煩程度,相對於能得到的好處並不划算,新設計採用具名管道會更直接。

設計很單純:透過 Mutex 判斷「已經在執行了」的第二個處理程序,連上既有執行個體正在等待連線的具名管道,把命令列參數用 JSON 之類的格式序列化後傳送過去。管道名稱佔用(squatting)的對策,以及用 CurrentUserOnly 做存取控制等,具名管道本身實作上的注意事項,已經整理在「如何選擇 Windows 處理程序間通訊」的具名管道章節裡,請依循該篇的做法。在偵測重複啟動這個情境下特有的注意事項只有下面這一點:

  • 就算通知傳送失敗,仍可以當成防止重複啟動成功。 有可能因為時機問題導致通知送不到,例如既有執行個體正在結束處理、已經關閉了管道伺服器。即使如此,「不讓第二個處理程序啟動」這個主要目的仍已達成,不需要因為通知失敗而彈出錯誤對話框。

8. 與主控台應用程式・服務的差異

本文的設計是以擁有視窗的桌面應用程式為前提。在主控台應用程式或批次工具中,與其「防止重複啟動」,「就算被重複執行也能安全共存」這種設計往往更務實,本質上會歸結成檔案整合的互斥控制問題。

Windows 服務的情況更不一樣。服務控制管理器(SCM)本來就不會同時啟動兩個同名服務,因此本文中以 Mutex 進行的偵測基本上是不必要的。在「UI 應用程式 + 常駐服務」這類架構中,本文的設計只用在 UI 那一側;服務那一側對於重複啟動・重複執行的思考方式則是另一個話題。服務的建立方式與設計要點,留待其他文章討論。

9. 實作範例 ── 用 Mutex 偵測並要求啟用

這是把第 2~7 章內容整合起來的實務最小配置。以 WPF 為前提,但在 WinForms 中只要把 Application.Current.Dispatcher 換成 Control.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,避免和其他應用程式的名稱衝突。
    // 因為想以「使用者層級・跨工作階段」限制成一個執行個體,除了 Global\ 之外,
    // 還要把使用者的 SID 嵌入名稱中。如果不含 SID 只加 Global\,
    // 所有使用者會共用同一個 Mutex,變成「整台機器一個執行個體」(第 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 也會被那個覆蓋),
            // 最後變成開了兩個視窗,啟用請求卻指向沒開的那一個
            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);

            // 自己(剛啟動的第二個處理程序)多半剛收到輸入事件,
            // 通常擁有設定前景視窗的權限。無條件把這個權限讓出去,
            // 讓既有執行個體那邊的 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 章補充),都不去區分
            // 通知送不到的原因,防止重複啟動的主要目的
            // (不讓第二個啟動)已經達成(第 7 章)
        }
    }

    private static void StartActivationServer()
    {
        // 單則訊息的上限。就算同一使用者的舊版輔助程式或損毀的
        // 客戶端持續無限傳送,也能保護伺服器端的記憶體
        const int MaxPayloadBytes = 64 * 1024;

        _ = Task.Run(async () =>
        {
            while (true)
            {
                try
                {
                    // 建構函式本身也放進 try 裡。因為 maxNumberOfServerInstances
                    // 是 1,在前一次連線的收尾還沒結束的時機,這裡可能拋出
                    // IOException,如果放在 try 外面,這一次失敗就會讓整個
                    // 背景工作停下來
                    using var pipe = new NamedPipeServerStream(
                        PipeName, PipeDirection.In, 1,
                        PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);

                    await pipe.WaitForConnectionAsync();

                    // 因為 maxNumberOfServerInstances 是 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)
                {
                    // 不論原因是連線中斷、超過時間・大小上限、酬載損毀,
                    // 還是分派過程中出現預期外例外,都當成這一次連線的
                    // 異常吞掉。因為讓這個工作本身死掉,會導致之後完全
                    // 收不到任何通知,所以迴圈必須永遠繼續。不過為了避免
                    // 在建構管道本身就在 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);
}

補充三個設計判斷:

  • 附加在 Global\ 之後的使用者識別碼,要選擇名稱不會變的那種。 WindowsIdentity.GetCurrent().User 回傳的 SecurityIdentifier,和使用者名稱不同,不會受改名影響,ToString() 會得到 S-1-5-21-... 格式的字串。11 如果直接把使用者名稱嵌入,帳戶改名或網域遷移會導致防止重複啟動失效,變成事故。
  • Mutex 的釋放與管道伺服器的停止,要作為應用程式結束處理的一部分明確執行。 上面的範例是在 finally 裡呼叫 ReleaseMutex,但在實際的應用程式裡,也請在關閉視窗的處理中用取消權杖,一併停止管道伺服器的迴圈。
  • version 欄位要從一開始就放進去。 未來很有可能會改變啟動參數的格式。從一開始就內建「忽略不認識的版本」這個判斷,就算某台機器上還留著舊版本的執行檔,也能安全地運作。

10. 總結

Windows 應用程式防止重複啟動,如果只看 new Mutex(true, name, out createdNew) 這幾行,看起來很單純。但要在實務上無事故地運作,就需要全面掌握周邊知識:Global\/Local\ 造成的工作階段可見性差異、Mutex 特有的執行緒所有權限制與 AbandonedMutexException 的處理方式,以及考量 SetForegroundWindow 限制的啟用設計。

實作順序上,建議先決定「想以哪個單位(工作階段・使用者・機器)防止重複啟動」(第 5 章),再依此對齊 Mutex 的命名空間與通知管道的範圍。接下來,既有執行個體的前置化就用 AllowSetForegroundWindow 讓出權限的標準做法,就算還是失敗,也抱著「不硬把視窗拉到最前面,用通知代替」的取捨心態,實作才不會出問題。如果對「每位使用者一個執行個體,還是整台機器一個執行個體」猶豫不決,建議先確認運作環境(是否併用 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 妨礙應用程式啟動,以及使用隨機名稱、或在每位使用者一個執行個體的情況下改用使用者設定檔下鎖定檔案的替代方案。 

  9. Microsoft Learn, Mutexes. 關於具名系統 Mutex 在整個 OS 都可見、屬於全域性質,因此建議從建立時就用存取控制安全性保護,以及透過 MutexSecurity 進行存取控制。 

  10. Microsoft Learn, PipeOptions Enum. 關於 CurrentUserOnly 在 Windows 上除了使用者帳戶外,也會驗證提升層級。 

  11. Microsoft Learn, WindowsIdentity.User Property. 關於這個屬性回傳使用者的安全識別碼(SID),以及 SID 在所有 Windows NT 實作中都能唯一識別使用者或群組。 

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

常見問題

整理諮詢這個主題時常見的問題。

在 C# 中要如何防止應用程式重複啟動?
基本寫法是 new Mutex(true, name, out bool createdNew)。就算已經存在同名的 Mutex 也不會拋出例外,只是 createdNew 會變成 false,所以要用這個值來分支,讓第二個處理程序結束。名稱最好嵌入產品專屬的 GUID,避免和其他公司的應用程式衝突。在 createdNew 為 false 的分支裡完全不去動 Mutex、立刻結束處理,是比較安全的做法。
為什麼在遠端桌面環境下防止重複啟動不生效?
因為 Mutex 名稱如果沒加前綴,預設會建立在 Local\(工作階段專屬)命名空間裡。在同一使用者同時擁有主機主控台與 RDP 多個工作階段的環境下,每個工作階段會被視為各自獨立的 Mutex,導致從第二個工作階段還是能正常啟動。要跨工作階段限制成一個,就必須加上 Global\ 前綴。不過只加 Global\ 的話會變成所有使用者共用同一台機器層級,如果想要「每位使用者一個執行個體」,還需要把使用者的 SID 也嵌入名稱中。
要如何把既有執行個體的視窗帶到最前面?
單純呼叫 SetForegroundWindow,因為 OS 的限制,大多會失敗,頂多讓工作列按鈕閃爍。標準做法是讓使用者剛剛雙擊啟動的第二個處理程序,用 AllowSetForegroundWindow(ASFW_ANY) 把自己擁有的前景設定權限讓給既有執行個體,再透過具名管道要求它自我啟用(activate)。就算這樣還是失敗的情況(例如透過工作排程器啟動),也不要硬把視窗拉到最前面,用通知代替即可。
AbandonedMutexException 該如何處理?
當擁有 Mutex 的執行緒沒有呼叫 ReleaseMutex 就結束(例如處理程序當機),下一個取得該 Mutex 的執行緒就會收到 AbandonedMutexException。這個例外其實表示等待本身是成功的,呼叫端已經取得所有權。正確做法不是忽略它,而是先檢查被保護狀態的一致性再使用。在正常結束的路徑中,於 finally 裡明確呼叫 ReleaseMutex,可以避免下次啟動時發生不必要的這個例外。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽