Deno 的 Worker 任务系统怎么用?适合哪些并行场景?
Deno 里常说的任务系统,实际落到代码里主要是两件事:用 deno task 管理项目脚本,用 Worker 把耗时计算放到独立线程。原文讨论的是后台异步任务,更准确地说是 Worker 模型。Worker 有自己的执行上下文和内存空间,不能直接共享主线程变量,只能靠 postMessage 传数据。它适合 CPU 密集型或可拆分的批处理,不适合把普通 I/O 都丢进去;如果只是请求接口或读几个小文件,Worker 的创建和序列化成本可能比收益还高。
这个区别很重要,因为很多文章会把 “task” 和 “worker” 混着讲。面试或项目评审时,应该先说明你讨论的是哪一层:命令编排层的 deno task,还是运行时并行层的 Worker。前者解决“怎么启动和约束命令”,后者解决“怎么让重计算不堵住主线程”。两者都能提升工程体验,但解决的问题完全不同。
追问
Deno 里怎么启动一个 Worker?
主线程用 new Worker(new URL("./worker.ts", import.meta.url).href, { type: "module" }) 创建 Worker,然后通过 postMessage 发消息。Worker 侧监听 self.onmessage,处理完再 self.postMessage 返回结果。这里的边界是消息会被结构化克隆,函数、类实例方法、部分复杂对象不能像普通引用一样传过去。踩坑点是路径最好用 new URL(..., import.meta.url),不要依赖当前工作目录,否则测试和生产启动目录一变就找不到文件。如果 Worker 文件还需要读本地模块或资源,主进程启动时仍然要给对应权限。权限不是因为进入 Worker 就自动放开,Deno 仍然会按运行命令的授权范围执行,这是它和一些传统脚本环境不一样的地方。
ts// main.ts const worker = new Worker(new URL("./worker.ts", import.meta.url).href, { type: "module", }); worker.onmessage = (event) => { console.log(event.data); worker.terminate(); }; worker.onerror = (event) => { console.error(event.message); worker.terminate(); }; worker.postMessage({ number: 21 });
ts// worker.ts self.onmessage = (event) => { const { number } = event.data; self.postMessage({ result: number * 2 }); };
bashdeno run --allow-read main.ts
Worker 适合处理什么任务,不适合处理什么任务?
Worker 适合图像像素处理、加密哈希、大数组计算、日志离线分析、批量文本转换这类 CPU 时间明显的任务。它不适合非常短的小任务,因为创建线程、加载模块、传递消息都有固定成本。也不适合需要频繁共享状态的逻辑,主线程和 Worker 来回通信太密会把性能优势吃掉。取舍上可以先在主线程写清楚逻辑,再用实际耗时数据决定是否拆到 Worker,而不是一开始就把架构做复杂。还有一个判断标准是数据传输成本:如果每次都要把几十 MB 的对象来回复制,Worker 的收益会明显下降。能用 ArrayBuffer 等可转移对象时,应该优先考虑转移所有权,减少复制带来的内存压力。
ts// worker.ts self.onmessage = (event) => { const nums = event.data as number[]; const sum = nums.reduce((acc, n) => acc + n, 0); self.postMessage(sum); };
如何把 Worker 封装成 Promise,避免回调散在业务里?
真实项目里通常不会在业务函数里到处写 onmessage 和 onerror,而是封装一个 runWorker,让调用方像等待普通异步函数一样等待结果。这样错误处理、超时、终止 Worker 都可以集中维护。边界是每次调用都新建 Worker 会更简单但成本更高,适合低频任务;高频任务应该考虑 Worker 池。最容易踩的坑是成功或失败后忘记 terminate(),导致后台线程一直占着资源,CI 或长驻服务里会越来越慢。封装时还可以加超时控制,避免 Worker 卡死后 Promise 永远不返回。超时后要主动终止 Worker,并把任务标记为失败或进入重试队列,否则调用方会以为系统只是“还在处理”。
tsexport function runWorker<T>(file: string, data: unknown): Promise<T> { return new Promise((resolve, reject) => { const worker = new Worker(new URL(file, import.meta.url).href, { type: "module" }); worker.onmessage = (e) => { resolve(e.data as T); worker.terminate(); }; worker.onerror = (e) => { reject(e); worker.terminate(); }; worker.postMessage(data); }); }
并行处理时为什么不能无限开 Worker?
Worker 不是越多越快,它会占用线程、内存和调度资源。CPU 密集型任务通常按机器核心数附近控制并发,比如 4 核机器开 4 个左右,再多可能只是上下文切换变多。批量文件处理还要考虑磁盘 I/O,开太多 Worker 可能把磁盘打满,反而让整体变慢。更稳妥的做法是做一个简单队列或 Worker 池,让任务排队进入固定数量的 Worker。Worker 池的实现也别一开始就追求复杂调度,先做到固定并发、先进先出、失败可返回就够了。等确实遇到长短任务混排、优先级或取消需求,再增加队列策略会更稳。
tsconst concurrency = Number(Deno.env.get("WORKER_CONCURRENCY") ?? 4); const chunks = [/* split big data here */]; for (let i = 0; i < chunks.length; i += concurrency) { const batch = chunks.slice(i, i + concurrency); await Promise.all(batch.map((chunk) => runWorker("./worker.ts", chunk))); }
bashWORKER_CONCURRENCY=4 deno run --allow-read --allow-env main.ts
Deno 的 deno task 和 Worker 是一回事吗?
不是。deno task 是项目脚本系统,类似 npm scripts,用来定义 test、dev、build 这类命令;Worker 是运行时代码里的并行执行机制。两者可以配合,例如用 deno task process 启动一个会创建 Worker 池的批处理程序。面试里如果把二者混为一谈,通常说明只看过标题没真正写过 Deno。项目里建议把常用命令写进 deno.json,把 Worker 并发、权限和入口文件固定下来,减少同事之间“我这里能跑”的差异。另外,deno task 里写权限时要尽量精确,比如只允许读输入目录、只允许写输出目录。把权限写进脚本后,团队成员运行同一个任务时环境更一致,也更容易在代码审查里发现权限扩大。
json{ "tasks": { "process": "deno run --allow-read --allow-write --allow-env main.ts", "test": "deno test --allow-read" } }
Deno 的 Worker 任务模型真正有价值的地方,是把重计算从主流程里拆出去,同时保留清晰的权限和启动命令。用之前先判断任务是否足够重、数据是否好切分、失败后是否能重试,比盲目并行更重要。