VisionPro-ToolBlock-UI阻塞排查报告.md 38 KB

VisionPro ToolBlock 加载/运行导致 UI 阻塞排查报告

初版日期:2026-07-12(首次调查) 修订日期:2026-07-12(日志复查 + 第二轮根因定位 + 修复验证) 涉及文件:TeamAAS-VM/Views/Home/ShowVisionRender.xaml.csTeamAAS-VM/Services/RemoteCommandService.csTeamAAS-VM/Helpers/VisionProHelper.csTeamAAS-VM/Core/Management.csTeamAAS-VM/Services/ProductService.cs 问题:多任务执行时只有相机图像在更新,软件按钮等 UI 操作假死无响应


修订记录

日期 版本 内容
2026-07-12 v1 初版:定位 ToolBlock.Run() COM 封送、VPP 同步加载、串行执行、countdown.Wait 等 4 个根因
2026-07-12 v2 日志复查:确认 P0(STA 线程隔离)已落地并验证通过;新增根因 5(RenderUpdate Dispatcher 洪泛)及对应修复

0. 日志复查结果(2026-07-12 第二轮)

0.1 数据来源

  • C:\Users\teamk\Desktop\新建文件夹\Log\LogInfo\2026-07\2026-07-12.txt
  • C:\Users\teamk\Desktop\新建文件夹\Log\LogError\2026-07\2026-07-12.htm
  • C:\Users\teamk\Desktop\新建文件夹\Log\LogWarn\2026-07\2026-07-12.txt

0.2 关键发现

✅ 根因 1(ToolBlock.Run COM 封送)已在当前代码中修复。

日志中可见所有 ToolBlock 执行均通过 VisionProHelper.RunToolBlockAsync 投递到独立 STA 后台线程:

[VP-STA] RunToolBlockAsync ENTER, callingThread=10, callingApartment=MTA, isUIThread=False, newThread=VP-Run-xxx
[VP-STA] STA-Run START, threadId=22, apartment=STA
[VP-STA] STA-Run DONE, threadId=22, elapsedMs=512
[VP-TRACE] ExecutePhotoAsync AFTER RunToolBlockAsync, proc=C1, thread=3, result=Accept

关键证据:callingApartment=MTAisUIThread=False,新线程 apartment=STA 且 ID 不是 1(UI 线程),确认 ToolBlock.Run() 已不再占用主 UI 线程。

❌ UI heartbeat 在假死期间仍然持续,但 UI 不可交互。

[UI-HEARTBEAT] UI thread alive, queueDepth=0, time=17:23:10.xxx
[UI-HEARTBEAT] UI thread alive, queueDepth=0, time=17:23:11.xxx
...(从未中断)

这说明不是线程死锁或同步阻塞,而是 UI 线程的消息泵虽然运行,但 Dispatcher 工作队列被渲染任务持续占满,用户输入(鼠标点击、按键)长时间得不到调度机会。

❌ 线程 [1] 仍在处理视觉结果和保存。

同一时段日志中 [1] 线程持续输出:

视觉流程[C1]执行结束!耗时:477.6ms
视觉流程[C2]执行结束!耗时:247.8ms
视觉流程[C3]执行结束!耗时:394.6ms
[SAVE-IMAGE] 开始保存流程...
当前产品SN不存在,无法保存图片!

这些操作通过 RenderUpdateNotification 事件 → 订阅端 → Dispatcher.BeginInvoke → Cognex 显示控件更新 + 截图保存,全部回到了 UI 线程。

结论:第一轮修复(STA 线程隔离)生效后,新的瓶颈变为 RenderUpdateNotification 订阅端在 UI 线程上的渲染/保存链路被高频触发导致饥饿


1. 场景描述

生产流程中触发固定相机检测时,会按以下顺序执行:

  1. 收到 PLC 触发信号 → 相机采集图像 → 告知 PLC 采集完成
  2. 图像传入 ToolBlock 1 运行视觉流程(同时继续接收下一个触发信号)
  3. 收到第二个触发信号 → 采集图像 → ToolBlock 2 运行
  4. 收到第三个触发信号 → 采集图像 → ToolBlock 3 运行
  5. ……以此类推

在此过程中可能同时存在多个 ToolBlock 在不同阶段执行,但 UI 完全失去响应。


2. 根因分析

🔴 根因 1:ToolBlock.Run() 是同步阻塞调用,且 Cognex VisionPro 是 COM/STA 组件

状态:✅ 已修复(VisionProHelper.RunToolBlockAsync),日志验证通过。

调用链(初版,修复前):

PlcCommandTriggerAsync (async Task, 运行于线程池线程)
  → await ExecuteProcedureAsync(...)                     // Management.cs:2564
    → await _remoteCommandService.ExecutePhotoGetSinglePoint1(...)  // Management.cs:3830
      → ExecutePhoto(procedure, imges, ...)               // RemoteCommandService.cs:2781
        → procedure.ToolBlock.Run()                       // 同步阻塞!line 2206

虽然外层方法全部标记为 async Task,但 CogToolBlock.Run() 本身是一个同步方法,它会直接阻塞当前调用线程直到视觉流程执行完毕。

