Trupe:在 .NET 中实现 Actor 模型

自 20 世纪 70 年代以来,Actor 模型一直是并发和分布式系统的强大范式,通过消息传递和隔离为复杂的同步问题提供了解决方案。虽然像 Proto.Actor 和 Akka.NET 这样的成熟框架提供了健壮的实现,但从头开始构建一个最小化版本能够为我们提供对底层机制和设计决策的宝贵见解。

在本文中,我将分享我在 C# 中实现基础 Actor 模型框架的历程,重点关注核心概念,同时保持最少的依赖项。这并非要替代可用于生产的框架,而是为了加深我们对其内部工作原理的理解。

项目设置

我们将针对 .NET 9.0 和 10.0 版本,并努力将依赖项保持在最低限度。我们唯一使用的第三方库是 Sigil,用于高性能的运行时代码生成。

Actor

第一个也是最核心的组件是 Actor。这将是我们框架的核心。我们将从一个 IActor 接口开始。它将有一个主要方法 ReceiveAsync,当 actor 接收到消息时会调用该方法。

我还添加了一个 Context 属性。目前我不会使用它,但稍后它将变得至关重要,例如用于访问 actor 的父级、子级或回复消息。

public interface IActor
{
    IActorContext? Context { get; set; }
    
    ValueTask ReceiveAsync(object? message, CancellationToken cancellationToken = default);
}

现在,我们面临第一个重大决策:是否支持强类型消息?如果支持,actors 可以处理特定消息类型而无需对 object 进行类型转换。这提高了类型安全性和开发者体验。

我决定通过添加一个继承自基础 `IActor` 的泛型接口来支持这一点:

public interface IActor<in TMessage> : IActor
{
    ValueTask ReceiveAsync(TMessage message, CancellationToken cancellationToken = default);
}

Actor 类现在可以实现 IActor<MyMessage1> 和 IActor<MyMessage2>,以便在不同的方法中处理不同的消息。

消息

下一个核心组件是消息(Message)。目前,这将是一个内部包装器,用于在邮箱和 actor 之间传递用户消息(Value)和 CancellationToken

我将定义一个接口和一个用于本地(进程内)消息的简单记录类型实现。将来,我们可以为网络通信创建 RemoteMessage 实现。

public interface IMessage
{
    object? Value { get; }
    
    CancellationToken CancellationToken { get; }
}

// 用于进程内消息的简单记录类型
public record LocalMessage(object? Value, CancellationToken CancellationToken = default) : IMessage;

邮箱

另一个基本组件是邮箱(Mailbox)。每个 actor 都将拥有自己的邮箱。当消息发送给 actor 时,首先会排入邮箱队列。然后,一个单独的进程(我们接下来将构建)会逐个取消队列中的消息并将它们传递给 actor。这确保了 actors 按顺序处理消息,这是 Actor 模型的关键保证。

public interface IMailbox
{
    ValueTask EnqueueAsync(IMessage message, CancellationToken cancellationToken = default);
    
    ValueTask<IMessage> DequeueAsync(CancellationToken cancellationToken = default);
}

对于我们的实现,我们将使用来自 System.Threading.Channels 命名空间的 Channel,它提供了优化的高性能生产者/消费者队列。

public class ChannelMailBox : IMailbox
{
    private readonly Channel<IMessage> _channel;
    public ChannelMailBox() : this(0) // 默认为无界
    {
        
    }

    public ChannelMailBox(int maxSize, BoundedChannelFullMode fullMode = BoundedChannelFullMode.Wait)
    {
        if (maxSize == 0)
        {
            _channel = Channel.CreateUnbounded<IMessage>(new UnboundedChannelOptions
            {
                SingleReader = true, // 只有我们的 ActorProcess 会读取
                SingleWriter = false // 多个生产者可以发送消息
            });
        }
        else
        {
            _channel = Channel.CreateBounded<IMessage>(new BoundedChannelOptions(maxSize)
            {
                SingleReader = true, // 只有我们的 ActorProcess 会读取
                SingleWriter = false, // 多个生产者可以发送消息
                FullMode = fullMode // 当邮箱已满时的操作
            });
        }
    }

