Rust游戏服务端开发随笔【架构简介】

1,933 阅读10分钟

简介

在游戏开发中,常用的并发模型为Actor模型,在Actor模型的实现中,Akka算是比较完全的,包含一整套分布式集群的解决方案。

由于笔者对Akka比较熟悉,所以在游戏框架的设计上,会基于Akka的框架来设计,由于Rust上并没有Akka相关的框架实现,所以笔者自己移植了一个actor-rs,已实现大部分功能,但不完善,还需要时间。本篇文章会介绍Actor模型以及Akka中一些核心组件的原理,方便读者理解整个游戏的架构模式。

Actor 模型

Actor模型是分布式、并发编程中的一种常见解决方案,其核心思想是每个Actor独立维护自己的状态,并基于消息进行通信

Actor模型.drawio.png

从上图中可以看到,每个Actor有一个自己的邮箱,存放着其它Actor给他发送的消息,整个Actor会依次处理邮箱中的消息,当这个Actor需要给其它Actor发送消息的时候,只需要拿到对应Actor的引用地址,然后将对应的消息发送过去了,为什么不是直接拿到邮箱呢?,因为在实际情况中,Actor会分布到不同的物理机器上,所以还需要引入一个中间层,来标识这个Actor,我们叫它ActorRef

通过上述模式,我们只需要保证在任意时刻,只有一个线程在调度该Actor进行消息处理,那么就不会有并发问题,也不需要锁,当然这个也是相对的,因为我们的邮箱在任意时刻都可能被多个线程往里面塞消息,所以邮箱需要保证没有数据竞争就好了,例如我们可以使用crossbeam中的channel或者tokio中的channel都可以达到这种效果。

Actor模型的缺点

异步问题

因为是基于消息去驱动Actor,那么只有在Actor内部,我们可以认为这些逻辑是同步的,一旦涉及到与其它Actor进行数据交互,那过程都是异步的,我们无法保证Actor A和Actor B向Actor C发送消息时,Actor C收到Actor A和Actor B时的顺序,因为中间可能会经过网络IO,甚至有可能消息会丢失;也不能保证到我们想要的数据回来之后,当前的Actor还是否处于对的逻辑状态,因为这个过程中此Actor还会接收其它消息,使其内部状态改变。

大量Actor状态管理复杂

对于游戏世界中有大量实体的情况,并且需要频繁的同步获取实体进行逻辑处理,使用Actor模型则相对困难,我们至少需要对每个实体发送一条消息,使其告诉游戏世界它当前的状态。按照常规思路,这些实体应当由游戏世界统一进行管理,当然这样会使得游戏世界里面单线程的一次性处理过多的逻辑,使得游戏响应变慢。这里其实涉及到另外一个问题,Actor粒度的划分问题,如何划分Actor,使得游戏逻辑尽可能的利用上CPU资源的同时,不会让游戏逻辑变得更加复杂。

Akka核心组件简介

在Akka框架中,有一些比较重要的组件:

  • Singleton 集群单例,在整个Actor集群中,所有Actor都可以访问到该Actor,适合某些单点全局业务
  • Distributed PubSub 集群发布订阅
  • Cluster Sharding 集群分片,可将Actor以某种分片逻辑,均匀的将Actor分布到不同的集群节点上而不用关心其位置,只要通过实体Id就可以与该Actor进行通

ActorRef

和其名字描述的一样,这个组件是Actor的引用,我们可以通过这个引用向对应的Actor发送消息,也可以将这个引用以任何方式传递到任何地方。实际的业务逻辑中,我们通常只会和ActorRef打交道。

ActorPath

ActorPath标识了这个Actor所在的位置,它通常的形式表示如下:

akka://my-sys/user/service-a/worker1
akka://my-sys@host.example.com:5678/user/service-b

通过ActorPath就可以精确的定位到这个Actor所在的位置,ActorRef里面会包裹ActorPath,当我们对ActorRef调用tell方法发送消息时,ActorRef会决定如何将此消息正确的发送到对应的Actor上。例如当我们的Actor就在本地机器上时,ActorRef里面可能就只保存一个mailbox的引用,只需要向mailbox中写入消息就可以了,当我们的Actor在远程机器上时,ActorRef里面可能就保存的是一个到对应远程机器上Actor的连接(这个取决于内部实现),当我们调用tell方法时,就会通过tcp或者udp亦或者其它通信协议将消息发送给远端的Actor。因此ActorRef会有许多的实现,例如LocalActorRefRemoteActorRefEmptyActorRef等等。

Actor中的层次结构

