多线程不是开 N 个线程:多模态索引系统的并发调度设计
多线程不是开 N 个线程:多模态索引系统的并发调度设计
全局多任务调度、Channel 并发控制、支持暂停、恢复与动态并发调整的并发模型
前情提要
多模态数据挖掘引擎 用于对用户自己的文档、图片、扫描件等数据进行持续分析和索引。
系统会把文件系统中的非结构化数据,逐步转换成可检索、可分析、可关联的数据资产。不同类型的数据会进入不同的处理链路。
| 数据类型 | 分析过程 | 形成的数据资产 |
|---|---|---|
| 文档类文件 | 文本抽取、分词、语义向量计算 | 文本倒排索引、文档语义索引 |
| 图片类文件 | 图片视觉内容标签化 | 视觉内容标签、置信度、图片视觉内容标签化倒排索引、图片视觉内容标签化语义索引 |
| 含人物图片 | 人脸检测、人脸特征提取、人物聚合 | 人脸特征、人脸语义索引、人物关系数据 |
| 多模态分析结果 | 元数据归一化、索引写入、状态推进 | 元数据(AI挖掘)、处理进度、统一检索结果 |
这类系统在运行时会面临几个比较现实的问题。
首先,处理链路比较长。一个文件从进入系统到完成索引,可能经历文件枚举、内容解析、图片视觉内容标签化、人脸识别、语义向量计算、倒排索引写入、元数据(AI挖掘)写入和处理进度更新。
其次,不同处理阶段之间存在依赖关系。有些任务只依赖文件路径,可以提前处理;有些任务必须等内容解析完成;有些任务虽然可以并发执行,但结果必须在批次结束时统一提交。
再者,系统通常是长时间后台运行的。用户可能随时添加新的数据源、暂停或恢复任务,也可能同时开启多个分析能力。随着任务数量变化,当前任务的并发度也需要动态调整,不能一直按启动时的配置运行。
因此,这篇文章讨论的重点不是某个具体多线程 API,而是一个更完整的并发执行问题:
任务开始
-> 全局多任务调度
-> 批次生成
-> Channel 控制并发度
-> 线程 Worker 并发执行
-> 批次等待与超时拆分
-> 主批次提交 / 补偿提交
-> 更新处理进度
后文会围绕这条链路展开,讨论在这个系统里,线程、Channel、资源复用、超时补偿、暂停恢复和批次提交分别应该放在什么边界上。
1. 为什么不能简单开 N 个线程
最直接的想法是:
文件列表 -> N 个线程 -> 每个线程处理一个文件 -> 处理完写结果
这个模型适合很简单的任务,但不适合多模态数据挖掘。
原因是系统里的处理过程存在明显的先后依赖。
| 阶段 | 依赖 | 产物 | 并发特点 |
|---|---|---|---|
| 文件枚举 | 数据源配置、扫描状态 | 文件批次 | 适合批量拉取 |
| 图片视觉内容标签化 | 文件路径、数据源配置 | 视觉内容标签 + 置信度 | 适合批次内并发 |
| 人脸识别 | 文件路径、数据源配置 | 人脸特征、观察结果 | 适合批次内并发 |
| 文档解析 | 文件内容 | 正文、基础元数据 | 适合线程 Worker 并发 |
| 语义向量计算 | 解析后的文本或标签 | 向量数据 | 适合受控并发 |
| 倒排索引构建 | 文本、标签、文件元数据 | 索引文档 | 适合局部构建后统一合并 |
| 元数据(AI挖掘)写入 | 图片视觉内容标签化、人脸识别等结果 | 派生元数据 | 应在批次收敛后统一提交 |
| 处理进度更新 | 批次提交结果 | Done / Failed / Retry 状态 | 应在提交完成后推进 |
也就是说,系统真正执行的是一条有依赖、有缓冲、有提交边界的链路:
文件批次
-> 前置 AI 挖掘
-> 内容解析
-> 索引文档构建
-> 局部结果收集
-> 批次统一提交
-> 状态推进
这里有几个问题,单纯开 N 个线程解决不了:
- 当前机器能承受多少并发?
- 新的数据源加入后,老任务要不要自动降并发?
- 用户点击暂停时,系统应该停在哪个位置?
- 某个文件卡住后,整个批次是否要一直等待?
- AI 推理对象能不能跨线程共享?
- 倒排索引、语义索引、元数据(AI挖掘)是否可以由多个线程随意提交?
- 处理进度应该在文件处理完时更新,还是在索引和元数据都提交后更新?
这些问题的答案,不在 Task.Run 里,而在架构边界里。
2. 总体执行链路
我更倾向于把整个并发模型看成一条“受控流水线”。
它不是让每个模块自己开线程,而是先由全局调度决定当前任务能跑多重,再通过 Channel 把并发度落到线程 Worker,最后在批次边界统一提交和推进状态。
这个图里最重要的不是节点多少,而是几个边界:
- 全局调度边界:当前任务不能自己决定并发度。
- Channel 边界:并发度由消费者分区落地。
- 线程 Worker 边界:资源在 Worker 内复用,避免跨线程乱共享。
- 批次边界:线程并发结果在这里收敛。
- 补偿边界:慢任务不拖死主批次,但完成后仍可补交结果。
3. 全局多任务调度:当前任务不能假装自己独占机器
私有化多模态数据挖掘引擎经常不是单任务运行。
用户可能先添加一个文档数据源,随后又添加一个图片目录,还可能同时开启图片视觉内容标签化、人脸识别、语义索引等能力。
如果每个任务都按“我独占机器”来计算并发度,最终资源一定会叠加失控。
任务 A:认为自己可以开 4 个线程 Worker
任务 B:认为自己可以开 4 个线程 Worker
任务 C:认为自己可以开 4 个线程 Worker
最终系统实际运行 12 个重任务线程 Worker
所以系统里需要一个统筹全局的多任务调度器。
它关心的不是某个模块想开几个线程,而是当前系统整体还能承受多少重任务:
- 用户性能偏好
- 当前机器资源
- 当前活跃重任务数量
- 数据源数量
- 当前任务启用的能力
- AI 模型资源占用
- 前台是否需要保持响应
- 是否存在暂停或恢复状态
可以简化理解为:
当前任务并发度
= 全局可用资源
/ 当前活跃重任务数量
× 当前任务权重
× 用户性能偏好系数
真实系统不会这么粗暴,但这个公式表达了核心思想:
任务越多,单个任务越不能跑满。
新任务加入时,不强行抢占当前线程 Worker
当新任务加入后,系统需要重新分配并发度。
但这不应该通过“强行杀掉当前 Worker”实现。
更稳的方式是让当前批次自然结束,然后在下一个批次切换新的执行计划。
这个设计背后的原则是:
动态并发调整尽量发生在批次边界,而不是发生在文件处理到一半的时候。
这样做的好处很实际:
- 不破坏正在处理的文件。
- 不需要迁移 Worker 内部状态。
- 不容易留下半提交结果。
- 调度策略变化对业务模块透明。
- Channel 可以在批次结束后按新的消费者分区数量重建。
这就是把“动态调度问题”转化成“批次边界上的执行计划切换”。
4. Channel 到底控制了什么
在这个系统里,Channel 不是普通队列。
它更像一段受控传送带:生产侧把批次里的文件逐项写入,消费侧用固定数量的消费者分区读取任务,每个消费者分区背后绑定一个线程 Worker。
批次生成器
│
▼
Channel
│
├─ 消费者分区 1 -> 线程 Worker 1
├─ 消费者分区 2 -> 线程 Worker 2
└─ 消费者分区 N -> 线程 Worker N
关键点是:
并发度不由文件数量决定,而由消费者分区数量决定。
一个批次里可以有 1000 个文件,但如果当前执行计划只允许 4 个消费者分区,那么同一时间最多只有 4 个线程 Worker 处理文件。
Channel 不是只负责排队
Channel 在这里至少承担五件事:
| 事情 | 说明 |
|---|---|
| 控制并发度 | 消费者分区数量决定同时运行多少线程 Worker |
| 提供背压 | 消费速度跟不上时,生产侧写入会等待 |
| 支持批次停止 | 当前批次写完后,可以停止继续写入并等待消费者 |
| 拆分超时 Worker | 批次结束时区分已完成部分和超时部分 |
| 承接并发度切换 | 下一个批次可以用新的消费者分区数量重建 Channel |
看起来它只是队列,但实际上它是并发执行和批次收敛之间的桥。
有界队列只能提供反压,不能判断消费者是否卡死
有界 Channel 可以防止任务无限堆积。
当生产速度超过消费速度时,写入会等待,这就是背压。
但背压只能说明“消费跟不上”,不能说明消费者为什么跟不上。
| 现象 | 可能原因 |
|---|---|
| Channel 积压 | 文件太多、批次太大、消费者慢、磁盘忙、AI 推理慢 |
| 某个 Worker 长时间不结束 | 正在处理大文件、外部库慢、GPU 推理慢、线程卡死 |
| 整体吞吐下降 | 资源不足、全局调度需要降并发、某些消费者阻塞 |
所以 Channel 积压只是信号,不是结论。
系统还需要知道线程 Worker 当前在做什么,例如:
当前文件
当前阶段
最近一次进度时间
当前是否仍在推进
是否已经进入批次等待
是否应该被拆成超时 Worker
这就是为什么批次结束时不能只看“队列空了没”,还要等待消费者分区,并判断是否有超时线程 Worker。
5. 线程 Worker:并发执行单元,也是资源所有权边界
线程 Worker 不是一个随手启动的匿名任务,而是一个稳定的执行槽。
每个线程 Worker 可以拥有自己的:
- 解析上下文
- 局部索引缓冲
- 语义向量临时缓冲
- 图片视觉内容标签化推理上下文
- 人脸识别推理上下文
- 元数据(AI挖掘)暂存区
- 当前处理状态
批次
├─ 线程 Worker 1:文件 1 -> 文件 4 -> 文件 7
├─ 线程 Worker 2:文件 2 -> 文件 5 -> 文件 8
└─ 线程 Worker 3:文件 3 -> 文件 6 -> 文件 9
线程 Worker 之间并发。
线程 Worker 内部顺序处理自己拿到的文件。
这看起来只是实现细节,但实际上决定了系统是否稳定。
为什么资源要归属到线程 Worker
如果每个文件都重新创建所有临时对象和 AI 推理资源,成本太高。
如果所有线程共享一套资源,又容易出现线程安全问题。
更稳的方式是:
线程 Worker 内部复用,线程 Worker 之间隔离。
| 资源 | 不成熟做法 | 更稳的做法 |
|---|---|---|
| 临时 buffer | 全局共享 | 线程 Worker 内复用 |
| 推理对象 | 当成线程安全单例 | 按线程 Worker 隔离 |
| 局部集合 | 每个文件大量 new | Worker 内复用并明确清理 |
| 索引文档缓冲 | 多线程直接写最终索引 | Worker 生成局部产物,批次统一提交 |
| 处理状态 | 散落在业务逻辑里 | Worker 上报当前阶段和文件 |
线程 Worker 的生命周期可以抽象成:
Acquire
-> ResetForNewTask
-> Process
-> FlushResult
-> ClearForReuse
对应的伪代码可以写成:
while (await channel.WaitToReadAsync(stopToken))
{
var item = await channel.ReadAsync(stopToken);
var workspace = worker.Workspace;
workspace.Reset(item);
try
{
var artifact = await AnalyzeAsync(item, workspace, stopToken);
workspace.Append(artifact);
}
finally
{
workspace.ClearForReuse();
}
}
这里的重点不是代码,而是顺序:
- 新文件开始前,先把 Worker 内部上下文初始化到明确状态。
- 处理过程中只使用当前 Worker 拥有的资源。
- 处理结果进入局部缓冲,不直接提交最终状态。
- 无论成功失败,都要清理可复用对象。
- 不把 Worker 内部 workspace 暴露给其他线程。
很多并发 bug 并不是两个线程同时写一个对象,而是上一个任务的残留状态污染了下一个任务。
例如:
| 池化资源 | 常见残留问题 |
|---|---|
List<T> / Dictionary<TKey,TValue> |
忘记清空,下一个文件读到旧数据 |
| 大数组 / buffer | 只覆盖前半段,后半段仍是旧内容 |
| 解析上下文 | 上一个文件路径、错误信息残留 |
| 推理结果容器 | 标签、分数、数量没有重置 |
| 批次状态 | 上一个批次的 source、计数混入下一批 |
所以线程 Worker 不是“线程编号”,而是资源所有权边界。
6. 完成通知也可能影响调度线程
并发系统里有一个很隐蔽的问题:完成通知并不总是“无副作用”的。
例如 TaskCompletionSource.SetResult()。
很多人会把它理解成“通知等待方结果好了”。这个理解只对了一半。
如果没有特别处理,等待方的续延逻辑有机会在当前调用线程上继续执行。
这意味着,当前线程本来只是队列消费者,处理完内部消息后准备通知调用方,但通知动作可能把它带进调用方后续逻辑。
队列消费者线程
-> 处理内部消息
-> SetResult()
-> 等待方续延获得执行机会
-> 执行外部业务逻辑
-> 可能耗时很久
-> 队列消费者线程迟迟回不到消息循环
这类问题看起来不像死锁,也不像异常。
系统只是“偶现变慢”“后续消息不消费了”“队列像是卡住了”。
本质上,是内部调度线程被外部逻辑借走了。
在后台索引系统里,文件变更、批次处理、扫描确认等地方都可能需要通知调用方“这批处理完成了”。
如果通知直接占用调度线程,就会破坏线程所有权边界。
更稳的做法是:完成通知本身也要成为一个边界。
var completion = new TaskCompletionSource<Result>(
TaskCreationOptions.RunContinuationsAsynchronously);
completion.TrySetResult(result);
或者在特定场景下,把完成动作转移到外部调度中执行。
重点不在某个写法,而在原则:
队列线程只负责队列内部推进,不负责执行等待方的后续业务逻辑。
这个问题和 Channel 的关系很直接。
Channel 消费者线程应该尽快回到消费循环,不应该被外部续延、回调或通知链路拖走。
7. 暂停、取消和超时不是一回事
后台索引任务需要支持用户暂停/恢复,也需要支持取消和超时处理。
这三个概念经常被混在一起,但它们不是一回事。
| 概念 | 含义 | 适合处理方式 |
|---|---|---|
| 暂停 | 用户希望系统暂时不要继续推进 | 在批次边界或安全点收敛 |
| 取消 | 任务不再需要继续执行 | 协作式退出,尽量清理状态 |
| 超时 | 某个 Worker 长时间没有结束 | 拆分主批次和补偿路径 |
暂停:应该停在安全边界
暂停不是强杀线程。
更合理的流程是:
用户请求暂停
-> 调度层标记暂停
-> 不再写入新的批次
-> 当前线程 Worker 尽量完成当前文件
-> 批次边界提交已完成结果
-> 保存处理进度
-> 进入暂停状态
恢复时:
用户请求恢复
-> 读取处理进度
-> 重新计算当前执行计划
-> 重建 Channel
-> 从上次位置继续
取消:不是所有任务都会听
CancellationToken 是协作式取消。
自己写的循环可以定期检查 token。
支持取消的异步 IO 可以传入 token。
但如果任务卡在不可控外部库里,token 可能只是一个变量。
调用方请求取消
!= 被调用任务已经停止
!= 外部库已经释放资源
!= 当前线程 Worker 可以立即复用
所以在复杂系统里,取消要分层:
| 任务类型 | 推荐策略 |
|---|---|
| 自己实现的循环任务 | 定期检查取消信号 |
| 可控异步 IO | 传入取消信号 |
| 耗时解析任务 | 设置超时,记录阶段,允许重试 |
| 不可控外部库 | 做超时检测和 Worker 隔离 |
| 可能卡死或崩溃的插件 | 考虑进程隔离 |
超时:发现问题,不代表问题已经结束
超时只是检测信号。
某个线程 Worker 超时,并不意味着它已经停止。
因此系统不能简单把它当成失败,也不能让它永远拖住主批次。
更稳的方式是:
批次等待超时
-> 已完成 Worker 进入主批次提交
-> 超时 Worker 挂入后台
-> 超时 Worker 完成后走补偿提交
-> 如果最终失败,记录为可诊断、可重试状态
这就是为什么本文一直强调批次边界。
没有批次边界,暂停、取消和超时都会变成一堆分散的标志位。
8. AI 推理并发不能套普通 CPU 任务的直觉
图片视觉内容标签化和人脸识别不是普通函数调用。
它们可能持有:
- 模型 session
- tensor buffer
- 图片解码 buffer
- 推理输入输出容器
- CPU/GPU 执行上下文
- 临时结果集合
普通 CPU 任务里,我们常常认为:
能并行就多开线程,共享对象有竞争就加锁。
但 AI 推理不是总能这么处理。
CPU 和 GPU 的并发策略不同
CPU 模式下,共享推理资源有时是合理的。
运行时可以利用内部线程池、缓存和 CPU 调度能力。
GPU 模式下,情况更复杂。
同一个 GPU session 如果被多个线程同时调用,可能遇到运行时限制、隐式串行、异常或性能下降。
一种简单修复是加全局锁。
这样安全,但吞吐会变差。
更稳的策略是按设备类型选择并发模型:
| 执行环境 | 更合适的策略 |
|---|---|
| CPU 推理 | 可以偏共享,保留运行时默认优化 |
| GPU 推理,同 session 不支持并发 | session 内串行化 |
| GPU 推理,需要更高吞吐 | 每个线程 Worker 拥有独立 session |
| GPU 设备切换 | 新任务使用新资源,旧资源等当前任务释放后再清理 |
可以简化理解为:
CPU 模式:
多个线程 Worker -> 共享推理资源
GPU 模式:
线程 Worker 1 -> GPU Session 1
线程 Worker 2 -> GPU Session 2
线程 Worker N -> GPU Session N
设备切换不能直接 Dispose 旧资源
用户可能切换 CPU/GPU,或者系统检测到设备策略变化。
如果旧任务正在推理,直接释放旧 session 是危险的。
更稳的方式是使用类似 generation / lease 的思想:
设备切换
-> 创建新一代推理资源
-> 新任务使用新资源
-> 旧资源标记为 retired
-> 等旧任务释放 lease
-> 再清理旧资源
这类设计看起来比加锁复杂,但它解决的是长期运行系统中的真实问题:
- 任务会并发
- 推理耗时不可完全预测
- 设备可能切换
- 旧资源不能在使用中释放
- 新任务不能继续拿到旧资源
这也是为什么线程 Worker 隔离很重要。
推理资源归属清楚,设备切换和资源清理才有边界。
9. 批次结束:把并发结果收敛成可恢复状态
批次结束不是“队列空了”这么简单。
它是系统状态收敛点。
批次结束时,系统需要做几件事:
- 停止当前批次继续写入 Channel。
- 等待消费者分区完成。
- 判断是否存在超时线程 Worker。
- 如果没有超时,合并全部局部产物。
- 如果存在超时,拆分已完成部分和超时部分。
- 主批次先提交已完成部分。
- 超时部分进入后台补偿。
- 提交倒排索引。
- 提交语义索引。
- 提交元数据(AI挖掘)。
- 更新处理进度。
- 根据新的全局调度结果决定下一批并发度。
为什么不让每个线程 Worker 自己提交?
因为最终状态不止一种:
| 最终状态 | 内容 |
|---|---|
| 倒排索引 | 文本 token、视觉内容标签 token、文件字段 |
| 语义索引 | 文档向量、图片视觉内容标签化语义向量、人脸特征 |
| 元数据(AI挖掘) | 视觉内容标签、置信度、人脸观察结果、人物关系 |
| 处理进度 | 当前处理到哪里、哪些成功、哪些失败、哪些需要重试 |
如果多个线程 Worker 分散提交,就会出现:
- 写入顺序不可控
- 失败恢复困难
- 进度可能提前推进
- 部分索引成功、部分元数据失败时难以诊断
- 新增模块会继续扩大提交复杂度
所以更稳的原则是:
线程 Worker 负责生成局部产物,批次结束负责改变最终状态。
10. 增量变更:重建和删除不能混在一起
全量索引只是系统的一部分。
长期运行的引擎还必须处理文件新增、修改、重命名、删除。
增量变更的复杂点在于:
它不仅要生成新索引,还要清理旧索引和旧派生数据。
文件变化
-> 分类:新增 / 修改 / 重命名 / 删除
-> 分流:重建链路 / 删除链路
-> 批次处理
-> 索引更新
-> 元数据(AI挖掘)更新
-> 状态推进
这里最重要的设计是:
删除链路和重建链路要分开。
| 链路 | 核心动作 |
|---|---|
| 重建链路 | 读取文件,重新分析,写入新索引和新元数据 |
| 删除链路 | 删除倒排索引文档,清理语义索引,清理元数据(AI挖掘),推进删除状态 |
如果把删除也伪装成普通重建,每个模块都要在内部判断“当前是不是删除”。
这会让复杂度扩散到所有分析模块里。
更好的边界是:
重建链路负责生成新结果,删除链路负责清理旧结果。
这样新增图片视觉内容标签化、人脸识别、语义索引等能力时,不需要每个模块都重新发明删除判断。
11. 一个抽象的执行模型
下面这段不是项目真实代码,而是表达模型。
重点是:
- 全局调度生成执行计划。
- Channel 用执行计划控制并发度。
- 线程 Worker 生成局部产物。
- 批次提交统一改变最终状态。
- 超时线程 Worker 进入补偿路径。
- 暂停/恢复在批次边界生效。
public sealed record GlobalExecutionSnapshot(
int ActiveHeavyTaskCount,
ResourcePreference UserPreference,
MachineResourceBudget MachineBudget);
public sealed record BatchExecutionPlan(
int WorkerCount,
int BatchSize,
MemoryBudget BufferBudget,
InferenceIsolationPolicy InferenceIsolation);
public sealed record WorkerContext(
int WorkerIndex,
LocalIndexBuffer IndexBuffer,
LocalSemanticBuffer SemanticBuffer,
LocalMetadataBuffer MetadataBuffer,
IReusableInferenceContext? InferenceContext);
public async Task RunIndexingTaskAsync(IndexingTask task)
{
while (await task.HasMoreFilesAsync())
{
await task.PauseSignal.WaitIfPausedAsync();
var snapshot = GlobalScheduler.GetSnapshot();
var plan = GlobalScheduler.Plan(task.Profile, snapshot);
var batch = await task.NextBatchAsync(plan.BatchSize);
var channel = ChannelFactory.Create(plan.WorkerCount);
var workers = WorkerContextFactory.Create(plan);
var result = await channel.RunBatchAsync(batch.Items, workers, async (worker, item) =>
{
worker.ResetFor(item);
var artifact = await AnalyzeFileAsync(item, worker.InferenceContext);
worker.IndexBuffer.Append(artifact.IndexDocuments);
worker.SemanticBuffer.Append(artifact.SemanticVectors);
worker.MetadataBuffer.Append(artifact.Metadata);
});
var completed = result.CompletedWorkers;
var timeout = result.TimeoutWorkers;
var commit = BatchCommit.Combine(completed);
await commit.FlushInvertedIndexAsync();
await commit.FlushSemanticIndexAsync();
await commit.FlushMetadataAsync();
await task.Progress.AdvanceAsync(batch, completed);
BackgroundCompensation.Register(timeout, async completedTimeoutWorkers =>
{
var compensation = BatchCommit.Combine(completedTimeoutWorkers);
await compensation.FlushInvertedIndexAsync();
await compensation.FlushSemanticIndexAsync();
await compensation.FlushMetadataAsync();
await task.Progress.AdvanceCompensationAsync(completedTimeoutWorkers);
});
}
}
这段模型想表达的是:
并发度不是写死的,而是每个批次根据全局状态重新生成;线程 Worker 不直接提交最终状态,而是生成局部产物;批次结束负责收敛;超时 Worker 不阻塞主批次,而是进入补偿路径。
12. 最后回到几个边界问题
这篇文章表面上讲的是多线程,实际上讲的是边界。
在这类系统里,我会反复问这些问题:
- 谁拥有线程?
- 谁能唤醒谁?
- 谁可能阻塞谁?
- 哪些资源可以共享?
- 哪些资源只能在线程 Worker 内复用?
- 哪些任务可以协作取消?
- 哪些任务只能做超时检测?
- Channel 积压是正常慢,还是消费者卡住?
- 用户暂停时,系统停在哪个安全点?
- 新任务加入后,当前任务什么时候降并发?
- 超时 Worker 完成后,结果如何补交?
- 倒排索引、语义索引、元数据(AI挖掘)何时推进到最终状态?
这些问题比“开几个线程”更重要。
多线程只是执行手段。
真正决定系统能不能长期稳定运行的,是这些边界是否清楚。
总结
私有化多模态数据挖掘引擎中的并发问题,本质上不是“怎么开线程”,而是“如何让多个有依赖的业务阶段在并发执行后稳定收敛”。
当系统同时承担文档解析、图片视觉内容标签化、人脸识别、语义向量计算、倒排索引构建、语义索引写入、元数据(AI挖掘)维护和处理进度推进时,简单开 N 个线程并不能解决问题。
真正可靠的设计需要几个关键点:
- 用全局多任务调度器控制整体资源分配。
- 用 Channel 把并发度落到执行层。
- 用消费者分区和线程 Worker 绑定执行上下文。
- 用线程 Worker 隔离和复用重资源。
- 用异步完成通知避免队列线程被外部续延借走。
- 用批次边界统一提交索引和元数据。
- 用暂停/恢复机制让用户安全控制后台任务。
- 用动态并发调整适应新任务加入。
- 用长任务补偿避免慢文件拖住主流程。
- 用处理进度记录支撑恢复、诊断和重试。
最终一句话:
多线程不是开 N 个线程,而是把线程、队列、资源、提交和恢复边界设计清楚。
这才是复杂后台系统里的并发架构能力。
浙公网安备 33010602011771号