Chapter 7 外部意图

最理想的情况当然是把所有的处理都放到串行轮询这个LOOP的中第二部分:

\[ \boxed{ \boxed{ \text{1. 读入数据} } \ \boxed{ \text{2. 处理逻辑} } \ \boxed{ \text{3. 刷写底层} } } \]

这样一来,各个ILogicet实例会以固定的顺序串行执行。

但事实上很多时候,我们需要从这个LOOP 的外部改变状态。在这种场景下,如果直接尝试写入,会带来非常大的不确定性——你甚至都不能确定对应的脏数据有没有被刷写到底层:也许在LOOP的某一轮结束后,你恰好手动更新了测点;但在同一时刻,下一轮正在从底层硬件读取缓存……这种并行不确定性会给程序员带来极大的不安。于是我引入了如下机制,以让来自外部的更改和自身的LOOP 串行执行:

\[ \boxed{ \boxed{ \text{0.执行意图} } \boxed{ \text{1. 读入数据} } \ \boxed{ \text{2. 处理逻辑} } \ \boxed{ \text{3. 刷写底层} } } \]

  1. \(\boxed{\text{执行意图}}\):在LOOP每一轮开始前,先处理队列头部的前几个意图,直到队列已空或者达到单轮处理的最大意图数量。完成之后,如果缓存区已脏,则立刻刷写底层。
  2. \(\boxed{\text{读入数据}}\): 从底层硬件读取数据到缓存区。
  3. \(\boxed{\text{处理逻辑}}\): 串行执行逻辑组件。
  4. \(\boxed{\text{刷写底层}}\): 刷写变更的点位到底层硬件。

意图入队

例如:

// 意图入队: 返回true 表示入队成功;如果入队失败,await t 将直接抛出异常
var res = proj.WriteIntent(
    "IoBox", 
    (entry, ct) => {
        ack.Value = true;
        return ValueTask.CompletedTask;
    }, 
    out var t
);

// 等待意图被执行完成
await t; 

注意,写意图本身只是将"意图"入队,out出的参数Task task并不会立刻被执行。意图一旦入队成功,则会在将来某个合适时间点被执行。你可以使用await task的手法,等待相关意图被执行完成,如果相关意图未能被正确执行,相关await会抛出异常。

意图执行和死锁陷阱

让我们重新审视这里的串行轮询机理:

\[ \boxed{ \boxed{ \text{0.执行意图} } \boxed{ \text{1. 读入数据} } \ \boxed{ \text{2. 处理逻辑} } \ \boxed{ \text{3. 刷写底层} } } \]

引入意图,是为了在原有的扫描循环之外,增加一种可以临时改变Tag的手段。但是如果意图执行等待得不恰当,有可能会引发死锁。

考虑这样一个情况:

  1. (循环内部)某个Logicet中,捕获到相关业务A的请求信号(req)。该Logicet遂对外发布通知,要求处理。
  2. (循环外部)某处理器捕获到相同通知,执行完相关业务逻辑,最后写入意图,用以通知PLC开始另一个业务B。
  3. (循环内部)该Logicet await上一步完成,再向PLC反馈与业务A请求(req)相匹配的ack信号。

你能发现这里潜在的死锁吗?

重点看第3步:

  • 该Logicet在等待外部处理器执行完;
  • 而外部处理器向运行时写入了意图,然后干了什么?它在等待该意图被执行。

这里出现了环路等待的情况:Logicet在等外部处理器处理完;而外部处理器在等意图被执行完——但是,意图执行需要等到LOOP下一轮开始的时候才有可能被执行!

正确的做法有很多:

  • 比如让外部处理不要直接写入意图——而是把修改tags的动作改到循环内部。
  • 或者,如果你一定要在外部写入意图,应该让内部不再死等外部结束:
if (req.GetTagValue<bool>() && !ack.GetTagValue<bool>() && Interlocked.CompareExchange(ref isBusy, 1, 0) == 0)
{

    // 如果外部处理器也需要写入意图并等待意图被执行完成,那么这里就不要等待,尽早结束
    _ = Task.Run(async () =>
    {
        // 这里的代码脱离了`Logicet`,所有的变更应当使用`WriteIntent`机制
        try
        {
            // 等待外部处理完成
            var evt = new ScrewResultEvt(block.CompleteBlock.DataBlock.ToResult());
            await _mediator.Publish(evt);

            // 写意图:确认
            var res = ctrl.Project!.WriteIntent(
                "IoBox", (entry, ct) =>
                {
                    ack.Value = true;
                    return ValueTask.CompletedTask;
                }, 
                out var t
            );
            await t;
        }
        catch (Exception ex)
        {
            // ... 错误处理
        }
        finally
        {
            var busy = Interlocked.CompareExchange(ref isBusy, 0, 1);
        }
    });
}
else if (!req.GetTagValue<bool>())
{
    ack.Value = false;
}

我个人更推荐尽量在Logicet内部进行信号处理,比起WriteIntent,Logicet结构清晰,而且执行时机固定、可预测。

还记得我们在逻辑组件中提到的注意点吗? 所有逻辑组件都是以预先设定的顺序串行轮询的,所以,在任何时候,都应该让ProcessAsync(entry, thisChannel)这个方法尽早结束,否则,串行约束会让后续的逻辑组件长时间得不到执行。

这里外部意图也有类似的要求——在任何时候,都应该让TagGrpWriteIntent这个委托尽早结束,否则,串行约束会让后续的逻辑组件长时间得不到执行。

.IsEnabled对外部意图的影响

ITagGrp和ITagCbnt都提供了.IsEabled属性,用来在自动轮询中跳过一些群组和分组。

在外部意图中,如果是通过ITagGrp.WrirteAsync(ct)和ITagCbnt.WriteAsync(ct)方法来写入数据到底层,通常没什么问题;但是,如果通过setter修改属性.Value,则新值会等待到下一轮循环执行才会刷新到底层。