更致命的是,Cognex VisionPro 基于 COM 组件CogToolBlock 内部需要 STA(单线程套间) 才能正常工作。WPF 应用的主 UI 线程是 STA 线程,而 Task.Run/async-await 在线程池(MTA)线程上执行。COM 运行时会自动将 ToolBlock.Run() 的调用封送(marshal)回主 UI 线程,这才是 UI 假死的直接原因。

结论:无论上层用了多少 async/awaitTask.RunToolBlock.Run() 最终都在 UI 线程上执行,且是同步阻塞的。

ToolBlock.Run() 的所有调用点(均在 RemoteCommandService.cs

行号 方法 上下文
1526 ExecutePhoto(procedure, out outputCollection) 带实时采图 + ToolBlock 运行
1690 ExecutePhoto(procedure, InputTerminal, out outputCollection) 带实时采图 + 输入终端 + ToolBlock 运行
1856 ExecutePhoto2(procedure, InputTerminal, out outputCollection) 同上,变体
2120 ExecutePhoto(procedure, imges, InputTerminal, ...) AI 推理路径
2206 ExecutePhoto(procedure, imges, InputTerminal, ...) 非 AI 路径,生产主流程使用
2985 ExecutePhotoEx(procedure, InputTerminal, ...) 带实时采图 + 输出图像

所有这 6 处 ToolBlock.Run() 均是同步阻塞调用,无一使用后台线程或异步封装。

修复状态

当前代码中所有 6 处调用已全部替换为 await VisionProHelper.RunToolBlockAsync(procedure.ToolBlock),实现在 TeamAAS-VM/Helpers/VisionProHelper.cs。该方法为每次调用创建独立 STA 后台线程执行 ToolBlock.Run(),日志(见 §0.2)证实 callingApartment=MTA、STA 线程 ID 非 1、UI heartbeat 不受 ToolBlock 执行时段影响。


🔴 根因 2:VPP 文件加载也同步阻塞

状态:⚠️ 部分修复。VisionProHelper.LoadVisionProObjectAsync 已提供异步加载能力,但 ProductService.Initialize() 等同步调用路径尚未全部迁移。

ProductService.cs

行号 方法 阻塞操作
~530 LoadProduct(Guid id)(同步) CogSerializer.LoadObjectFromFile(path) 加载 .vpp
~597 LoadProductAsync(Guid id) _vppCache.GetOrAdd 回调中同步调用 CogSerializer.LoadObjectFromFile
~95 Initialize() 循环调用 LoadProduct() 加载所有产品的 VPP
~481 SaveProduct(ProductModel p, string filePath) 同步调用 CogSerializer.LoadObjectFromFile 加载模板 ToolBlock

当通过 SetCurrentProduct()LoadProduct() 加载 VPP 时,若调用方在 UI 线程上,反序列化会直接阻塞 UI。


🟡 根因 3:多 ToolBlock 串行执行,非真正并发

状态:⚠️ 待评估。当前仍为串行 foreach + await,但 STA 线程隔离后阻塞影响已大幅降低。

Management.cs 第 2520~2640 行区域

分支 AsysConfig.PhotosUse == true,即"逐点采图+逐点处理"模式,第 2547 行):

foreach (var prcId in procIds)
{
    var tt = await GrabImageEx(...);         // 采图(同步等待)
    await ExecuteProcedureAsync(...);        // ToolBlock.Run() → 阻塞(同步等待)
    // ↑ 上一个 ToolBlock 完全结束才进入下一个循环
}

分支 BPhotosUse == false,即"全部采图后批量处理"模式,第 2577 行起):

  • 图像采集被并发化(Task.WhenAll)✅
  • 但 ToolBlock 执行仍然串行 ❌:

    foreach (var item in ImageTaskResults)
    {
    await ExecuteProcedureAsync(...);   // 一个一个串行执行!
    }
    

🟡 根因 4:countdown.Wait() 同步阻塞线程池线程

状态:⚠️ 待修复。日志中可见 countdown wait start, expected=6, current=1 记录。

Management.cs 第 ~2650 行

bool countdownCompleted = countdown.Wait(TimeSpan.FromSeconds(5));

这会同步阻塞当前线程池线程最长 5 秒。如果此线程正在处理 PLC 回调,后续 PLC 信号处理全被延迟。


🔴 根因 5(第二轮新增):RenderUpdateNotification 渲染/保存链路在 UI 线程上形成 Dispatcher 洪泛

状态:✅ 已修复。将每帧独立 Dispatcher 投递改为后台入队 + Background 优先级批处理,修复在 ShowVisionRender.xaml.cs

5.1 问题机理

当 ToolBlock 多任务并发运行且结果密集返回时,每个视觉流程完成都会触发:

