多线程不是开 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,最后在批次边界统一提交和推进状态。

flowchart TB subgraph A[任务启动] A1[数据源索引任务开始] A2[读取数据源配置] A3[生成初始执行计划] end subgraph B[全局多任务调度] B1[注册当前任务] B2[统计活跃重任务] B3[分配当前任务并发额度] B4[生成批次执行参数] end subgraph C[Channel 调度] C1[生成文件批次] C2[逐项写入 Channel] C3[消费者分区控制并发度] end subgraph D[线程 Worker 执行] D1[线程 Worker 1] D2[线程 Worker 2] D3[线程 Worker N] end subgraph E[局部产物] E1[文本特征] E2[图片视觉内容标签化结果] E3[人脸特征] E4[语义向量] E5[局部索引文档] E6[元数据(AI挖掘)] end subgraph F[批次等待与超时处理] F1[停止当前批次写入] F2[等待消费者分区完成] F3{是否存在超时线程 Worker} F4[拆分已完成部分与超时部分] F5[超时线程 Worker 挂入后台] end subgraph G[主批次提交] G1[合并已完成局部产物] G2[提交倒排索引] G3[提交语义索引] G4[提交元数据(AI挖掘)] G5[更新处理进度] end subgraph H[补偿提交] H1[等待超时线程 Worker 完成] H2[补偿合并产物] H3[补偿提交] H4[更新补偿进度] end A1 --> A2 --> A3 --> B1 B1 --> B2 --> B3 --> B4 --> C1 C1 --> C2 --> C3 C3 --> D1 C3 --> D2 C3 --> D3 D1 --> E1 D1 --> E2 D2 --> E3 D2 --> E5 D3 --> E4 D3 --> E6 C2 --> F1 E1 --> F2 E2 --> F2 E3 --> F2 E4 --> F2 E5 --> F2 E6 --> F2 F1 --> F2 --> F3 F3 -->|否| G1 F3 -->|是| F4 F4 --> G1 F4 --> F5 F5 --> H1 --> H2 --> H3 --> H4 G1 --> G2 G1 --> G3 G1 --> G4 G2 --> G5 G3 --> G5 G4 --> G5

这个图里最重要的不是节点多少,而是几个边界:

  • 全局调度边界:当前任务不能自己决定并发度。
  • Channel 边界:并发度由消费者分区落地。
  • 线程 Worker 边界:资源在 Worker 内复用,避免跨线程乱共享。
  • 批次边界:线程并发结果在这里收敛。
  • 补偿边界:慢任务不拖死主批次,但完成后仍可补交结果。

3. 全局多任务调度:当前任务不能假装自己独占机器

私有化多模态数据挖掘引擎经常不是单任务运行。

用户可能先添加一个文档数据源,随后又添加一个图片目录,还可能同时开启图片视觉内容标签化、人脸识别、语义索引等能力。

如果每个任务都按“我独占机器”来计算并发度,最终资源一定会叠加失控。

任务 A:认为自己可以开 4 个线程 Worker
任务 B:认为自己可以开 4 个线程 Worker
任务 C:认为自己可以开 4 个线程 Worker

最终系统实际运行 12 个重任务线程 Worker

所以系统里需要一个统筹全局的多任务调度器。

它关心的不是某个模块想开几个线程,而是当前系统整体还能承受多少重任务:

  • 用户性能偏好
  • 当前机器资源
  • 当前活跃重任务数量
  • 数据源数量
  • 当前任务启用的能力
  • AI 模型资源占用
  • 前台是否需要保持响应
  • 是否存在暂停或恢复状态

可以简化理解为:

当前任务并发度
  = 全局可用资源
    / 当前活跃重任务数量
    × 当前任务权重
    × 用户性能偏好系数

真实系统不会这么粗暴,但这个公式表达了核心思想:

任务越多,单个任务越不能跑满。

新任务加入时,不强行抢占当前线程 Worker

当新任务加入后,系统需要重新分配并发度。