    public async ValueTask EnqueueAsync(IMessage message, CancellationToken cancellationToken = default)
    {
        await _channel.Writer.WriteAsync(message, cancellationToken);
    }

    public async ValueTask<IMessage> DequeueAsync(CancellationToken cancellationToken = default)
    {
        return await _channel.Reader.ReadAsync(cancellationToken);
    }
}

PID 或 Actor 引用

Actor 引用(也称为进程 ID 或 PID)是外部代码用于与 actor 交互的句柄。永远不应直接持有 actor 类本身的引用。所有通信必须通过引用进行。这强制实施封装和位置透明性(引用可能指向另一台机器上的 actor)。

让我们定义接口和本地实现。目前,它只会有一个 Send 方法,即"即发即忘"模式。

// 用于与 actor 交互的面向公众的接口
public interface IActorReference
{
    Uri Name { get; }
    
    void Send<TMessage>(TMessage message);

    Task SendAsync<TMessage>(TMessage message, CancellationToken cancellationToken = default);
}

LocalActorReference 实现的工作很简单:将用户的消息包装在我们的 IMessage 信封中,然后将其排入 actor 的邮箱。

public class LocalActorReference(Uri name, IMailbox mailbox) : IActorReference
{
    public Uri Name { get; } = name;

    public void Send<TMessage>(TMessage message)
    {
        mailbox.EnqueueAsync(new LocalMessage(message))
            .GetAwaiter()
            .GetResult();
    }

    public async Task Send<TMessage>(TMessage message, CancellationToken cancellationToken = default)
    {
        await mailbox.EnqueueAsync(new LocalMessage(message), cancellationToken);
    }
}

Actor 进程

这是我们核心系统的最后一部分。ActorProcess 是负责 actor 生命周期的"引擎"或"运行器"。它拥有 actor 实例及其邮箱。其职责是运行一个后台任务,持续执行以下操作:

1. 从邮箱中取出一条消息
2. 将消息传递给 Actor ReceiveAsync 方法
3. 等待下一条消息

public class ActorProcess(IActor actor, IMailbox mailbox)
{
    private static readonly ConcurrentDictionary<Type, Dictionary<Type, Func<IActor, object, CancellationToken, ValueTask>>> Actions = new();
    private readonly Dictionary<Type, Func<IActor, object, CancellationToken, ValueTask>> _actions = Actions.GetOrAdd(actor.GetType(), CreateActions);
    private CancellationTokenSource? _cancellationTokenSource;
    
    private Task? _consumer;

    public void Start()
    {
        Stop();
        
        _cancellationTokenSource = new CancellationTokenSource();
        _consumer = ExecuteAsync(_cancellationTokenSource.Token);
        
        _consumer = Task.Factory.StartNew(async ct => await ExecuteAsync((CancellationToken)ct), 
            _cancellationTokenSource.Token, 
            _cancellationTokenSource.Token, 
            TaskCreationOptions.LongRunning, 
            TaskScheduler.Default);
    }

    public void Stop()
    {
        if (_cancellationTokenSource != null)
        {
            _cancellationTokenSource.Cancel();
            _cancellationTokenSource.Dispose();
            _cancellationTokenSource = null;
        }


        if (_consumer != null)
        {
            try
            {
                _consumer.GetAwaiter().GetResult();
            }
            catch (OperationCanceledException)
            {
                
            }

            _consumer = null;
        }
    }

    public async Task StartAsync()
    {
        await StopAsync();
        
        _cancellationTokenSource = new CancellationTokenSource();
        _consumer = ExecuteAsync(_cancellationTokenSource.Token);
        
        _consumer = Task.Factory.StartNew(async ct => await ExecuteAsync((CancellationToken)ct), 
            _cancellationTokenSource.Token,
            _cancellationTokenSource.Token,
            TaskCreationOptions.LongRunning,
            TaskScheduler.Default);
    }
    