RemoteCommandService.ExecutePhotoAsync()
  → _eventAggregator.GetEvent<RenderUpdateNotification>().Publish(showRender)   // 每条结果 1~2 次 Publish
  → ShowVisionRender.UpdateRenderModuleSource(render)                            // 订阅端(后台线程)
    → imageCopy = render.Image.CopyBase(CopyPixels)                              // 深拷贝图像(后台线程,开销小)
    → gridRender.Dispatcher.BeginInvoke(() => { ... })                           // ⚠️ 每帧一次独立 UI 投递
      → recordDisplay.Image = imageCopy;                                         // Cognex 显示控件设图(UI 线程,重)
      → recordDisplay.StaticGraphics.AddList(graphic, "");                       // 叠加图形(UI 线程)
      → recordDisplay.Record = record;                                           // 叠加 Record(UI 线程)
      → RequestSaveImage() → disp.CreateContentBitmap()                          // WinForms 截图(UI 线程,极重)
        → SaveImageAsync()                                                        // 磁盘 I/O(已切到后台线程,✅ 不占 UI)

核心矛盾:每个视觉结果都独立 Dispatcher.BeginInvoke(默认优先级 = Normal),当 6 个相机依次在 ~2 秒内完成并投递 6~12 个 UI 更新时,UI 线程的 Dispatcher 队列被这些渲染任务塞满。用户鼠标点击的 Input 优先级消息排在这些 Normal 优先级工作项后面,长时间得不到处理 → 表现为"画面在刷新但点不动按钮"。

5.2 日志证据

[1] 线程(UI 线程)在 17:23:10 ~ 17:23:16 期间密集执行视觉结果处理和保存,而 [UI-HEARTBEAT] 在此期间仍在正常跳动(每 ~500ms),queueDepth=0(消息队列没有积压)——说明死锁不存在,确实是 UI Dispatcher 被占满。

此外,无产品 SN 时仍在做深拷贝、截图等无效 UI 工作后才报"当前产品SN不存在,无法保存图片!",进一步浪费 UI 线程时间。

5.3 修复方案

修改文件TeamAAS-VM/Views/Home/ShowVisionRender.xaml.cs

策略:将"每帧一次独立 UI 投递"改为"后台入队 + UI 线程 Background 优先级批量排空"。

// 新增字段
private readonly ConcurrentQueue<ShowRender> _pendingRenderUpdates = new ConcurrentQueue<ShowRender>();
private int _renderDrainScheduled;

// EnqueueRenderUpdate:后台线程入队(替代旧 UpdateRenderModuleSource)
private void EnqueueRenderUpdate(ShowRender render)
{
    // 后台线程深拷贝图像
    ICogImage imageCopy = render.Image?.CopyBase(CogImageCopyModeConstants.CopyPixels);
    // 入队
    _pendingRenderUpdates.Enqueue(new ShowRender { ..., Image = imageCopy, ... });
    ScheduleRenderDrain();
}

// ScheduleRenderDrain:UI 线程按 Background 优先级批量排空
private void ScheduleRenderDrain()
{
    if (Interlocked.CompareExchange(ref _renderDrainScheduled, 1, 0) != 0)
        return;  // 已有排空任务在运行,避免重复投递

    _ = gridRender.Dispatcher.InvokeAsync(async () =>
    {
        while (_pendingRenderUpdates.TryDequeue(out var render))
        {
            ApplyRenderUpdate(render);
            // 每处理 2 帧出让 UI 线程,让 Input 优先级消息有机会执行
            if (processed % 2 == 0 && !_pendingRenderUpdates.IsEmpty)
                await Dispatcher.Yield(DispatcherPriority.Input);
        }
    }, DispatcherPriority.Background);  // ← 关键:Background 优先级,不抢用户输入
}

附加优化:在 RequestSaveImage 入口增加无产品 SN 的提前返回(原本要走到截图阶段才检测),减少无效 UI 工作。

5.4 验证结果

  • 用户现场验证:多任务执行时 UI 按钮可正常点击交互,假死现象消失。✅
  • 新增诊断日志 [UI-RENDER] Pending render queue depth=N 可在积压时预警。

3. 完整调用路径图

3.1 初版路径(修复前,ToolBlock.Run 直接通过 COM 封送阻塞 UI 线程)

sequenceDiagram
    participant PLC
    participant OPC_UA_Callback
    participant PlcCommandTriggerAsync
    participant RemoteCommandService
    participant UI_Thread as UI 线程 (STA)
    participant CogToolBlock

    PLC->>OPC_UA_Callback: 触发信号 (In_FixedCameracheckExe)
    OPC_UA_Callback->>PlcCommandTriggerAsync: Task.Run → 线程池线程
    PlcCommandTriggerAsync->>RemoteCommandService: ExecutePhotoGetSinglePoint1()
    RemoteCommandService->>RemoteCommandService: ExecutePhoto()
    Note over RemoteCommandService: 设置 InputImage 等终端
    RemoteCommandService->>CogToolBlock: procedure.ToolBlock.Run()
    Note over CogToolBlock: COM 组件需要 STA
    CogToolBlock-->>UI_Thread: COM Marshal → 封送到 UI 线程执行
    Note over UI_Thread: 🔴 UI 线程被同步阻塞
    UI_Thread-->>CogToolBlock: 视觉流程执行完毕
    CogToolBlock-->>RemoteCommandService: 返回结果
    RemoteCommandService-->>PlcCommandTriggerAsync: 结果

