背景
某天产品经理带来了一个客户需求:希望桌面客户端可以同时打开两个实例——实例 A 与实例 B 互不干扰、独立运行。
听起来简单?打开代码一看,全是坑。
原始的单例锁机制
项目中使用了 Electron 内置的 app.requestSingleInstanceLock() 来限制单实例:
const getTheLock = app.requestSingleInstanceLock();
if (!getTheLock) {
// 获取单例锁失败,说明已有一个实例在运行,当前实例直接退出
app.quit();
} else {
// 正在运行中的第一个实例监听到第二个实例的启动事件
app.on('second-instance', (event, args) => {
const focusedWindow = BrowserWindow.getFocusedWindow();
if (focusedWindow === null) {
BrowserWindow.getAllWindows().forEach((win) => {
if (win.isMinimized()) win.restore();
win.focus();
win.setAlwaysOnTop(true);
setTimeout(() => {
win.setAlwaysOnTop(false);
if (win.isVisible()) win.show();
}, 300);
});
}
});
}
第二个实例一启动就会被干掉。那是不是只要去掉这段代码就行了?
没那么简单。去掉后一大堆新问题冒出来了:
- 客户要求最多只能开 2 个,不能无限开
- 两个客户端的数据必须隔离,登录态、localStorage 不能互相污染
- 自动更新怎么办?两个实例同时触发更新会不会打架?
- 存在互斥操作怎么办?比如应用执行任务时,另一个实例不能同时执行
requestSingleInstanceLock 的底层原理
在动手之前,先搞清楚这个锁到底是什么。
Windows:
Electron 调用系统级的 Mutex(互斥锁)。锁的名字类似 ElectronApp_[appName],基于应用名称唯一生成。第二个实例调用 requestSingleInstanceLock() 时发现 Mutex 已存在,返回 false。
macOS / Linux: Electron 使用的是 Unix 域 Socket 文件锁。在系统临时目录下创建一个命名 socket 文件。第二个实例发现 socket 已被监听,就不会继续启动。
锁文件的位置可以通过 app.getPath('userData') 获取。本地开发一般在:
/Users/xxx/Library/Application Support/Electron/
查看这个目录会发现里面不仅有 SingletonLock,还有 Cookies、Local Storage 等文件。看到这些,思路就来了——如果能让两个实例使用不同的 userData 目录,就能天然实现数据隔离和独立的单例锁。
两个 userData 的具体实现
在主进程启动早期(app.whenReady 之前)根据启动参数切换 userData 路径。核心原则只有两条:
- 先拿到默认目录(基础目录)
- 按实例模式追加子目录(如
Primary/Secondary)
import { app } from 'electron';
import path from 'path';
// 默认目录,例如:~/Library/Application Support/YourApp
const baseUserData = app.getPath('userData');
// 例:通过启动参数区分实例模式(脱敏示例)
const isSecondaryMode = process.argv.includes('--mode=secondary') || process.argv.includes('--secondary');
if (isSecondaryMode) {
app.setPath('userData', path.join(baseUserData, 'Secondary'));
} else {
app.setPath('userData', path.join(baseUserData, 'Primary'));
}
切换后的效果是:
- 实例 A:
.../YourApp/Primary - 实例 B:
.../YourApp/Secondary
这一步实际上一次性解决了两个核心问题:数据隔离 和 双开能力。
其一,Cookies、Local Storage、IndexedDB、更新缓存等都落在各自目录里,实例 A/B 的状态天然隔离,不会互相污染。
其二,requestSingleInstanceLock 在 macOS/Linux 的底层依赖同一套用户数据上下文(socket/锁文件)。当 userData 被拆分为两个目录后,两个实例对应的是两套不同的锁上下文,不再竞争同一把锁,因此可以并存运行(即实现双开)。
到这里,双开和数据隔离的问题都解决了,但还有一个关键问题:存在互斥操作怎么办?
例如更新下载/安装、任务执行这类操作,仍然需要保证同一时刻只有一个实例在执行,否则就会出现并发冲突。
围绕“跨进程互斥”这个目标,先后评估过几条方案,但都被否决了。
被否决的方案
方案一:基于内存的 Mutex 锁
Windows 上用系统 Mutex 可以精确控制实例数量,但 macOS 和 Linux 没有原生的 Mutex API。Electron 在这两个平台上的 requestSingleInstanceLock 底层是文件锁,跨进程的内存锁做不到。
方案二:基于本地文件读写字段通信
让第一个实例写一个标记文件,第二个实例读取判断。问题是:两个进程几乎同时启动时,存在竞态条件。可能两个实例都读到"还没有人在运行",然后都认为自己是第一个,标记文件方案无法解决并发问题。
方案三:使用 Redis 等内存数据库
用 Redis 做分布式锁可以完美解决并发问题。但这是桌面客户端,不是服务端——要求用户本地装一个 Redis 实例,或者自己内嵌一个,对安装包体积和运维成本来说都太重了。这种方案适合服务端,不适合客户端场景。
最终方案:proper-lockfile + 心跳守护
最终采用了 proper-lockfile(文件级别的跨进程锁库) + 心跳子进程守护 的方案。整体流程如下:
sequenceDiagram
participant U as User
participant A as Instance A (Main)
participant B as Instance B (Main)
participant H as Heartbeat Worker
participant L as Lock Files (/tmp/locks)
U->>A: 启动实例 A
A->>L: acquire(lock: appRunning)
L-->>A: success
A->>H: fork 心跳子进程
H-->>A: ping (每 3s)
U->>B: 启动实例 B
B->>L: check/process-count + acquire
L-->>B: 若已达到上限则拒绝,否则允许
B->>H: fork 心跳子进程
H-->>B: ping (每 3s)
Note over A,B: 任一实例触发更新/任务前先尝试获取对应锁<br/>downloadPatch/installPatch/appRunning
alt 实例 A 正常退出
A->>L: releaseOwnLocks()
A-->>H: IPC disconnect
H->>L: 幂等清理并退出
else 实例 A 异常崩溃
H-->>H: 检测 process.connected = false
H->>L: releaseOwnLocks()
H-->>H: exit
end
为什么选 proper-lockfile
proper-lockfile 是一个专门解决跨进程文件锁问题的 npm 库,它在底层使用了原子操作(mkdir 创建 .lock 目录) 来避免竞态条件——因为操作系统保证 mkdir 在文件系统层面是原子的。相比于普通的文件读写,不存在并发问题。
锁的设计
将不同的互斥操作抽象为不同的锁:
import lockfile from 'proper-lockfile';
export enum LockedKeys {
Install = 'installPatch', // 自动更新-安装
Download = 'downloadPatch', // 自动更新-下载
AppRunning = 'appRunning', // 应用正在执行任务
}
const LOCK_DIR = path.join(os.tmpdir(), `locks-${os.userInfo().username}`);
// 锁的过期时间设置为 30 天(setTimeout 最大值)
const LOCK_STALE = 1000 * 60 * 60 * 24 * 30;
锁文件统一存放在系统临时目录下,按当前系统用户名隔离,避免多用户场景冲突。
加锁 / 释放锁
// 申请锁
export async function acquireProcessLock(key: string): Promise<boolean> {
const lockPath = path.join(LOCK_DIR, `${key}`);
const metaPath = path.join(LOCK_DIR, `${key}_${runnerMode}.meta.json`);
if (!fs.existsSync(lockPath)) fs.writeFileSync(lockPath, '');
try {
await lockfile.lock(lockPath, { retries: 0, stale: LOCK_STALE });
// 写入元信息:哪个进程、什么时间、什么实例模式获取了这把锁
fs.writeFileSync(metaPath, JSON.stringify({
pid: process.pid,
startedAt: new Date().toISOString(),
instanceName: runnerMode, // 'primary' 或 'secondary'(示例化命名)
}));
return true;
} catch {
return false;
}
}
// 释放锁
export async function releaseProcessLock(key: string) {
const lockPath = path.join(LOCK_DIR, `${key}`);
const metaPath = path.join(LOCK_DIR, `${key}_${runnerMode}.meta.json`);
if (!fs.existsSync(lockPath)) return false;
lockfile.unlockSync(lockPath);
return fs.unlinkSync(metaPath);
}
每把锁在加锁时会写入一个 .meta.json 元信息文件,记录是哪个进程(PID)、什么实例模式(A/B)在什么时间获取了锁。这样方便排查问题,也方便在释放时只清理自己创建的锁。
实例数量限制
不再依赖 Electron 的 requestSingleInstanceLock,而是通过 ps 命令统计进程数量 来判断当前有多少个实例在运行(示例代码已做概念脱敏):
export function hasAnotherInstance() {
if (product.modeInOne === false) {
let count = 0;
try {
if (process.platform === 'linux') {
// Linux 进程名跟执行文件名有关,分别统计实例 A 和实例 B
const secondaryNum = execSync(
`ps aux | grep 'AppBinary' | grep -- '--mode=secondary' | grep -v grep | wc -l`,
{ encoding: 'utf8' }
);
const primaryNum = execSync(
`ps aux | grep -E '/opt/App/AppBinary$' | wc -l`,
{ encoding: 'utf8' }
);
count = (Number(primaryNum.trim()) > 0 ? 1 : 0)
+ (Number(secondaryNum.trim()) > 0 ? 1 : 0);
} else {
const stdout = execSync(
`ps aux | grep 'YourAppName' | grep 'main.js' | wc -l`,
{ encoding: 'utf8' }
);
count = parseInt(stdout.trim(), 10);
}
if (count === 2) return true; // 已经有 2 个了,不能再开
} catch (error) {
logger.error('exec shell error:', error);
}
}
return false;
}
这段代码在自动更新等关键节点被调用,确保双开场景下不会出现两个实例同时触发安装的情况。
心跳守护:防止崩溃后锁文件残留
文件锁有一个致命问题:如果进程意外崩溃(被 kill -9、OOM 等),锁文件不会被自动清理。下次启动时,残留的锁文件会让新实例误以为有进程在运行,导致死锁。
采用的方案是引入一个心跳子进程:
// 心跳子进程 - 独立于主进程运行
class HeartbeatWorker extends BaseWorker {
override startHeartbeat(): void {
process.title = 'heartbeat';
// 每隔 3s 向父进程发送心跳
setInterval(async () => {
if (process.connected) {
process.send?.({ action: 'ping' });
} else {
// 与父进程断开连接 → 父进程已崩溃
// 子进程负责清理残留的锁文件,然后自行退出
logger.info('heartbeat worker exit');
try {
await releaseOwnLocks();
} catch (error) {
logger.error('releaseOwnLocks failed', error);
}
process.exit();
}
}, 3 * 1000);
}
}
工作原理:
- 主进程启动时 fork 一个心跳子进程
- 子进程每 3 秒通过 IPC 向主进程发送
ping - 如果
process.connected变为false,说明父进程已经崩溃或被强杀 - 子进程调用
releaseOwnLocks()清理所有自己创建的锁文件,然后退出
releaseOwnLocks 只会释放当前模式(A/B)创建的锁,不会误删另一个实例的锁:
export async function releaseOwnLocks(): Promise<void> {
if (!fs.existsSync(LOCK_DIR)) return;
const entries = fs.readdirSync(LOCK_DIR, { withFileTypes: true });
const fileNames = entries.map((e) => e.name)?.filter((item) => item.includes('json'));
for (const fileName of fileNames) {
const [lockKey, fileMode] = fileName?.split('.')?.[0].split('_');
// 只释放自己模式创建的锁
if (runnerMode == fileMode) {
deleteLockFile(lockKey);
}
}
}
总结
| 方案 | 优点 | 缺点 | 结论 |
|---|---|---|---|
| 系统 Mutex 锁 | 原生性能好 | macOS/Linux 不支持 | 否决 |
| 文件读写标记 | 简单 | 并发竞态,不可靠 | 否决 |
| Redis 内存数据库 | 并发安全 | 客户端太重 | 否决 |
| proper-lockfile + 心跳 | 原子操作无竞态、崩溃后自动清理 | 需要额外子进程 | 采用 |
桌面应用的"双开"远不止去掉一行 requestSingleInstanceLock 那么简单。它牵扯到数据隔离、并发安全、崩溃恢复、自动更新互斥等一系列问题。最终通过 proper-lockfile 的原子文件锁解决并发安全问题,通过心跳子进程解决崩溃后锁残留问题,通过 ps 命令统计进程数量控制实例上限,形成了一套完整的双开方案。