Electron 桌面应用多实例实践:数据隔离与跨进程互斥

背景

某天产品经理带来了一个客户需求:希望桌面客户端可以同时打开两个实例------实例 A 与实例 B 互不干扰、独立运行。

听起来简单?打开代码一看,全是坑。

原始的单例锁机制

项目中使用了 Electron 内置的 app.requestSingleInstanceLock() 来限制单实例:

typescript 复制代码
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);
      });
    }
  });
}

第二个实例一启动就会被干掉。那是不是只要去掉这段代码就行了?

没那么简单。去掉后一大堆新问题冒出来了:

  1. 客户要求最多只能开 2 个,不能无限开
  2. 两个客户端的数据必须隔离,登录态、localStorage 不能互相污染
  3. 自动更新怎么办?两个实例同时触发更新会不会打架?
  4. 存在互斥操作怎么办?比如应用执行任务时,另一个实例不能同时执行

requestSingleInstanceLock 的底层原理

在动手之前,先搞清楚这个锁到底是什么。

Windows: Electron 调用系统级的 Mutex(互斥锁) 。锁的名字类似 ElectronApp_[appName],基于应用名称唯一生成。第二个实例调用 requestSingleInstanceLock() 时发现 Mutex 已存在,返回 false

macOS / Linux: Electron 使用的是 Unix 域 Socket 文件锁。在系统临时目录下创建一个命名 socket 文件。第二个实例发现 socket 已被监听,就不会继续启动。

锁文件的位置可以通过 app.getPath('userData') 获取。本地开发一般在:

bash 复制代码
/Users/xxx/Library/Application Support/Electron/

查看这个目录会发现里面不仅有 SingletonLock,还有 CookiesLocal Storage 等文件。看到这些,思路就来了------如果能让两个实例使用不同的 userData 目录,就能天然实现数据隔离和独立的单例锁

两个 userData 的具体实现

在主进程启动早期(app.whenReady 之前)根据启动参数切换 userData 路径。核心原则只有两条:

  1. 先拿到默认目录(基础目录)
  2. 按实例模式追加子目录(如 Primary / Secondary
typescript 复制代码
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

这一步实际上一次性解决了两个核心问题:数据隔离双开能力

其一,CookiesLocal StorageIndexedDB、更新缓存等都落在各自目录里,实例 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: 任一实例触发更新/任务前先尝试获取对应锁
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 在文件系统层面是原子的。相比于普通的文件读写,不存在并发问题。

锁的设计

将不同的互斥操作抽象为不同的锁:

typescript 复制代码
import lockfile from 'proper-lockfile';

export enum LockedKeys {
  Install = 'installPatch',   // 自动更新-安装
  Download = 'downloadPatch', // 自动更新-下载
  AppRunning = 'appRunning',  // 应用正在执行任务
}

const LOCK_DIR = path.join(os.tmpdir(), `shadowBotLocks-${os.userInfo().username}`);

// 锁的过期时间设置为 30 天(setTimeout 最大值)
const LOCK_STALE = 1000 * 60 * 60 * 24 * 30;

锁文件统一存放在系统临时目录下,按当前系统用户名隔离,避免多用户场景冲突。

加锁 / 释放锁

typescript 复制代码
// 申请锁
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 命令统计进程数量 来判断当前有多少个实例在运行(示例代码已做概念脱敏):

typescript 复制代码
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 等),锁文件不会被自动清理。下次启动时,残留的锁文件会让新实例误以为有进程在运行,导致死锁。

采用的方案是引入一个心跳子进程

typescript 复制代码
// 心跳子进程 - 独立于主进程运行
class HeartbeatWorker extends BaseWorker {
  override startHeartbeat(): void {
    process.title = 'shadowBot_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);
  }
}

工作原理:

  1. 主进程启动时 fork 一个心跳子进程
  2. 子进程每 3 秒通过 IPC 向主进程发送 ping
  3. 如果 process.connected 变为 false,说明父进程已经崩溃或被强杀
  4. 子进程调用 releaseOwnLocks() 清理所有自己创建的锁文件,然后退出

releaseOwnLocks 只会释放当前模式(A/B)创建的锁,不会误删另一个实例的锁:

typescript 复制代码
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 命令统计进程数量控制实例上限,形成了一套完整的双开方案。

相关推荐
前端Hardy1 天前
Electrobun 正式登场:仅 12MB,JS 桌面开发迎来轻量化新方案!
前端·javascript·electron
羊吖2 天前
Vue3 + Electron 实现纯本地人脸识别登录一体机(离线可用、无云端、带页面跳转)
前端·javascript·electron
卸载引擎2 天前
NTP 授时(Network Time Protocol)核心解读,工控机electron程序自动联网授时案例
前端·javascript·electron
codingWhat2 天前
Electron 入门实战:用一个加法计算器吃透 Electron 核心概念
前端·javascript·electron
loriloy2 天前
Electron 桌面端身份认证 - 本地回环重定向认证 Loopback Interface Redirection
electron·登录认证
ujainu4 天前
Electron 实战:将用户输入保存到本地文件 —— 基于 `fs.writeFileSync` 与 IPC 的安全写入方案
javascript·安全·electron
ujainu4 天前
在 HarmonyOS PC 上实现自定义窗口样式的 Electron 应用详解
华为·electron·harmonyos
ujainu4 天前
Electron 极简时钟应用开发全解析:托盘驻留、精准北京时间与 HarmonyOS PC 适配实战
javascript·electron·harmonyos
小圣贤君4 天前
在 Electron 里造一个「搜书 + 下载」:从 so-novel 到 51mazi 的爬虫实践
前端·人工智能·爬虫·electron·ai写作·小说下载·网文下载