关键点:即使调用从线程池发起,ToolBlock.Run() 通过 COM 封送回到了 UI 线程执行,且是同步阻塞的。

3.2 当前路径(P0 修复后:STA 隔离 + 渲染批处理)

sequenceDiagram
    participant PLC
    participant OPC_UA as OPC UA 回调
    participant ThreadPool as 线程池
    participant VP_STA as STA 后台线程 (VP-Run-xxx)
    participant Remote as RemoteCommandService
    participant UI_Thread as UI 线程
    participant CogDisplay as CogRecordDisplay
    participant Disk as 磁盘 I/O

    PLC->>OPC_UA: 触发信号
    OPC_UA->>ThreadPool: Task.Run → PlcCommandTriggerAsync
    ThreadPool->>Remote: ExecutePhotoGetSinglePoint1()
    Remote->>VP_STA: await RunToolBlockAsync(toolBlock)
    Note over VP_STA: new Thread(ApartmentState.STA)<br/>toolBlock.Run() 在独立线程
    VP_STA-->>Remote: 视觉结果
    Remote->>Remote: Publish(RenderUpdateNotification)
    Note over Remote: 后台线程订阅 EnqueueRenderUpdate<br/>深拷贝图像后入队
    Remote-->>UI_Thread: Dispatcher.InvokeAsync(Background)<br/>批量排空 _pendingRenderUpdates
    UI_Thread->>CogDisplay: 设图 + Graphic + Record
    Note over UI_Thread: 每 2 帧 Yield(Input)<br/>用户点击可插队
    UI_Thread-->>Disk: 后台 Task.Run 保存图像

关键变化

  1. ToolBlock.Run() 在独立 STA 线程执行,不再触碰 UI 线程
  2. 渲染更新从"每帧独立 Normal 优先级"改为"批量 Background 优先级 + Yield"
  3. 保存逻辑的磁盘 I/O 已在后台线程,与渲染解耦

4. 修复方案与实施状态

方案 A(P0 — 已完成 ✅):将 ToolBlock.Run() 放入专用 STA 后台线程

VisionPro 的 COM 组件要求 STA 线程,但不应使用 UI 线程。需要创建独立的 STA 后台线程:

/// <summary>
/// 在专用 STA 后台线程中异步运行 VisionPro ToolBlock。
/// 避免 COM 封送到 UI 线程导致界面假死。
/// </summary>
public static Task RunToolBlockAsync(CogToolBlock toolBlock)
{
    var tcs = new TaskCompletionSource<bool>();
    var thread = new Thread(() =>
    {
        try
        {
            toolBlock.Run();
            tcs.SetResult(true);
        }
        catch (Exception ex)
        {
            tcs.SetException(ex);
        }
    });
    thread.SetApartmentState(ApartmentState.STA); // 关键:STA 套间
    thread.IsBackground = true;
    thread.Start();
    return tcs.Task;
}

然后在 RemoteCommandService.cs 中替换所有 6 处 procedure.ToolBlock.Run()

// 替换前
procedure.ToolBlock.Run();

// 替换后
await RunToolBlockAsync(procedure.ToolBlock);

⚠️ 注意:如果同一个 CogToolBlock 实例需要被多个 ToolBlock 并发使用,VisionPro 可能不支持多线程同时操作同一个 ToolBlock 实例。建议为每个并发执行创建独立的 ToolBlock 实例(clone),或者在 ToolBlock 级别做串行化保护。

实施状态:✅ 已完成

  • 文件:TeamAAS-VM/Helpers/VisionProHelper.cs(新增)
  • 方法:RunToolBlockAsync(CogToolBlock) — 每次调用创建独立 STA 后台线程
  • 所有 6 处 ToolBlock.Run() 已替换为 await VisionProHelper.RunToolBlockAsync(procedure.ToolBlock)
  • 日志验证:[VP-STA] STA-Run START, threadId=XX, apartment=STA 中 threadId 均非 1

方案 B(P1):多 ToolBlock 真正并发执行

状态:⚠️ 待评估。STA 隔离后串行的阻塞影响已大幅降低,可酌情排期。

Management.cs 第 2620 行附近,将串行 foreach 改为并发 Task.WhenAll

// 替换串行 foreach
var procedureTasks = ImageTaskResults
    .Where(item => item.IsSucceed)
    .Select(async item =>
    {
        bool Is3d = Is3D(item.procedureId);
        await ExecuteProcedureAsync(item.cameraIndex, item.procedureId,
                                     item.Images, Is3d, currentProduct);
    });
await Task.WhenAll(procedureTasks);

⚠️ 注意:需要确保 AllResultlockResultsAll 等共享集合的线程安全性(当前已使用 ConcurrentBag,应该可以)。

方案 C(P2):VPP 加载异步化

ProductService.csLoadProductAsync 中确保反序列化在后台线程:

prc.ToolBlock = await Task.Run(() =>
    _vppCache.GetOrAdd(prcPath, path =>
        CogSerializer.LoadObjectFromFile(path) as CogToolBlock));

