tesseract.js 深入解析:Worker 与 Scheduler 两种 OCR 任务执行模式
本文围绕 tesseract.js 中运行文字识别任务的两种核心方式展开:直接使用单个 Worker 执行识别,以及通过 Scheduler 管理 Worker 池并行执行批量任务。读完本文,你将理解两种模式各自的适用场景、Scheduler 的队列调度机制(可对照源码验证),以及在生产级 Node.js 服务端中周期性重建 Worker/Scheduler 的原因与做法,并掌握可直接复制运行的完整示例代码。
tesseract.js 提供两种运行识别任务(recognition jobs)的方式:
- 直接使用 Worker:创建一个 worker,直接在其上调用
recognize; - 使用 Scheduler + 多个 Worker:语法更复杂,但对大批量任务,通过多 worker 并行处理可以显著提升性能。
各函数的详细文档可参考 API 文档。
方式一:直接使用 Worker
以下片段演示了使用单个 worker 从图片中识别文字:
(async () => {
const worker = await Tesseract.createWorker('eng');
const { data: { text } } = await worker.recognize('https://tesseract.projectnaptha.com/img/eng_bw.png');
console.log(text);
await worker.terminate();
})();
为什么应该把创建 Worker 与识别分开
在实际使用中,createWorker 步骤应与 worker.recognize 步骤分离,这样做有两个直接收益:
- Worker 可以提前准备好——例如在页面加载时就创建 worker,而不是等用户上传待识别图片后再开始初始化(初始化包含加载 WASM 核心与语言数据,耗时明显);
- Worker 可以复用于多个识别任务——不必每识别一张图就重新创建 worker 并重新加载语言数据。
但要注意:所有识别完成后,务必调用 worker.terminate() 释放内存。
从源码看,createWorker 在创建时并不是“拿到即用”的:它会先 spawnWorker 生成一个子环境(浏览器中是 Web Worker,Node 中是子进程),然后按 loadInternal() → loadLanguageInternal(langs) → initializeInternal(langs, oem, config) 的顺序串行下发初始化任务(见 src/createWorker.js),只有这三个阶段全部完成后 createWorker 返回的 Promise 才 resolve。这正是“worker 可提前创建、复用多次”这一建议背后的成本来源——核心与语言数据的加载只在创建/初始化阶段发生一次。
此外,src/Tesseract.js 中还提供了一个一次性快捷方法 Tesseract.recognize(image, langs, options):它内部自动 createWorker、执行识别并在 finally 中 terminate。这种方式适合临时脚本,但把“创建 + 识别 + 销毁”绑死在了一起,无法体现上述复用收益,批量场景下应改用显式的 worker 或 scheduler 写法。
方式二:使用 Scheduler + Worker 池
tesseract.js 支持用 scheduler 执行任务。Scheduler 是一个内部包含多个 worker 的对象,它用这些 worker 并行执行任务。下面是一个“4 个 worker 并行执行 10 个识别任务”的完整示例:
const scheduler = Tesseract.createScheduler();
// 创建 worker 并加入 scheduler
const workerGen = async () => {
const worker = await Tesseract.createWorker('eng');
scheduler.addWorker(worker);
}
const workerN = 4;
(async () => {
const resArr = Array(workerN);
for (let i=0; i<workerN; i++) {
resArr[i] = workerGen();
}
await Promise.all(resArr);
/** 添加 10 个识别任务 */
const results = await Promise.all(Array(10).fill(0).map(() => (
scheduler.addJob('recognize', 'https://tesseract.projectnaptha.com/img/eng_bw.png').then((x) => console.log(x.data.text))
)))
await scheduler.terminate(); // 同时终止所有 worker
})();
单任务场景下 scheduler 并不比直接用 worker 更高效,但它可以快速并行执行大量任务。仓库中的 examples/node/scheduler.js 是该示例的 Node 可运行版本(注意其 createWorker('eng', 1, { cachePath: '.' }) 中显式指定了缓存路径),浏览器端则可见 examples/browser/basic-scheduler.html:它监听文件上传控件,对每个上传文件调用 scheduler.addJob('recognize', files[i]) 投递任务,注释中明确指出“单文件时性能相近,多文件时并行处理速度显著更快”。
Scheduler 的 API
API 文档中列出的 Scheduler 方法如下:
createScheduler(): Scheduler—— 工厂函数,创建一个管理任务队列和 worker 池的 scheduler;scheduler.addWorker(worker): string—— 将 worker 加入 worker 池,建议一个 worker 只加入一个 scheduler,返回 worker 的 id;scheduler.addJob(action, ...payload): Promise—— 把任务加入队列,scheduler 会等待并找到空闲 worker 来执行;scheduler.getQueueLen(): number—— 返回当前任务队列长度;scheduler.getNumWorkers(): number—— 返回已加入 scheduler 的 worker 数量;scheduler.terminate(): Promise—— 终止所有已加入的 worker,用于快速清理。
源码级调度机制
createScheduler 的实现揭示了任务是如何被分配的,核心是三块状态:
workers:worker 池(以worker.id为键);jobQueue:待执行任务数组,FIFO 顺序;runningWorkers:正在执行的 worker 与其任务的映射。
调度逻辑在 dequeue() 中(src/createScheduler.js):只要队列非空,就按 workers 中的 key 顺序遍历,找到第一个“当前没有正在运行的任务”(runningWorkers[wIds[i]] 为 undefined)的 worker,让队首任务(jobQueue[0])执行并立即 break。每次 addWorker、任务完成(finally 中)都会再次触发 dequeue,从而保持队列尽可能被消费。
几个值得注意的实现细节:
addJob要求至少有一个 worker:addJob内部检查getNumWorkers() === 0时直接抛出You need to have at least one worker before adding jobs(src/createScheduler.js),即先加任务后加 worker 会立即报错,而不是排队等待;- 任务与 jobId 的关联:
queue()通过 createJob 创建{ id, action, payload }结构,并以job.id作为传给 worker 动作的jobId,例如w['recognize'].apply(this, [...payload, job.id])——这使得 worker 内部可以按action-jobId唯一定位待 resolve 的 Promise,并在日志中追踪任务流转; terminate的副作用:scheduler.terminate()会遍历workers逐个worker.terminate(),并把jobQueue置空——即文档中“It also terminates all workers”的出处。
由于 dequeue 选择的是“第一个空闲 worker”而非某个指定 worker,任务与 worker 的绑定关系是非确定性的。这也是文档强调的一条重要约束:加入同一 scheduler 的 worker 应当是同质的(homogenous)——相同的语言、相同的参数配置。否则识别结果会取决于任务恰好被分配到了哪个 worker。这一点从 queue/dequeue 源码结构上可以印证:调度器不感知 worker 之间的差异,只做“有空闲就派活”这一件事。
仓库的测试 tests/scheduler.test.mjs 验证了并行正确性:预先创建 5 个 worker,然后分别只取 1、3、5 个加入新的 scheduler,每次向 scheduler 投递 10 个 recognize 任务并用 Promise.all 等待全部完成,断言返回结果数量与任务数一致(expect(rets.length).to.be(NUM_JOBS)),覆盖了 1 到多 worker 的边界情况。
在 Node.js 服务端代码中复用 Worker/Scheduler
Worker 和 Scheduler 都是可复用的,官方也建议在不同任务之间复用它们。但有一个重要的例外:在长时间运行的 Node.js 服务端代码中,同一个 worker/scheduler 连续使用一周会出问题。因此在服务端场景中,应当定期销毁并重建 worker/scheduler——例如每 500 个任务后 terminate 并重新创建一次 scheduler。
文档给出了两条具体的技术原因:
- WASM 内存只增不减:受 WebAssembly 通用限制,分配给 worker 的内存只能随时间增长。因此一张特别大的图片会永久性抬高该 worker 的内存占用,后续再处理小图片也不会回收;
- 内部词典会“学习”膨胀:worker 会随时间把任务中遇到的新词加入内部词典。这一行为在识别同一文档或同一组相关文档时是有益的,但在识别数百个互不相关的文档时未必是期望行为——如果一个 scheduler 一周跑几千个任务,内部词典最终会膨胀并混入大量拼写错误(typos)。
对应到源码,worker 的销毁路径是 worker.terminate(调用 terminateWorker 后把内部 worker 置为 null),而 scheduler 的销毁路径则是 scheduler.terminate(终止全部 worker 并清空队列)。服务端实践上,即是用这两个 terminate 加上重新 createScheduler/createWorker 的循环,实现文档建议的周期性“重置”。
小结:如何选择
- 单个任务或低频识别:直接
createWorker+recognize即可,语法最简;记得把 worker 创建放在等待用户输入之前,识别完成后terminate; - 批量图片、多文件并发(如浏览器端一次上传多张图片、服务端批量处理):用
createScheduler建立 4 个左右的 worker 池,逐个addJob投递任务,最后scheduler.terminate()统一清理; - 长期运行的 Node.js 服务:在 scheduler 复用之上叠加“每 N 个任务重建一次”的运维策略,以规避 WASM 内存只增不减与内部词典膨胀两个问题。
相关文档与代码入口:docs/workers_vs_schedulers.md、API 文档、src/createScheduler.js、src/createWorker.js、examples/node/scheduler.js、tests/scheduler.test.mjs。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00