使用Async / Await在C#中模拟Actor

Actor模型是基于具有小单线程对象(Actor),可经由并行消息仅相互作用(相对于共享状态,并发的方法的锁条件变量等)。这种隔离不仅使得更容易推理Actor,而且允许不同的Actor分布在不同的机器上,而不需要对架构进行任何重大改变。Erlang是一种以可靠性和可扩展性着称的语言,它基于Actor模型,这并非巧合。

毫不奇怪,有几个项目涉及将Actor模型(或类似Actor的东西)带到.Net。有(取消的Axum语言(以前的DevLabs项目),任务并行库数据流库,并发和协调运行时,以及大量的小东西

在这篇文章中,我希望能够解释一个很好的方法来模拟C#中的Actor,并且最简单的方法。为此,我们将利用新的C#5 异步/等待功能。基本上:“消息”将是发布到消息队列的异步方法的调用和结果(在某些机制的帮助下)。

激励例子

让我们从目的地的一个例子开始。作为actor实现的队列:

public sealed class QueueActor {
    // (note: we need to implement the ActorSynchronizationContext class)
    private readonly ActorSynchronizationContext _messageQueue
        = new ActorSynchronizationContext();
    private readonly Queue _items = new Queue();

    public async Task EnqueueAsync(T item) {
        // (note: we need to implement a custom awaiter to allow awaiting the message queue)
        await _messageQueue;
        _items.Enqueue(item);
    }
    
    // (note: using my option type (see post) for the result)
    public async Task> TryDequeueAsync() {
        await _messageQueue;
        if (_items.Count == 0) return May.NoValue;
        return _items.Dequeue();
    }
}

此队列有两个公共方法:EnqueueAsync和DequeueAsync。两者都从等待消息队列开始。这将立即将任务结果返回给调用者,表示最终结果,并将方法的其余部分发布到消息队列中(在轮到它时执行)。EnqueueAsync最终会将一个项目放入队列,而DequeueAsync最终会尝试从队列中删除一个项目。如果任一方法都抛出异常,那么异常将传播到任务结果中。如果您之前使用过异步/等待功能,那么这里的所有内容都应该是熟悉的(除了等待某些不是任务的语义之外)。

使用此actor队列看起来与使用普通队列完全相同,除非您需要等待更改:

// the async keyword allows using await
// we return a task, instead of void, so callers can await us finishing
async Task UseQueue() {
    var q = new QueueActor;

    // sending messages and waiting for the responses
    await q.EnqueueAsync(1);
    May r1 = await q.TryDequeueAsync(); // r1 will contain 1
    May r2 = await q.TryDequeueAsync(); // r2 will contain no value

    // spamming messages, then later checking for the responses
    Task t3 = q.EnqueueAsync(2);
    Task> t4 = q.TryDequeueAsync();
    Task> t5 = q.TryDequeueAsync();
    await t3; // if our enqueue had failed somehow, this would rethrow the exception
    var r5 = await t5; // r5 will contain no value
    var r4 = await t4; // r4 will contain 2
}

此示例显示了与队列actor交互的两种方法。您可以逐个调用和等待方法。这使您可以使用中间结果来做出决策。或者,您可以调用一系列方法,然后只等待结果。这样更快,因为往返于演员的往返次数较少,但更容易出错。例如,忘记等待其中一个结果(如果它可能失败)会导致一个未终止程序的未观察到的异常(除非你有一个未观察到的任务异常处理程序设置)。此外,如果其中一个早期方法失败,您仍然会在序列中调用后面的方法(oops ...)。

因此,总结我们从这个例子中学到的东西,我们的演员将按如下方式工作:

  • actor使用消息队列来按顺序处理运行事务而不重叠(在其他一些线程上,不阻塞调用者)。
  • 消息由返回任务的方法表示。
  • 每个actor方法都从等待消息队列开始。这会导致方法的其余部分稍后运行。调用者将立即获得一个结果,一个任务,表示该方法的实际最终结果。
  • 呼叫者通过等待他们收到的任务(或使用ContinueWith / Wait)来访问对其消息的最终响应。

请注意,每个单独的actor方法实际上代表三个“消息”:来自调用者的请求(“请将项目排队”),稍后对调用者的响应(“我列入您的项目”),以及隐式等待完成 - 给来电者的消息(“自我注意:他们将我的项目排入队列,处理它”)。对消息进行这些不同的表示(方法调用,任务结果,隐式返回上下文)可能在理论上不干净,但在实践中它非常方便。能够等待结果允许使用看似惯用的C#,同时允许各种并发交互(在意外状态下意外结束的风险更小)。

为了使这一切工作,我们需要实现两件事:消息队列类型(ActorSynchronizationContext)和允许等待消息队列的自定义awaiter(SynchronizationContextAwaiter)。

Actor背景

在.Net中,同步上下文大致对应于运行方法的策略。例如,UI同步上下文将采用“已发布”方法并将它们排队以在UI线程上运行。另一方面,默认同步上下文在线程池上运行方法。

为什么同步上下文很重要?因为,默认情况下,等待任务将在同一上下文中继续执行。这意味着如果您处于UI上下文中并等待任务,则您的方法的其余部分仍将在UI上下文中执行。这比在每次异步操作之后手动调用回来更方便(就像以前一样)。由于我们的actor方法将返回任务,因此为每个actor提供同步上下文允许它们发送消息并等待响应而不会意外地离开它们各自的上下文。

参与者同步上下文必须满足的主要要求是排除。一次只能处理一条消息。我们所有的actor同步上下文实际上都会包装一个底层上下文,以确保一个接一个地运行已发布的方法而不是一次运行。有效地执行此操作非常棘手,但幸运的是,大多数非常棘手的东西已经打包到ConcurrentQueue类中。我们只需确保运行队列中的内容:

public sealed class ActorSynchronizationContext : SynchronizationContext {
    private readonly SynchronizationContext _subContext;
    private readonly ConcurrentQueue _pending = new ConcurrentQueue();
    private int _pendingCount;
    
    public ActorSynchronizationContext(SynchronizationContext subContext = null) {
        this._subContext = subContext ?? new SynchronizationContext();
    }

    public override void Post(SendOrPostCallback d, object state) {
        if (d == null) throw new ArgumentNullException("d");
        _pending.Enqueue(() => d(state));

        // trigger consumption when the queue was empty
        if (Interlocked.Increment(ref _pendingCount) == 1) 
            _subContext.Post(Consume, null);
    }
    private void Consume(object state) {
        var surroundingContext = Current;
        try {
            // temporarily replace surrounding sync context with this context
            SetSynchronizationContext(this);

            // run pending actions until there are no more
            do {
                Action a;
                _pending.TryDequeue(out a); // always succeeds, due to usage of _pendingCount
                a.Invoke(); // if an enqueued action throws... well, that's very bad
            } while (Interlocked.Decrement(ref _pendingCount) > 0);

        } finally {
            SetSynchronizationContext(surroundingContext); // restore surrounding sync context
        }
    }

    public override void Send(SendOrPostCallback d, object state) {
        throw new NotSupportedException();
    }
    public override SynchronizationContext CreateCopy() {
        return this;
    }
}

上面的代码管理通过_pendingCount字段运行consume方法。_pendingCount字段在排队操作后以原子方式递增,并在出列(和调用)操作后以原子方式递减。最初,正好一个生产者将是将_pendingCount从0增加到1的生产者。他们负责触发消费的开始。如果没有更多的动作被排队,消费者只能从1递减到0。当递减到0时,动作可能在队列中,但只有当生产者即将从0递增到1并重新触发消耗时!当消息看起来像队列是空的时,消费者可以停止,即使它不是!

(自我放纵:当我想到上述管理消费的策略时,我真的很开心。我以前解决这个问题的方式要求消费者在退出之前尝试重新获取,这很丑陋,因为一个线程可以在技术上被锁定在一个无限循环的释放,看到一个变化,重新获得,并看到已经处理了变化。)

这里有一些样板。actor上下文不支持同步入口(Send),不能让CreateCopy返回基类的副本(线程池上下文),并且需要post方法来查看actor上下文,而不是底层上下文,如当前的同步上下文。还有其他一些我们可以覆盖的方法,但它们没有很好地记录,我实际上并不确定它们在哪里被使用......(如果有人能够启发我,那将是值得赞赏的。那么操作完成的是什么? ?为什么等待虚拟?)。

无论如何,现在我们有了同步上下文,我们想要使用它。

等待上下文

目前,如果没有自定义awaiter,我们需要将我们的actor方法的主体嵌套在传递给它们的消息队列的post方法的lambda表达式中。我们可以通过使同步上下文等待来避免这样的问题,理解这意味着“进入上下文”而不是通常的“一旦完成此任务,就恢复当前上下文”等待的语义。

让一个类等待,需要给它一个'GetAwaiter'方法。GetAwaiter方法可以是成员方法或扩展方法,只要编译器可以找到它。GetAwaiter返回的类型必须实现INotifyCompletion接口,具有IsCompleted属性,具有OnCompleted方法,并具有GetResult方法。

IsCompleted确定是否可以跳过等待。可以立即提取已完成的任务的结果,而不是注册回调。一旦等待的事情完成,OnCompleted就会注册一个回调来运行(如果它已经完成,则回调会立即运行)。GetResult用于获取值或重新抛出等待事物中包含的异常,一旦完成。GetResult的类型对应于等待的东西将包含的值的类型。在我们的情况下,它将是无效的。

有关编写其他地方可用的自定义等待者的更多信息。就我们的目的而言,我所涵盖的内容足以将一些东西拼凑在一起:

public sealed class SynchronizationContextAwaiter : INotifyCompletion {
    private readonly SynchronizationContext _context;
    public SynchronizationContextAwaiter(SynchronizationContext context) {
        if (context == null) throw new ArgumentNullException("context");
        _context = context;
    }
    public bool IsCompleted {
        get {
            // always re-enter, even if already in the context
            return false;
        }
    }
    public void OnCompleted(Action action) {
        // resume inside the context
        _context.Post(x => action(), null);
    }
    public void GetResult() {
        // no value to return, no exceptions to propagate
    }
}
public static class SynchronizationContextExtensions {
    public static SynchronizationContextAwaiter GetAwaiter(this SynchronizationContext context) {
        if (context == null) throw new ArgumentNullException("context");
        return new SynchronizationContextAwaiter(context);
    }
}

通过我们项目中包含的代码,我们可以等待我们的消息队列(以及其他同步上下文)。编译器将负责调用GetAwaiter并注册“方法的其余部分”作为传递给OnCompleted方法的回调。我们不必再担心这些细节了。

使用我们的新权力

现在我已经解释了底层机器,我们可以继续讨论一个更复杂的例子。出于某种原因,人们总是使用银行账户来进行并发示例。所以...我想我们会这样做:

public sealed class BankAccountActor {
    private readonly ActorSynchronizationContext _messageQueue
        = new ActorSynchronizationContext();
    
    private decimal _balance;

    public async Task CheckBalanceAsync() {
        await _messageQueue;
        return _balance;
    }
    private void WriteToTransactionLog(object entry) {
        // ... save to persistent storage ...
        throw new NotImplementedException();
    }
    public async Task> GetTransactionLogAsync() {
        await _messageQueue;
        throw new NotImplementedException();
    }

    public async Task DepositAndGetNewBalanceAsync(decimal amount, object transactionId) {
        // note: any thrown exceptions will be packaged into the resulting task
        if (amount <= 0) throw new ArgumentOutOfRangeException("amount", "amount <= 0");
        await _messageQueue;
        
        var newBalance = _balance + amount;
        _balance = newBalance;

        WriteToTransactionLog(new {type = "deposit", id = transactionId, amount});
        return _newBalance;
    }

    public async Task WithdrawAndGetNewBalanceAsync(decimal amount, object transactionId) {
        if (amount <= 0) throw new ArgumentOutOfRangeException("amount", "amount <= 0");
        await _messageQueue;
        
        var newBalance = _balance - amount;
        if (newBalance < 0) {
            WriteToTransactionLog(new { type = "failed withdrawal", id = transactionId, amount });
            throw new InsufficientFundsException(_balance, amount);
        }
        _balance = newBalance;

        WriteToTransactionLog(new { type = "successful withdrawal", id = transactionId, amount });
        return _balance;
    }
}

让我们回顾一下这个例子不会出错的三件事。

  1. 余额存储为小数,而不是浮点数或双精度数。这样可以确保,如果您从1美元的余额开始,并提取10¢十次,您不会得到非零余额(如-0.0000015¢,享受这些费用!)。如果你要处理钱,这绝对必要的
  2. 在将事务报告为已完成之前,会将事务记录到持久存储中。如果系统崩溃,则可以重建正在进行的操作以及是否完成提款/存款/等。如果你要处理钱,这绝对必要的
  3. 存款/取款操作返回新余额。能够访问紧接在之后和/或紧接在之前状态的快照通常非常有用。例如,Interlocked.CompareExchange返回引用位置中的先前值,允许您确定交换是否以及为何发生。

当然,我仍然需要实施最重要的银行支持操作:将资金从一个账户转移到另一个账户。但这对于“银行角色”而言比银行账户参与者更有意义,并且引入了复杂性:转移不能以原子方式完成。

如果我们使用锁,那么我们就可以获得两个账户的锁(但不是以可能相反的顺序!),确保系统永远不会在资金既不是账户的状态下查看。这很诱人,但请记住,一旦我们想要从一个计算机系统到另一个计算机系统进行银行间转账,这种方法就会中断。演员模型可能不允许我们以原子方式转移资金,但现实也不行。有一个不可避免的通信延迟,撤回的资金尚未存入。

由于转移不能以原子方式完成,我们需要一种不同的策略来确保资金不会丢失。实际上,帐户实现已经有一个:日志记录。所有的工作基本上已经完成:

// inside a BankActor ...
public async Task TransferAsync(BankAccountActor source,
                                BankAccountActor destination,
                                decimal amount,
                                object transactionId) {
    if (source == null) throw new ArgumentNullException("source");
    if (destination == null) throw new ArgumentNullException("destination");
    if (amount <= 0) throw new ArgumentOutOfRangeException("amount", "amount <= 0");
    await _messageQueue;

    // note: if the source has insufficient funds, the exception will be propagated
    // note: the source will write the withdrawal to persistent storage
    // note: after awaiting, we will be posted back onto the message queue automatically
    await source.AttemptWithdrawAndGetNewBalanceAsync(amount, id);
        
    // note: the destination will write the deposit to persistent storage
    // note: technically we should be logging here to, but it would clutter the example
    await destination.DepositAndGetNewBalanceAsync(amount, id);
}

如果此方法在任何时候崩溃,从银行帐户进行记录将使重建转移状态成为可能。来源的帐户日志中是否没有退出的日志条目?然后转移甚至没有开始。源帐户的日志中是否有日志条目,但目的地中没有?然后,当系统崩溃时,传输不完整,需要回滚或完成。源帐户的日志和目标帐户的日志中是否有日志条目?然后传输成功完成,无需任何操作。

请注意,习惯于“与演员一起思考”需要时间。解决问题的最佳方法,特别是如果您的旧解决方案涉及多个锁定。例如,因为actor就像分布式系统一样,时间和排序变得更加复杂(参见:矢量时钟)。

(不幸的是,我无法开始讨论和解释使用演员的所有有趣的复杂性,或者我们会在这里待好几天!)

概要

actor模型是一种接近高可靠性和可分发性的并发性的方法。我们可以通过利用新的异步/等待功能,以非常简洁的方式模拟C#中的actor。

每个actor都成为具有消息队列(ActorSynchronizationContext)的类的实例。您通过调用其中一个公共异步方法“向实例发送消息”,并通过等待结果“接收消息”。一对夫妇自定义类型使这成为可能。

 

http://twistedoakstudios.com/blog/Post2061_emulating-actors-in-c-with-asyncawait

你可能感兴趣的:(使用Async / Await在C#中模拟Actor)