同时将所有同步加载路径(Initialize, SetCurrentProduct, LoadLastProduct)改为异步调用。

方案 D(P3):countdown.Wait() 改为异步等待

// 替换同步阻塞
// bool countdownCompleted = countdown.Wait(TimeSpan.FromSeconds(5));

// 改为异步等待
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
bool countdownCompleted;
try
{
    await Task.Run(() => countdown.Wait(cts.Token), cts.Token);
    countdownCompleted = true;
}
catch (OperationCanceledException)
{
    countdownCompleted = false;
}

5. 优先级与影响评估

优先级 修改项 涉及文件 状态 风险 收益
P0 ToolBlock.Run() → STA 线程异步化 RemoteCommandService.cs(6 处)、VisionProHelper.cs(新增) ✅ 已完成 中:需验证 ToolBlock 实例线程安全性 直接解决 ToolBlock 导致的 UI 假死
P0 RenderUpdateNotification 渲染批处理 ShowVisionRender.xaml.cs ✅ 已完成 低:仅改变调度策略,不改业务逻辑 解决多任务渲染导致的 UI 饥饿
P1 多 ToolBlock 并发执行 Management.cs(1 处) ⚠️ 待评估 低:共享集合已线程安全 减少总流程耗时
P2 VPP 加载异步化 ProductService.cs(4 处) ⚠️ 部分完成 低:纯 I/O 操作 产品切换时 UI 不卡
P3 countdown.Waitawait Management.cs(1 处) ⚠️ 待修复 低:等价语义 避免线程池阻塞

6. 注意事项

  1. VisionPro 线程安全性CogToolBlock 实例可能不支持多线程并发操作。如果多个 ToolBlock 需要同时运行,应确保使用不同的 CogToolBlock 实例。建议在 ProcedureModel 中为每个并发执行 clone 独立的 ToolBlock。

  2. STA 线程数量:方案 A 会为每次 ToolBlock.Run() 创建新的 STA 线程。如果 ToolBlock 执行频繁,可考虑使用 STA 线程池复用线程,减少线程创建开销。

  3. SendTaskMessage 已改用消息队列:从代码来看(Management.cs:4280),SendTaskMessage 已经改为 ConcurrentQueue + Dispatcher.BeginInvoke 的异步模式,不会直接阻塞 UI。这点已正确处理。

  4. PlcCommandTrigger 入口已使用 Task.Run 卸载Management.cs:1530),OPC UA 回调线程不会长时间被占。但后续的 PlcCommandTriggerAsync 内部仍因 ToolBlock.Run() 的 COM 封送导致 UI 阻塞。


7. 详细修改计划

7.1 实施概览

gantt
    title 修改实施甘特图
    dateFormat  YYYY-MM-DD
    axisFormat  %m/%d
    section Phase 1 - 基础设施
    Step1-创建RunToolBlockAsync工具方法       :done, a1, 2026-07-12, 1d
    section Phase 2 - P0 核心修复
    Step2-修改RemoteCommandService.cs(6处)    :done, a2, after a1, 1d
    Step3-编译验证+本地冒烟测试              :done, a3, after a2, 1d
    section Phase 2b - P0 渲染修复(新增)
    Step3b-RenderUpdate批处理+Background优先级 :done, a3b, after a3, 1d
    section Phase 3 - P1 并发优化
    Step4-修改Management.cs ToolBlock并发执行 :a4, after a3b, 1d
    section Phase 4 - P2 VPP加载
    Step5-修改ProductService.cs异步加载      :a5, after a4, 1d
    section Phase 5 - P3 Countdown
    Step6-修改Management.cs countdown异步等待 :a6, after a5, 1d
    section Phase 6 - 集成测试
    Step7-完整流程回归测试                    :a7, after a6, 2d

7.2 前置条件

  • 确认开发环境可正常编译当前代码
  • 准备至少一台可连接的真实相机和 PLC 用于集成测试
  • 准备 2~3 个包含有效 .vpp ToolBlock 的测试产品
  • 确保 VisionPro 运行时许可在测试机上可用
  • 建议在版本控制中创建分支 fix/toolblock-ui-blocking 进行修改

7.3 分步实施详情


Step 1:创建 RunToolBlockAsync 工具方法

目标:提供一个可复用的 STA 线程异步执行 ToolBlock.Run() 的基础设施。

新增文件TeamAAS-VM/Helpers/VisionProHelper.cs(推荐路径)

代码清单

// TeamAAS-VM/Helpers/VisionProHelper.cs
using Cognex.VisionPro.ToolBlock;
using System;
using System.Threading;
using System.Threading.Tasks;

