防止 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」这种事故,在createdNew为false的分支里完全不去动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 有一个 Semaphore、AutoResetEvent 等其他同步对象没有的限制:它会强制检查线程 ID,只能由获取它的那个线程释放。2 从别的线程调用 ReleaseMutex 会抛出 ApplicationException(「调用方线程并未拥有此互斥体」)。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,妨碍应用程序启动。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 在内部除了要求 SYNCHRONIZE 和 MUTEX_MODIFY_STATE,还会要求 DELETE/READ_CONTROL/WRITE_DAC/WRITE_OWNER(STANDARD_RIGHTS_REQUIRED),因此如果以「管理员身份」启动第一个,再以普通权限启动第二个,Mutex 的创建・打开调用本身就可能抛出 UnauthorizedAccessException。 也就是说,第 7 章写的「只有通知管道被 ACL 挡下,防止重复启动本身仍然成功」这个前提会崩溃,异常可能在更早的检测阶段就抛出来。这条路径也应被视为「已经有别的实例在运行,或提升级别不同以致无法用常规手段判断」的信号:把 Mutex/MutexAcl.Create 的调用本身用 try/catch (UnauthorizedAccessException) 包起来,遇到异常时放弃启动并安静地退出(或者和第 7 章「通知送不到也没关系」一样,让防止重复启动这件事往安全的方向倾斜),是比较妥当的实现。如果还需要跨提升级别的通知,就不要用 CurrentUserOnly,改成用 PipeSecurity 明确构建基于 SID 的 ACL 这种设计。
6. 把已有实例带到最前面 ── SetForegroundWindow 的限制
通过 Mutex 确认「已经在运行了」之后,多数应用程序应该都想把已有实例的窗口带到最前面。这时单纯对已有进程调用 SetForegroundWindow,多数会失败。
Windows 严格限制哪些进程可以设置前台窗口。根据官方文档,除非调用方进程符合下列其中之一,SetForegroundWindow 实际上不会把窗口带到最前面,只会让任务栏按钮闪烁。6
- 调用方进程本身就是当前的前台进程
- 调用方进程是由前台进程启动的
- 调用方进程刚收到最近一次的输入事件
- 当前没有前台窗口
- 前台进程或调用方进程正在被调试
在检测重复启动的场景下,已有实例(在后台运行的第一个进程)多数不满足这些条件。反过来,用户刚刚双击启动的第二个进程,通常刚收到输入事件不久,因此拥有设置前台窗口的权限。实务中的标准做法就是利用这个不对称性。
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);
即使这样做,还是有救不回来的情况(比如通过任务计划程序启动,第二个进程自己也没有前台权限)。这种情况下,比较妥当的设计是只用任务栏闪烁来通知,不要硬把窗口拉到最前面。作为用户通知的手段,可以固定使用 Toast 通知,并把窗口的 WindowState 从 Minimized 还原成 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 进程间通信 ── 命名管道 / TCP / gRPC / 共享内存 / COM 判断表
- Windows 用户配置文件入门 - AppData 与 NTUSER.DAT
- 遇到未预期异常时该终止还是继续的判断表
相关咨询领域
小村软件有限责任公司处理包括防止重复启动、窗口控制在内的 Windows 桌面应用程序设计・实现,远程桌面环境特有的错误原因调查,以及既有应用程序的设计审查。
参考链接
-
Microsoft Learn, Mutex Constructor. 关于命名 Mutex 已存在时 createdNew 会变成 false 且不抛出异常,以及 initiallyOwned 带来的初始所有权只有在 createdNew 为 true 时才有效。 ↩ ↩2 ↩3
-
Microsoft Learn, Mutex Class. 关于命名 Mutex 未指定前缀时默认为 Local\、Global\/Local\ 在终端服务会话之间可见性的差异,以及 Mutex 以线程为单位强制所有权(与其他同步对象不同)。 ↩ ↩2 ↩3
-
Microsoft Learn, Kernel Object Namespaces. 关于每个会话独立的命名空间与全局命名空间的结构,以及通过 Global\/Local\ 前缀指定命名空间的方式。 ↩ ↩2
-
Microsoft Learn, Mutex.ReleaseMutex Method. 关于未拥有的线程调用 ReleaseMutex 会抛出 ApplicationException,以及线程没释放 Mutex 就结束时 Mutex 会进入放弃状态。 ↩ ↩2
-
Microsoft Learn, AbandonedMutexException Class. 关于下一个获取已放弃 Mutex 的线程会收到 AbandonedMutexException,以及这代表等待本身成功、调用方已获得 Mutex 所有权。 ↩ ↩2
-
Microsoft Learn, SetForegroundWindow function. 关于能设置前台窗口的进程条件,以及不满足条件时仅会让任务栏按钮闪烁。 ↩ ↩2
-
Microsoft Learn, AllowSetForegroundWindow function. 关于能设置前台窗口的进程可以把这个权限让给其他进程,以及指定 ASFW_ANY 可以允许任意进程。 ↩ ↩2
-
Microsoft Learn, CreateMutexW function (synchapi.h). 关于用命名 Mutex 限制成单一实例时,恶意用户可以先创建同名 Mutex 妨碍应用程序启动,以及使用随机名称、或在每个用户一个实例的情况下改用用户配置文件下锁文件的替代方案。 ↩
-
Microsoft Learn, Mutexes. 关于命名系统 Mutex 在整个 OS 都可见、属于全局性质,因此建议从创建时就用访问控制安全性保护,以及通过
MutexSecurity进行访问控制。 ↩ -
Microsoft Learn, PipeOptions Enum. 关于 CurrentUserOnly 在 Windows 上除了用户账户外,也会验证提升级别。 ↩
-
Microsoft Learn, WindowsIdentity.User Property. 关于这个属性返回用户的安全标识符(SID),以及 SID 在所有 Windows NT 实现中都能唯一标识用户或组。 ↩
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
如何理解 Windows 的会话隔离 ── Session 0・RDP・多用户同时运行
整理 Windows 应用程序开发者容易混淆的「会话」概念。说明服务无法显示 UI 的 Session 0 隔离原因、RDP 连接时会话的行为、命名对象的会话隔离,以及共享 PC・RDS 环境中常见的设计失误,从实务角度解说。
Windows 的进程间通信该怎么选 ── 命名管道 / TCP / gRPC / 共享内存 / COM 判断表
整理 Windows 应用程序之间应该如何选择通信方式。用判断表整理命名管道、本地 TCP、gRPC、共享内存、文件对接、COM 各自的优势与陷阱,并从实务角度说明 UI+服务分离・32bit/64bit 桥接・权限边界等常见架构,以及命名管道的实现示例。
在 WinForms/WPF 应用中集成 Entra ID 认证 —— MSAL.NET 与 WAM Broker 的实务架构
本文以实务视角整理在 WinForms/WPF 桌面应用中集成 Entra ID(原 Azure AD)认证的步骤:公共客户端的思路、ROPC 被弃用的现状、应用注册、MSAL.NET 的 AcquireTokenSilent 模式、WAM Broker、令牌缓存的持久化,...
业务应用的日期时间与时区 ── 从 DateTime 的陷阱到 UTC 存储原则、测试设计
服务器迁移后时间整整差了 9 个小时、只有海外分支机构的日期会变成前一天——本文从 DateTime 的 Kind 与隐式转换梳理日期时间事故的根本原因。内容涵盖与 DateTimeOffset 的取舍、UTC 存储与 ISO 8601 原则、TimeZoneInfo 与夏...
WPF 高 DPI 支持──「不是应该对 DPI 很强吗」却仍模糊、渗色的原因与对策
WPF 以 DIP(1/96 英寸)进行布局,从一开始就是 System DPI Aware,但移动到 DPI 不同的显示器时整体会渗色,位图与细线也会模糊。本文从实务角度整理原因判断、.NET Framework 4.6.2/.NET 上的 Per-Monitor DPI...
常见问题
汇总了咨询这一主题时常见的问题。
- 在 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 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。