在Akka的Actor系统中,Actor是有层次结构的,这样的好处是方便管理Actor的生命周期,当一个Actor停止时,它所有的子Actor都会递归的停止。最顶层的Actor叫做Root guardian,然后它有两个子Actor,一个为User guardian,另一个为System guardian。System guardian下面放的是Actor系统内部创建的Actor,通常我们不关心这部分,用户创建的Actor都在User guardian下面。

Actor层次结构.drawio.png

当某个Actor停止时,会从当前Actor的叶子节点开始停止Actor,例如我们当前的Actor层次结构为:Actor A有两个子Actor Actor B和Actor C,然后Actor B还有一个子Actor Actor D,那么我们停止Actor A时,Actor的停止顺序为:Actor D -> Actor B -> Actor C -> Actor A。

Cluster Sharding

Cluster Sharding是一种将同类型的Actor(我们称之为Entity)部署到集群中多个节点上的模式,通过这种模式,我们很容易通过水平扩容的方式,来降低集群的负载。分片过程是动态的,当一个节点被新添加进来或者从集群中移除时,分不清在该节点上的分片会被移动到其它可用的节点上,这个过程被称之为rebalance

Actor Cluster.drawio.png

从上图可以看到,每个节点上会有一个名叫Shard Region的Actor,代表着一块分片的区域,它负责管理其下所有的分片,需要注意的是,不同类型的Entity会有不同的Shard Region,Shard Region下面会有一组Shard,这些是实际承载Entity的地方。我们还注意到,在每个节点上,有一个ShardCoordinator的Actor,这其实是一个集群单例,负责管理某一类型的分片信息,例如当前有哪些Shard Region分别在哪些节点上,每个Shard Region上管理着哪些分片等信息,新启动的Shard Region会和Shard Coordinator沟通,以确定其它Entity所在的位置。所以,这个Actor只会在某一个节点上进行实例化,其余节点上启动的都是Proxy,Proxy会负责将消息路由到正确的ShardCoordinator上。

当启动某一类型的集群分片时,我们会实现两个函数,一个是根据消息返回ShardId,另外一个是根据消息返回EntityId,这样我们就可以确定要如何路由这条消息,当负责转发这条消息的Shard发现自己名下并没有这个EntityId的Actor,时,它会把该Entity创建出来,然后将消息转发给它。

在实际的应用中,我们在某个节点上启动Shard Region时,表示这个节点可以承载Shard,当然,我们也可以启动一个Shard Region Proxy,表示此节点不承载对应的Entity,那么此Shard Region的作用就是把消息路由到正确的Entity上。可以看到Shard Region充当了一层代理,屏蔽了具体Entity所在的位置,我们要向某个具体的Entity发送消息时,只需要指明此Entity的Id即可。Shard Region会首先根据Shard Coordinator的信息,得到这个Entity的Shard具体所在的Shard Region,然后将消息转发给Entity所在的Shard Region,然后再由Entity所在的Shard Region将消息转发给Shard,最后由Shard将消息转发给Entity。

pub type ShardId = String;

pub type EntityId = String;

pub trait MessageExtractor: Send + Sync + DynClone + Debug {
    fn entity_id(&self, message: &crate::ShardEnvelope) -> EntityId;

    fn shard_id(&self, message: &crate::ShardEnvelope) -> ShardId;

    fn unwrap_message(&self, message: crate::ShardEnvelope) -> DynMessage {
        message.message
    }
}

dyn_clone::clone_trait_object!(MessageExtractor);

通过Cluster Sharding,我们就解决了大量的Entity在集群中如何分布的问题,可以很轻松的对集群进行扩容缩容。

核心API简介

Actor

#[async_trait]
pub trait Actor: Send + Any {
    #[allow(unused_variables)]
    async fn started(&mut self, context: &mut ActorContext) -> anyhow::Result<()> {
        Ok(())
    }

    #[allow(unused_variables)]
    async fn stopped(&mut self, context: &mut ActorContext) -> anyhow::Result<()> {
        Ok(())
    }

    #[allow(unused_variables)]
    fn on_child_failure(&mut self, context: &mut ActorContext, child: &ActorRef, error: &anyhow::Error) -> Directive {
        Directive::Resume
    }

    #[allow(unused_variables)]
    async fn on_recv(&mut self, context: &mut ActorContext, message: DynMessage) -> anyhow::Result<()>;

    async fn handle_message<A: Actor>(
        actor: &mut A,
        context: &mut ActorContext,
        message: DynMessage,
    ) -> anyhow::Result<()>
        where
            Self: Sized
    {
        match message.downcast_user_delegate::<A>() {
            Ok(message) => {
                message.handle(context, actor).await?;
            }
            Err(error) => {
                error!("{:?}", error);
            }
        }
        Ok(())
    }
}