但这不应该通过“强行杀掉当前 Worker”实现。
更稳的方式是让当前批次自然结束,然后在下一个批次切换新的执行计划。

flowchart TB subgraph A[任务 A 正在运行] A1[当前批次处理中] A2[当前线程 Worker 数量 = 4] end subgraph B[任务 B 加入] B1[任务 B 注册到全局调度器] B2[重新计算活跃任务数量] B3[任务 A 新线程 Worker 数量 = 2] end subgraph C[批次边界切换] C1[任务 A 当前批次完成] C2[提交当前批次结果] C3[按新计划重建 Channel] C4[下一批次线程 Worker 数量 = 2] end A1 --> A2 --> C1 B1 --> B2 --> B3 B3 --> C3 C1 --> C2 --> C3 --> C4

这个设计背后的原则是:

动态并发调整尽量发生在批次边界,而不是发生在文件处理到一半的时候。

这样做的好处很实际:

  • 不破坏正在处理的文件。
  • 不需要迁移 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

看起来它只是队列,但实际上它是并发执行和批次收敛之间的桥。

flowchart TB subgraph A[批次生产侧] A1[文件枚举] A2[批次生成] A3[逐项写入 Channel] end subgraph B[Channel] B1[有界队列] B2[消费者分区 1] B3[消费者分区 2] B4[消费者分区 N] end subgraph C[线程 Worker] C1[线程 Worker 1] C2[线程 Worker 2] C3[线程 Worker N] end subgraph D[批次等待] D1[停止当前批次写入] D2[等待消费者分区完成] D3{是否存在超时线程 Worker} D4[拆分已完成部分与超时部分] D5[超时线程 Worker 挂入后台] end subgraph E[主批次] E1[合并已完成产物] E2[提交主批次结果] E3[更新处理进度] end subgraph F[补偿] F1[等待超时线程 Worker 完成] F2[补偿合并产物] F3[补偿提交] F4[更新补偿进度] end A1 --> A2 --> A3 --> B1 B1 --> B2 --> C1 B1 --> B3 --> C2 B1 --> B4 --> C3 A3 --> D1 C1 --> D2 C2 --> D2 C3 --> D2 D1 --> D2 --> D3 D3 -->|否| E1 D3 -->|是| D4 D4 --> E1 D4 --> D5 E1 --> E2 --> E3 D5 --> F1 --> F2 --> F3 --> F4

有界队列只能提供反压,不能判断消费者是否卡死

有界 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();
    }
}

这里的重点不是代码,而是顺序:

  1. 新文件开始前,先把 Worker 内部上下文初始化到明确状态。
  2. 处理过程中只使用当前 Worker 拥有的资源。
  3. 处理结果进入局部缓冲,不直接提交最终状态。
  4. 无论成功失败,都要清理可复用对象。
  5. 不把 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
  -> 从上次位置继续
flowchart TB subgraph A[运行中] A1[线程 Worker 正在处理当前批次] A2[用户请求暂停] end subgraph B[暂停收敛] B1[停止写入新批次] B2[等待当前 Worker 到达安全点] B3[提交已完成结果] B4[更新处理进度] B5[进入暂停状态] end subgraph C[恢复] C1[用户请求恢复] C2[读取处理进度] C3[重新计算执行计划] C4[重建 Channel] C5[继续处理] end A1 --> A2 --> B1 --> B2 --> B3 --> B4 --> B5 B5 --> C1 --> C2 --> C3 --> C4 --> C5

取消:不是所有任务都会听

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. 批次结束:把并发结果收敛成可恢复状态

批次结束不是“队列空了”这么简单。

它是系统状态收敛点。

批次结束时,系统需要做几件事:

  1. 停止当前批次继续写入 Channel。
  2. 等待消费者分区完成。
  3. 判断是否存在超时线程 Worker。
  4. 如果没有超时,合并全部局部产物。
  5. 如果存在超时,拆分已完成部分和超时部分。
  6. 主批次先提交已完成部分。
  7. 超时部分进入后台补偿。
  8. 提交倒排索引。
  9. 提交语义索引。
  10. 提交元数据(AI挖掘)。
  11. 更新处理进度。
  12. 根据新的全局调度结果决定下一批并发度。
