先说结论:在鸿蒙 PC 上跑 Electron,凡是单次超过 300ms 的同步计算,都别在主进程里一把梭。我把同一批任务放到主进程、worker_threads、渲染进程 Web Worker 三处各跑一遍,wall-clock 差出 4 倍,主进程那版还把整个窗口冻了三秒。
为啥在鸿蒙 PC 上尤其要当回事?Electron 的窗口事件循环跑在主进程的消息泵上,主进程一旦被同步计算占满,所有窗口——不是你正在操作那个,是全部——统统失去响应。Windows 上大家习惯"卡一下就卡一下",但鸿蒙 PC 的窗口合成更敏感,卡够两秒系统就给你弹"应用无响应",上架审核都悬。我那个导入功能当时就是这么翻车的——用户点完"开始导入"去倒杯水,回来发现窗口灰着、任务管理器里 CPU 占满,还以为中招了。这个体感在 demo 给客户看的时候尤其致命,你正讲得起劲,屏幕先卡了。
先上最蠢的那版,我第一版就是这么写的:
// main.ts —— 方案一:主进程同步串行处理(别学)import { app, BrowserWindow, ipcMain } from 'electron';import { parseHeavy } from './heavy';ipcMain.handle('import-sync', async (_e, files: string[]) => { const out = []; for (const f of files) { out.push(parseHeavy(f)); // 每份 ~600ms,主线程被占满,所有窗口全冻 } return out;});
parseHeavy 干的活很朴素:读文件、JSON.parse、按城市分组求和、算占比。单份 50MB,纯 CPU,不碰 IO。问题不在函数本身,在主进程被它占着那会儿,你点关闭按钮都得排队。
我承认我一开始就是这么写的——因为最省事,三行搞定。直到产品经理甩来一句"你这窗口是死了吗",我才意识到没法糊弄。那天下午我重开了三次应用,每次导入都卡到以为程序崩了,其实主进程只是在憋着 parse,连渲染一帧都腾不出手。老实说这种"先跑通再说"的写法我老犯,每次都付点利息,这次利息是半天,换来一条再也忘不掉的规矩。
换成 worker_threads,代码其实没多几行:
// main.ts —— 方案二:主进程派 4 个 worker 并发(推荐)import { Worker } from 'node:worker_threads';import { cpus } from 'node:os';ipcMain.handle('import-workers', async (_e, files: string[]) => { const pool = Math.min(4, cpus().length); const chunks = Array.from({ length: pool }, (_, i) => files.filter((_, k) => k % pool === i)); const tasks = chunks.map(chunk => new Promise((resolve, reject) => { const w = new Worker('./heavy.worker.js', { workerData: chunk }); w.on('message', resolve); w.on('error', reject); })); return (await Promise.all(tasks)).flat();});
顺带一提,worker 数量我一开始拍脑袋设了 8,结果比开 4 个还慢。后来才想明白:那台机是 8 逻辑核、4 物理核,超线程在纯计算上不翻倍,8 个 worker 抢内存带宽反而互相拖后腿。现在我用 Math.min(4, cpus().length),小机器自动降到 2,反而最稳。调并发数这事没有银弹,得上你自己的机器测,别照抄我的 4。
worker 那边就一句话的事:
// heavy.worker.tsimport { parentPort, workerData } from 'node:worker_threads';import { parseHeavy } from './heavy';parentPort.postMessage((workerData as string[]).map(parseHeavy));
怕你说我藏着,把 parseHeavy 也贴出来——它是个纯函数,不读全局、不碰 DOM,这样丢进 worker 才安全:
// heavy.ts —— 纯函数,可安全丢进 workerimport { readFileSync } from 'node:fs';export interface Result { city: string; total: number; ratio: number }export function parseHeavy(path: string): Result[] { const raw = JSON.parse(readFileSync(path, 'utf-8')) as { city: string; amount: number }[]; const sum = new Map<string, number>(); for (const r of raw) sum.set(r.city, (sum.get(r.city) ?? 0) + r.amount); const grand = [...sum.values()].reduce((a, b) => a + b, 0); return [...sum].map(([city, total]) => ({ city, total, ratio: total / grand }));}
worker_threads 有个坑我得提醒你:传进 worker 的数据走的是 structured clone,class 实例的方法会丢,所以上面我只传文件路径字符串,文件在 worker 内部各自读。你要是图方便直接 postMessage(giantObject),轻则数据残缺,重则当场抛 DataCloneError——这个错我替你踩过了,排查起来一脸懵。
第一次上 worker 我还犯过更蠢的错:worker 里算完了,我忘了 postMessage,Promise 永远 pending,界面转圈转到天黑。这个坑躺了一晚上,第二天才看见是漏了那一行,你上 worker 前先把这句写进肌肉记忆。
第三套是塞渲染进程的 Web Worker。想法是"卡也是卡前端那个页面,主窗口还能动"。实测主窗口确实没冻,但用户正盯着的导入进度页自己卡了,体验半斤八两。而且渲染进程的 Web Worker 里没有 Node API,读文件得走主进程 fs 再 IPC 传回来,等于绕一圈,反倒更慢,我后来就弃了这路子。这条路子唯一的甜头是代码不用碰主进程,但对你这种要在 worker 里读本地大文件的需求基本是死路。
测量方法说清楚,免得你质疑数据水:wall-clock 用主进程 performance.now() 包住整段调用,worker 内部各自取均值;10 份 × 50MB,鸿蒙 PC / 8 核,每套跑 5 轮取中位。结果如下:
你没看错,worker_threads 把总耗时从 6.1s 压到 1.5s,差不多 4 倍。原因不玄乎:4 个 worker 真并行,4 份文件同时 parse,主进程只管收结果。
插一句测量上的老实话:我报的是整批的 wall-clock,不是单个 worker 的耗时。单 worker 其实比主进程那一份还慢一点点(多了序列化开销),但 4 个一起跑把总量摊薄了。你要是只导一份文件,worker 反而亏,别被"4 倍"冲昏头。报告里的数字都是我机器上的,你那边硬件不同可能略有出入。
等一下,这里我漏说一个前提:worker 之间不能共享大对象,我是把文件路径传进去、在各自 worker 里读文件,所以内存峰值反而比主进程一次性 load 全部更低——10 份全堆在内存里那可是 500MB 起步,低端机直接被干爆。
雷达鸭的桌面端这次导入就是从主进程那版迁到 worker_threads 的,原本 200 个案例文件要转半天圈圈,现在基本秒回。
说到这你大概知道我站哪边了。但有个例外得讲清楚:活儿本来就 200ms 以内、又不频繁,主进程同步反而省心——为这点时间起 worker 的通信开销都够你再跑两遍。我做过测算,单次 < 150ms 的任务,postMessage 加序列化比直接算还慢。不是说 worker 万能,是把对的东西放对的地方,这点我踩完坑才真正信。
所以我现在给自己定的规矩:超过 300ms、或会重复触发(比如拖拽时实时聚合),一律 worker_threads;零散小活儿,主进程一把梭。
说真的,鸿蒙 PC 上的 Electron 对多进程调度的支持比我想的稳,worker_threads 没踩到啥平台特有坑。等哪天官方把 SharedArrayBuffer 在鸿蒙上的限制放开,我大概会把聚合中间结果也丢进共享内存,还能再榨一点。
提一句调试:鸿蒙 PC 上 worker 的报错默认不会冒到主进程的 DevTools,你得单独开 chrome://inspect 或给 worker 挂 parentPort.on('error'),不然又是转圈转到怀疑人生。我现在每个 worker 都先挂好 error 监听再写逻辑,少熬很多夜。
你平时 Electron 里遇到这种重活是怎么处理的?欢迎留言聊聊。
老三,10+ 年软件开发经验,软件设计师 / 人工智能应用工程师。目前专注鸿蒙应用(ArkTS 北向)与 Web 前端,折腾 AI 自动化。偶尔在 CSDN 写点鸿蒙和 AI 方向的踩坑笔记。
本文遵循 MIT 协议,转载请注明出处。