namespace TeamAAS_VP.Helpers
{
    /// <summary>
    /// VisionPro 工具辅助类。
    /// 提供在独立 STA 线程中异步执行 CogToolBlock 的能力,
    /// 避免 COM 封送到主 UI 线程导致界面假死。
    /// </summary>
    public static class VisionProHelper
    {
        /// <summary>
        /// 在专用 STA 后台线程中异步运行 ToolBlock。
        /// 每次调用创建一个新的 STA 线程,ToolBlock 执行完毕后线程自动退出。
        /// </summary>
        /// <param name="toolBlock">要执行的 CogToolBlock 实例</param>
        /// <returns>异步任务,完成时表示 ToolBlock 执行完毕</returns>
        public static Task RunToolBlockAsync(CogToolBlock toolBlock)
        {
            if (toolBlock == null)
                throw new ArgumentNullException(nameof(toolBlock));

            var tcs = new TaskCompletionSource<bool>();
            var thread = new Thread(() =>
            {
                try
                {
                    toolBlock.Run();
                    tcs.SetResult(true);
                }
                catch (Exception ex)
                {
                    tcs.SetException(ex);
                }
            })
            {
                IsBackground = true,
                Name = $"VP-Run-{Guid.NewGuid():N}"
            };
            thread.SetApartmentState(ApartmentState.STA);
            thread.Start();
            return tcs.Task;
        }

        /// <summary>
        /// 在专用 STA 后台线程中异步加载 VisionPro 序列化对象(.vpp 文件等)。
        /// </summary>
        /// <typeparam name="T">反序列化的目标类型</typeparam>
        /// <param name="filePath">.vpp 文件完整路径</param>
        /// <returns>包含反序列化对象的异步任务</returns>
        public static Task<T> LoadVisionProObjectAsync<T>(string filePath) where T : class
        {
            if (string.IsNullOrEmpty(filePath))
                throw new ArgumentNullException(nameof(filePath));

            var tcs = new TaskCompletionSource<T>();
            var thread = new Thread(() =>
            {
                try
                {
                    var obj = CogSerializer.LoadObjectFromFile(filePath) as T;
                    tcs.SetResult(obj);
                }
                catch (Exception ex)
                {
                    tcs.SetException(ex);
                }
            })
            {
                IsBackground = true,
                Name = $"VP-Load-{Guid.NewGuid():N}"
            };
            thread.SetApartmentState(ApartmentState.STA);
            thread.Start();
            return tcs.Task;
        }

        /// <summary>
        /// 在专用 STA 后台线程中异步保存 VisionPro 对象到文件。
        /// </summary>
        /// <param name="obj">要保存的对象</param>
        /// <param name="filePath">目标文件路径</param>
        /// <returns>异步任务</returns>
        public static Task SaveVisionProObjectAsync(object obj, string filePath)
        {
            if (obj == null) throw new ArgumentNullException(nameof(obj));
            if (string.IsNullOrEmpty(filePath)) throw new ArgumentNullException(nameof(filePath));

            var tcs = new TaskCompletionSource<bool>();
            var thread = new Thread(() =>
            {
                try
                {
                    CogSerializer.SaveObjectToFile(obj, filePath);
                    tcs.SetResult(true);
                }
                catch (Exception ex)
                {
                    tcs.SetException(ex);
                }
            })
            {
                IsBackground = true,
                Name = $"VP-Save-{Guid.NewGuid():N}"
            };
            thread.SetApartmentState(ApartmentState.STA);
            thread.Start();
            return tcs.Task;
        }
    }
}

验证:编译通过即可,该文件无外部依赖(仅依赖 VisionPro SDK)。


Step 2(P0):修改 RemoteCommandService.cs — 所有 ToolBlock.Run() 替换为异步

目标:将所有 6 处同步 ToolBlock.Run() 替换为 await VisionProHelper.RunToolBlockAsync()

涉及文件TeamAAS-VM/Services/RemoteCommandService.cs

修改点清单

序号 行号 所在方法 修改内容
2.1 1526 ExecutePhoto(procedure, out outputCollection) 方法签名改为 async Task<bool>ToolBlock.Run()await VisionProHelper.RunToolBlockAsync(procedure.ToolBlock)
2.2 1690 ExecutePhoto(procedure, InputTerminal, out outputCollection) 同 2.1
2.3 1856 ExecutePhoto2(procedure, InputTerminal, out outputCollection) 同 2.1
2.4 2120 ExecutePhoto(procedure, imges, InputTerminal, ..., is3dmodel, ...) AI 推理路径内的 ToolBlock.Run() 改为 await ...
2.5 2206 ExecutePhoto(procedure, imges, InputTerminal, ..., is3dmodel, ...) 非 AI 路径内的 ToolBlock.Run() 改为 await ...
2.6 2985 ExecutePhotoEx(...) 方法签名改为 async Task<bool>ToolBlock.Run()await ...

详细修改:修改点 2.5(最关键的生产路径)

这是 PhotosUse == false 模式下生产主流程使用的路径,影响最大。

*修改前(约第 2198~2260 行区域)*:

else
{
    // 3. 运行视觉工具
    LogHelper.WriteLogInfo("开始运行视觉工具");
    procedure.ToolBlock.Run();      //运行ToolBlock

    if (procedure.ToolBlock.RunStatus.Result == CogToolResultConstants.Accept)
    {
        // ... 结果处理 ...
    }
}

*修改后*:

