C#轻量级工业流程调度引擎:基于CSP模型的运动控制与视觉任务协同框架
本文还有配套的精品资源,点击获取
简介:专为.NET工业自动化开发设计的C#流程调度框架,采用类Go的CSP(通信顺序进程)模型实现并发逻辑,摆脱传统多线程锁、状态机跳转和PLC式硬编码依赖。支持树状结构的任务编排,可灵活配置单线程、多线程或UI线程调度模式,满足运动控制指令精准下发、视觉检测流程同步触发、HMI界面实时响应等典型场景需求。内置微秒级精度定时器、任务优先级分级、运行时暂停/恢复/强制终止等控制能力,单线程调度吞吐超100万次/秒,稳定处理千点级IO信号。模块间高度解耦,核心逻辑封装在Go、WorkerFlow、CsGo等独立项目中,配套WinForm测试工程(FormTest、WaitForm)、完整解决方案(CsGo.sln)及详细说明文档(readme.md、doc.md),开箱即用验证流程依赖、跨线程消息传递、任务生命周期管理等功能。适用于CNC控制器、AOI自动光学检测系统、智能装配线等对确定性、低延迟和可维护性要求严苛的工控软件开发。
1. 项目概述:为什么工业自动化需要一个“会说话”的调度引擎?
在CNC加工中心调试现场,我亲眼见过一套AOI检测软件因为视觉任务和运动轴控制抢同一个线程锁,导致图像采集帧率从30fps骤降到8fps,最终误判率飙升——不是算法不行,是调度逻辑拖了后腿。这背后暴露的,是传统工控软件开发里一个被长期忽视的底层矛盾:我们用PLC式的硬编码写逻辑,用WinForms的UI线程塞进运动指令,再靠一堆ManualResetEvent和lock块去“缝合”视觉流程,结果就是代码越写越像迷宫,响应越来越不可预测。而这个C#轻量级工业流程调度引擎,本质上是在.NET生态里,第一次把Go语言那套“不要通过共享内存来通信,而要通过通信来共享内存”的CSP哲学,真正落地到真实产线设备上。
它不是又一个Task.Run封装库,也不是状态机生成器,而是一个可嵌入、可裁剪、可确定性执行的调度内核。关键词里的“CSP调度”,指的是每个任务(比如“移动X轴到位置50.2mm”或“触发相机拍照并等待结果”)都作为独立的通信进程存在,它们之间不直接读写对方变量,而是通过类型安全的Channel 收发结构化消息;“运动控制框架”意味着它天然适配脉冲输出、编码器反馈、急停信号这类强实时信号处理场景,所有定时器精度实测稳定在±2μs以内;“视觉任务编排”则体现在它能把OpenCVSharp的图像预处理、Halcon的模板匹配、甚至第三方SDK的异步回调,统一纳入同一棵任务树中,按依赖关系自动串行/并行调度。你不需要改写现有运动控制库或视觉SDK,只要把它们的调用包装成一个GoRoutine,扔进调度器,剩下的同步、超时、错误传播、资源释放,全由引擎接管。配套的FormTest工程里,一个按钮点击就能启动包含“轴归零→拍照→图像分析→根据结果决定是否打标→更新UI进度条”的完整闭环,整个过程没有一行Thread.Sleep,没有一个lock关键字,也没有任何InvokeRequired判断——这才是工业软件该有的呼吸感。
这套东西适合谁?如果你正在用C#开发CNC控制器上位机,厌倦了每次加一个新轴控逻辑就要重画一遍状态转换图;如果你在做AOI检测系统,发现视觉流程一复杂,UI就卡顿,日志里全是“跨线程操作无效”的异常;如果你负责智能装配线的HMI开发,客户今天要加扫码枪触发,明天要接RFID读头,后天又要对接MES下发工单,而你的主逻辑已经臃肿到不敢动——那么它就是为你写的。它不替代PLC,但能让你的PC端控制软件拥有接近PLC的确定性;它不取代WPF,但能让WinForms界面在千点IO刷新下依然丝滑。核心价值就一句话:把工程师从“协调线程打架”的体力劳动里解放出来,专注解决真正的工艺问题。
2. 整体架构与设计思路:为什么是CSP,而不是Actor或Reactive?
2.1 CSP模型在工控场景的不可替代性
很多人第一反应是:“CSP不是Go的专利吗?.NET里不是有Actor模型(Orleans)、响应式编程(Rx.NET)更成熟?”这个问题我踩过坑也验证过数据。2022年在某汽车焊装线项目里,我们对比过三种方案处理12轴同步运动+4路视觉流的调度:
- Actor模型(Orleans):每个轴建一个Grain,视觉模块建一个Grain,靠消息传递。问题在于Grain激活/反激活开销大,单次消息延迟平均15ms,且无法保证消息严格FIFO——当“X轴到位”和“Y轴到位”两条消息几乎同时到达主控Grain时,顺序错乱直接导致轨迹插补错误;
- Rx.NET:用Observable.FromEventPattern监听编码器中断,CombineLatest聚合多路信号。问题在于背压(Backpressure)控制极其脆弱,一旦某路视觉处理耗时突增(比如光照变化导致模板匹配变慢),上游事件就会堆积,最终OOM崩溃;
- 本框架的CSP实现:所有轴控任务和视觉任务跑在同一个调度器实例中,通过
Channel<AxisMoveCmd>和Channel<ImageResult>进行通信。关键在于它的Channel是带容量限制的同步队列(默认容量1),发送方必须等到接收方消费完上一条才允许发下一条。这就天然形成了“生产者-消费者”的节拍约束——X轴任务发完“到位”消息后,必须等主流程接收并触发Y轴指令,才能继续下一步。实测12轴+4视觉流满载时,端到端确定性延迟抖动<±3μs,远优于PLC常见的10ms扫描周期。
CSP胜出的核心,在于它把时间维度上的确定性和空间维度上的隔离性做了完美绑定。每个GoRoutine(即CSP中的“进程”)都是一个封闭的执行单元,它只关心自己收什么消息、发什么消息、超时怎么处理。没有共享状态,就没有竞态条件;没有隐式依赖,就没有调试噩梦。你在FormTest里看到的那个“WaitForm”,表面是个等待窗体,底层其实是用Go.Routine(() => { ... }).Wait()启动了一个永不退出的协程,它持续监听Channel<ProgressUpdate>,收到消息就更新ProgressBar——整个过程UI线程完全不参与调度,纯粹是消息驱动的被动响应。
2.2 树形任务结构的设计动机与优势
传统工控软件常用“状态机+事件驱动”组织逻辑,比如一个拧紧工序的状态流转:Idle → TorqueRampUp → TargetTorqueHold → AngleCheck → Complete。这种写法的问题是:状态爆炸。当你要支持“中途暂停后恢复”、“扭矩超限自动降档”、“角度偏差过大触发复位”等多个分支时,状态数呈指数增长,一个状态机类轻易突破2000行。
本框架采用显式树形依赖结构(TaskTree),从根本上规避这个问题。看WorkerFlow.csproj里的核心定义:
public class TaskNode { public string Id { get; set; } public Func<CancellationToken, Task> Execute { get; set; } public List<TaskNode> Children { get; set; } = new(); public TimeSpan? Timeout { get; set; } public int Priority { get; set; } // 数值越小优先级越高 }一个拧紧工序被拆解为:
- 根节点TightenSequence(Priority=0)
- 子节点RampUpTorque(Priority=1,依赖编码器反馈)
- 子节点HoldTargetTorque(Priority=2,依赖RampUp完成)
- 子节点CheckAngle(Priority=3,依赖Hold完成)
- 子节点LogResult(Priority=4)
执行时,调度器按优先级BFS遍历树,但只有父节点成功完成后,子节点才被激活。这意味着:
- 暂停操作只需冻结根节点,整棵树自动挂起;
- 恢复时从最后一个完成节点的子树重新开始,无需保存所有中间状态;
- 错误传播天然形成:CheckAngle失败,LogResult不会执行,错误沿树向上抛给TightenSequence统一处理。
这种结构让复杂工艺逻辑变得像乐高积木——你可以把“拧紧”、“涂胶”、“扫码”各自封装成独立TaskTree,再用一个MasterTree把它们按工位顺序串联。CsGo.sln里的GoTest.csproj就演示了如何用TaskTree.Combine(tightenTree, glueTree, scanTree)构建装配线主流程,代码量比同等功能的状态机减少65%。
2.3 三模式调度的工程取舍:单线程为何是默认选项?
框架支持单线程、多线程、UI线程三种调度模式,但文档和示例里强烈推荐单线程模式作为默认起点。这不是技术保守,而是对工控场景深刻理解后的主动选择。
单线程调度(GoScheduler.Default):所有GoRoutine在一个专用线程(非UI线程)中顺序执行。优势在于极致的确定性——没有上下文切换开销,没有缓存行失效,没有锁竞争。实测在i5-8250U上,单线程每秒可完成127万次空任务调度(
Go.Routine(() => {})),处理千点IO信号时CPU占用率稳定在12%以下。它的代价是:某个GoRoutine若执行耗时操作(如阻塞IO),会拖慢整棵树。解决方案是——绝不允许阻塞操作!所有硬件交互必须用异步API(如SerialPort.BaseStream.ReadAsync)或委托给专用工作线程(见2.4节)。多线程调度(GoScheduler.Parallel):为每个优先级分配独立线程池。适用于计算密集型视觉任务(如Halcon的Blob分析),但会引入线程安全问题。框架为此提供
ThreadLocalChannel<T>,确保消息只在同一线程内流转,避免跨线程序列化开销。UI线程调度(GoScheduler.UI):本质是
SynchronizationContext.Post的封装。仅用于必须在UI线程执行的操作(如Control.Invoke更新控件)。注意:它应是树的最末端叶子节点,绝不能作为父节点——否则整个调度树会被拖进UI线程,导致界面假死。
我的经验是:90%的工控逻辑(轴控、IO扫描、简单视觉判断)用单线程足够;剩下10%的重负载视觉任务,用Go.RunOnThreadPool(() => HeavyVisionWork())显式卸载到后台线程,再通过Channel把结果送回主调度树。这样既保持主干确定性,又榨干多核性能。
3. 核心组件解析与实操要点
3.1 GoRoutine:轻量级协程的.NET实现原理
框架的Go.Routine方法看似简单,实则暗藏玄机。它并非基于async/await(那是编译器生成的状态机),而是用ThreadLocal+ConcurrentQueue+SpinWait手工实现的协作式调度。看Go.cs里的关键片段:
public static class Go { private static readonly ThreadLocal<GoScheduler> _scheduler = new ThreadLocal<GoScheduler>(() => GoScheduler.Default); public static void Routine(Func<CancellationToken, Task> action) { var scheduler = _scheduler.Value; // 将action包装为可被调度器识别的WorkItem var workItem = new WorkItem { Action = action, Priority = 0, CreationTime = Stopwatch.GetTimestamp() }; scheduler.Enqueue(workItem); // 线程安全入队 } }这里的精妙在于GoScheduler的Enqueue方法:
public void Enqueue(WorkItem item) { // 使用无锁队列,避免lock带来的抖动 _workQueue.Enqueue(item); // 关键:如果当前线程不是调度线程,且调度线程处于Sleep状态, // 则用SpinWait唤醒它——这是微秒级响应的基石 if (_isSleeping && Thread.CurrentThread != _schedulerThread) { _wakeUpEvent.Set(); // ManualResetEventSlim } }这意味着:当你在UI线程调用Go.Routine(() => MoveAxis(100)),调度器线程会在≤5μs内被唤醒并执行该任务。相比之下,Task.Run的线程池调度延迟通常在100μs~1ms量级,对运动控制而言已是灾难。
实操要点:
- 绝对禁止在GoRoutine中调用Thread.Sleep、Task.Wait、Task.Result等阻塞API。正确做法是用await Task.Delay(ms, token),调度器会自动挂起当前协程,让出执行权给其他任务;
- 若必须调用同步API(如老式串口库),务必用Task.Run(() => LegacySyncCall())卸载,并通过Channel接收结果;
-CancellationToken不是摆设!所有长时间运行的GoRoutine(如持续监听IO)必须定期检查token.IsCancellationRequested,否则Stop()调用无法终止它。
3.2 Channel :类型安全的进程间通信管道
CSP的灵魂是Channel,本框架的Channel<T>实现直击工控痛点。它不是简单的ConcurrentQueue<T>,而是具备背压控制、超时熔断、错误隔离三大特性:
// 创建一个容量为1的通道,超时3秒,错误时自动关闭 var cmdChannel = Channel.CreateBounded<MoveCommand>(1) .WithTimeout(TimeSpan.FromSeconds(3)) .WithErrorHandler(ex => Log.Error(ex)); // 发送端(运动控制模块) await cmdChannel.Writer.WriteAsync(new MoveCommand { Axis = "X", Position = 50.2m }); // 接收端(主调度树) await foreach (var cmd in cmdChannel.Reader.ReadAllAsync(ct)) { await ExecuteMove(cmd); // 执行移动 // 注意:这里不需await,因为ExecuteMove是同步的 // Channel会自动阻塞发送端直到此循环体结束 }为什么容量限制为1是黄金法则?
在运动控制中,“发指令”和“收反馈”必须严格一一对应。如果通道容量设为10,当轴控模块因故障卡住,10条指令会堆积在通道里,一旦恢复,轴会疯狂执行积压指令,造成机械碰撞。容量为1强制发送方必须等待接收方处理完当前指令,才能发下一条——这正是PLC“扫描周期”的软件模拟。
实操避坑指南:
-Channel.Reader.ReadAllAsync()是长连接,适合主流程;Channel.Reader.TryRead(out T item)适合轮询场景(如快速扫描IO点);
- 跨线程使用Channel时,务必用Channel.CreateUnbounded<T>()创建无界通道,并配合ChannelWriter<T>.TryWrite()避免死锁;
- 在FormTest的WaitForm.cs里,UI更新逻辑是这样写的:csharp // 在UI线程初始化 var uiChannel = Channel.CreateUnbounded<ProgressUpdate>(); uiChannel.Reader.ReadAllAsync().ForEachAsync(update => this.Invoke((MethodInvoker)(() => progressBar.Value = update.Percent))); // 其他线程只需uiChannel.Writer.WriteAsync(new ProgressUpdate{...})
这种写法彻底消灭了InvokeRequired判断,且UI更新频率完全由Channel推送节奏决定,不会因主线程繁忙而丢帧。
3.3 高精度定时器:μs级精度的实现细节
工控场景常需“延时100ms后触发相机”,传统System.Timers.Timer精度仅15ms,Stopwatch又无法触发回调。框架的PreciseTimer基于QueryPerformanceCounter(QPC)实现:
public class PreciseTimer { private readonly long _frequency = Stopwatch.Frequency; private readonly long _targetTicks; private readonly Action _callback; public PreciseTimer(TimeSpan delay, Action callback) { _targetTicks = (long)(delay.TotalSeconds * _frequency); _callback = callback; } public void Start() { var startTime = Stopwatch.GetTimestamp(); while (Stopwatch.GetTimestamp() - startTime < _targetTicks) { // 关键:用SpinWait而非Sleep,避免线程调度延迟 SpinWait.SpinOnce(); } _callback(); } }实测在Windows 10 LTSC上,100ms定时误差稳定在±0.8μs。但要注意:SpinWait会100%占用一个CPU核心,因此框架默认只对≤100ms的短延时启用此模式;超过100ms则自动降级为Task.Delay+SpinWait混合模式,平衡精度与资源消耗。
实操配置建议:
- 在App.config中可配置全局定时策略:xml <appSettings> <add key="PreciseTimer.MaxSpinMs" value="50" /> <add key="PreciseTimer.UseHighResolution" value="true" /> </appSettings>
- 对于AOI系统的“曝光时间控制”,必须用PreciseTimer;对于“工单下发间隔”,用Task.Delay足矣;
-WaitForm里模拟的“等待3秒”就是用PreciseTimer实现的,你可以在WaitForm.cs第87行看到new PreciseTimer(TimeSpan.FromSeconds(3), () => Close()).Start()。
3.4 任务生命周期管理:暂停/恢复/强制终止的底层机制
传统线程Suspend/Resume已被废弃,因为极易死锁。本框架的生命周期控制基于协程状态机+协作式取消:
public enum GoState { Running, Paused, Stopped, Completed } public class GoRoutine { private GoState _state = GoState.Running; private readonly CancellationTokenSource _cts = new(); public void Pause() => _state = GoState.Paused; public void Resume() => _state = GoState.Running; public void Stop() { _state = GoState.Stopped; _cts.Cancel(); // 通知所有await操作 } public async Task ExecuteAsync() { while (_state == GoState.Running) { try { await _userAction(_cts.Token); // 用户代码 break; // 正常完成 } catch (OperationCanceledException) { if (_state == GoState.Stopped) break; // 被Stop终止 if (_state == GoState.Paused) await Task.Delay(1, _cts.Token); // 暂停时休眠1ms } } } }关键洞察:暂停不是“冻结线程”,而是让协程在每次await后检查状态,若为Paused则主动让出执行权。这保证了:
- 暂停期间CPU占用率为0;
- 恢复时从await处精确续跑,无状态丢失;
- 强制终止时,所有await操作立即抛出OperationCanceledException,用户代码可捕获并做清理(如关闭串口、释放GDI资源)。
在FormTest的“暂停/恢复”按钮事件里,你看到的是:
private void btnPause_Click(object sender, EventArgs e) { _mainTree.Pause(); // 递归暂停整棵树 } private void btnResume_Click(object sender, EventArgs e) { _mainTree.Resume(); // 从最后一个完成节点的子树恢复 }这种设计让复杂流程的调试变得直观——你可以随时暂停,检查Channel里还有多少未处理消息,观察各轴当前位置,再决定是恢复还是终止。
4. 实操过程与核心环节实现
4.1 从零搭建一个CNC轴控流程(含视觉触发)
我们以FormTest工程为蓝本,手把手实现一个“X轴移动到指定位置→触发相机拍照→等待图像分析结果→根据结果决定是否执行Y轴移动”的闭环。这不是Demo,而是真实产线简化版。
第一步:定义领域消息类型(Models/目录下)
// 运动指令 public record MoveCommand(string Axis, decimal Position, decimal Speed = 100m); public record AxisStatus(string Axis, decimal Position, bool IsMoving, bool IsError); // 视觉指令 public record CaptureCommand(string CameraId, string ImagePath); public record AnalysisResult(bool IsOk, string DefectType, Rectangle DefectArea); // UI反馈 public record ProgressUpdate(int Percent, string Message);第二步:编写轴控GoRoutine(Workers/AxisController.cs)
public static class AxisController { private static readonly Channel<MoveCommand> _cmdChannel = Channel.CreateBounded<MoveCommand>(1).WithTimeout(TimeSpan.FromSeconds(5)); public static ChannelReader<MoveCommand> CommandReader => _cmdChannel.Reader; static AxisController() { // 启动后台协程,永不停止 Go.Routine(async ct => { await foreach (var cmd in _cmdChannel.Reader.ReadAllAsync(ct)) { try { // 调用真实运动库(此处用模拟) await SimulateAxisMove(cmd.Axis, cmd.Position, cmd.Speed); // 发送状态更新到UI通道 await UiChannels.StatusChannel.Writer.WriteAsync( new AxisStatus(cmd.Axis, cmd.Position, false, false)); } catch (Exception ex) { await UiChannels.StatusChannel.Writer.WriteAsync( new AxisStatus(cmd.Axis, 0, false, true)); Log.Error($"Axis {cmd.Axis} move failed: {ex.Message}"); } } }); } }第三步:编写视觉处理协程(Workers/VisionProcessor.cs)
public static class VisionProcessor { private static readonly Channel<CaptureCommand> _captureChannel = Channel.CreateBounded<CaptureCommand>(1); public static ChannelReader<CaptureCommand> CaptureReader => _captureChannel.Reader; static VisionProcessor() { Go.Routine(async ct => { await foreach (var cmd in _captureChannel.Reader.ReadAllAsync(ct)) { try { // 调用OpenCVSharp拍照(异步) var image = await CaptureImageAsync(cmd.CameraId); // 同步分析(计算密集,卸载到线程池) var result = await Go.RunOnThreadPool(() => AnalyzeImage(image, cmd.ImagePath)); // 结果发回主流程 await MainChannels.AnalysisResultChannel.Writer.WriteAsync(result); } catch (Exception ex) { await MainChannels.AnalysisResultChannel.Writer.WriteAsync( new AnalysisResult(false, "CaptureFailed", Rectangle.Empty)); } } }); } }第四步:构建主任务树(Program.cs中)
static async Task Main(string[] args) { // 初始化调度器(单线程模式) var scheduler = GoScheduler.Default; // 构建树形流程 var mainTree = new TaskNode("CNCWorkflow") { Priority = 0, Execute = async ct => { // 1. X轴移动 await AxisController.CommandReader.Writer.WriteAsync( new MoveCommand("X", 50.2m)); // 2. 等待X轴到位(监听状态通道) await foreach (var status in UiChannels.StatusChannel.Reader.ReadAllAsync(ct)) { if (status.Axis == "X" && !status.IsMoving) break; } // 3. 触发视觉拍照 await VisionProcessor.CaptureReader.Writer.WriteAsync( new CaptureCommand("TopCam", @"C:\temp\img.jpg")); // 4. 等待分析结果 await foreach (var result in MainChannels.AnalysisResultChannel.Reader.ReadAllAsync(ct)) { if (result.IsOk) { // 5. 条件执行Y轴 await AxisController.CommandReader.Writer.WriteAsync( new MoveCommand("Y", 25.1m)); break; } else { Log.Warn($"Defect detected: {result.DefectType}"); break; } } } }; // 启动主流程 await scheduler.StartAsync(mainTree); }第五步:在WinForm中集成(FormTest.cs)
public partial class FormTest : Form { private readonly GoScheduler _scheduler = GoScheduler.Default; public FormTest() { InitializeComponent(); // 订阅UI更新通道 UiChannels.ProgressChannel.Reader.ReadAllAsync() .ForEachAsync(update => this.Invoke((MethodInvoker)(() => { progressBar.Value = update.Percent; lblStatus.Text = update.Message; }))); } private async void btnStart_Click(object sender, EventArgs e) { // 启动主任务树 await _scheduler.StartAsync(BuildMainTree()); } }整个流程没有Thread.Sleep,没有lock,没有InvokeRequired,所有跨模块通信都通过类型安全的Channel完成。你可以在FormTest里点击“Start”,观察ProgressBar从0%走到100%,同时Console输出每一步的日志——这就是CSP调度的呼吸感。
4.2 多线程视觉任务的性能优化实践
当视觉任务计算量激增(如4K图像实时检测),单线程调度会成为瓶颈。此时需启用多线程模式,但必须遵循铁律:视觉计算本身在后台线程,结果传递回主调度树。
在GoTest.csproj中,我们演示了如何用Go.RunOnThreadPool卸载重负载:
// 在主调度树中 Execute = async ct => { // 1. 触发拍照(在主调度线程) await VisionProcessor.CaptureReader.Writer.WriteAsync(cmd); // 2. 启动后台视觉分析(卸载到线程池) var analysisTask = Go.RunOnThreadPool(() => HeavyHalconAnalysis(imageData)); // 3. 主线程不阻塞,继续做其他事(如更新UI) await UiChannels.ProgressChannel.Writer.WriteAsync( new ProgressUpdate(50, "Analyzing...")); // 4. 等待结果(此时主线程可能已执行其他任务) var result = await analysisTask; // 5. 结果发回主流程处理 await MainChannels.AnalysisResultChannel.Writer.WriteAsync(result); };性能实测数据(i7-10875H + RTX3060):
| 方案 | 4K图像分析耗时 | CPU占用率 | 调度延迟抖动 |
|------|----------------|------------|----------------|
| 单线程调度 | 182ms | 92% | ±12μs |
| 多线程卸载 | 47ms | 38% | ±3μs |
关键技巧:
-Go.RunOnThreadPool内部使用Task.Run,但会自动捕获当前CancellationToken,确保Stop()能终止后台任务;
- 视觉结果通道必须用Channel.CreateUnbounded<T>(),避免后台线程因通道满而阻塞;
- 在doc.md的“性能调优”章节,详细记录了不同图像尺寸、算法复杂度下的线程池大小配置建议(如1080p图像配4线程,4K配8线程)。
4.3 WinForm测试工程深度解析:FormTest与WaitForm的协同逻辑
FormTest和WaitForm不是两个孤立窗体,而是CSP调度的活体教科书。
FormTest:主控制台,展示任务树构建、启动、暂停、停止全流程。其核心是
TaskTreeBuilder类,用链式语法构建树:csharp var tree = TaskTree.Create("Main") .Then(MoveXAxis(50.2m)) .Then(WaitForAxis("X")) .Then(TriggerCamera("TopCam")) .Then(WaitForVisionResult()) .OnError(HandleVisionError);WaitForm:专门处理“等待”语义的模态窗体。它不包含业务逻辑,只做一件事:监听指定Channel,收到消息即关闭。
WaitForm.cs的关键代码:
```csharp
public partial class WaitForm : Form
{
private readonly ChannelReader
