多进程 / 多线程场景下的仪器资源独占与共享方案

TestPlatform Aging Software · SQLite 设备注册 + Aging.Infrastructure 租约 + OpenTAP 序列执行器 三栈融合设计 · 2026-11-03
目录 1. 现状盘点与问题定义 2. 两类并发场景的严格区分 3. 五个候选方案逐一评估与横向对比 4. 推荐目标架构:分层混合(租约表 + 仲裁规则 + 按需 Broker) 5. 租约完整生命周期:申请 / 续期 / 释放 / 超时抢占 6. 全自动 vs 手动维护的互斥仲裁规则 7. 异常场景处理:死锁 / 孤儿锁 / 时钟 / 幂等 8. 独占 or 分时复用:设备分组与 Slot 绑定策略 9. 本仓库改造清单(具体文件路径 + 接口签名 + DDL) 10. 测试要点:单元 / 多进程集成 / 崩溃演练 11. 分阶段实施计划与工作量估算 12. 风险、反模式与不推荐做法 13. 结论

1. 现状盘点与问题定义

先说结论:你们的架构已经"半只脚"踩在正确方案上。 ResourceOwnerToken(含 Generation 世代号)、reservation_ref 字段、IAutoDeviceLease 作用域——这些概念与工业界标准的"租约(Lease)+ 栅栏令牌(Fencing Token)"模型一一对应。唯一的问题是:整套机制活在内存里。

1.1 现状代码事实核查

组件文件现状跨进程能力
ResourceManager Aging.Infrastructure/Core/ResourceManager.cs ConcurrentDictionary 存预约 + SemaphoreSlim(1,1) 每资源细锁;Generation 全局自增 ❌ 纯进程内存,其他进程完全不可见
ResourceOwnerToken Contracts/ResourceOwnerToken.cs TokenId + OwnerType(Production/Maintenance) + Generation + IssuedAt/ExpiresAt ⚠️ 结构可序列化,但目前没有跨进程校验路径
AutoDeviceFacade / Lease Services/AutoDeviceFacade.cs Reserve → DeviceService 分派 → finally Release;Lease 作用域复用 ❌ 依赖内存版 ResourceManager
DeviceService Services/DeviceService.cs 执行前 ValidateOwner(内存查表)→ AcquireOperationAsync → driver 下发 ❌ 校验仅本进程有效
DebugSessionManager Plugin.DeviceDebug/Model/DebugSessionManager.cs RequestMaintenance 已捕获 ResourceConflictException,并把 reservation.ReservationId 写入 session.ReservationRef 持久化 ⚠️ 已预留挂接点,差一个"活的"仲裁源
debug_session 表 Plugin.DeviceDebug/Model/DeviceDebugModel.cs 有 reservation_ref TEXT、state、closed_at,无心跳/过期列 ⚠️ 可扩展
IPC 设施 ConnectionUtil/PipeServer.cs · PipeClient.cs 命名管道(Message 模式)已实现:多实例服务端、连接事件、请求-响应 SendMessage(带超时) ✅ 可直接作为 Device Broker 的传输层,无需新造轮子
OpenTAP SynchronizationManager OpenTAP 9.31 引擎 进程内 RequestLock(resource, timeout) 按仪器互斥 ❌ 官方文档明确:仅进程内

1.2 问题本质拆解

"多工位共用一台仪器"其实包含 三个不同层面的问题,必须分开解决,混在一起谈必然方案走形:
  1. 仲裁(Arbitration):谁有权在这台仪器上发命令?→ 需要跨进程可见、可持久化、崩溃可回收的租约状态。
  2. 连接独占(Connection):物理句柄(COM 口、USB、CAN 卡)同一时刻只能被一个进程 open;网口仪器则允许同机多连接但需要"逻辑独占"语义。→ 需要把连接持有者与租约持有者绑定,必要时引入代理进程。
  3. 命令串行化(Serialization):分时复用型设备(如共享温箱),允许多工位排队下发原子命令片段。→ 需要队列/栅栏令牌,而非独占租约。

2. 两类并发场景的严格区分