    public async Task StopAsync()
    {
        if (_cancellationTokenSource != null)
        {
            await _cancellationTokenSource.CancelAsync();
            _cancellationTokenSource.Dispose();
            _cancellationTokenSource = null;
        }


        if (_consumer != null)
        {
            try
            {
                await _consumer;
            }
            catch (OperationCanceledException)
            {
                
            }

            _consumer = null;
        }
    }

    private async Task ExecuteAsync(CancellationToken cancellationToken)
    {
        while (!cancellationToken.IsCancellationRequested)
        {
            try
            {
                var message = await mailbox.DequeueAsync(cancellationToken);
                if (message.Value != null && _actions.TryGetValue(message.Value.GetType(), out var action))
                {
                    await action(actor, message.Value, message.CancellationToken);
                }
                else
                {
                    await actor.ReceiveAsync(message.Value, message.CancellationToken);
                }
            }
            catch (OperationCanceledException)
            {
            }
        }
    }

    private static Dictionary<Type, Func<IActor, object, CancellationToken, ValueTask>> CreateActions(Type actorType)
    {
        var types = actorType.GetInterfaces();
        var actions = new Dictionary<Type, Func<IActor, object, CancellationToken, ValueTask>>();
        foreach (var type in types)
        {
            if (!type.IsGenericType || type.GetGenericTypeDefinition() != typeof(IActor<>))
            {
                continue;
            }

            var method = type.GetMethods()[0];
            var exe = Emit<Func<IActor, object, CancellationToken, ValueTask>>
                .NewDynamicMethod($"Receive{Guid.NewGuid():N}")
                .LoadArgument(0)
                .CastClass(type)
                .LoadArgument(1)
                .CastClass(type.GenericTypeArguments[0])
                .LoadArgument(2)
                .CallVirtual(method)
                .Return();
                
            actions[type.GenericTypeArguments[0]] = exe.CreateDelegate();
        }

        return actions;
    }   
}

整合:使用示例

那么,我们如何使用目前为止构建的内容呢?让我们创建一个处理 `SimpleMessage` 的简单 actor,并将所有内容连接在一起。

var mailbox = new ChannelMailBox();
var process = new ActorProcess(new SimpleActor(), mailbox);
var reference = new LocalActorReference(
  new Uri("localhost:test_with_obj"), 
  mailbox);
        
process.Start();
        
var message = new SimpleMessage();
reference.Send(message);

while (!message.Received && !cancellationToken.IsCancellationRequested)
{
    Task.Delay(100, cancellationToken).Wait(cancellationToken);
}
        
process.Stop();

public class SimpleTypedActor : IActor<SimpleMessage>
{
    public ValueTask ReceiveAsync(SimpleMessage message, CancellationToken cancellationToken = default)
    {
        message.Received = true;
        return ValueTask.CompletedTask;
    }

    public IActorContext? Context { get; set; }
    public ValueTask ReceiveAsync(object? message, CancellationToken cancellationToken = default)
    {
        throw new NotImplementedException();
    }
}

public class SimpleMessage
{
    public bool Received { get; set; }
}

如您所见,在它准备好使用之前,我还有很多工作要做。

结论与后续步骤

我们已成功构建了 Actor 模型框架的核心!我们实现了类型化和非类型化消息接收、高性能邮箱,以及用于路由消息的动态分派系统。

这仅仅是个开始。可用于生产的系统还需要许多其他功能,例如:

  • 监管策略:当 actor 失败时重启它们的策略
  • 远程处理:actor 跨网络边界通信的能力
  • Actor 层次结构:用于系统结构化的父子关系
  • 高级调度:对线程和执行上下文的更多控制

此实现为理解如何在 ActorsMailboxes References 这些核心原语之上构建这些更高级功能提供了坚实的基础。

参考资料

为了获得灵感并了解可用于生产的框架是如何构建的,我强烈推荐查看这些项目:

protoactor 
Akka.Net   

项目 GitHub: https://github.com/lillo42/trupe

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值