在定义Actor的时候,需要实现此trait,其中的方法比较简单,就不介绍了。

CodecMessage

pub trait CodecMessage: Any + Send {
    fn into_any(self: Box<Self>) -> Box<dyn Any>;

    fn as_any(&self) -> &dyn Any;

    fn into_codec(self: Box<Self>) -> Box<dyn CodecMessage>;

    fn decoder() -> Option<Box<dyn MessageDecoder>> where Self: Sized;

    fn encode(self: Box<Self>, reg: &MessageRegistry) -> anyhow::Result<Vec<u8>>;

    fn clone_box(&self) -> anyhow::Result<Box<dyn CodecMessage>>;

    fn cloneable(&self) -> bool;

    fn into_dyn(self) -> DynMessage;
}

#[async_trait]
pub trait Message: CodecMessage {
    type A: Actor;

    async fn handle(self: Box<Self>, context: &mut ActorContext, actor: &mut Self::A) -> anyhow::Result<()>;
}

这个是Actor消息的trait接口,其中decoderencode和序列化有关,当这个消息需要进行网络传输的时候,需要对这两个方法进行实现,clone_box是对消息进行克隆的方法,在实际情况中,某些消息是不支持克隆的,所以还有一个方法cloneable判断这个消息是否可以克隆,这个通常在框架中用到,实际情况中比较少用到,into_dyn这个方法会将当前消息封装成DynMessage,在我们的框架中,Actor消息都由DynMessage来代表,因为要传输不同的消息类型,所以需要将消息用Box进行包裹,并且提供一系列的方法使其可以进行向下转型到原来的类型。

在实际定义Actor消息的时候,我们会使用过程宏的方式来辅助实现这些接口,我们只需要实现handle中的逻辑就行了。

DynMessage

pub struct DynMessage {
    name: &'static str,
    ty: MessageType,
    message: Box<dyn CodecMessage>,
}

例子

下面给出一个计算斐波那契数列的例子

use std::time::Duration;

use async_trait::async_trait;
use rand::Rng;
use tracing::info;

use actor_core::{Actor, DynMessage, Message};
use actor_core::actor::actor_system::ActorSystem;
use actor_core::actor::context::{ActorContext, Context};
use actor_core::actor::props::Props;
use actor_core::actor::timers::Timers;
use actor_core::actor_ref::actor_ref_factory::ActorRefFactory;
use actor_core::CEmptyCodec;
use actor_core::config::actor_setting::ActorSetting;
use actor_core::ext::init_logger_with_filter;

pub fn fibonacci(n: i32) -> u64 {
    if n < 0 {
        panic!("{} is negative!", n);
    } else if n == 0 {
        panic!("zero is not a right argument to fibonacci()!");
    } else if n == 1 {
        return 1;
    }

    let mut sum = 0;
    let mut last = 0;
    let mut curr = 1;
    for _i in 1..n {
        sum = last + curr;
        last = curr;
        curr = sum;
    }
    sum
}

struct FibActor {
    timers: Timers,
}

impl FibActor {
    fn new(context: &mut ActorContext) -> anyhow::Result<Self> {
        let timers = Timers::new(context)?;
        Ok(Self {
            timers,
        })
    }
}

#[async_trait]
impl Actor for FibActor {
    async fn started(&mut self, context: &mut ActorContext) -> anyhow::Result<()> {
        let n = rand::thread_rng().gen_range(1..=50);
        self.timers.start_timer_with_fixed_delay(None, Duration::from_millis(100), Fib(n), context.myself().clone());
        info!("{} started", context.myself());
        Ok(())
    }

    async fn on_recv(&mut self, context: &mut ActorContext, message: DynMessage) -> anyhow::Result<()> {
        Self::handle_message(self, context, message).await
    }
}

#[derive(Debug, Clone, CEmptyCodec)]
struct Fib(i32);

#[async_trait]
impl Message for Fib {
    type A = FibActor;

    async fn handle(self: Box<Self>, _context: &mut ActorContext, _actor: &mut Self::A) -> anyhow::Result<()> {
        fibonacci(self.0);
        Ok(())
    }
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    init_logger_with_filter("actor=info");
    let system = ActorSystem::new("mikai233", ActorSetting::default())?;
    system.spawn_anonymous(Props::new_with_ctx(|ctx| { FibActor::new(ctx) }))?;
    system.await?;
    Ok(())
}

结语

通过这篇文章,读者应该对Actor模型有了大致的了解,下一篇文章将介绍如何在游戏中使用这一模式以及各个游戏节点的设计、数据交互。