graph TB subgraph 场景A["场景 A:同进程多线程(单 HMI 主程序内)"] A1["OpenTAP 并行步骤 / MultiSite 调度线程"] --- A2["WPF UI 线程(手动调试)"] A3["ResultProxy 落库线程"] end subgraph 场景B["场景 B:跨进程 / 跨机器"] B1["工位进程1 tap.exe/Station1.exe"] B2["工位进程2 Station2.exe"] B3["HMI 主程序(维护工程师)"] B4["其他机器上的执行节点
(多机共测一台仪器)"] end subgraph 解决手段["分别对应的手段"] S1["OpenTAP SynchronizationManager
+ 现有内存版 ResourceManager
(保持不变 ✅)"] S2["持久化租约表(DB Lease)
+ Fencing Generation
+ 心跳 TTL + 仲裁策略
(本次新建 ⭐)"] end 场景A --> S1 场景B --> S2
维度场景 A:进程内多线程场景 B:跨进程/跨机器
典型触发TestPlan 中 Parallel 步骤 / MultiSite 同时跑不同分支多工位独立进程、HMI 与自动测试并行、CI 节点
互斥原语SemaphoreSlim / lock / SynchronizationManager.RequestLockOS 命名内核对象 / DB 租约 / Broker 队列
崩溃表现进程死 → 锁随进程消失(内核回收)持有者死亡后锁残留,必须靠 TTL/看门狗回收
可见性天然可见(同一堆)必须外部化状态(DB / Broker / 共享内存)
你们的现状已解决(ResourceManager 内存版即是干这个的)完全未解决——本次方案的主战场
设计原则:场景 A 用轻量内存锁(快),场景 B 用持久化租约(可靠),两层复合成一个 LayeredResourceManager,对上层 IAutoDeviceFacade 完全透明。

3. 五个候选方案逐一评估与横向对比

方案 ①:全部收敛到单进程 + OpenTAP 内置同步

把所有工位执行体都塞进一个主进程(一个 TestPlan 跑多分支),完全依赖 SynchronizationManager。

评估:物理上最省事,但违反老化测试的独立故障域原则——一个工位 StackOverflow/原生驱动崩溃拖死全部 N 个工位;老化工龄动辄几十小时,进程级隔离是刚需。内存泄漏在多进程下可以按工位轮换回收,单进程无解。
结论:仅作为兜底简化形态(单工位小型机台可用),不作为多工位目标架构。

方案 ②:命名内核对象(Named Mutex / Named Semaphore)

new Mutex(false, "Global\\Lease_CAN-001"),跨进程抢内核对象。

优点:实现成本≈0;进程死亡时内核自动释放 Abandoned(同机可靠)。
致命缺陷: 结论:不能单独成立;可以作为方案③的"同机加速信号"做混合(见 §4 分层设计)。不推荐用 NamedEvent 轮询模拟租约(竞态多、调试地狱)。

方案 ③:数据库租约表(Lease + TTL + 心跳 + 崩溃回收)⭐ 推荐主体

把租约状态持久化到 data.db 新表 device_lease,用 BEGIN IMMEDIATE 事务实现跨进程原子的"比较并换入"(CAS),TTL 过期即被视为崩溃可回收,Generation 作栅栏令牌防"僵尸持有者"。

与现有 IResourceManager 的衔接:接口签名一个都不改。新增 DbLeaseStore 内部类,ResourceManager 升级为组合模式:内存字典继续做本进程快速路径与信号量互斥,预约/校验同时读写 DB 表。上层 AutoDeviceFacade / DeviceService / DebugSessionManager / IAutoDeviceLease 零改动,只换 Bootstrapper 里的注册实例。
限制:SQLite 是单机文件库——多机共享需把该表迁到服务端数据库(或走方案④ Broker 代理),且 SQLite 写并发要靠 WAL + 短事务,租约操作设计成毫秒级点查/点写,实测支撑每工位秒级命令流毫无压力。 结论:跨进程仲裁的最佳性价比,作为主体方案。

方案 ④:独立设备代理进程(Device Broker + IPC)

一台机器上跑一个 DeviceBroker.exe,所有物理连接(COM/USB/CAN 卡句柄、SCPI TCP socket)只由它持有;各工位进程通过命名管道把命令转发给 Broker 排队执行。仓库现成的 ConnectionUtil/PipeServer·PipeClient 即可做传输层。

解决的核心痛点(租约表解决不了的): 代价:多一个常驻进程要运维(看门狗、升级、日志);所有 I/O 多一跳 IPC(~毫秒级,老化场景可忽略);开发量最大。 结论:串口/USB/CAN 卡这类"单机物理独占"设备的终局方案;但建议 Phase 3 再上,Phase 1 用租约表 + "租约持有期内保持连接"即可满足大部分场景。

方案 ⑤:直接复用 OpenTAP 生态能力

逐项核查 OpenTAP 现成能力能否覆盖跨进程需求(详细分析见姊妹篇 《OpenTAP Instrument + 插件包机制分析》§9):
OpenTAP 能力是什么能否解决跨进程仲裁
Instrument : Resource 资源模型仪器生命周期托管 + 编辑器绑定❌ 实例生命周期仅进程内;不感知其他进程
SynchronizationManager进程内按仪器互斥锁❌ 设计目标就是单进程
ResultProxy / 远程执行(tap run --remote、Session.Local)把一个 TestPlan 切分到远端执行节点,结果回流⚠️ 解决"执行分布",不解决"两台机抢一台仪器"——每个远端 session 各自还是单进程视野
BenchStore / ResourceSettings(9.23+)台架资源配置持久化(XML)⚠️ 配置可以共享,仲裁状态不行(无 TTL/心跳概念)
私有包仓库分发驱动以 .opentap 包安装✅ 解决驱动分发(与并发仲裁正交,值得并行推进)
IResourcePlan/ResourceConflicts(9.20+)资源规划/冲突静态检查(编辑器里提示"两个序列用了同一仪器")⚠️ 只能提前发现冲突,运行时多进程并发仍不仲裁
结论:OpenTAP 生态没有任何一块能直接替代跨进程运行时仲裁——这是开源测试框架的普遍空白,也正是你们 IResourceManager 体系的战略价值。能复用的是:分发(包机制)、配置模型(BenchStore 思路)、以及把最终驱动做成 Instrument 插件的长期形态。

横向对比总表

维度①单进程收敛②Named Mutex③DB 租约表 ⭐④Device Broker⑤OpenTAP 现成
并发安全(跨进程)伪命题(无多进程)互斥可以,但无状态语义强一致(DB 事务 CAS)单点串行化,最强不支持
崩溃残留处理进程死=锁消失Abandoned 可感知但盲接TTL 自动过期 + 回收Broker 死=全停(需看门狗)—
优先级/抢占/优雅交接代码可实现表达不了状态列+grace 字段原生支持队列策略任意定制无
审计可追溯无无表即历史(可挂 debug_operation)Broker 全权日志ResultProxy 有结果无仲裁
性能开销纳秒级微秒级毫秒级(WAL 点写)毫秒级(IPC 一跳)—
实现成本0(重构架构为负)0.5 人日3~4 人日5~8 人日已内建/不适用
可扩展到多机否否需换服务端 DB是(Broker=网关)部分
物理句柄独占问题天然规避不解决(close/open 抖动)租约期内保连接可缓解根治(唯一持有者)不解决
调试难度低内核对象黑盒低(一张表全透明,SELECT 即现场)需双进程联调—
与现有代码衔接大改需新增旁路IResourceManager 实现层替换,上层零改Driver 层拆分转发端/宿主端正交复用

4. 推荐目标架构:分层混合(租约表 + 仲裁规则 + 按需 Broker)

graph TB subgraph L4["上层调用(保持不变)"] TS["OpenTAP TestStep
(全自动 · IAutoDeviceLease 作用域)"] DBG["DeviceDebug UI
(手动维护 · DebugSessionManager)"] end subgraph L3["门面层 Aging.Infrastructure/Services"] FAC["AutoDeviceFacade
(租约申请/心跳/栅栏校验)"] SVC["DeviceService
(命令分派 + owner 校验升级为跨进程)"] end subgraph L2["仲裁层(本次核心改造)"] LRM["LayeredResourceManager : IResourceManager"] MEM["进程内层:SemaphoreSlim + 内存缓存
(复用现有 ResourceManager 逻辑)"] DBL["跨进程层:DbLeaseStore
device_lease 表 · BEGIN IMMEDIATE CAS"] POL["仲裁策略引擎:优先级/抢占/grace"] LRM --> MEM & DBL & POL end subgraph L1["物理连接层(Phase 3 按需)"] OWN["本进程直连:网口仪器(SCPI/TCP/CAN总线模拟)
租约=逻辑独占,句柄随租约开关"] BRK["DeviceBroker.exe:串口/USB/CAN卡唯一持有者
复用 ConnectionUtil.PipeServer/PipeClient"] end TS & DBG --> FAC --> SVC --> LRM DBL --> SQLITE[("data.db
device_lease 表
WAL 模式")] SVC --> OWN & BRK

4.1 分层职责与决策规则

层解决什么何时启用
内存层(已有)同进程多线程细锁、快速失败始终(零成本)
DB 租约层(新增)跨进程运行时仲裁、优先级抢占、审计、崩溃回收Phase 1 —— 主体投入
Broker 层(新增)物理句柄独占、多机共享、命令队列Phase 3 —— 仅当出现:① 串口/USB 重开成本不可接受 ② 多机共测一台机上的设备 ③ 分时复用设备要排队
OpenTAP 生态驱动分发(包)、配置共享(Bench 思路)与 Phase 1/3 正交并行推进

5. 租约完整生命周期:申请 / 续期 / 释放 / 超时抢占

5.1 租约状态机

stateDiagram-v2 [*] --> Free: device_lease 无行/已回收 Free --> Active: Acquire(事务CAS成功
generation++ 写入 owner) Active --> Active: Heartbeat 续期
(extends expires_at) Active --> PendingHandover: 更高优先级申请到达
(Maintenance 请求,写 grace 截止) PendingHandover --> Released: 持有者优雅让渡
(完成当前 TestStep 后 Release) PendingHandover --> ForceReleased: grace 超时
(请求者可抢占 generation++) Active --> Expired: 心跳断(进程崩溃/断网)
expires_at < DB now Expired --> Free: 回收(任何 Acquire 顺带 DELETE
+ 后台 Sweep 兜底) Active --> Released: 正常 Dispose/ReleaseReservation Released --> Free

5.2 申请 + 抢占 + 栅栏拒绝时序

sequenceDiagram autonumber participant P1 as 工位进程1
(Production 租约持有者) participant DB as device_lease 表
(SQLite WAL) participant HMI as 工位进程2
(Maintenance 请求者) participant DRV as DeviceService/驱动 Note over P1,DB: —— 正常持租 + 心跳 —— P1->>DB: Acquire CAN-001(BEGIN IMMEDIATE→无冲突→INSERT gen=41) DB-->>P1: lease(tokenA, gen41, expires=now+30s) loop 每 10s(TTL/3) P1->>DB: Renew WHERE token=tokenA AND gen=41 DB-->>P1: 影响行数=1 ✅ expires 顺延 end P1->>DRV: ExecuteCommandAsync("OUTP ON") [携带 gen41] DRV->>DB: FenceCheck(gen41) → 通过 ✅ DRV-->>P1: OK Note over HMI,DB: —— 维护模式抢占 —— HMI->>DB: RequestMaintenance(CAN-001) DB-->>HMI: 已占用 → 写 state=PendingHandover,
grace_until=now+30s, 返回"等待让渡" P1->>DB: (下一次心跳发现 state=PendingHandover) P1->>P1: 完成当前 TestStep → 主动 Release P1->>DB: Release(tokenA) → DELETE 行 HMI->>DB: 轮询/通知后重试 Acquire → 成功(gen=42, Maintenance) HMI->>HMI: DebugSessionManager 进入 MaintenanceActive Note over P1,DRV: —— 僵尸持有者被栅栏拦截 —— P1->>P1: (崩溃复活/网络分区后仍以为自己持有 gen41) P1->>DRV: ExecuteCommandAsync(旧 tokenA gen41) DRV->>DB: FenceCheck: 表内 gen=42 ≠ 41 DRV-->>P1: ❌ LEASE_STALE → 本地熔断,会话转 RecoveryRequired

5.3 核心 SQL(原子申请,一次事务完成回收+检查+写入)

BEGIN IMMEDIATE;                        -- 拿写锁:跨进程串行化入口(SQLite WAL 下读写不互斥,写写排队)

-- ① 顺带回收所有已过期租约(懒回收,无需独立守护也能自愈)
DELETE FROM device_lease
 WHERE resource_key = @rk AND expires_at < datetime('now');

-- ② 检查当前有效占用
SELECT owner_id, owner_type, state, grace_until INTO @cur FROM device_lease WHERE resource_key = @rk;

-- ③ 无占用 → 插入新租约(generation 取全局序)
INSERT INTO device_lease(resource_key, owner_id, owner_type, token_id, generation,
                         machine_name, host_process_id, state, acquired_at, heartbeat_at, expires_at)
VALUES(@rk, @owner, @otype, @token, @gen, @machine, @pid, 0,
        datetime('now'), datetime('now'), datetime('now', '+30 seconds'));

-- ④ 有占用且请求者是 Maintenance → 不直接抢,标记让渡窗口
UPDATE device_lease SET state = 1, grace_until = datetime('now','+30 seconds')
 WHERE resource_key = @rk AND state = 0;

COMMIT;

5.4 续期(心跳)与失效判定

// AutoDeviceLease 内部:TTL/3 周期定时器(Phase 1 即实现)
var affected = db.ExecuteNonQuery(@"
    UPDATE device_lease
       SET heartbeat_at = datetime('now'),
           expires_at   = datetime('now', '+30 seconds'),
           state        = CASE WHEN state = 1 THEN state ELSE 0 END   -- 保留让渡标记
     WHERE resource_key = @rk AND token_id = @token AND generation = @gen");

if (affected == 0) {
    // 行不在了或 generation 已被超越 ⇒ 我已被抢占/回收 —— 本地立即熔断:
    // 1) 停止下发任何命令  2) 释放物理连接  3) 会话标记 RecoveryRequired  4) 上报一次告警
    OnLeaseLost();
}
if (db.ReadState() == PendingHandover && InStepBoundary) {
    Dispose();   // 优雅让渡:只在 TestStep 边界释放,绝不在一条命令中间放手
}
三条铁律:① 所有时间戳一律用 datetime('now')(DB 时钟),客户端时间仅作参考列;② 每条命令下发前必须 FenceCheck(一行点查,成本≈1ms);③ 让渡/释放只发生在 TestStep 边界,保证设备不处于"半条指令"状态。

6. 全自动 vs 手动维护的互斥仲裁规则

目标场景:工程师正打开机柜接 CAN 线(维护模式),全自动程序绝不能突然下发 OUTP ON。规则明确定义如下(策略引擎集中实现于新增 Aging.Infrastructure/Core/LeaseArbiter.cs):

6.1 优先级矩阵(行=当前持有者,列=新请求者)

请求:Production(全自动)请求:Maintenance(手动维护)请求:ReadOnly(监控/看数据)
持有:Production 排队(FIFO 或按工位优先级,带超时) 让渡流程:PendingHandover + grace 30s;超时未让渡 → 允许强占 + 旧持有者栅栏失效 放行(不建租约,走旁路只读)
持有:Maintenance 直接拒绝(ResourceConflictException,"有人在维护";UI 弹窗告知工程师) 拒绝(第二个人也不许碰——一台设备同时只允许一个维护会话,复用现有 OpenSession 重复检查逻辑) 放行
持有:ReadOnly 立即放行(读锁不构成障碍) 立即放行 放行
与现有代码的挂接点:DebugSessionManager.RequestMaintenance() 已经做了 ReserveAsync + 捕获 ResourceConflictException → 只需把内存版换成 Layered 版即自动获得跨进程拒绝;ValidateCommand() 已有"MaintenanceActive + ValidateOwner"双重检查,把 ValidateOwner 改为查 DB 栅栏,就得到"人在接线时程序连命令都发不出"的硬保证——即使自动程序逻辑有 bug。

6.2 维护模式退出与自动流程恢复

6.3 审计闭环

7. 异常场景处理:死锁 / 孤儿锁 / 时钟 / 幂等

异常场景成因对策机制落点
多设备死锁(工位A锁{电源}等{CAN},B反序) 组申请加锁顺序不一致 ① 全局强制按 resource_key 字典序申请(AcquireGroupAsync 内部排序)
② 事务内"全有或全无":任一冲突则整体回滚重试(wait-die:新请求者退避重试,老持有者不被饿死)
③ 组申请总超时(默认 60s)
AutoDeviceFacade 新增 AcquireGroupAsync
进程崩溃 → 孤儿锁 kill / BSOD / 断电,Release 没跑 ① TTL 30s 自动过期(任何 Acquire 顺带 DELETE 过期行 = 懒回收)
② 同机看门狗:lease 记录 machine_name + host_process_id,本机新进程启动时枚举存活 PID 提前回收(不用等 TTL)
③ HMI"强制释放"按钮(需 ADMIN 角色,写审计 note="force_release")
device_lease 列 + Bootstrapper 启动自检
僵尸持有者(网络分区/GC停顿后复活继续发命令) 进程活着但被抢占过 栅栏令牌:命令必须携带 (token_id, generation),DB 中 generation 已变 → 驱动入口直接拒绝(§5.2 时序 15-17 步)。这是比 TTL 更根本的安全网——TTL 管"谁能拿",栅栏管"拿过的人过期后不能碰"。 DeviceService.ExecuteCommandAsync 入口 FenceCheck
时钟不一致(多机 NTP 漂移) expires 用本地时间比较会误判 ① 全部时间判定用 DB 侧时钟(datetime('now')),客户端只传相对 TTL("+30 seconds"),从不传绝对时间
② Phase 3 Broker 化后统一用 Broker 时钟
③ 多机 + 共享 DB 场景要求 NTP(运维项,误差<1s 即可)
SQL 写法约束(§5.3 全部合规)
心跳线程被 UI 卡顿饿死 WPF 主线程阻塞 / 进程假死 心跳用独立后台线程(非 UI Dispatcher),且心跳失败连续 2 次即本地熔断——宁可误停不可漏停 AutoDeviceLease 内 Timer 实现
重试不幂等(超时后重发"OUTP ON"是安全的,重发"计数器+1"不安全) 网络/DB 抖动自动重试 ① 命令分级:设备层可安全重发(设置型 SET xx=yy ✅ 幂等;累加/触发型 ⚠️ 禁止自动重试,上抛)
② debug_operation.operation_id 已是客户端生成 GUID → 作为幂等键 INSERT OR IGNORE,重复到达不二次执行
③ 老化场景的 SCPI/CAN 写命令多为设置型,天然幂等友好,但必须在驱动接口上用 CommandSemantics.Idempotent 显式声明
DeviceCommandResult + 驱动装饰器
SQLite 写竞争(N 个工位 × 心跳+申请集中写一行区域) 写锁串行化 WAL 模式 + busy_timeout=5000 + 事务只包住 2~3 条点写(<5ms);老化命令节奏(秒级)下并发写压力可忽略。真到高频(>100 写/秒)再迁服务端 DB 连接串 PRAGMA(见 §9 DbLeaseStore)
抢占风暴(两个高优请求同时到达) 并发 PendingHandover 标记 仲裁在 BEGIN IMMEDIATE 事务内串行判定;第二个维护请求会看到"已有维护排队"而被告知排队位置,不产生双 grace 竞争 事务天然覆盖

8. 独占 or 分时复用:设备分组与 Slot 绑定策略

8.1 设备访问模式(新增列,注册页可配)

模式语义租约形态典型设备(本项目)
Exclusive 独占 一个工位全程持有,测试期间别人碰不到 整步骤级长租约(TTL 靠心跳维持数小时) 工位专用电源、DUT 专用 CAN 卡通道
TimeShared 分时串行 多条命令排队过设备,每条原子 命令级短租约(秒级)或 Broker 队列 共享 DAQ 读数、公用万用表
SharedRead 共享读+独占写 监控随便读(旁路),写必须抢租约 读不走仲裁;写=Exclusive 租约 温箱(状态人人可看,设温必须独占)、CAN 总线监听

8.2 决策规则(新增 DeviceDefinition.access_mode 列,注册页下拉选择)

一问:"两个工位同时用这台设备,物理上/协议上会不会互相干扰?"
会(串口指令交叉、电源被改压)→ Exclusive;
不会但语义会乱(万用表 A 读数是 B 触发下产生的)→ TimeShared(串行队列);
读无害、写有害(温箱状态广播)→ SharedRead。
判据来源:设备传输层类型 + 协议是否支持会话隔离。CAN 卡默认 Exclusive(通道级可细分),TCP SCPI 看仪器连接数限制(多数 DMM 只允许 1 个 socket → 实质 Exclusive)。

8.3 工位 ↔ 设备分组:绑定到 Slot 体系

graph LR subgraph 台架["物理台架"] S1["Slot-A(工位1)"] --- S2["Slot-B(工位2)"] P1["PS-001 专用电源"] --- S1 C1["CAN-001 卡 CH0"] --- S1 C2["CAN-002 卡 CH1"] --- S2 T1["CHAMBER-01 共享温箱"] --- S1 & S2 end subgraph 数据["data.db 映射(已有表 + 新列)"] M1["SlotDeviceMapping
SlotId ↔ DeviceId+ChannelId"] G1["新增 device_group:
group_key → {resource_keys}
access_mode 按设备取"] end

9. 本仓库改造清单(具体到文件)

9.1 数据库 DDL(新增到 Plugin.DeviceConfig/Model/DeviceConfigModel.cs InitDatabase 的 sqls 列表)

-- ① 跨进程租约表(核心新表)
CREATE TABLE IF NOT EXISTS device_lease (
    resource_key     TEXT PRIMARY KEY,        -- 'CAN-001:Generic' 与 ResourceKey.ToString() 一致
    owner_id         TEXT NOT NULL,           -- 'Station3.Run20261103' 工位可读标识
    owner_type       INTEGER NOT NULL,        -- 0=Production 1=Maintenance 2=ReadOnly
    token_id         TEXT NOT NULL,           -- ResourceOwnerToken.TokenId
    generation       INTEGER NOT NULL,        -- 栅栏令牌:每次成功申请 +1(全局取 max+1)
    machine_name     TEXT NOT NULL DEFAULT '',
    host_process_id  INTEGER NOT NULL DEFAULT 0,
    slot_id          TEXT,                    -- 关联工位 Slot(组审计用)
    state            INTEGER NOT NULL DEFAULT 0,  -- 0=Active 1=PendingHandover
    acquired_at      TEXT NOT NULL,
    heartbeat_at     TEXT NOT NULL,
    expires_at       TEXT NOT NULL,           -- 全部由 DB datetime('now') 生成
    grace_until      TEXT,
    note             TEXT DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_lease_expire ON device_lease(expires_at);

-- ② 设备访问模式(现有表加列,老库幂等迁移)
ALTER TABLE DeviceDefinition ADD COLUMN access_mode INTEGER NOT NULL DEFAULT 0;
--    0=Exclusive 1=TimeShared 2=SharedRead

-- ③ 工位设备组(组租约的展开依据;也可不建表,运行时 JOIN SlotDeviceMapping)
CREATE TABLE IF NOT EXISTS device_group (
    group_key   TEXT PRIMARY KEY,     -- = SlotId 或自定义
    resource_key TEXT NOT NULL,
    order_hint  INTEGER DEFAULT 0,
    PRIMARY KEY (group_key, resource_key)
);

-- ④ debug_session 加 3 列(会话↔租约联动可视化)
ALTER TABLE debug_session ADD COLUMN lease_generation INTEGER;
ALTER TABLE debug_session ADD COLUMN lease_owner_type INTEGER;
ALTER TABLE debug_session ADD COLUMN heartbeat_at TEXT;

-- ⑤ debug_operation 加 2 列(命令级追溯)
ALTER TABLE debug_operation ADD COLUMN lease_generation INTEGER;
ALTER TABLE debug_operation ADD COLUMN idempotency_key TEXT;   -- 幂等重试去重
CREATE UNIQUE INDEX IF NOT EXISTS idx_op_dedup ON debug_operation(idempotency_key) WHERE idempotency_key != '';

9.2 接口与实现改动

文件改动类型内容
Aging.Infrastructure/Contracts/IResourceManager.cs 扩展(向后兼容) 新增 3 个方法,默认实现委托现有语义:
Task<bool> RenewAsync(ResourceOwnerToken, ResourceKey, int ttlSeconds, CancellationToken)
(bool valid, string? holderInfo, int? graceSeconds) ProbeConflict(ResourceKey) // UI 显示"被谁占用"
Task<GroupLeaseHandle> AcquireGroupAsync(string groupKey, ResourceOwnerToken owner, CancellationToken ct)
Aging.Infrastructure/Core/DbLeaseStore.cs ★新建 新增 §5.3/§5.4 的 SQL 封装;WAL/busy_timeout PRAGMA;懒回收;同机 PID 看门狗。约 250 行
Aging.Infrastructure/Core/ResourceManager.cs 改造为组合 保留内存字典 + SemaphoreSlim(快速路径);预约/校验转发 DbLeaseStore;离线模式(DB 不可达)降级为纯内存 + 每 5s 重连并在重连后栅栏自检
Aging.Infrastructure/Core/LeaseArbiter.cs ★新建 新增 §6.1 优先级矩阵的策略引擎:让渡/拒绝/排队判定,策略参数(grace、抢占开关)进配置
Aging.Infrastructure/Services/AutoDeviceFacade.cs 增强 ① ReserveCoreAsync 的 owner 标识从随机 Guid 改为可配置 StationGlobals.StationId(多机/多工位可读性);② AutoDeviceLease 构造函数启动后台心跳 Timer(TTL/3),Dispose 停止;③ 捕获 PendingHandover → TryYieldAtBoundary()
Aging.Infrastructure/Services/DeviceService.cs 增强 ValidateOwner 升级为 DB 栅栏校验(查 generation 匹配);不匹配返回 ErrorCode="LEASE_STALE" 结构化失败,不抛裸异常
Aging.Infrastructure/Drivers/DriverFactory.cs + 各驱动 Phase 3 新增 transport=="CAN-REMOTE" 等代理型驱动(把命令序列化为 Broker 请求);现有直连驱动不动。IBrokeredTransport 接口定义于 Contracts
Plugin.DeviceDebug/Model/DebugSessionManager.cs 微调 RequestMaintenance 处理 PendingHandover(转异步等待 + UI 回调"等待工位让渡,剩余 X 秒");冲突异常 message 已含占用者 → 直接给 ToastHelper
Plugin.DeviceDebug/Model/DeviceDebugModel.cs 微调 debug_session 新增列写入;启动时 SELECT ... WHERE state NOT IN (Closed) AND heartbeat过期 做孤儿会话回收(置 Faulted)
Plugin.DeviceConfig/Model/DeviceConfigModel.cs DDL §9.1 的建表/加列(幂等,IF NOT EXISTS / try-catch ALTER);DeviceEditModal.xaml(.cs) 增加 access_mode 下拉
Aging.Infrastructure/InfrastructureBootstrapper.cs 配置点 注册 LayeredResourceManager(带 dbPath)替代纯内存版;读 StationId;可选注册 BrokerClient 传输
★新建 DeviceBroker/ 控制台工程(Phase 3) 新增 引用 ConnectionUtil 现成 PipeServer;协议:{op, deviceId, channel, payload, token, gen, idemKey};内部按设备队列 + 复用 DriverFactory 创建真实驱动;崩溃看门狗(父进程句柄失联自动退出交 HMI 拉起)

9.3 关键接口签名(Phase 1 核心代码示例)

// DbLeaseStore —— 原子申请(对应 §5.3 SQL)
public sealed class DbLeaseStore
{
    public DbLeaseStore(string dataDbPath) { /* PRAGMA journal_mode=WAL; busy_timeout=5000 */ }

    public enum AcquireResult { Acquired, Renewed, QueuedHandover, RejectedMaintenanceHeld, RejectedByProductionHeld }

    public AcquireResult TryAcquire(string resourceKey, ResourceOwnerToken owner, OwnerType type,
                                string machine, int pid, string? slotId, int ttlSeconds);
    public bool  Renew(string resourceKey, string tokenId, long generation, int ttlSeconds);
    public bool  Release(string resourceKey, string tokenId);
    public long  CurrentGeneration(string resourceKey);          // FenceCheck 用
    public int   SweepExpired();                                   // 懒回收兜底
    public (bool valid, string? holder, int? graceSec) ProbeConflict(string resourceKey);
}

// AutoDeviceLease 心跳(Phase 1)——独立线程,UI 卡顿不影响
_beacon = new Timer(_ => {
    try {
        if (!_store.Renew(DeviceId, _owner.TokenId, _owner.Generation, _ttl))
            { LeaseLost?.Invoke(this, EventArgs.Empty); return; }
        if (_store.IsPendingHandover(DeviceId)) _handoverTcs.TrySetResult(true);
        _failCount = 0;
    } catch { if (++_failCount >= 2) LeaseLost?.Invoke(this, EventArgs.Empty); }
}, null, _ttlMs/3, _ttlMs/3);

// TestStep 基类在步骤边界让渡(集成测试演示用法)
if (lease.HandoverRequested) lease.Dispose();   // 步骤结束点检查,绝不在命令中途放手

10. 测试要点

10.1 单元(HMI.UnitTest 或新建 xUnit)

用例验证点
Acquire 并发 20 线程同 key恰好 1 个 Acquired,其余 Rejected/Renewed(同 token);generation 严格 +1 无跳变竞争
Renew 用旧 generation返回 false(栅栏语义)
TTL 过期 → 直接 Acquire 过期持有者的 key懒回收生效:先 DELETE 过期行再插入成功
仲裁矩阵全遍历(3×3 持有×请求)§6.1 每个格子一个断言
组申请部分冲突全回滚,不留下"占了一半"的行

10.2 多进程集成(利用 Plugin.IntegrationTest 扩展,重点)

在"全自动测试" Tab 增加 【多进程压力用例】按钮:
// 伪代码:主进程 fork N 个自身 --station-worker 模式的子进程
for (int s = 0; s < 4; s++)
    Process.Start(exe, $"--worker {s} --device CAN-001 --loops 200");

// 子 worker 逻辑:循环 [TryAcquire → 发一帧 → 校验 → Dispose],统计成功轮转次数
// 主进程断言:① 任意时刻 device_lease 只有 1 行有效 ② 所有帧的 generation 单调 ③ 无双写
//           ④ 中途 Kill(随机 worker) → 其余 worker ≤ TTL+5s 内接管,测试继续收敛为 PASS
额外两个杀手级用例:

11. 分阶段实施计划与工作量

阶段交付物解决的痛点工作量
Phase 1
跨进程租约
device_lease DDL;DbLeaseStore;ResourceManager 组合化;心跳 + 栅栏 + 懒回收;Bootstrapper 切换;多进程集成测试 多进程互斥仲裁 ✅ 崩溃自愈 ✅ 审计可追溯 ✅ 上层零改动 ✅ 3~4 人日
Phase 2
仲裁规则 & UI
LeaseArbiter 优先级矩阵;维护抢占/让渡流程;DebugSessionManager PendingHandover;HMI"占用者信息/强制释放";access_mode 注册页 + 组租约 AcquireGroupAsync "人在接线程序不能动"硬保证 ✅ 工位分组 ✅ 2~3 人日
Phase 3
Device Broker(按需触发)
DeviceBroker 工程(复用 ConnectionUtil 管道);代理型驱动 *BrokeredDriver;多机接入;命令队列 + TimeShared 策略落地 串口/USB 句柄独占根治 ✅ 多机共享 ✅ 分时命令串行化 ✅ 5~8 人日
Phase 4
演进(可选)
驱动迁移为 OpenTAP Instrument 插件包(.opentap);Bench 配置与 DeviceConfig 单源化;HMI 内嵌驱动管理(Image API) 厂商驱动生态复用、分发运维 另评估
触发 Phase 3 的判断标准(避免过度设计):出现以下任一情况才立项——①串口/USB 设备租约易手频率高到 close/open 抖动成为瓶颈;②确有 2 台以上电脑需要操作同一台物理仪器;③出现 ≥1 台 TimeShared 命令级串行设备有并行排队需求。否则 Phase 1+2 已闭环主要风险。

12. 风险、反模式与不推荐做法

反模式后果正确做法
多进程各自 open 同一 COM 口/CAN 卡句柄"碰运气"WriteFile 交错、数据撕裂、蓝屏级未定义行为要么租约保证单持有者(Phase 1),要么 Broker 唯一持有(Phase 3)。物理层不允许并发是硬约束,任何软件方案不能挑战
仅用 Named Mutex 当租约崩溃后接管方对设备状态全盲(输出可能还开着)Mutex 只配做同机快速信号,状态与交接语义必须在 DB 表(§3 方案②评估)
网络共享盘(SMB)上放 data.db 供多机写SQLite 锁在 SMB 语义不可靠 → 双持有者 = 真实炸机风险多机场景:Phase 3 Broker(每台物理机一份 DB)或租约表迁到服务端 SQL
客户端自己算 expires_at 传绝对时间时钟漂移导致提前/延迟回收只传相对 TTL,DB 侧 datetime('now') 计算(§7)
TTL 设 5 分钟"求稳"崩溃后其他工位干等 5 分钟TTL 30s + 心跳 10s;同机 PID 看门狗秒级兜底
抢占发生在一条多字节 CAN 事务中间设备卡在半包状态让渡点严格对齐 TestStep 边界/单条命令完成(§5.4 铁律③)
维护模式不做栅栏校验"反正有 UI 弹窗"UI 弹窗可被忽略,程序 bug 绕不过驱动入口 FenceCheck 是物理闸门(§7 僵尸行),弹窗只是体验层
心跳挂在 UI DispatcherWPF 主线程卡顿→假死误判被抢占独立后台线程(§9.3 代码已合规)

已知局限(诚实声明)

13. 结论

  1. 两类场景分别治理:进程内多线程继续用现有内存锁 + OpenTAP 同步;跨进程才是真正缺口,不要用一个机制硬吃两个场景。
  2. 主体方案 = SQLite 租约表(TTL+心跳+generation 栅栏+懒回收),作为 IResourceManager 的跨进程实现,上层 IAutoDeviceFacade/AutoDeviceLease/DebugSessionManager 接口签名与业务代码零改动(Phase 1,3~4 人日)。
  3. 安全硬保证靠栅栏,不靠礼貌:每条命令驱动入口校验 generation,实现"人在接线时程序物理发不出指令"(§6 信息+§7 僵尸行)。抢占/让渡只在 TestStep 边界,grace 默认 30s。
  4. Device Broker(Phase 3)仅在触发条件出现时立项:串口/USB 句柄抖动、多机共测、分时排队——三者任一。传输层直接复用仓库现成的 ConnectionUtil.PipeServer/PipeClient,不必引入 WCF/gRPC。
  5. OpenTAP 生态解决的是"分发与形态"而非"运行时仲裁"——跨进程仲裁是它的空白,你们的 IResourceManager + 租约表恰好补位,这是本项目差异化资产;长期把物理仪器包装成 OpenTAP Instrument 插件包与租约体系并行不悖。
  6. 独占 vs 分时用 access_mode 显式建模(注册页可选),工位分组复用 SlotDeviceMapping + 组租约按 key 字典序原子申请,杜绝死锁。
  7. 崩溃残留是唯一真正难的问题,TTL+看门狗+懒回收三层冗余解决;测试用 Kill 进程演练接管收敛(§10.2),上线前必须过。