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

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

日期:2026-07-12 涉及文件:TeamAAS-VM/Services/ProductService.cs、TeamAAS-VM/Core/Management.cs(~1640 行)、TeamAAS-VM/Services/RemoteCommandService.cs 问题:加载 VPP 和运行 ToolBlock 时 UI 假死,尤其多 ToolBlock 串行场景下更加明显


1. 场景描述

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

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

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


2. 根因分析

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

调用链(RemoteCommandService.cs + Management.cs):

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/await 或 Task.Run,ToolBlock.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() 均是同步阻塞调用,无一使用后台线程或异步封装。


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

在 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 串行执行,非真正并发

在 Management.cs 第 2520~2640 行区域:

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

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

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

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

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

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

Management.cs 第 ~2650 行:

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

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


3. 完整调用路径图

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 线程执行,且是同步阻塞的。


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 级别做串行化保护。

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

在 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);

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

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

在 ProductService.cs 的 LoadProductAsync 中确保反序列化在后台线程:

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 处) 中:需验证 ToolBlock 实例线程安全性 直接解决 UI 假死
P1 多 ToolBlock 并发执行 Management.cs(1 处) 低:共享集合已线程安全 减少总流程耗时
P2 VPP 加载异步化 ProductService.cs(4 处) 低:纯 I/O 操作 产品切换时 UI 不卡
P3 countdown.Wait → await 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工具方法       :a1, 2026-07-12, 1d
    section Phase 2 - P0 核心修复
    Step2-修改RemoteCommandService.cs(6处)    :a2, after a1, 2d
    Step3-编译验证+本地冒烟测试              :a3, after a2, 1d
    section Phase 3 - P1 并发优化
    Step4-修改Management.cs ToolBlock并发执行 :a4, after a3, 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 加 await(ExecutePhoto2)
ExecutePhotoGetSinglePoint RemoteCommandService.cs 3037 加 await
第二处 ExecutePhotoGetSinglePoint1 RemoteCommandService.cs 3126 加 await
ExecutePhotoGetSinglePointWithImage RemoteCommandService.cs 3217 加 await(ExecutePhotoEx)

这些调用方全部已经是 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.LoadObjectFromFile → VisionProHelper.LoadVisionProObjectAsync
5.2 ~597 LoadProductAsync(Guid id) _vppCache.GetOrAdd 回调中的同步加载改为使用 VisionProHelper
5.3 ~481 SaveProduct(ProductModel p, string filePath) CogSerializer.LoadObjectFromFile / CogSerializer.SaveObjectToFile → VisionProHelper 异步方法

详细修改: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.cs — countdown.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 个工作日)