拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C# WCS仓库控制系统源码解析:设备通信与任务调度实战

C# WCS仓库控制系统源码解析:设备通信与任务调度实战

简介:WCS-HY 仓库控制系统是一套基于 C# 开发的完整工程代码,面向从事仓储自动化、物流调度及 WMS/WCS 系统集成的开发人员与实施工程师。系统围绕设备协调、任务调度与系统对接展开,可帮助理解 WCS 在自动化仓库中的中间层控制逻辑,以及 C# 与 .NET 框架下的界面、驱动与通信实现。压缩包共 2000 个文件,以 C# 源码、XAML 界面定义、DLL 类库、EXE 可执行程序及 config/xml/json 配置文件为主体,另含 PLC 配置、版本说明等多种工程文件,整包约 251.76 MB,适合作为二次开发或项目参考。目前已有 282 人学习下载。资料不仅包含可编译运行的代码框架,还涵盖多类设备驱动与通信配置、界面控件资源、运行日志与缓存定义等细节,便于对照研究系统模块划分和关键接口。通过分析源码结构与配置文件,可以较快上手 WCS 与 WMS 对接、搬运设备调度及异常处理等典型场景,为实际项目落地提供有价值的参考。

1. WCS 控制系统代码 C#:一份能跑的仓储调度骨架

接手过一个电商分拨仓库的 WCS(Warehouse Control System)控制系统改造,甲方现场的中控电脑上跑着一套十年前的老系统,界面是灰底黑字,任务积压的时候调度员得拿笔抄单号。说白了,WCS 就是仓储里接 WMS 和底层设备之间的那层“翻译官兼调度员”——上游仓储管理系统告诉你哪个订单要出库,WCS 转成堆垛机、输送线、RGV 能听懂的动作指令,然后把设备状态和任务结果反馈回去。这套用 C# 写的 WCS-HY 控制系统代码,拆开看就是一套标准的仓储设备调度骨架:设备通信、任务队列、状态管理、数据落库。适合正在做上位机、做仓储物流控制系统的工程师直接改来用,也适合刚接触 WCS 的人拿它当活教材读。下面按我实际拆解的路径,把这套代码值得看的部分和必须避开的坑一起讲清楚。

2. 设备通信层:OPC-UA 与 Socket 双通道怎么选型

2.1 选型逻辑:为什么 WCS 通常同时保留两条通信链路

WCS 控制系统里最容易被低估的就是设备通信层。很多人以为对接设备就是写个 TCP 客户端连上就完事,实际现场根本不是这么回事。你面对的设备五花八门:堆垛机控制器可能是西门子,输送线电控箱里可能是欧姆龙或三菱,RGV 小车可能是某个小厂定制的控制板,只开放了自定义协议。一套成熟的 WCS 代码不会押注某一种通信方式,而是同时保留两条链路:一条走 OPC-UA,用来对接支持标准协议的 PLC;另一条走 Socket 自定义报文,用来对接只给你一份文档的设备。

我见过不少初版 WCS 只实现了 Socket,结果现场新增的两台堆垛机控制器的 PLC 只开放了 OPC-UA 接口,临时加驱动的代价就是三周工期延误。反过来,也有团队只做了 OPC-UA,结果对接一台老式输送线时发现对方根本不支持 OPC 协议。所以这套代码里“双通道”的设计思路值得直接抄——统一的设备接口抽象层,上层调度逻辑不关心下面走的是 OPC 还是 Socket,只有通信适配器不同。这也是我判断一份 WCS 代码是否合格的第一道分水岭。

2.2 OPC-UA 对接西门子 PLC:一个稳定的读取循环

如果设备端是西门子 S7-1200/1500 这类 PLC,常见做法是用 OPC-UA 把 PLC 里的 DB 块映射成节点,WCS 定时读取这些节点获取设备状态,写入节点下发动作指令。下面这个读取循环是这类代码的骨架,逻辑不复杂,关键在超时和重连处理。

