单实例锁 Single Instance
本教程共 42 篇 · 第 30 篇 · 更新于 2026-08-03
30. 单实例锁 Single Instance
本节目标
- 说清什么样的应用需要单实例、什么样的不需要
- 会配置
options.SingleInstanceLock并生成合适的UniqueId - 看懂
SecondInstanceData里的Args和WorkingDirectory - 会在收到第二实例通知时把窗口正确地拉到前台
- 了解三个平台各自的实现机制和随之而来的限制
30-1 为什么要限制多开
先说清楚:多开本身不是错。终端模拟器、文本编辑器多开几个窗口很正常。
真正需要单实例的是这几类情况。
独占资源。 应用要写一个本地数据库文件、监听一个固定端口、占用某个硬件设备。两个进程同时抢,轻则报错,重则数据损坏。
文件关联和深链。 这是最主要的场景。用户在资源管理器里选中五个文件一起打开,系统会启动五个进程。用户在网页上连点三次深链,桌面上冒出三个窗口。都不是预期行为。
常驻托盘类应用。 用户忘了它已经在后台跑着,又从开始菜单点了一次,结果托盘上出现两个图标。
Wails 把这套机制封装成了 SingleInstanceLock,配置在应用选项里。
30-2 最小配置
在 wails.Run 的 options.App 里加一个 SingleInstanceLock 字段:
package main
import (
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
)
func main() {
app := NewApp()
err := wails.Run(&options.App{
Title: "wails-open-file",
Width: 1024,
Height: 768,
AssetServer: &assetserver.Options{Assets: assets},
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
OnStartup: app.startup,
SingleInstanceLock: &options.SingleInstanceLock{
UniqueId: "e3984e08-28dc-4e3d-b70a-45e961589cdc",
OnSecondInstanceLaunch: app.onSecondInstanceLaunch,
},
Bind: []interface{}{
app,
},
})
if err != nil {
println("Error:", err.Error())
}
}
两个字段:
UniqueId:应用的唯一标识。Windows 和 macOS 拿它生成命名互斥体的名字,Linux 拿它生成 dbus 名称。OnSecondInstanceLaunch:第二个实例启动时触发的回调。
UniqueId 建议直接用 UUID,随便找个工具生成一个然后写死在代码里。
Warning
UniqueId千万别在不同项目之间复制粘贴。两个应用用了同一个 ID,它们会互相把对方当成「自己的第二个实例」,结果就是 A 应用开着的时候 B 应用启动不了。这种 bug 排查起来非常费劲。
配好之后行为就变了:第二次启动应用时,新进程发现锁已被占用,会把自己的命令行参数发给第一个实例,然后立刻退出。用户看到的效果是「点了没反应」——所以回调里必须做点什么,至少把窗口亮出来。
30-3 处理第二实例的数据
回调签名是这样:
func (a *App) onSecondInstanceLaunch(secondInstanceData options.SecondInstanceData)
SecondInstanceData 里有两样东西:
| 字段 | 说明 |
|---|---|
Args | 第二个实例收到的命令行参数 |
WorkingDirectory | 第二个实例启动时所在的工作目录 |
WorkingDirectory 别忽略。命令行里用户可能传的是相对路径,比如 myapp ./notes/a.md。第一个实例的工作目录和第二个可能完全不同,不用这个字段去拼绝对路径,你会找不到文件。
官方给出的完整示例是这样:
var wailsContext *context.Context
// NewApp creates a new App application struct
func NewApp() *App {
return &App{}
}
// startup is called when the app starts. The context is saved
// so we can call the runtime methods
func (a *App) startup(ctx context.Context) {
wailsContext = &ctx
}
func (a *App) onSecondInstanceLaunch(secondInstanceData options.SecondInstanceData) {
secondInstanceArgs = secondInstanceData.Args
println("user opened second instance", strings.Join(secondInstanceData.Args, ","))
println("user opened second from", secondInstanceData.WorkingDirectory)
runtime.WindowUnminimise(*wailsContext)
runtime.Show(*wailsContext)
go runtime.EventsEmit(*wailsContext, "launchArgs", secondInstanceArgs)
}
有两个地方值得单独说。
第一,窗口不会自动弹出来。 回调触发不代表窗口会获得焦点。窗口可能被最小化了,可能藏在别的应用后面。所以要手动调 runtime.WindowUnminimise 恢复最小化,再调 runtime.Show 显示窗口。
第二,emit 前面加了 go。 这是为了不阻塞回调。事件投递如果因为某些原因卡住,回调迟迟不返回会影响后续处理。
把它整理成更贴近实际项目的写法:
package main
import (
"context"
"path/filepath"
"strings"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
type App struct {
ctx context.Context
}
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
}
func (a *App) onSecondInstanceLaunch(data options.SecondInstanceData) {
// 把窗口拉到用户面前
runtime.WindowUnminimise(a.ctx)
runtime.Show(a.ctx)
// 相对路径要基于第二实例的工作目录解析
var files []string
for _, arg := range data.Args {
if strings.HasPrefix(arg, "-") {
continue // 跳过命令行开关
}
p := arg
if !filepath.IsAbs(p) {
p = filepath.Join(data.WorkingDirectory, p)
}
files = append(files, filepath.Clean(p))
}
if len(files) > 0 {
go runtime.EventsEmit(a.ctx, "launchArgs", files)
}
}
前端接收:
import { useEffect } from "react";
export function useLaunchArgs(onFiles: (files: string[]) => void) {
useEffect(() => {
const off = window.runtime.EventsOn("launchArgs", (files: string[]) => {
onFiles(files);
});
return () => off();
}, [onFiles]);
}
NoteLinux 上部分窗口管理器为了防止应用抢焦点,会拒绝把窗口强行提到最前。这不是 Wails 的 bug,是桌面环境的策略。表现通常是任务栏图标闪烁提示,用户点一下才切过去。
30-4 三个平台的实现机制
了解底层实现能帮你判断「这个坑该找谁」。
Windows:用命名互斥体(named mutex)实现锁,名字由 UniqueId 生成。数据通过一个共享窗口,用 SendMessage 传给第一个实例。
macOS:同样用命名互斥体做锁。数据走 NSDistributedNotificationCenter,也就是系统的分布式通知中心。
Linux:用 dbus 实现。dbus 名称由 UniqueId 生成,数据也通过 dbus 传递。
由此能推出几条实际结论。
Linux 环境如果没有跑 dbus(某些精简的容器、纯 TTY 环境),单实例锁可能不工作。这在服务器上不常见——桌面应用本来也不该跑在那儿——但用 Docker 做 CI 测试时可能撞上。
Windows 的命名互斥体是有作用域的。不同用户会话之间默认互不干扰,也就是说 A 用户和 B 用户可以各开一个实例。这通常正是你想要的。
数据传递通道都是系统级的进程间通信,容量不是无限的。别指望通过 Args 传几兆的内容过去,它设计出来就是传几个路径的。
30-5 安全:第二实例的数据不可信
官方文档专门强调了这一点:单实例锁没有实现任何安全通信协议。
这意味着什么?意味着理论上任何本地进程都可以模仿「第二个实例」,往你的应用里塞数据。你的回调收到的 Args 和 WorkingDirectory,都要当成来路不明的输入对待。
具体该怎么防:
校验路径。 用 filepath.Clean 规整,检查有没有 .. 越界,确认文件确实存在、扩展名是你支持的。
校验参数格式。 深链的 URL 要走第 29 章讲的白名单分发,不要因为「它是从自己的第二实例来的」就放松检查。
限制数量。 用户一次拖十个文件正常,一次来一万个就不正常了。加个上限,防止界面被撑爆。
const maxLaunchFiles = 50
func (a *App) onSecondInstanceLaunch(data options.SecondInstanceData) {
runtime.WindowUnminimise(a.ctx)
runtime.Show(a.ctx)
files := sanitizeArgs(data.Args, data.WorkingDirectory)
if len(files) > maxLaunchFiles {
files = files[:maxLaunchFiles]
}
if len(files) > 0 {
go runtime.EventsEmit(a.ctx, "launchArgs", files)
}
}
30-6 和文件关联、深链配合
前两章反复提到单实例锁,现在可以把三者串起来看。
不开单实例锁时的链路:
用户双击文件 → 系统启动新进程 → 新进程从 os.Args 读路径 → 新窗口
开了单实例锁之后:
用户双击文件 → 系统启动新进程 → 新进程发现锁被占
→ 参数发给第一个实例 → 新进程退出
→ 第一个实例 OnSecondInstanceLaunch 触发 → 已有窗口置前并加载文件
所以完整的启动参数处理要分两条路:
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
// 路径一:本进程就是第一个实例,参数在自己的 os.Args 里
if args := os.Args[1:]; len(args) > 0 {
wd, _ := os.Getwd()
a.enqueueLaunchArgs(sanitizeArgs(args, wd))
}
}
// 路径二:后来的实例把参数转交过来
func (a *App) onSecondInstanceLaunch(data options.SecondInstanceData) {
runtime.WindowUnminimise(a.ctx)
runtime.Show(a.ctx)
a.enqueueLaunchArgs(sanitizeArgs(data.Args, data.WorkingDirectory))
}
enqueueLaunchArgs 就是第 28 章讲的那个待处理队列:前端没准备好就先存着,准备好了立刻发。两条路径复用同一份逻辑,不容易出岔子。
macOS 上还要加第三条路径——Mac.OnFileOpen 和 Mac.OnUrlOpen 回调。它们也调 enqueueLaunchArgs,逻辑依然共用。
常见误区
误区一:wails dev 下测不出效果。 dev 模式的进程管理和正式运行不一样,测单实例最好用 wails build 出来的产物。
误区二:忘了在回调里显示窗口。 用户双击文件,什么都没发生,会以为程序坏了。WindowUnminimise 加 Show 是标配。
误区三:以为回调里能直接操作前端 DOM。 不能。Go 侧只能通过事件通知前端,界面变更由 React 自己完成。
误区四:在回调里做耗时操作。 读大文件、发网络请求这些丢到 goroutine 里去,别卡住回调。
误区五:把 UniqueId 写成应用名。 「MyApp」这种字符串重名概率不低。老老实实用 UUID。
小结
单实例锁的配置只有两个字段,但它牵着一整条链路:文件关联、深链、托盘常驻,都要靠它来避免多开。
落地时抓住三个动作。生成一个专属的 UUID,别和任何项目重复。回调里先把窗口显示出来,别让用户以为点了没反应。把收到的参数当成外部输入来校验,路径要规整、格式要检查、数量要限制。
再加一条工程建议:os.Args 和 OnSecondInstanceLaunch 两条路径共用同一个处理函数,配合待处理队列,跨平台的启动参数逻辑就能收敛成一份代码。
下一章讲本地开发联调,把 wails dev 这条命令背后的东西拆开看。