else
{
    // 3. 运行视觉工具(在独立 STA 线程中异步执行,避免阻塞 UI 线程)
    LogHelper.WriteLogInfo("开始运行视觉工具");
    await VisionProHelper.RunToolBlockAsync(procedure.ToolBlock);

    if (procedure.ToolBlock.RunStatus.Result == CogToolResultConstants.Accept)
    {
        // ... 结果处理(保持不变) ...
    }
}

级联影响ExecutePhoto 系列方法返回类型从 bool 变为 Task<bool>,其调用方需要做以下适配:

调用方 文件 行号 适配方式
ExecutePhotoGetSinglePoint1 RemoteCommandService.cs 2781 await
ExecutePhotoGetSinglePoint2 RemoteCommandService.cs 2866 awaitExecutePhoto2
ExecutePhotoGetSinglePoint RemoteCommandService.cs 3037 await
第二处 ExecutePhotoGetSinglePoint1 RemoteCommandService.cs 3126 await
ExecutePhotoGetSinglePointWithImage RemoteCommandService.cs 3217 awaitExecutePhotoEx

这些调用方全部已经是 async Task 方法,只需在调用处加 await 即可,改动量很小。


Step 3(P0 后续):编译验证 + 本地冒烟测试

编译验证

dotnet build TeamAAS-VM.sln

冒烟测试检查点

  1. 手动加载一个产品,观察 UI 是否响应正常
  2. 手动点击"单次执行"按钮运行一个 ToolBlock,确认 UI 不卡顿
  3. 模拟触发一个 AOI 点位(cameraIndex=1),检查:
    • 图像是否正常采集
    • ToolBlock 是否正常执行
    • UI 在整个过程中是否保持响应
    • PLC 信号(Out_FixedCameraPickStatus)是否正确写入
  4. 检查日志中 STA 线程命名(VP-Run-xxx)是否正常出现

Step 4(P1):修改 Management.cs — 多 ToolBlock 真正并发执行

涉及文件TeamAAS-VM/Core/Management.cs

修改位置:约第 2610~2635 行,图像批处理完成后的 foreach 循环

*修改前*:

_ = Task.Run(async () =>
{
    var imageBatchWatch = Stopwatch.StartNew();
    var ImageTaskResults = await Task.WhenAll(_ImageTaskList);
    imageBatchWatch.Stop();
    // ...
    foreach (var item in ImageTaskResults)         // ← 串行
    {
        if (item.IsSucceed)
        {
            bool Is3d = Is3D(item.procedureId);
            if (targetPoint.AiVision == 1)
            {
                var targetPoint2 = currentProduct.AoiPoints?.FirstOrDefault(p => p.Number == cameraIndex);
                await ExecuteProcedureAsync(targetPoint.AiVision, targetPoint2.CameraProcedureId2, item.Images, Is3d, currentProduct);
            }
            await ExecuteProcedureAsync(item.cameraIndex, item.procedureId, item.Images, Is3d, currentProduct);
        }
        // ...
    }
});

*修改后*:

_ = Task.Run(async () =>
{
    var imageBatchWatch = Stopwatch.StartNew();
    var ImageTaskResults = await Task.WhenAll(_ImageTaskList);
    imageBatchWatch.Stop();
    // ...
    // 并发执行所有 ToolBlock(每个 ToolBlock 在独立 STA 线程中运行)
    var procedureTasks = new List<Task>();
    foreach (var item in ImageTaskResults)
    {
        if (item.IsSucceed)
        {
            bool Is3d = Is3D(item.procedureId);
            if (targetPoint.AiVision == 1)
            {
                var targetPoint2 = currentProduct.AoiPoints?.FirstOrDefault(p => p.Number == cameraIndex);
                procedureTasks.Add(ExecuteProcedureAsync(targetPoint.AiVision, targetPoint2.CameraProcedureId2, item.Images, Is3d, currentProduct));
            }
            procedureTasks.Add(ExecuteProcedureAsync(item.cameraIndex, item.procedureId, item.Images, Is3d, currentProduct));
        }
        else
        {
            SendTaskMessage($"图像采集失败!{item.cameraIndex}: {item.procedureId}", MessageLevel.Alarm);
            AllResult.Add(false);
            ClearCountdown();
        }
    }
    // 等待所有并发 ToolBlock 执行完成
    await Task.WhenAll(procedureTasks);
});

⚠️ 注意:因为 Step 2 已经确保每个 ToolBlock.Run() 在独立 STA 线程中执行,且每个 AOI 点位使用不同的 ProcedureModel(即不同的 ToolBlock 实例),所以多线程并发执行不同 ToolBlock 是安全的。


Step 5(P2):修改 ProductService.cs — VPP 加载/保存异步化

涉及文件TeamAAS-VM/Services/ProductService.cs

修改点

序号 行号 方法 修改
5.1 ~530 LoadProduct(Guid id) CogSerializer.LoadObjectFromFileVisionProHelper.LoadVisionProObjectAsync
5.2 ~597 LoadProductAsync(Guid id) _vppCache.GetOrAdd 回调中的同步加载改为使用 VisionProHelper
5.3 ~481 SaveProduct(ProductModel p, string filePath) CogSerializer.LoadObjectFromFile / CogSerializer.SaveObjectToFileVisionProHelper 异步方法