public class OpcUaDeviceClient { private readonly string _endpointUrl; private readonly string _nodePrefix = "ns=2;s=Device1."; private Session _session; private const int ReadTimeoutMs = 2000; public bool Connect() { try { var config = new ApplicationConfiguration { ApplicationName = "WCS-HY Client", ApplicationUri = "urn:localhost:WCSHY:Client", SecurityMode = ApplicationSecurityMode.None }; config.CertificateValidator = new CertificateValidator(); var endpoint = CoreClientUtils.SelectEndpoint(_endpointUrl, useSecurity: false); _session = Session.Create(config, endpoint, endpointUrl: _endpointUrl, sessionName: "WCS Session"); _session.KeepAlive += (s, e) => { if (e.Status == ServiceResult.Good) return; // 保活失败时触发重连,不能在这里直接建连接,只做标记 _needReconnect = true; }; return _session != null; } catch (Exception ex) { _needReconnect = true; return false; } } public int ReadInt32(string tagName) { var nodeId = new NodeId($"{_nodePrefix}{tagName}"); var value = _session.ReadValue(nodeId, TransportDefaults.DefaultOperationTimeout); return Convert.ToInt32(value); } public void WriteBoolean(string tagName, bool value) { var nodeId = new NodeId($"{_nodePrefix}{tagName}"); _session.WriteValue(nodeId, value); } }

这段代码里最有价值的是 KeepAlive 事件里的处理方式:只标记断连状态,不在回调里直接重连。回调线程里做网络重连会造成连接状态竞争,两个线程同时创建 Session 的结果就是控制台疯狂报错。我一般会在主调度循环里检查_needReconnect标记,统一在业务线程里执行Connect()。另外注意TransportDefaults.DefaultOperationTimeout,现场 PLC 响应慢的时候这个值要适当放大,默认值在一些老 PLC 上会频繁触发超时。

参数说明:_nodePrefix前缀要和 PLC 的程序块命名对应,ns=2;s=是 OPC-UA 标准写法;SecurityMode.None是为了兼容现场各种老设备,如果甲方要求加密,得改配证书,那也是 WCS 上线前就该做完的事,别拖到联调阶段。

2.3 Socket 直连输送线控制器:指令与心跳的约定

对只开放 TCP 接口的设备,通信协议通常是你和电控工程师一起定的。最常见的坑是协议里只有指令没有心跳,或者心跳超时时间设置得太短导致设备被误判离线。下面是我常用的一个 Socket 客户端模板,里面把心跳和业务报文合在一起处理。

public class SocketDeviceClient { private TcpClient _client; private readonly object _sendLock = new object(); private readonly CancellationTokenSource _cts = new CancellationTokenSource(); public async Task RunAsync(string ip, int port, int heartbeatIntervalMs = 5000) { while (!_cts.IsCancellationRequested) { if (_client == null || !_client.Connected) { await ReconnectAsync(ip, port); } var heartbeat = Encoding.UTF8.GetBytes("{\"type\":\"heartbeat\",\"ts\":\"" + DateTime.Now.ToString("HH:mm:ss.fff") + "\"}\n"); lock (_sendLock) { _client.GetStream().Write(heartbeat, 0, heartbeat.Length); } var response = await ReadLineAsync(); // 读取一行业务响应 if (string.IsNullOrEmpty(response)) { // 连续三次无响应才判定离线,避免网络抖动误判 _noResponseCount++; if (_noResponseCount >= 3) _client.Close(); } else { _noResponseCount = 0; OnMessageReceived?.Invoke(response); } await Task.Delay(heartbeatIntervalMs); } } private async Task ReconnectAsync(string ip, int port) { await Task.Delay(3000); // 等待设备端恢复 _client = new TcpClient(); await _client.ConnectAsync(ip, port); } }

心跳间隔我会给 3000~5000 毫秒,具体取决于设备控制器的扫描周期。如果电控工程师告诉你 PLC 扫描周期是 50 毫秒,心跳给短一点没问题;如果对方是国产一体机,扫描周期可能不稳定,心跳给到 8 秒也不丢人。这里有个玄学判断标准:心跳间隔至少要是设备扫描周期的 30 倍。另外字符串报文末尾一定带换行符,不然 ReadLineAsync 会一直等下去。很多联调翻车就是协议里没写清楚报文结束符,双方各自理解,一挂一整天。

3. 任务调度与队列:从 WMS 下发到设备执行的中间态设计

3.1 任务状态机的六个状态与流转条件

WCS 代码的核心不是通信,是任务状态机。WMS 下发一个出库任务,WCS 接收后要经历一系列状态才能变成设备动作。一套清晰的 C# 任务状态机代码,会让后续所有排错都变简单。状态机设计差的话,现场会出现任务“卡在中间态”这种最难查的故障。

public enum WcsTaskStatus { Pending = 0, // 已接收,等待分配设备 Assigned = 1, // 已绑定设备,等待执行 Transit = 2, // 设备正在执行 Completed = 3, // 执行完成 Failed = 4, // 执行失败 Cancelled = 5 // 已取消 } public class WcsTask { public string TaskId { get; set; } public string WmsOrderId { get; set; } public string SourceLocation { get; set; } public string TargetLocation { get; set; } public WcsTaskStatus Status { get; set; } public DateTime CreateTime { get; set; } public DateTime? StartTime { get; set; } public DateTime? FinishTime { get; set; } public string BoundDeviceId { get; set; } public string ErrorMessage { get; set; } public bool CanTransitionTo(WcsTaskStatus target) { // 只允许单向流转 + 失败/取消可以到终态 return target == Status + 1 || (Status == WcsTaskStatus.Pending && target == WcsTaskStatus.Cancelled) || (Status is WcsTaskStatus.Assigned or WcsTaskStatus.Transit && target == WcsTaskStatus.Failed); } }

状态流转的关键设计是“单向递增”:Pending → Assigned → Transit → Completed,中间任何一步都能进 Failed 或 Cancelled。禁止任务从 Completed 往回走,这是很多电商仓储系统的死穴——有一次现场退货任务被误触发重跑,同一托盘从 Completed 变成 Transit,WMS 那边的库存已经扣减了,两边数据对不上,差出 12 个托。从那以后我要求所有 WCS 状态机代码必须写CanTransitionTo,任何非法流转直接抛异常并记录日志。

3.2 核心调度循环:先到先服务与优先级抢占的折中

WCS 调度循环的代码往往是最有争议的部分。先到先服务实现简单,但现实情况是紧急订单不断插入,没有优先级机制的话,紧急订单要等 40 分钟。优先级抢占设计得好能提升效率,设计得不好就是任务饿死、设备空跑。

public class TaskScheduler { private readonly ConcurrentQueue<WcsTask> _pendingQueue = new ConcurrentQueue<WcsTask>(); private readonly object _deviceLock = new object(); private readonly Dictionary<string, string> _deviceState = new Dictionary<string, string>(); public WcsTask PickNextTask(string deviceId) { // 高优先级任务优先挑选 var urgentTasks = _pendingQueue .Where(t => t.Priority == TaskPriority.High && t.Status == WcsTaskStatus.Pending) .OrderBy(t => 0) // 高优先级内部按创建时间排 .ThenBy(t => t.CreateTime) .ToList(); if (urgentTasks.Any()) { return urgentTasks.First(); } // 普通任务按创建时间排,先到先服务 return _pendingQueue .Where(t => t.Status == WcsTaskStatus.Pending) .OrderBy(t => t.CreateTime) .FirstOrDefault(); } }

这个实现有两个隐藏问题。第一,Priority == TaskPriority.High的常量比较写死了,现场如果调整优先级策略,得改代码重新编译。我会把优先级做成配置项,从数据库里读。第二,= 0这种写法虽然能让高优先级任务内部按创建时间排,但如果高优先级任务持续涌入,普通任务可能一整班都得不到执行。常见的折中方案是“防饿死”:同一设备上执行的连续 N 个任务里,至少保留一个普通优先级任务。这个 N 一般设置成 5,也就是说 4 个紧急任务后必插 1 个普通任务。这个逻辑我会放在调度循环外面一个独立的计数器里,避免污染核心调度代码。

3.3 任务与设备绑定:设备占用表怎么写

WCS 的调度核心除了队列,还有设备占用表。任务要绑定到具体设备,这个绑定关系如果处理不好,会出现两台设备同时抢同一个输送段的情况。实际项目里,我用一张内存字典维护设备占用关系。

public class DeviceOccupancyManager { private readonly Dictionary<string, string> _deviceOwner = new Dictionary<string, string>(); private readonly object _lock = new object(); public bool TryOccupyDevice(string deviceId, string taskId) { lock (_lock) { if (_deviceOwner.ContainsKey(deviceId)) { return false; // 设备已被占用 } _deviceOwner[deviceId] = taskId; return true; } } public void ReleaseDevice(string deviceId, string taskId) { lock (_lock) { if (_deviceOwner.TryGetValue(deviceId, out var owner) && owner == taskId) { _deviceOwner.Remove(deviceId); } } } }

这里必须用lock包裹,不能依赖 ConcurrentDictionary 的原子性方法——TryAdd虽然原子,但 “检查占用者是否为当前任务” 这个复合操作不是原子的。占用表还涉及一个特别容易踩的坑:任务失败后的设备释放。有些团队在catch块里只记录错误日志而忘记释放设备,结果设备被一个失败任务锁到下班。我现在的习惯是任务状态机进入 Failed 或 Cancelled 时,统一在finally里调用ReleaseDevice,不放任何特例。

4. SQL Server 数据落库与日志回滚:数据库设计与状态一致性

4.1 四张核心表:任务、设备、日志、配置

WCS 数据落库如果用 Entity Framework 或者 Dapper 都行,但表结构设计才是核心。看过不少初版代码把设备状态、任务、日志全塞一张表里,后来查询和归档全都痛苦。一套标准的 WCS 数据库至少四张表,我拆解这套代码时发现它的表设计也是这个思路。

-- 1. 任务主表,一任务一条记录 CREATE TABLE wcs_task ( task_id VARCHAR(50) PRIMARY KEY, -- 任务号,WMS 下发或 WCS 自动生成 wms_order_id VARCHAR(50) NOT NULL, -- WMS 订单号 source_location VARCHAR(30) NOT NULL, -- 起点库位 target_location VARCHAR(30) NOT NULL, -- 目标库位 status INT NOT NULL DEFAULT 0, -- 状态枚举:0待分配 1已分配 2执行中 3完成 4失败 5取消 priority INT NOT NULL DEFAULT 5, -- 优先级,1最低 10最高 bound_device_id VARCHAR(30), -- 绑定的设备编号 create_time DATETIME2 NOT NULL, start_time DATETIME2, finish_time DATETIME2, error_msg NVARCHAR(500) ); CREATE INDEX idx_task_status ON wcs_task(status); CREATE INDEX idx_task_createtime ON wcs_task(create_time); -- 2. 设备实时状态表 CREATE TABLE wcs_device_status ( device_id VARCHAR(30) PRIMARY KEY, device_type VARCHAR(20), -- STACKER/CONVEYOR/RGV current_location VARCHAR(30), task_id VARCHAR(50), -- 当前执行任务 status INT NOT NULL DEFAULT 0, -- 0离线 1空闲 2忙碌 3故障 last_heartbeat DATETIME2 NOT NULL );

priority字段在 WMS 下发任务里经常是空的,WCS 要做默认值兜底。我在实际项目里习惯把优先级默认设为 5,然后通过一个配置项控制“当 WMS 传了空值时使用什么默认值”,而不是写死在代码里。任务表用task_id做主键,wms_order_id不是主键——同一个 WMS 订单可能拆成 WCS 的多个子任务,这事在拆包出库的场景里特别常见。

4.2 批量写入与事务边界:避免把数据库当缓存

WCS 的调度性能瓶颈经常不在设备,而在数据库。状态每次变化都写一条日志,设备心跳每 2 秒写一次,如果再用低效的逐条 INSERT,数据库很快就成了瓶颈。这套 WCS 代码里用了 SqlBulkCopy 批量写日志,这个做法值得抄。

public class WcsLogWriter { private readonly List<WcsLogEntry> _buffer = new List<WcsLogEntry>(); private readonly object _lock = new object(); private readonly int _flushThreshold = 500; private readonly Timer _flushTimer; public WcsLogWriter(int flushIntervalSeconds = 5) { _flushTimer = new Timer(_ => Flush(), null, flushIntervalSeconds * 1000, flushIntervalSeconds * 1000); } public void Add(WcsLogEntry entry) { lock (_lock) { _buffer.Add(entry); if (_buffer.Count >= _flushThreshold) { Flush(); } } } public void Flush() { List<WcsLogEntry> snapshot; lock (_lock) { if (_buffer.Count == 0) return; snapshot = new List<WcsLogEntry>(_buffer); _buffer.Clear(); } using var conn = new SqlConnection(_connectionString); using var bulkCopy = new SqlBulkCopy(conn); bulkCopy.DestinationTableName = "wcs_task_log"; bulkCopy.ColumnMappings.Add("task_id", "task_id"); bulkCopy.ColumnMappings.Add("log_time", "log_time"); bulkCopy.ColumnMappings.Add("log_type", "log_type"); bulkCopy.ColumnMappings.Add("content", "content"); var dataTable = new DataTable(); dataTable.Columns.Add("task_id", typeof(string)); dataTable.Columns.Add("log_time", typeof(DateTime)); dataTable.Columns.Add("log_type", typeof(string)); dataTable.Columns.Add("content", typeof(string)); foreach (var entry in snapshot) { dataTable.Rows.Add(entry.TaskId, entry.LogTime, entry.LogType, entry.Content); } conn.Open(); bulkCopy.WriteToServer(dataTable); } }

批量写的核心是“先攒后写”:攒 500 条或者满 5 秒就刷一次,数据库压力小一个量级。这个模式的坑在于进程退出时缓冲区里的日志会丢,所以 WCS 程序退出前必须手动调一次Flush()。

4.3 状态不一致时的回滚与补偿

WCS 和 WMS 之间的数据一致性是仓库现场最头疼的问题。设备已经执行完动作,但任务状态没更新到数据库,WMS 那边任务卡在“执行中”不释放。常见的补偿机制是“定时对账”而不是“实时强一致”,这套代码里用了一个状态补偿任务。

public void Reconciliation(int timeoutMinutes = 30) { // 找出所有卡在执行中但设备已经空闲/离线的任务 var staleTasks = _db.Query(@" SELECT task_id, task_id AS taskid, status FROM wcs_task WHERE status = 2 -- Transit AND bound_device_id IN ( SELECT device_id FROM wcs_device_status WHERE status IN (0, 1) -- 离线或空闲 ) AND start_time < DATEADD(MINUTE, -@timeout, GETDATE())", new { timeout = timeoutMinutes }); foreach (var task in staleTasks) { // 先查设备反馈,确认任务是否已实际完成 var deviceReportedDone = _deviceService.ConfirmTaskFinished(task.taskid); if (deviceReportedDone) { _db.Execute("UPDATE wcs_task SET status = 3, finish_time = GETDATE() WHERE task_id = @id", new { id = task.taskid }); } else { _db.Execute("UPDATE wcs_task SET status = 4, error_msg = '超时未完成,补偿置为失败' WHERE task_id = @id", new { id = task.taskid }); } } }

这个补偿逻辑不追求实时,每 5 分钟跑一次就行。关键是start_time < DATEADD(MINUTE, -@timeout, GETDATE())这个条件,只处理超时任务,避免刚下发的正常任务被误判。我在现场被这个问题坑过一次:输送线上有一个传感器接触不良,任务实际已走了 70%,但状态一直停在“执行中”。WMS 不让发新任务,仓库那条线就瘫痪着,直到我手动把这个任务改成“完成”才恢复。从那以后我所有 WCS 项目里,对账脚本是第一批交付物,不是最后一版才补的。

5. WCS 调试避坑:五条高频故障与排查记录

5.1 任务卡住不动:是设备的 ACK 丢了,还是调度循环被阻塞

现象:WCS 中控上看任务状态是“已分配”,但现场设备纹丝不动,日志里也没有任何设备动作记录。

原因排查时我们通常先看三个点:一是调度循环是否被某个耗时操作阻塞(比如误把数据库查询写进了调度线程里并且没有超时);二是指令是否发出去了但设备端没回 ACK;三是指令发出去了,设备也执行了,但 WCS 没收到完成回执,状态没有继续往下走。这套代码里有一个很常见的低级失误,就是调度循环里调用了同步的Task.Delay而不是await Task.Delay,导致整个调度线程被卡住,现场表现就是“界面活着,任务不走”。解决办法是把所有设备写入操作放进独立的任务队列,调度循环本身只负责挑选任务,不直接写通信。

5.2 心跳超时误判:设备被反复踢下线

现象:设备状态表里一台堆垛机在“在线/离线”之间反复横跳,每 5 分钟跳一次,值班同事以为设备坏了,实际设备运行得好好的。

原因:心跳超时时间设置太短,或者心跳报文和业务报文的处理是同一个管道,业务量大时心跳被排在后面,WCS 端等不到就判定离线。解决这两个问题要从两端下手:设备端把心跳独立成一个发送线程,业务报文走另一个线程;WCS 端把“超时未收到心跳”和“连续 N 次超时未收到心跳”区分开,后者才判定为离线。N 我一般设 3 或 5。

5.3 线程内异常:一个设备线程崩掉整条分拨线

现象:有段时间整个 WCS 每隔 40 分钟自动卡死,必须重启程序才能恢复。查了 Windows 事件日志,发现是某个设备的通信线程抛了异常,异常没有被捕获导致进程崩溃。更隐蔽的是,这种崩溃往往不在主线程,表现出来就是死循环 / 卡死,而不是直接闪退。

原因:在一个设备通信线程的try-catch里嵌套了另一个对象的调用,被调对象内部有自己的线程,内部异常逃逸出来把整个进程带崩。

解决:给所有常驻线程套上全局异常处理,线上环境配置TaskScheduler.UnobservedTaskException和AppDomain.CurrentDomain.UnhandledException两个事件,记录完整堆栈。同时给设备的接收循环加一个兜底catch(Exception),不允许任何线程因未捕获异常死亡。

5.4 数据库连接池耗尽:频繁开关连接的后果

现象:WCS 跑了 3 小时后,日志里开始大量报“连接池已满”,全部任务停顿。重启后又能撑 3 小时。

原因:一个常见的翻车现场是using块里只释放了命令对象没释放连接对象;另一个是日志写入用了独立线程,这个线程里每次循环都new SqlConnection,用完没 Close,连接池里的连接被耗尽。

解决:统一封装数据库访问基类,所有数据库操作走同一个入口,连接对象的释放放在finally里,不做任何例外。另外把连接池的最大连接数从默认的 100 调小到 30,逼迫代码尽早发现泄漏而不是延迟爆发。顺带提一句:SQL Server 连接字符串里加Pooling=True;Min Pool Size=5能降低冷启动时的首次连接延迟,这也是提升 WCS 启动速度的小技巧。

5.5 时间戳精度:毫秒级重复导致排序失效

现象:任务按创建时间排序时,同一毫秒创建的多个任务顺序错乱,导致高优先级任务不一定是先创建的。这个坑看起来小,但会引出一系列更隐蔽的问题。

原因:DateTime.Now的底层精度是毫秒级,高并发场景下两个任务的时间戳可能完全相同,然后排序就没法保证 FIFO。

解决:创建时间字段用DateTime.UtcNow而不是DateTime.Now,同时给任务实体增加一个自增序号SequenceId,排序时先按优先级再按SequenceId,完全避免依赖时间戳。另外 SQL 里的排序也要写双字段:ORDER BY priority DESC, sequence_id ASC。这个改法成本极低,但能根治这种隐蔽的乱序问题。

6. 从单机到多客户端:TCP Listener 与线程模型进阶

如果现场不止一台设备需要 TCP 接入,前面那个单设备客户端就不够用了。WCS 要能同时监听多台设备连接,这就要用 TCP Listener 加每客户端一个接收线程的模式。下面这段代码是我在新项目里常用的一种结构,比单纯AcceptTcpClientAsync套循环要稳得多。

public class TcpDeviceServer { private TcpListener _listener; private readonly Dictionary<string, ClientConnection> _clients = new Dictionary<string, ClientConnection>(); private readonly object _lock = new object(); public async Task StartAsync(int port) { _listener = new TcpListener(IPAddress.Any, port); _listener.Start(); while (true) { var tcpClient = await _listener.AcceptTcpClientAsync(); var connection = new ClientConnection(tcpClient, this); lock (_lock) { _clients[connection.ClientId] = connection; } _ = Task.Run(connection.ReceiveLoopAsync); // 抛出式后台运行,异常由全局处理器接管 } } public void SendToAll(string message) { lock (_lock) { foreach (var client in _clients.Values.ToList()) { try { client.Send(message); } catch (IOException) { // 写入失败说明对端已断开,下次心跳会触发清理 } } } } }

这个实现的关键点有三个。第一,_ = Task.Run(...)后面不需要保存返回值,异常统一走全局UnobservedTaskException,不会裸奔。第二,SendToAll里对每个客户端独立try-catch,一个客户端断线不会影响其他客户端的发送。第三,我用Dictionary<string, ClientConnection>管理连接而不是List<TcpClient>,因为要支持后续把任务只发送给指定设备。这里我要多说一句:TCP 设备管理在 WCS 里属于“表面简单、实际复杂”的部分,我踩过的最大的坑是退出时没有清理监听,导致端口被占用,重启 WCS 要等 30 秒。从那以后我强制所有服务类实现一个StopAsync()方法,把释放 listener、关闭所有客户端连接、等待线程退出的逻辑写全,每次发布前走一遍Start → 运行 → Stop → Start的验证流程,希望帮到你。

本文还有配套的精品资源,点击获取

返回列表