<!DOCTYPE html>
|
<html lang="zh-CN">
|
<head>
|
<meta charset="UTF-8">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<title>多进程/多线程场景下的仪器资源独占与共享方案</title>
|
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
<style>
|
* { box-sizing: border-box; }
|
body {
|
font-family: -apple-system, "Segoe UI", "Microsoft YaHei", "PingFang SC", sans-serif;
|
max-width: 1240px; margin: 0 auto; padding: 32px 24px 80px;
|
background: #FAFAFA; color: #212121; line-height: 1.75;
|
}
|
h1 { font-size: 29px; color: #1565C0; border-bottom: 3px solid #1976D2; padding-bottom: 12px; }
|
h2 { font-size: 23px; color: #0D47A1; margin-top: 42px; padding-left: 12px; border-left: 4px solid #FF9800; }
|
h3 { font-size: 17px; color: #37474F; margin-top: 26px; }
|
h4 { font-size: 15px; color: #455A64; margin-top: 18px; }
|
.subtitle { color: #757575; font-size: 14px; margin-bottom: 24px; }
|
.card { background: white; border-radius: 8px; padding: 18px 22px; margin: 16px 0; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
|
.highlight { background: #FFF8E1; border-left: 4px solid #FFC107; padding: 12px 16px; margin: 12px 0; border-radius: 4px; }
|
.info { background: #E3F2FD; border-left: 4px solid #2196F3; padding: 12px 16px; margin: 12px 0; border-radius: 4px; }
|
.success { background: #E8F5E9; border-left: 4px solid #4CAF50; padding: 12px 16px; margin: 12px 0; border-radius: 4px; }
|
.warn { background: #FBE9E7; border-left: 4px solid #FF5722; padding: 12px 16px; margin: 12px 0; border-radius: 4px; }
|
table { width: 100%; border-collapse: collapse; margin: 16px 0; background: white; border-radius: 6px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,0.06); font-size: 13.5px; }
|
th { background: #1976D2; color: white; padding: 9px 12px; text-align: left; font-weight: 600; }
|
td { padding: 8px 12px; border-bottom: 1px solid #EEEEEE; vertical-align: top; }
|
tr:nth-child(even) td { background: #FAFAFA; }
|
tr:hover td { background: #E3F2FD; }
|
code { font-family: "Consolas", "Monaco", monospace; background: #F5F5F5; color: #C62828; padding: 2px 6px; border-radius: 3px; font-size: 13px; }
|
pre { background: #263238; color: #ECEFF1; padding: 16px 20px; border-radius: 6px; overflow-x: auto; font-family: "Consolas", monospace; font-size: 13px; line-height: 1.55; }
|
pre code { background: transparent; color: inherit; padding: 0; font-size: 13px; }
|
.mermaid { background: white; padding: 20px; border-radius: 8px; margin: 20px 0; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,0.06); overflow-x: auto; }
|
ul { padding-left: 22px; } li { margin: 4px 0; }
|
.toc { background: #F5F5F5; padding: 16px 24px; border-radius: 6px; margin: 20px 0; }
|
.toc a { color: #1976D2; text-decoration: none; display: block; padding: 3px 0; }
|
.toc a:hover { text-decoration: underline; }
|
.kwd { color: #FFCC80; } .str { color: #C5E1A5; } .cmt { color: #546E7A; } .fn { color: #82AAFF; }
|
.good { color: #2E7D32; font-weight: 600; } .bad { color: #C62828; font-weight: 600; } .mid { color: #EF6C00; font-weight: 600; }
|
footer { margin-top: 60px; padding-top: 20px; border-top: 1px solid #E0E0E0; color: #757575; font-size: 12px; text-align: center; }
|
.path { font-family: Consolas, monospace; font-size: 12.5px; background: #ECEFF1; color: #37474F; padding: 1px 6px; border-radius: 3px; }
|
</style>
|
</head>
|
<body>
|
|
<h1>多进程 / 多线程场景下的仪器资源独占与共享方案</h1>
|
<div class="subtitle">TestPlatform Aging Software · SQLite 设备注册 + Aging.Infrastructure 租约 + OpenTAP 序列执行器 三栈融合设计 · 2026-11-03</div>
|
|
<div class="toc">
|
<strong>目录</strong>
|
<a href="#s1">1. 现状盘点与问题定义</a>
|
<a href="#s2">2. 两类并发场景的严格区分</a>
|
<a href="#s3">3. 五个候选方案逐一评估与横向对比</a>
|
<a href="#s4">4. 推荐目标架构:分层混合(租约表 + 仲裁规则 + 按需 Broker)</a>
|
<a href="#s5">5. 租约完整生命周期:申请 / 续期 / 释放 / 超时抢占</a>
|
<a href="#s6">6. 全自动 vs 手动维护的互斥仲裁规则</a>
|
<a href="#s7">7. 异常场景处理:死锁 / 孤儿锁 / 时钟 / 幂等</a>
|
<a href="#s8">8. 独占 or 分时复用:设备分组与 Slot 绑定策略</a>
|
<a href="#s9">9. 本仓库改造清单(具体文件路径 + 接口签名 + DDL)</a>
|
<a href="#s10">10. 测试要点:单元 / 多进程集成 / 崩溃演练</a>
|
<a href="#s11">11. 分阶段实施计划与工作量估算</a>
|
<a href="#s12">12. 风险、反模式与不推荐做法</a>
|
<a href="#s13">13. 结论</a>
|
</div>
|
|
<!-- ================================================================= -->
|
<h2 id="s1">1. 现状盘点与问题定义</h2>
|
|
<div class="card">
|
<strong>先说结论:你们的架构已经"半只脚"踩在正确方案上。</strong>
|
<code>ResourceOwnerToken</code>(含 Generation 世代号)、<code>reservation_ref</code> 字段、<code>IAutoDeviceLease</code> 作用域——这些概念与工业界标准的"租约(Lease)+ 栅栏令牌(Fencing Token)"模型一一对应。唯一的问题是:<strong>整套机制活在内存里</strong>。
|
</div>
|
|
<h3>1.1 现状代码事实核查</h3>
|
<table>
|
<tr><th>组件</th><th>文件</th><th>现状</th><th>跨进程能力</th></tr>
|
<tr>
|
<td>ResourceManager</td>
|
<td><span class="path">Aging.Infrastructure/Core/ResourceManager.cs</span></td>
|
<td><code>ConcurrentDictionary</code> 存预约 + <code>SemaphoreSlim(1,1)</code> 每资源细锁;Generation 全局自增</td>
|
<td class="bad">❌ 纯进程内存,其他进程完全不可见</td>
|
</tr>
|
<tr>
|
<td>ResourceOwnerToken</td>
|
<td><span class="path">Contracts/ResourceOwnerToken.cs</span></td>
|
<td>TokenId + OwnerType(Production/Maintenance) + Generation + IssuedAt/ExpiresAt</td>
|
<td class="mid">⚠️ 结构可序列化,但目前没有跨进程校验路径</td>
|
</tr>
|
<tr>
|
<td>AutoDeviceFacade / Lease</td>
|
<td><span class="path">Services/AutoDeviceFacade.cs</span></td>
|
<td>Reserve → DeviceService 分派 → finally Release;Lease 作用域复用</td>
|
<td class="bad">❌ 依赖内存版 ResourceManager</td>
|
</tr>
|
<tr>
|
<td>DeviceService</td>
|
<td><span class="path">Services/DeviceService.cs</span></td>
|
<td>执行前 <code>ValidateOwner</code>(内存查表)→ <code>AcquireOperationAsync</code> → driver 下发</td>
|
<td class="bad">❌ 校验仅本进程有效</td>
|
</tr>
|
<tr>
|
<td>DebugSessionManager</td>
|
<td><span class="path">Plugin.DeviceDebug/Model/DebugSessionManager.cs</span></td>
|
<td>RequestMaintenance 已捕获 <code>ResourceConflictException</code>,并把 <code>reservation.ReservationId</code> 写入 <code>session.ReservationRef</code> 持久化</td>
|
<td class="mid">⚠️ 已预留挂接点,差一个"活的"仲裁源</td>
|
</tr>
|
<tr>
|
<td>debug_session 表</td>
|
<td><span class="path">Plugin.DeviceDebug/Model/DeviceDebugModel.cs</span></td>
|
<td>有 <code>reservation_ref TEXT</code>、<code>state</code>、<code>closed_at</code>,无心跳/过期列</td>
|
<td class="mid">⚠️ 可扩展</td>
|
</tr>
|
<tr>
|
<td>IPC 设施</td>
|
<td><span class="path">ConnectionUtil/PipeServer.cs · PipeClient.cs</span></td>
|
<td>命名管道(Message 模式)已实现:多实例服务端、连接事件、请求-响应 SendMessage(带超时)</td>
|
<td class="good">✅ 可直接作为 Device Broker 的传输层,无需新造轮子</td>
|
</tr>
|
<tr>
|
<td>OpenTAP SynchronizationManager</td>
|
<td>OpenTAP 9.31 引擎</td>
|
<td>进程内 <code>RequestLock(resource, timeout)</code> 按仪器互斥</td>
|
<td class="bad">❌ 官方文档明确:仅进程内</td>
|
</tr>
|
</table>
|
|
<h3>1.2 问题本质拆解</h3>
|
<div class="highlight">
|
"多工位共用一台仪器"其实包含 <strong>三个不同层面</strong>的问题,必须分开解决,混在一起谈必然方案走形:
|
<ol>
|
<li><strong>仲裁(Arbitration)</strong>:谁<em>有权</em>在这台仪器上发命令?→ 需要跨进程可见、可持久化、崩溃可回收的<strong>租约状态</strong>。</li>
|
<li><strong>连接独占(Connection)</strong>:物理句柄(COM 口、USB、CAN 卡)同一时刻只能被一个进程 <code>open</code>;网口仪器则允许同机多连接但需要"逻辑独占"语义。→ 需要把<strong>连接持有者与租约持有者绑定</strong>,必要时引入代理进程。</li>
|
<li><strong>命令串行化(Serialization)</strong>:分时复用型设备(如共享温箱),允许多工位排队下发原子命令片段。→ 需要<strong>队列/栅栏令牌</strong>,而非独占租约。</li>
|
</ol>
|
</div>
|
|
<!-- ================================================================= -->
|
<h2 id="s2">2. 两类并发场景的严格区分</h2>
|
|
<div class="mermaid">
|
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["其他机器上的执行节点<br/>(多机共测一台仪器)"]
|
end
|
subgraph 解决手段["分别对应的手段"]
|
S1["OpenTAP SynchronizationManager<br/>+ 现有内存版 ResourceManager<br/>(保持不变 ✅)"]
|
S2["持久化租约表(DB Lease)<br/>+ Fencing Generation<br/>+ 心跳 TTL + 仲裁策略<br/>(本次新建 ⭐)"]
|
end
|
场景A --> S1
|
场景B --> S2
|
</div>
|
|
<table>
|
<tr><th>维度</th><th>场景 A:进程内多线程</th><th>场景 B:跨进程/跨机器</th></tr>
|
<tr><td>典型触发</td><td>TestPlan 中 Parallel 步骤 / MultiSite 同时跑不同分支</td><td>多工位独立进程、HMI 与自动测试并行、CI 节点</td></tr>
|
<tr><td>互斥原语</td><td>SemaphoreSlim / lock / SynchronizationManager.RequestLock</td><td>OS 命名内核对象 / DB 租约 / Broker 队列</td></tr>
|
<tr><td>崩溃表现</td><td>进程死 → 锁随进程消失(内核回收)</td><td class="bad">持有者死亡后锁<strong>残留</strong>,必须靠 TTL/看门狗回收</td></tr>
|
<tr><td>可见性</td><td>天然可见(同一堆)</td><td>必须外部化状态(DB / Broker / 共享内存)</td></tr>
|
<tr><td>你们的现状</td><td class="good">已解决(ResourceManager 内存版即是干这个的)</td><td class="bad">完全未解决——本次方案的主战场</td></tr>
|
</table>
|
|
<div class="info"><strong>设计原则:场景 A 用轻量内存锁(快),场景 B 用持久化租约(可靠),两层复合成一个 <code>LayeredResourceManager</code>,对上层 <code>IAutoDeviceFacade</code> 完全透明。</strong></div>
|
|
<!-- ================================================================= -->
|
<h2 id="s3">3. 五个候选方案逐一评估与横向对比</h2>
|
|
<h3>方案 ①:全部收敛到单进程 + OpenTAP 内置同步</h3>
|
<div class="card">
|
把所有工位执行体都塞进一个主进程(一个 TestPlan 跑多分支),完全依赖 <code>SynchronizationManager</code>。
|
<br><br>
|
<span class="mid">评估:</span>物理上最省事,<strong>但违反老化测试的独立故障域原则</strong>——一个工位 <code>StackOverflow</code>/原生驱动崩溃拖死全部 N 个工位;老化工龄动辄几十小时,进程级隔离是刚需。内存泄漏在多进程下可以按工位轮换回收,单进程无解。
|
<br><strong>结论:仅作为兜底简化形态(单工位小型机台可用),不作为多工位目标架构。</strong>
|
</div>
|
|
<h3>方案 ②:命名内核对象(Named Mutex / Named Semaphore)</h3>
|
<div class="card">
|
<code>new Mutex(false, "Global\\Lease_CAN-001")</code>,跨进程抢内核对象。
|
<br><br>
|
<strong>优点:</strong>实现成本≈0;进程死亡时内核自动释放 Abandoned(同机可靠)。
|
<br><strong>致命缺陷:</strong>
|
<ul>
|
<li><strong>无语义状态</strong>:Mutex 只知道"谁拿着",不知道"拿去干什么、什么时候该还"——<code>AbandonedMutexException</code> 时上一个持有者<em>操作到哪一步了</em>完全不可知,对"人在接线/输出已打开"这类安全状态是盲区;</li>
|
<li><strong>无优先级仲裁</strong>:维护模式抢占生产、优雅交接(handover)都表达不了;</li>
|
<li><strong>无审计</strong>:谁在什么时间持有了多久,内核对象不记录,与你们 <code>debug_operation</code> 审计体系脱节;</li>
|
<li><strong>不跨机器</strong>;<code>Global\</code> 命名在 Windows 服务/会话隔离下还有权限坑。</li>
|
</ul>
|
<strong>结论:不能单独成立;可以作为方案③的"同机加速信号"做混合(见 §4 分层设计)。不推荐用 NamedEvent 轮询模拟租约(竞态多、调试地狱)。</strong>
|
</div>
|
|
<h3>方案 ③:数据库租约表(Lease + TTL + 心跳 + 崩溃回收)⭐ 推荐主体</h3>
|
<div class="card">
|
把租约状态持久化到 <code>data.db</code> 新表 <code>device_lease</code>,用 <code>BEGIN IMMEDIATE</code> 事务实现跨进程原子的"比较并换入"(CAS),TTL 过期即被视为崩溃可回收,Generation 作栅栏令牌防"僵尸持有者"。
|
<br><br>
|
<strong>与现有 IResourceManager 的衔接:</strong>接口签名<strong>一个都不改</strong>。新增 <code>DbLeaseStore</code> 内部类,<code>ResourceManager</code> 升级为组合模式:内存字典继续做本进程快速路径与信号量互斥,预约/校验同时读写 DB 表。上层 <code>AutoDeviceFacade / DeviceService / DebugSessionManager / IAutoDeviceLease</code> 零改动,只换 Bootstrapper 里的注册实例。
|
<br><strong>限制:</strong>SQLite 是单机文件库——多机共享需把该表迁到服务端数据库(或走方案④ Broker 代理),且 SQLite 写并发要靠 WAL + 短事务,租约操作设计成毫秒级点查/点写,实测支撑每工位秒级命令流毫无压力。
|
<strong>结论:跨进程仲裁的最佳性价比,作为主体方案。</strong>
|
</div>
|
|
<h3>方案 ④:独立设备代理进程(Device Broker + IPC)</h3>
|
<div class="card">
|
一台机器上跑一个 <code>DeviceBroker.exe</code>,<strong>所有物理连接(COM/USB/CAN 卡句柄、SCPI TCP socket)只由它持有</strong>;各工位进程通过命名管道把命令转发给 Broker 排队执行。仓库现成的 <span class="path">ConnectionUtil/PipeServer·PipeClient</span> 即可做传输层。
|
<br><br>
|
<strong>解决的核心痛点(租约表解决不了的):</strong>
|
<ul>
|
<li><strong>句柄独占</strong>:串口/USB 不允许第二个进程 <code>CreateFile</code>。若仅靠租约,每次租约易手都要 close→open 一次,USB 重枚举可达秒级且丢缓冲;Broker 让连接<strong>永不换手</strong>,切换的只是"逻辑所有权";</li>
|
<li><strong>多机共享</strong>:A 机插着 CAN 卡,B/C 机的测试也能经 TCP/管道让 Broker 代发——Broker 天然是跨机网关;</li>
|
<li><strong>命令级串行化</strong>:分时复用设备(共享温箱)在 Broker 内是单队列,比租约抢占高效得多。</li>
|
</ul>
|
<strong>代价</strong>:多一个常驻进程要运维(看门狗、升级、日志);所有 I/O 多一跳 IPC(~毫秒级,老化场景可忽略);开发量最大。
|
<strong>结论:串口/USB/CAN 卡这类"单机物理独占"设备的<strong>终局方案</strong>;但建议 Phase 3 再上,Phase 1 用租约表 + "租约持有期内保持连接"即可满足大部分场景。</strong>
|
</div>
|
|
<h3>方案 ⑤:直接复用 OpenTAP 生态能力</h3>
|
<div class="card">
|
逐项核查 OpenTAP 现成能力能否覆盖跨进程需求(详细分析见姊妹篇 <a href="./OpenTAP_Instrument_Package_Analysis.html">《OpenTAP Instrument + 插件包机制分析》§9</a>):
|
<table>
|
<tr><th>OpenTAP 能力</th><th>是什么</th><th>能否解决跨进程仲裁</th></tr>
|
<tr><td><code>Instrument : Resource</code> 资源模型</td><td>仪器生命周期托管 + 编辑器绑定</td><td class="bad">❌ 实例生命周期仅进程内;不感知其他进程</td></tr>
|
<tr><td><code>SynchronizationManager</code></td><td>进程内按仪器互斥锁</td><td class="bad">❌ 设计目标就是单进程</td></tr>
|
<tr><td>ResultProxy / 远程执行(<code>tap run --remote</code>、Session.Local)</td><td>把一个 TestPlan 切分到远端执行节点,结果回流</td><td class="mid">⚠️ 解决"执行分布",不解决"两台机抢一台仪器"——每个远端 session 各自还是单进程视野</td></tr>
|
<tr><td>BenchStore / ResourceSettings(9.23+)</td><td>台架资源配置持久化(XML)</td><td class="mid">⚠️ 配置可以共享,<strong>仲裁状态不行</strong>(无 TTL/心跳概念)</td></tr>
|
<tr><td>私有包仓库分发</td><td>驱动以 <code>.opentap</code> 包安装</td><td class="good">✅ 解决驱动分发(与并发仲裁正交,值得并行推进)</td></tr>
|
<tr><td><code>IResourcePlan</code>/ResourceConflicts(9.20+)</td><td>资源规划/冲突<strong>静态检查</strong>(编辑器里提示"两个序列用了同一仪器")</td><td class="mid">⚠️ 只能提前发现冲突,运行时多进程并发仍不仲裁</td></tr>
|
</table>
|
<strong>结论:OpenTAP 生态没有任何一块能直接替代跨进程运行时仲裁——这是开源测试框架的普遍空白,也正是你们 IResourceManager 体系的战略价值。能复用的是:分发(包机制)、配置模型(BenchStore 思路)、以及把最终驱动做成 Instrument 插件的长期形态。</strong>
|
</div>
|
|
<h3>横向对比总表</h3>
|
<table>
|
<tr><th>维度</th><th>①单进程收敛</th><th>②Named Mutex</th><th>③DB 租约表 ⭐</th><th>④Device Broker</th><th>⑤OpenTAP 现成</th></tr>
|
<tr><td>并发安全(跨进程)</td><td class="bad">伪命题(无多进程)</td><td class="mid">互斥可以,但无状态语义</td><td class="good">强一致(DB 事务 CAS)</td><td class="good">单点串行化,最强</td><td class="bad">不支持</td></tr>
|
<tr><td>崩溃残留处理</td><td class="good">进程死=锁消失</td><td class="mid">Abandoned 可感知但盲接</td><td class="good">TTL 自动过期 + 回收</td><td class="mid">Broker 死=全停(需看门狗)</td><td>—</td></tr>
|
<tr><td>优先级/抢占/优雅交接</td><td class="mid">代码可实现</td><td class="bad">表达不了</td><td class="good">状态列+grace 字段原生支持</td><td class="good">队列策略任意定制</td><td class="bad">无</td></tr>
|
<tr><td>审计可追溯</td><td class="bad">无</td><td class="bad">无</td><td class="good">表即历史(可挂 debug_operation)</td><td class="good">Broker 全权日志</td><td class="mid">ResultProxy 有结果无仲裁</td></tr>
|
<tr><td>性能开销</td><td class="good">纳秒级</td><td class="good">微秒级</td><td class="mid">毫秒级(WAL 点写)</td><td class="mid">毫秒级(IPC 一跳)</td><td>—</td></tr>
|
<tr><td>实现成本</td><td class="good">0(重构架构为负)</td><td class="good">0.5 人日</td><td class="good">3~4 人日</td><td class="mid">5~8 人日</td><td class="good">已内建/不适用</td></tr>
|
<tr><td>可扩展到多机</td><td class="bad">否</td><td class="bad">否</td><td class="mid">需换服务端 DB</td><td class="good">是(Broker=网关)</td><td class="mid">部分</td></tr>
|
<tr><td>物理句柄独占问题</td><td class="good">天然规避</td><td class="bad">不解决(close/open 抖动)</td><td class="mid">租约期内保连接可缓解</td><td class="good">根治(唯一持有者)</td><td class="bad">不解决</td></tr>
|
<tr><td>调试难度</td><td class="good">低</td><td class="mid">内核对象黑盒</td><td class="good">低(一张表全透明,SELECT 即现场)</td><td class="mid">需双进程联调</td><td>—</td></tr>
|
<tr><td>与现有代码衔接</td><td class="mid">大改</td><td class="mid">需新增旁路</td><td class="good">IResourceManager 实现层替换,上层零改</td><td class="mid">Driver 层拆分转发端/宿主端</td><td class="good">正交复用</td></tr>
|
</table>
|
|
<!-- ================================================================= -->
|
<h2 id="s4">4. 推荐目标架构:分层混合(租约表 + 仲裁规则 + 按需 Broker)</h2>
|
|
<div class="mermaid">
|
graph TB
|
subgraph L4["上层调用(保持不变)"]
|
TS["OpenTAP TestStep<br/>(全自动 · IAutoDeviceLease 作用域)"]
|
DBG["DeviceDebug UI<br/>(手动维护 · DebugSessionManager)"]
|
end
|
subgraph L3["门面层 Aging.Infrastructure/Services"]
|
FAC["AutoDeviceFacade<br/>(租约申请/心跳/栅栏校验)"]
|
SVC["DeviceService<br/>(命令分派 + owner 校验升级为跨进程)"]
|
end
|
subgraph L2["仲裁层(本次核心改造)"]
|
LRM["LayeredResourceManager : IResourceManager"]
|
MEM["进程内层:SemaphoreSlim + 内存缓存<br/>(复用现有 ResourceManager 逻辑)"]
|
DBL["跨进程层:DbLeaseStore<br/>device_lease 表 · BEGIN IMMEDIATE CAS"]
|
POL["仲裁策略引擎:优先级/抢占/grace"]
|
LRM --> MEM & DBL & POL
|
end
|
subgraph L1["物理连接层(Phase 3 按需)"]
|
OWN["本进程直连:网口仪器(SCPI/TCP/CAN总线模拟)<br/>租约=逻辑独占,句柄随租约开关"]
|
BRK["DeviceBroker.exe:串口/USB/CAN卡唯一持有者<br/>复用 ConnectionUtil.PipeServer/PipeClient"]
|
end
|
TS & DBG --> FAC --> SVC --> LRM
|
DBL --> SQLITE[("data.db<br/>device_lease 表<br/>WAL 模式")]
|
SVC --> OWN & BRK
|
</div>
|
|
<h3>4.1 分层职责与决策规则</h3>
|
<table>
|
<tr><th>层</th><th>解决什么</th><th>何时启用</th></tr>
|
<tr><td>内存层(已有)</td><td>同进程多线程细锁、快速失败</td><td class="good">始终(零成本)</td></tr>
|
<tr><td>DB 租约层(新增)</td><td>跨进程运行时仲裁、优先级抢占、审计、崩溃回收</td><td class="good">Phase 1 —— 主体投入</td></tr>
|
<tr><td>Broker 层(新增)</td><td>物理句柄独占、多机共享、命令队列</td><td class="mid">Phase 3 —— 仅当出现:① 串口/USB 重开成本不可接受 ② 多机共测一台机上的设备 ③ 分时复用设备要排队</td></tr>
|
<tr><td>OpenTAP 生态</td><td>驱动分发(包)、配置共享(Bench 思路)</td><td class="mid">与 Phase 1/3 正交并行推进</td></tr>
|
</table>
|
|
<!-- ================================================================= -->
|
<h2 id="s5">5. 租约完整生命周期:申请 / 续期 / 释放 / 超时抢占</h2>
|
|
<h3>5.1 租约状态机</h3>
|
<div class="mermaid">
|
stateDiagram-v2
|
[*] --> Free: device_lease 无行/已回收
|
Free --> Active: Acquire(事务CAS成功<br/>generation++ 写入 owner)
|
Active --> Active: Heartbeat 续期<br/>(extends expires_at)
|
Active --> PendingHandover: 更高优先级申请到达<br/>(Maintenance 请求,写 grace 截止)
|
PendingHandover --> Released: 持有者优雅让渡<br/>(完成当前 TestStep 后 Release)
|
PendingHandover --> ForceReleased: grace 超时<br/>(请求者可抢占 generation++)
|
Active --> Expired: 心跳断(进程崩溃/断网)<br/>expires_at < DB now
|
Expired --> Free: 回收(任何 Acquire 顺带 DELETE<br/>+ 后台 Sweep 兜底)
|
Active --> Released: 正常 Dispose/ReleaseReservation
|
Released --> Free
|
</div>
|
|
<h3>5.2 申请 + 抢占 + 栅栏拒绝时序</h3>
|
<div class="mermaid">
|
sequenceDiagram
|
autonumber
|
participant P1 as 工位进程1<br/>(Production 租约持有者)
|
participant DB as device_lease 表<br/>(SQLite WAL)
|
participant HMI as 工位进程2<br/>(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,<br/>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
|
</div>
|
|
<h3>5.3 核心 SQL(原子申请,一次事务完成回收+检查+写入)</h3>
|
<pre><code>BEGIN IMMEDIATE; <span class="cmt">-- 拿写锁:跨进程串行化入口(SQLite WAL 下读写不互斥,写写排队)</span>
|
|
<span class="cmt">-- ① 顺带回收所有已过期租约(懒回收,无需独立守护也能自愈)</span>
|
DELETE FROM device_lease
|
WHERE resource_key = @rk AND expires_at < datetime('now');
|
|
<span class="cmt">-- ② 检查当前有效占用</span>
|
SELECT owner_id, owner_type, state, grace_until INTO @cur FROM device_lease WHERE resource_key = @rk;
|
|
<span class="cmt">-- ③ 无占用 → 插入新租约(generation 取全局序)</span>
|
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'));
|
|
<span class="cmt">-- ④ 有占用且请求者是 Maintenance → 不直接抢,标记让渡窗口</span>
|
UPDATE device_lease SET state = 1, grace_until = datetime('now','+30 seconds')
|
WHERE resource_key = @rk AND state = 0;
|
|
COMMIT;</code></pre>
|
|
<h3>5.4 续期(心跳)与失效判定</h3>
|
<pre><code><span class="cmt">// AutoDeviceLease 内部:TTL/3 周期定时器(Phase 1 即实现)</span>
|
<span class="kwd">var</span> affected = db.<span class="fn">ExecuteNonQuery</span>(<span class="str">@"
|
UPDATE device_lease
|
SET heartbeat_at = datetime('now'),
|
expires_at = datetime('now', '+30 seconds'),
|
state = CASE WHEN state = 1 THEN state ELSE 0 END <span class="cmt">-- 保留让渡标记</span>
|
WHERE resource_key = @rk AND token_id = @token AND generation = @gen"</span>);
|
|
<span class="kwd">if</span> (affected == 0) {
|
<span class="cmt">// 行不在了或 generation 已被超越 ⇒ 我已被抢占/回收 —— 本地立即熔断:</span>
|
<span class="cmt">// 1) 停止下发任何命令 2) 释放物理连接 3) 会话标记 RecoveryRequired 4) 上报一次告警</span>
|
<span class="fn">OnLeaseLost</span>();
|
}
|
<span class="kwd">if</span> (db.<span class="fn">ReadState</span>() == PendingHandover && InStepBoundary) {
|
<span class="fn">Dispose</span>(); <span class="cmt">// 优雅让渡:只在 TestStep 边界释放,绝不在一条命令中间放手</span>
|
}</code></pre>
|
|
<div class="highlight"><strong>三条铁律:</strong>① 所有时间戳一律用 <code>datetime('now')</code>(DB 时钟),客户端时间仅作参考列;② 每条命令下发前必须 <code>FenceCheck</code>(一行点查,成本≈1ms);③ 让渡/释放只发生在 TestStep 边界,保证设备不处于"半条指令"状态。</div>
|
|
<!-- ================================================================= -->
|
<h2 id="s6">6. 全自动 vs 手动维护的互斥仲裁规则</h2>
|
|
<p>目标场景:工程师正打开机柜接 CAN 线(维护模式),全自动程序绝不能突然下发 <code>OUTP ON</code>。规则明确定义如下(策略引擎集中实现于新增 <span class="path">Aging.Infrastructure/Core/LeaseArbiter.cs</span>):</p>
|
|
<h3>6.1 优先级矩阵(行=当前持有者,列=新请求者)</h3>
|
<table>
|
<tr><th></th><th>请求:Production(全自动)</th><th>请求:Maintenance(手动维护)</th><th>请求:ReadOnly(监控/看数据)</th></tr>
|
<tr>
|
<td><strong>持有:Production</strong></td>
|
<td class="mid">排队(FIFO 或按工位优先级,带超时)</td>
|
<td class="mid"><strong>让渡流程</strong>:PendingHandover + grace 30s;超时未让渡 → 允许强占 + 旧持有者栅栏失效</td>
|
<td class="good">放行(不建租约,走旁路只读)</td>
|
</tr>
|
<tr>
|
<td><strong>持有:Maintenance</strong></td>
|
<td class="bad"><strong>直接拒绝</strong>(ResourceConflictException,"有人在维护";UI 弹窗告知工程师)</td>
|
<td class="bad">拒绝(第二个人也不许碰——一台设备同时只允许一个维护会话,复用现有 OpenSession 重复检查逻辑)</td>
|
<td class="good">放行</td>
|
</tr>
|
<tr>
|
<td><strong>持有:ReadOnly</strong></td>
|
<td class="good">立即放行(读锁不构成障碍)</td>
|
<td class="good">立即放行</td>
|
<td class="good">放行</td>
|
</tr>
|
</table>
|
|
<div class="info"><strong>与现有代码的挂接点:</strong><span class="path">DebugSessionManager.RequestMaintenance()</span> 已经做了 <code>ReserveAsync</code> + 捕获 <code>ResourceConflictException</code> → 只需把内存版换成 Layered 版即自动获得跨进程拒绝;<span class="path">ValidateCommand()</span> 已有"MaintenanceActive + ValidateOwner"双重检查,把 <code>ValidateOwner</code> 改为查 DB 栅栏,就得到<strong>"人在接线时程序连命令都发不出"</strong>的硬保证——即使自动程序逻辑有 bug。</div>
|
|
<h3>6.2 维护模式退出与自动流程恢复</h3>
|
<ul>
|
<li>工程师退出维护(<span class="path">ExitMaintenanceBtn_Click → CloseSession</span>):释放行 + <code>UPDATE debug_session SET state=Closed</code>(现有逻辑保持);</li>
|
<li>自动工位对 PendingHandover 的感知延迟 ≤ 心跳周期(10s),老化长步骤(小时级)不适用抢占——对 <code>grace</code> 内未完成的步骤,策略改为<strong>"拒绝进入,已运行不打断"</strong>(可配置:安全类工位选择强占+设备紧急安全态;产能类工位选择排队等待);</li>
|
<li>HMI 侧建议:设备被自动占用时,维护申请显示"占用者:Station-3 · 已运行 01:23:45 · 预计剩余 02:10"(数据全在 <code>device_lease</code> + <code>debug_session</code>,一条 JOIN 即可)。</li>
|
</ul>
|
|
<h3>6.3 审计闭环</h3>
|
<ul>
|
<li><code>device_lease</code> 表本身就是"占用历史"(Release 时改写 <code>released_at</code> 再保留 7 天,或双表:当前 + 归档);</li>
|
<li>每条经 <code>DeviceService</code> 的命令继续写 <code>debug_operation</code>,新增两列 <code>owner_type, lease_generation</code>,即可回答"这条 OUTP ON 是哪个工位、哪一代持有者发的"——事故追溯闭环。</li>
|
</ul>
|
|
<!-- ================================================================= -->
|
<h2 id="s7">7. 异常场景处理:死锁 / 孤儿锁 / 时钟 / 幂等</h2>
|
|
<table>
|
<tr><th>异常场景</th><th>成因</th><th>对策</th><th>机制落点</th></tr>
|
<tr>
|
<td><strong>多设备死锁</strong>(工位A锁{电源}等{CAN},B反序)</td>
|
<td>组申请加锁顺序不一致</td>
|
<td class="good">① 全局强制按 <code>resource_key</code> 字典序申请(AcquireGroupAsync 内部排序)<br>② 事务内"全有或全无":任一冲突则整体回滚重试(<strong>wait-die</strong>:新请求者退避重试,老持有者不被饿死)<br>③ 组申请总超时(默认 60s)</td>
|
<td>AutoDeviceFacade 新增 <code>AcquireGroupAsync</code></td>
|
</tr>
|
<tr>
|
<td><strong>进程崩溃 → 孤儿锁</strong></td>
|
<td>kill / BSOD / 断电,Release 没跑</td>
|
<td class="good">① TTL 30s 自动过期(任何 Acquire 顺带 DELETE 过期行 = 懒回收)<br>② 同机看门狗:lease 记录 <code>machine_name + host_process_id</code>,本机新进程启动时枚举存活 PID 提前回收(不用等 TTL)<br>③ HMI"强制释放"按钮(需 ADMIN 角色,写审计 note="force_release")</td>
|
<td>device_lease 列 + Bootstrapper 启动自检</td>
|
</tr>
|
<tr>
|
<td><strong>僵尸持有者</strong>(网络分区/GC停顿后复活继续发命令)</td>
|
<td>进程活着但被抢占过</td>
|
<td class="good">栅栏令牌:命令必须携带 <code>(token_id, generation)</code>,DB 中 generation 已变 → 驱动入口直接拒绝(§5.2 时序 15-17 步)。<strong>这是比 TTL 更根本的安全网</strong>——TTL 管"谁能拿",栅栏管"拿过的人过期后不能碰"。</td>
|
<td>DeviceService.ExecuteCommandAsync 入口 FenceCheck</td>
|
</tr>
|
<tr>
|
<td><strong>时钟不一致</strong>(多机 NTP 漂移)</td>
|
<td>expires 用本地时间比较会误判</td>
|
<td class="good">① 全部时间判定用 DB 侧时钟(<code>datetime('now')</code>),客户端只传相对 TTL("+30 seconds"),从不传绝对时间<br>② Phase 3 Broker 化后统一用 Broker 时钟<br>③ 多机 + 共享 DB 场景要求 NTP(运维项,误差<1s 即可)</td>
|
<td>SQL 写法约束(§5.3 全部合规)</td>
|
</tr>
|
<tr>
|
<td><strong>心跳线程被 UI 卡顿饿死</strong></td>
|
<td>WPF 主线程阻塞 / 进程假死</td>
|
<td class="good">心跳用<strong>独立后台线程</strong>(非 UI Dispatcher),且心跳失败连续 2 次即本地熔断——宁可误停不可漏停</td>
|
<td>AutoDeviceLease 内 Timer 实现</td>
|
</tr>
|
<tr>
|
<td><strong>重试不幂等</strong>(超时后重发"OUTP ON"是安全的,重发"计数器+1"不安全)</td>
|
<td>网络/DB 抖动自动重试</td>
|
<td class="good">① 命令分级:设备层可安全重发(设置型 SET xx=yy ✅ 幂等;累加/触发型 ⚠️ 禁止自动重试,上抛)<br>② <code>debug_operation.operation_id</code> 已是客户端生成 GUID → 作为幂等键 INSERT OR IGNORE,重复到达不二次执行<br>③ 老化场景的 SCPI/CAN 写命令多为设置型,天然幂等友好,但必须在驱动接口上用 <code>CommandSemantics.Idempotent</code> 显式声明</td>
|
<td>DeviceCommandResult + 驱动装饰器</td>
|
</tr>
|
<tr>
|
<td><strong>SQLite 写竞争</strong>(N 个工位 × 心跳+申请集中写一行区域)</td>
|
<td>写锁串行化</td>
|
<td class="good">WAL 模式 + busy_timeout=5000 + 事务只包住 2~3 条点写(<5ms);老化命令节奏(秒级)下并发写压力可忽略。真到高频(>100 写/秒)再迁服务端 DB</td>
|
<td>连接串 PRAGMA(见 §9 DbLeaseStore)</td>
|
</tr>
|
<tr>
|
<td><strong>抢占风暴</strong>(两个高优请求同时到达)</td>
|
<td>并发 PendingHandover 标记</td>
|
<td class="good">仲裁在 <code>BEGIN IMMEDIATE</code> 事务内串行判定;第二个维护请求会看到"已有维护排队"而被告知排队位置,不产生双 grace 竞争</td>
|
<td>事务天然覆盖</td>
|
</tr>
|
</table>
|
|
<!-- ================================================================= -->
|
<h2 id="s8">8. 独占 or 分时复用:设备分组与 Slot 绑定策略</h2>
|
|
<h3>8.1 设备访问模式(新增列,注册页可配)</h3>
|
<table>
|
<tr><th>模式</th><th>语义</th><th>租约形态</th><th>典型设备(本项目)</th></tr>
|
<tr>
|
<td><code>Exclusive</code> 独占</td>
|
<td>一个工位全程持有,测试期间别人碰不到</td>
|
<td>整步骤级长租约(TTL 靠心跳维持数小时)</td>
|
<td>工位专用电源、DUT 专用 CAN 卡通道</td>
|
</tr>
|
<tr>
|
<td><code>TimeShared</code> 分时串行</td>
|
<td>多条命令排队过设备,每条原子</td>
|
<td>命令级短租约(秒级)或 Broker 队列</td>
|
<td>共享 DAQ 读数、公用万用表</td>
|
</tr>
|
<tr>
|
<td><code>SharedRead</code> 共享读+独占写</td>
|
<td>监控随便读(旁路),写必须抢租约</td>
|
<td>读不走仲裁;写=Exclusive 租约</td>
|
<td>温箱(状态人人可看,设温必须独占)、CAN 总线监听</td>
|
</tr>
|
</table>
|
|
<h3>8.2 决策规则(新增 <code>DeviceDefinition.access_mode</code> 列,注册页下拉选择)</h3>
|
<div class="card">
|
<b>一问:</b>"两个工位<strong>同时</strong>用这台设备,物理上/协议上会不会互相干扰?"<br>
|
<b>会</b>(串口指令交叉、电源被改压)→ <code>Exclusive</code>;<br>
|
<b>不会但语义会乱</b>(万用表 A 读数是 B 触发下产生的)→ <code>TimeShared</code>(串行队列);<br>
|
<b>读无害、写有害</b>(温箱状态广播)→ <code>SharedRead</code>。<br>
|
<b>判据来源:</b>设备传输层类型 + 协议是否支持会话隔离。CAN 卡默认 Exclusive(通道级可细分),TCP SCPI 看仪器连接数限制(多数 DMM 只允许 1 个 socket → 实质 Exclusive)。
|
</div>
|
|
<h3>8.3 工位 ↔ 设备分组:绑定到 Slot 体系</h3>
|
<div class="mermaid">
|
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<br/>SlotId ↔ DeviceId+ChannelId"]
|
G1["新增 device_group:<br/>group_key → {resource_keys}<br/>access_mode 按设备取"]
|
end
|
</div>
|
|
<ul>
|
<li><strong>复用现有 <span class="path">SlotDeviceMapping</span> 表</strong>:工位身份 = <code>SlotId</code>。工位进程启动参数带 SlotId → 组租约自动展开为该 Slot 绑定的全部设备 resource_key 列表(按字典序申请);</li>
|
<li><strong>组申请原子化</strong>:新增 <code>AcquireGroupAsync(groupKey, ...)</code>:查 <code>device_group</code>(或即时 JOIN SlotDeviceMapping)→ 排序 → 单事务逐台 INSERT,任一冲突整体回滚 → 避免"占了一半"的半死状态;</li>
|
<li><strong>共享设备不进组</strong>:<code>CHAMBER-01</code> 这类跨 Slot 设备在 <code>DeviceDefinition.access_mode</code> 标 TimeShared/SharedRead,组申请时跳过,命令执行时才抢短租约;</li>
|
<li><strong>CAN 多工位共卡</strong>:resource key 细化到 <code>CAN_CARD01:CH2</code>——<code>ResourceKey</code> 本来就支持 Channel(见 <span class="path">Contracts/ResourceKey.cs</span>),CAN 帧 ID 级再细分可用 <code>resource_type='CAN_FRAME'</code> + channel=帧段(Broker 阶段实现命令级串行化时够用)。</li>
|
</ul>
|
|
<!-- ================================================================= -->
|
<h2 id="s9">9. 本仓库改造清单(具体到文件)</h2>
|
|
<h3>9.1 数据库 DDL(新增到 <span class="path">Plugin.DeviceConfig/Model/DeviceConfigModel.cs</span> InitDatabase 的 sqls 列表)</h3>
|
<pre><code><span class="cmt">-- ① 跨进程租约表(核心新表)</span>
|
CREATE TABLE IF NOT EXISTS device_lease (
|
resource_key TEXT PRIMARY KEY, <span class="cmt">-- 'CAN-001:Generic' 与 ResourceKey.ToString() 一致</span>
|
owner_id TEXT NOT NULL, <span class="cmt">-- 'Station3.Run20261103' 工位可读标识</span>
|
owner_type INTEGER NOT NULL, <span class="cmt">-- 0=Production 1=Maintenance 2=ReadOnly</span>
|
token_id TEXT NOT NULL, <span class="cmt">-- ResourceOwnerToken.TokenId</span>
|
generation INTEGER NOT NULL, <span class="cmt">-- 栅栏令牌:每次成功申请 +1(全局取 max+1)</span>
|
machine_name TEXT NOT NULL DEFAULT '',
|
host_process_id INTEGER NOT NULL DEFAULT 0,
|
slot_id TEXT, <span class="cmt">-- 关联工位 Slot(组审计用)</span>
|
state INTEGER NOT NULL DEFAULT 0, <span class="cmt">-- 0=Active 1=PendingHandover</span>
|
acquired_at TEXT NOT NULL,
|
heartbeat_at TEXT NOT NULL,
|
expires_at TEXT NOT NULL, <span class="cmt">-- 全部由 DB datetime('now') 生成</span>
|
grace_until TEXT,
|
note TEXT DEFAULT ''
|
);
|
CREATE INDEX IF NOT EXISTS idx_lease_expire ON device_lease(expires_at);
|
|
<span class="cmt">-- ② 设备访问模式(现有表加列,老库幂等迁移)</span>
|
ALTER TABLE DeviceDefinition ADD COLUMN access_mode INTEGER NOT NULL DEFAULT 0;
|
<span class="cmt">-- 0=Exclusive 1=TimeShared 2=SharedRead</span>
|
|
<span class="cmt">-- ③ 工位设备组(组租约的展开依据;也可不建表,运行时 JOIN SlotDeviceMapping)</span>
|
CREATE TABLE IF NOT EXISTS device_group (
|
group_key TEXT PRIMARY KEY, <span class="cmt">-- = SlotId 或自定义</span>
|
resource_key TEXT NOT NULL,
|
order_hint INTEGER DEFAULT 0,
|
PRIMARY KEY (group_key, resource_key)
|
);
|
|
<span class="cmt">-- ④ debug_session 加 3 列(会话↔租约联动可视化)</span>
|
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;
|
|
<span class="cmt">-- ⑤ debug_operation 加 2 列(命令级追溯)</span>
|
ALTER TABLE debug_operation ADD COLUMN lease_generation INTEGER;
|
ALTER TABLE debug_operation ADD COLUMN idempotency_key TEXT; <span class="cmt">-- 幂等重试去重</span>
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_op_dedup ON debug_operation(idempotency_key) WHERE idempotency_key != '';</code></pre>
|
|
<h3>9.2 接口与实现改动</h3>
|
<table>
|
<tr><th>文件</th><th>改动类型</th><th>内容</th></tr>
|
<tr>
|
<td><span class="path">Aging.Infrastructure/Contracts/IResourceManager.cs</span></td>
|
<td>扩展(向后兼容)</td>
|
<td>新增 3 个方法,默认实现委托现有语义:<br>
|
<code>Task<bool> RenewAsync(ResourceOwnerToken, ResourceKey, int ttlSeconds, CancellationToken)</code><br>
|
<code>(bool valid, string? holderInfo, int? graceSeconds) ProbeConflict(ResourceKey)</code> <span class="cmt">// UI 显示"被谁占用"</span><br>
|
<code>Task<GroupLeaseHandle> AcquireGroupAsync(string groupKey, ResourceOwnerToken owner, CancellationToken ct)</code></td>
|
</tr>
|
<tr>
|
<td><span class="path">Aging.Infrastructure/Core/DbLeaseStore.cs</span> ★新建</td>
|
<td>新增</td>
|
<td>§5.3/§5.4 的 SQL 封装;WAL/busy_timeout PRAGMA;懒回收;同机 PID 看门狗。约 250 行</td>
|
</tr>
|
<tr>
|
<td><span class="path">Aging.Infrastructure/Core/ResourceManager.cs</span></td>
|
<td>改造为组合</td>
|
<td>保留内存字典 + SemaphoreSlim(快速路径);预约/校验转发 <code>DbLeaseStore</code>;离线模式(DB 不可达)降级为纯内存 + 每 5s 重连并在重连后栅栏自检</td>
|
</tr>
|
<tr>
|
<td><span class="path">Aging.Infrastructure/Core/LeaseArbiter.cs</span> ★新建</td>
|
<td>新增</td>
|
<td>§6.1 优先级矩阵的策略引擎:让渡/拒绝/排队判定,策略参数(grace、抢占开关)进配置</td>
|
</tr>
|
<tr>
|
<td><span class="path">Aging.Infrastructure/Services/AutoDeviceFacade.cs</span></td>
|
<td>增强</td>
|
<td>① <code>ReserveCoreAsync</code> 的 owner 标识从随机 Guid 改为可配置 <code>StationGlobals.StationId</code>(多机/多工位可读性);② <code>AutoDeviceLease</code> 构造函数启动后台心跳 Timer(TTL/3),Dispose 停止;③ 捕获 PendingHandover → <code>TryYieldAtBoundary()</code></td>
|
</tr>
|
<tr>
|
<td><span class="path">Aging.Infrastructure/Services/DeviceService.cs</span></td>
|
<td>增强</td>
|
<td><code>ValidateOwner</code> 升级为 DB 栅栏校验(查 generation 匹配);不匹配返回 <code>ErrorCode="LEASE_STALE"</code> 结构化失败,不抛裸异常</td>
|
</tr>
|
<tr>
|
<td><span class="path">Aging.Infrastructure/Drivers/DriverFactory.cs</span> + 各驱动</td>
|
<td>Phase 3</td>
|
<td>新增 <code>transport=="CAN-REMOTE"</code> 等代理型驱动(把命令序列化为 Broker 请求);现有直连驱动不动。<code>IBrokeredTransport</code> 接口定义于 Contracts</td>
|
</tr>
|
<tr>
|
<td><span class="path">Plugin.DeviceDebug/Model/DebugSessionManager.cs</span></td>
|
<td>微调</td>
|
<td><code>RequestMaintenance</code> 处理 <code>PendingHandover</code>(转异步等待 + UI 回调"等待工位让渡,剩余 X 秒");冲突异常 message 已含占用者 → 直接给 ToastHelper</td>
|
</tr>
|
<tr>
|
<td><span class="path">Plugin.DeviceDebug/Model/DeviceDebugModel.cs</span></td>
|
<td>微调</td>
|
<td>debug_session 新增列写入;启动时 <code>SELECT ... WHERE state NOT IN (Closed) AND heartbeat过期</code> 做孤儿会话回收(置 Faulted)</td>
|
</tr>
|
<tr>
|
<td><span class="path">Plugin.DeviceConfig/Model/DeviceConfigModel.cs</span></td>
|
<td>DDL</td>
|
<td>§9.1 的建表/加列(幂等,IF NOT EXISTS / try-catch ALTER);<span class="path">DeviceEditModal.xaml(.cs)</span> 增加 access_mode 下拉</td>
|
</tr>
|
<tr>
|
<td><span class="path">Aging.Infrastructure/InfrastructureBootstrapper.cs</span></td>
|
<td>配置点</td>
|
<td>注册 <code>LayeredResourceManager</code>(带 dbPath)替代纯内存版;读 <code>StationId</code>;可选注册 BrokerClient 传输</td>
|
</tr>
|
<tr>
|
<td>★新建 <span class="path">DeviceBroker/</span> 控制台工程(Phase 3)</span></td>
|
<td>新增</td>
|
<td>引用 ConnectionUtil 现成 PipeServer;协议:<code>{op, deviceId, channel, payload, token, gen, idemKey}</code>;内部按设备队列 + 复用 DriverFactory 创建真实驱动;崩溃看门狗(父进程句柄失联自动退出交 HMI 拉起)</td>
|
</tr>
|
</table>
|
|
<h3>9.3 关键接口签名(Phase 1 核心代码示例)</h3>
|
<pre><code><span class="cmt">// DbLeaseStore —— 原子申请(对应 §5.3 SQL)</span>
|
<span class="kwd">public sealed class</span> DbLeaseStore
|
{
|
<span class="kwd">public</span> DbLeaseStore(<span class="kwd">string</span> dataDbPath) { <span class="cmt">/* PRAGMA journal_mode=WAL; busy_timeout=5000 */</span> }
|
|
<span class="kwd">public enum</span> AcquireResult { Acquired, Renewed, QueuedHandover, RejectedMaintenanceHeld, RejectedByProductionHeld }
|
|
<span class="kwd">public</span> AcquireResult TryAcquire(<span class="kwd">string</span> resourceKey, ResourceOwnerToken owner, OwnerType type,
|
<span class="kwd">string</span> machine, <span class="kwd">int</span> pid, <span class="kwd">string</span>? slotId, <span class="kwd">int</span> ttlSeconds);
|
<span class="kwd">public bool</span> Renew(<span class="kwd">string</span> resourceKey, <span class="kwd">string</span> tokenId, <span class="kwd">long</span> generation, <span class="kwd">int</span> ttlSeconds);
|
<span class="kwd">public bool</span> Release(<span class="kwd">string</span> resourceKey, <span class="kwd">string</span> tokenId);
|
<span class="kwd">public long</span> CurrentGeneration(<span class="kwd">string</span> resourceKey); <span class="cmt">// FenceCheck 用</span>
|
<span class="kwd">public int</span> SweepExpired(); <span class="cmt">// 懒回收兜底</span>
|
<span class="kwd">public</span> (bool valid, <span class="kwd">string</span>? holder, <span class="kwd">int</span>? graceSec) ProbeConflict(<span class="kwd">string</span> resourceKey);
|
}
|
|
<span class="cmt">// AutoDeviceLease 心跳(Phase 1)——独立线程,UI 卡顿不影响</span>
|
_beacon = <span class="kwd">new</span> Timer(_ => {
|
<span class="kwd">try</span> {
|
<span class="kwd">if</span> (!_store.Renew(DeviceId, _owner.TokenId, _owner.Generation, _ttl))
|
{ LeaseLost?.Invoke(<span class="kwd">this</span>, EventArgs.Empty); <span class="kwd">return</span>; }
|
<span class="kwd">if</span> (_store.IsPendingHandover(DeviceId)) _handoverTcs.TrySetResult(<span class="kwd">true</span>);
|
_failCount = 0;
|
} <span class="kwd">catch</span> { <span class="kwd">if</span> (++_failCount >= 2) LeaseLost?.Invoke(<span class="kwd">this</span>, EventArgs.Empty); }
|
}, <span class="kwd">null</span>, _ttlMs/3, _ttlMs/3);
|
|
<span class="cmt">// TestStep 基类在步骤边界让渡(集成测试演示用法)</span>
|
<span class="kwd">if</span> (lease.HandoverRequested) lease.Dispose(); <span class="cmt">// 步骤结束点检查,绝不在命令中途放手</span></code></pre>
|
|
<!-- ================================================================= -->
|
<h2 id="s10">10. 测试要点</h2>
|
|
<h3>10.1 单元(<span class="path">HMI.UnitTest</span> 或新建 xUnit)</h3>
|
<table>
|
<tr><th>用例</th><th>验证点</th></tr>
|
<tr><td>Acquire 并发 20 线程同 key</td><td>恰好 1 个 Acquired,其余 Rejected/Renewed(同 token);generation 严格 +1 无跳变竞争</td></tr>
|
<tr><td>Renew 用旧 generation</td><td>返回 false(栅栏语义)</td></tr>
|
<tr><td>TTL 过期 → 直接 Acquire 过期持有者的 key</td><td>懒回收生效:先 DELETE 过期行再插入成功</td></tr>
|
<tr><td>仲裁矩阵全遍历(3×3 持有×请求)</td><td>§6.1 每个格子一个断言</td></tr>
|
<tr><td>组申请部分冲突</td><td>全回滚,不留下"占了一半"的行</td></tr>
|
</table>
|
|
<h3>10.2 多进程集成(利用 <span class="path">Plugin.IntegrationTest</span> 扩展,重点)</h3>
|
<div class="card">
|
在"全自动测试" Tab 增加 <strong>【多进程压力用例】</strong>按钮:
|
<pre><code><span class="cmt">// 伪代码:主进程 fork N 个自身 --station-worker 模式的子进程</span>
|
<span class="kwd">for</span> (<span class="kwd">int</span> s = 0; s < 4; s++)
|
Process.Start(exe, $<span class="str">"--worker {s} --device CAN-001 --loops 200"</span>);
|
|
<span class="cmt">// 子 worker 逻辑:循环 [TryAcquire → 发一帧 → 校验 → Dispose],统计成功轮转次数</span>
|
<span class="cmt">// 主进程断言:① 任意时刻 device_lease 只有 1 行有效 ② 所有帧的 generation 单调 ③ 无双写</span>
|
<span class="cmt">// ④ 中途 Kill(随机 worker) → 其余 worker ≤ TTL+5s 内接管,测试继续收敛为 PASS</span></code></pre>
|
额外两个杀手级用例:
|
<ul>
|
<li><strong>崩溃孤儿锁</strong>:<code>Process.Kill</code> 持有者(不跑 Dispose)→ 断言 ≤35s 后被其他工位自动接管;<code>host_process_id</code> 同机看门狗路径则 ≤2s 回收;</li>
|
<li><strong>维护抢占演练</strong>:worker A 长循环持租 → UI 发起 RequestMaintenance → 断言 grace 内让渡、Maintenance 获得、A 重申请被拒(RejectionMessage 含"维护");期间 A 若违规硬发命令 → 驱动返回 <code>LEASE_STALE</code>。</li>
|
</ul>
|
</div>
|
|
<!-- ================================================================= -->
|
<h2 id="s11">11. 分阶段实施计划与工作量</h2>
|
|
<table>
|
<tr><th>阶段</th><th>交付物</th><th>解决的痛点</th><th>工作量</th></tr>
|
<tr>
|
<td><strong>Phase 1<br>跨进程租约</strong></td>
|
<td>device_lease DDL;<code>DbLeaseStore</code>;<code>ResourceManager</code> 组合化;心跳 + 栅栏 + 懒回收;Bootstrapper 切换;多进程集成测试</td>
|
<td class="good">多进程互斥仲裁 ✅ 崩溃自愈 ✅ 审计可追溯 ✅ 上层零改动 ✅</td>
|
<td><strong>3~4 人日</strong></td>
|
</tr>
|
<tr>
|
<td><strong>Phase 2<br>仲裁规则 & UI</strong></td>
|
<td><code>LeaseArbiter</code> 优先级矩阵;维护抢占/让渡流程;<span class="path">DebugSessionManager</span> PendingHandover;HMI"占用者信息/强制释放";access_mode 注册页 + 组租约 <code>AcquireGroupAsync</code></td>
|
<td class="good">"人在接线程序不能动"硬保证 ✅ 工位分组 ✅</td>
|
<td><strong>2~3 人日</strong></td>
|
</tr>
|
<tr>
|
<td><strong>Phase 3<br>Device Broker(按需触发)</strong></td>
|
<td><span class="path">DeviceBroker</span> 工程(复用 ConnectionUtil 管道);代理型驱动 <code>*BrokeredDriver</code>;多机接入;命令队列 + TimeShared 策略落地</td>
|
<td class="good">串口/USB 句柄独占根治 ✅ 多机共享 ✅ 分时命令串行化 ✅</td>
|
<td><strong>5~8 人日</strong></td>
|
</tr>
|
<tr>
|
<td><strong>Phase 4<br>演进(可选)</strong></td>
|
<td>驱动迁移为 OpenTAP Instrument 插件包(.opentap);Bench 配置与 DeviceConfig 单源化;HMI 内嵌驱动管理(Image API)</td>
|
<td class="good">厂商驱动生态复用、分发运维</td>
|
<td>另评估</td>
|
</tr>
|
</table>
|
|
<div class="success"><strong>触发 Phase 3 的判断标准(避免过度设计):</strong>出现以下任一情况才立项——①串口/USB 设备租约易手频率高到 close/open 抖动成为瓶颈;②确有 2 台以上电脑需要操作同一台物理仪器;③出现 ≥1 台 TimeShared 命令级串行设备有并行排队需求。否则 Phase 1+2 已闭环主要风险。</div>
|
|
<!-- ================================================================= -->
|
<h2 id="s12">12. 风险、反模式与不推荐做法</h2>
|
|
<table>
|
<tr><th>反模式</th><th>后果</th><th>正确做法</th></tr>
|
<tr><td class="bad">多进程各自 open 同一 COM 口/CAN 卡句柄"碰运气"</td><td>WriteFile 交错、数据撕裂、蓝屏级未定义行为</td><td>要么租约保证单持有者(Phase 1),要么 Broker 唯一持有(Phase 3)。<strong>物理层不允许并发是硬约束,任何软件方案不能挑战</strong></td></tr>
|
<tr><td class="bad">仅用 Named Mutex 当租约</td><td>崩溃后接管方对设备状态全盲(输出可能还开着)</td><td>Mutex 只配做同机快速信号,状态与交接语义必须在 DB 表(§3 方案②评估)</td></tr>
|
<tr><td class="bad">网络共享盘(SMB)上放 data.db 供多机写</td><td>SQLite 锁在 SMB 语义不可靠 → 双持有者 = 真实炸机风险</td><td>多机场景:Phase 3 Broker(每台物理机一份 DB)或租约表迁到服务端 SQL</td></tr>
|
<tr><td class="bad">客户端自己算 expires_at 传绝对时间</td><td>时钟漂移导致提前/延迟回收</td><td>只传相对 TTL,DB 侧 <code>datetime('now')</code> 计算(§7)</td></tr>
|
<tr><td class="bad">TTL 设 5 分钟"求稳"</td><td>崩溃后其他工位干等 5 分钟</td><td>TTL 30s + 心跳 10s;同机 PID 看门狗秒级兜底</td></tr>
|
<tr><td class="bad">抢占发生在一条多字节 CAN 事务中间</td><td>设备卡在半包状态</td><td>让渡点严格对齐 TestStep 边界/单条命令完成(§5.4 铁律③)</td></tr>
|
<tr><td class="bad">维护模式不做栅栏校验"反正有 UI 弹窗"</td><td>UI 弹窗可被忽略,程序 bug 绕不过</td><td>驱动入口 FenceCheck 是物理闸门(§7 僵尸行),弹窗只是体验层</td></tr>
|
<tr><td class="bad">心跳挂在 UI Dispatcher</td><td>WPF 主线程卡顿→假死误判被抢占</td><td>独立后台线程(§9.3 代码已合规)</td></tr>
|
</table>
|
|
<h3>已知局限(诚实声明)</h3>
|
<ul>
|
<li><strong>SQLite 单写者</strong>:租约写 QPS 上限约百级;老化节拍(秒级)远低于此,但高频遥测不要走租约表(走结果通路)。</li>
|
<li><strong>Phase 1 后多机仍受限</strong>:单机内多进程完全解决;多机需要把 <code>device_lease</code> 所在库放到服务器(表结构不变,<code>DbLeaseStore</code> 换连接工厂即可——这是 Phase 3 前的最小台阶)。</li>
|
<li><strong>Broker 单点</strong>:Phase 3 引入看门狗拉起;Broker 重启期间命令短暂不可用(设备本身不受影响)。</li>
|
</ul>
|
|
<!-- ================================================================= -->
|
<h2 id="s13">13. 结论</h2>
|
|
<div class="success">
|
<ol>
|
<li><strong>两类场景分别治理</strong>:进程内多线程继续用现有内存锁 + OpenTAP 同步;跨进程才是真正缺口,不要用一个机制硬吃两个场景。</li>
|
<li><strong>主体方案 = SQLite 租约表(TTL+心跳+generation 栅栏+懒回收)</strong>,作为 <code>IResourceManager</code> 的跨进程实现,上层 <code>IAutoDeviceFacade</code>/<code>AutoDeviceLease</code>/<code>DebugSessionManager</code> 接口签名与业务代码<strong>零改动</strong>(Phase 1,3~4 人日)。</li>
|
<li><strong>安全硬保证靠栅栏,不靠礼貌</strong>:每条命令驱动入口校验 generation,实现"人在接线时程序物理发不出指令"(§6 信息+§7 僵尸行)。抢占/让渡只在 TestStep 边界,grace 默认 30s。</li>
|
<li><strong>Device Broker(Phase 3)仅在触发条件出现时立项</strong>:串口/USB 句柄抖动、多机共测、分时排队——三者任一。传输层直接复用仓库现成的 <code>ConnectionUtil.PipeServer/PipeClient</code>,不必引入 WCF/gRPC。</li>
|
<li><strong>OpenTAP 生态解决的是"分发与形态"而非"运行时仲裁"</strong>——跨进程仲裁是它的空白,你们的 IResourceManager + 租约表恰好补位,这是本项目差异化资产;长期把物理仪器包装成 OpenTAP Instrument 插件包与租约体系并行不悖。</li>
|
<li><strong>独占 vs 分时用 <code>access_mode</code> 显式建模</strong>(注册页可选),工位分组复用 <code>SlotDeviceMapping</code> + 组租约按 key 字典序原子申请,杜绝死锁。</li>
|
<li><strong>崩溃残留是唯一真正难的问题,TTL+看门狗+懒回收三层冗余解决</strong>;测试用 Kill 进程演练接管收敛(§10.2),上线前必须过。</li>
|
</ol>
|
</div>
|
|
<footer>
|
多进程/多线程仪器资源独占与共享方案 · TapProject Aging Infrastructure 架构设计 · 2026-11-03<br>
|
关联文档:<a href="./OpenTAP_Instrument_Package_Analysis.html">OpenTAP Instrument + 插件包机制分析</a> ·
|
<a href="./DeviceConfig_DeviceDebug_Architecture.html">仪器注册与硬件调试调用关系</a>
|
</footer>
|
|
<script>
|
mermaid.initialize({
|
startOnLoad: true, theme: 'default', securityLevel: 'loose',
|
fontFamily: 'Segoe UI, Microsoft YaHei, sans-serif',
|
flowchart: { curve: 'basis', padding: 16 },
|
sequence: { showSequenceNumbers: false, actorMargin: 50 }
|
});
|
</script>
|
|
</body>
|
</html>
|