详细修改:5.2 示例

*修改前(LoadProductAsync,约第 564~600 行)*:

prc.ToolBlock = _vppCache.GetOrAdd(prcPath, path =>
{
    return CogSerializer.LoadObjectFromFile(path) as CogToolBlock;
});

*修改后*:

// 异步加载 VPP,避免同步 I/O 阻塞调用线程
prc.ToolBlock = await _vppCache.GetOrAddAsync(prcPath, async path =>
{
    return await VisionProHelper.LoadVisionProObjectAsync<CogToolBlock>(path);
});

⚠️ 注意:需要使用支持异步回调的 GetOrAddAsync 扩展方法,或自行实现。若 ConcurrentDictionary 原生不支持,可改为带锁的 Dictionary + 异步判断:

if (!_vppCache.TryGetValue(prcPath, out var cached))
{
    cached = await VisionProHelper.LoadVisionProObjectAsync<CogToolBlock>(prcPath);
    _vppCache.TryAdd(prcPath, cached);
}
prc.ToolBlock = cached;

Step 6(P3):修改 Management.cscountdown.Wait() 改为异步

涉及文件TeamAAS-VM/Core/Management.cs

修改位置:约第 2650 行

*修改前*:

bool countdownCompleted = countdown.Wait(TimeSpan.FromSeconds(5));

*修改后*:

// 异步等待 countdown,避免同步阻塞线程池线程
bool countdownCompleted;
using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)))
{
    try
    {
        await Task.Run(() => countdown.Wait(cts.Token), cts.Token);
        countdownCompleted = true;
    }
    catch (OperationCanceledException)
    {
        countdownCompleted = false;
    }
}

Step 7:完整流程回归测试

测试场景矩阵

场景 描述 预期结果
TC-01 单点位单 ToolBlock 触发 ToolBlock 正常执行,UI 不卡顿,PLC 信号正确
TC-02 单点位多 ToolBlock (PhotosUse=false) 多个 ToolBlock 并发执行,总耗时约等于最慢的那个,UI 不卡顿
TC-03 多点位顺序触发(模拟 1→2→3→stop) 每个位点 ToolBlock 按时序执行,UI 持续响应
TC-04 产品加载/切换 加载新产品的 VPP 不阻塞 UI,ToolBlock 就绪后可正常执行
TC-05 连续 20 次触发循环 无内存泄漏,无线程泄漏(STA 线程正常退出)
TC-06 异常 case:VPP 文件损坏 异常正确捕获,countdown.Signal() 正常调用,不卡死
TC-07 超时 case:ToolBlock 执行超时 countdown 超时机制正常,按 NG 处理

测试工具

  • SendTaskMessage 日志中检查 STA 线程命名 VP-Run-xxx
  • 在任务管理器中观察线程数量变化(每次 ToolBlock 执行完毕后 STA 线程应退出)
  • PlcCommandTriggerAsync 入口处已有的 [PLC-TRACE] 日志中对比修改前后的时序

7.4 回滚方案

如果修改后出现不可预期的问题,可执行以下回滚步骤:

  1. Git 回滚git revert 对应的 commit,或切换到修改前的分支
  2. If 仅需禁用 STA 线程化:在 VisionProHelper.RunToolBlockAsync 中添加配置开关:

    public static async Task RunToolBlockAsync(CogToolBlock toolBlock)
    {
       // 紧急回滚开关:从 app.config 读取
       if (ConfigurationManager.AppSettings["VisionPro:UseStaThread"] == "false")
       {
           toolBlock.Run();  // 回退到原始同步调用
           return;
       }
       // ... STA 线程逻辑 ...
    }
    

7.5 潜在风险与缓解措施

风险 概率 影响 缓解措施
同一 ToolBlock 实例被多线程并发调用 崩溃或结果错乱 确认每个 AOI 点位的 ProcedureModel 使用不同 ToolBlock 实例;若不成立,在 P1 改为按位点串行
STA 线程过多导致资源耗尽 内存增长、调度延迟 当前场景最多同时 ~8 个 ToolBlock,STA 线程数可控;若未来扩展,可引入 SemaphoreSlim 限流
TaskCompletionSource 未正确释放 内存泄漏 STA 线程 finally 块中确保 TrySetResult/TrySetException
现有调用方依赖同步返回结果 编译错误 所有调用方已在 async Task 上下文中,仅需加 await
VisionPro 许可检查在 STA 线程中失败 ToolBlock 无法执行 保留原始异常信息,通过 tcs.SetException 向上传递

7.6 预估工作量

步骤 内容 预估工时
Step 1 创建 VisionProHelper.cs 0.5h
Step 2 修改 RemoteCommandService.cs(6 处 + 5 处调用方 await) 2h
Step 3 编译验证 + 冒烟测试 1h
Step 4 修改 Management.cs 并发执行 1h
Step 5 修改 ProductService.cs VPP 加载 1.5h
Step 6 修改 Management.cs countdown 0.5h
Step 7 完整流程回归测试 3h
合计 约 9.5h(1~2 个工作日)