flowchart TB subgraph A[批次结束] A1[停止写入 Channel] A2[等待消费者分区完成] A3{是否存在超时线程 Worker} end subgraph B[无超时] B1[合并全部局部产物] end subgraph C[有超时] C1[拆分已完成部分与超时部分] C2[合并已完成局部产物] C3[超时部分进入后台补偿] end subgraph D[主批次提交] D1[提交倒排索引] D2[提交语义索引] D3[提交元数据(AI挖掘)] D4[更新处理进度] end subgraph E[下一批准备] E1[读取新的全局调度结果] E2[重建 Channel] E3[进入下一批] end A1 --> A2 --> A3 A3 -->|否| B1 A3 -->|是| C1 C1 --> C2 C1 --> C3 B1 --> D1 B1 --> D2 B1 --> D3 C2 --> D1 C2 --> D2 C2 --> D3 D1 --> D4 D2 --> D4 D3 --> D4 D4 --> E1 --> E2 --> E3

为什么不让每个线程 Worker 自己提交?

因为最终状态不止一种:

最终状态 内容
倒排索引 文本 token、视觉内容标签 token、文件字段
语义索引 文档向量、图片视觉内容标签化语义向量、人脸特征
元数据(AI挖掘) 视觉内容标签、置信度、人脸观察结果、人物关系
处理进度 当前处理到哪里、哪些成功、哪些失败、哪些需要重试

如果多个线程 Worker 分散提交,就会出现:

  • 写入顺序不可控
  • 失败恢复困难
  • 进度可能提前推进
  • 部分索引成功、部分元数据失败时难以诊断
  • 新增模块会继续扩大提交复杂度

所以更稳的原则是:

线程 Worker 负责生成局部产物,批次结束负责改变最终状态。


10. 增量变更:重建和删除不能混在一起

全量索引只是系统的一部分。
长期运行的引擎还必须处理文件新增、修改、重命名、删除。

增量变更的复杂点在于:
它不仅要生成新索引,还要清理旧索引和旧派生数据。

文件变化
  -> 分类:新增 / 修改 / 重命名 / 删除
  -> 分流:重建链路 / 删除链路
  -> 批次处理
  -> 索引更新
  -> 元数据(AI挖掘)更新
  -> 状态推进

这里最重要的设计是:
删除链路和重建链路要分开。

链路 核心动作
重建链路 读取文件,重新分析,写入新索引和新元数据
删除链路 删除倒排索引文档,清理语义索引,清理元数据(AI挖掘),推进删除状态
flowchart TB subgraph A[文件变化] A1[新增] A2[修改] A3[重命名] A4[删除] end subgraph B[变化分类] B1[需要重建] B2[只需删除] end subgraph C[重建链路] C1[批次准备] C2[线程 Worker 并发分析] C3[索引与元数据提交] C4[更新重建进度] end subgraph D[删除链路] D1[删除倒排索引] D2[清理语义索引] D3[清理元数据(AI挖掘)] D4[更新删除状态] end A1 --> B1 A2 --> B1 A3 --> B1 A3 --> B2 A4 --> B2 B1 --> C1 --> C2 --> C3 --> C4 B2 --> D1 --> D2 --> D3 --> D4

如果把删除也伪装成普通重建,每个模块都要在内部判断“当前是不是删除”。
这会让复杂度扩散到所有分析模块里。

更好的边界是:

重建链路负责生成新结果,删除链路负责清理旧结果。

这样新增图片视觉内容标签化、人脸识别、语义索引等能力时,不需要每个模块都重新发明删除判断。


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 个线程,而是把线程、队列、资源、提交和恢复边界设计清楚。

这才是复杂后台系统里的并发架构能力。

posted @ 2026-05-10 15:44  darklx  阅读(17)  评论(0)    收藏  举报