Actors 是一种异步编程模式,它的提出主要是为了解决 “多线程,MTA” 编程带来的复杂性、困难度。
但这些都不是 Actors 本身提供的最大优点,它真正的价值是易于解决多线程处理,容易导致共享资源(share resources)之间产生竞争,故而导致 deadlock(死锁)。
一个软件工程项目里面并非所有的开发人员都经历过严格的 “多核编程” 数年摧残,纵然受过多年毒打的开发人员有时也容易写出 deadlock(死锁)的疑难问题,往往经验及技术越强的人搞出死锁就越难解决。
设计模式及编程模型,提出目的都是为了让代码写起来更简单、更易于阅读、后期工程维护,但不意味着设计模式及编程模型的应用会让代码执行的效率更高,或许是更低也说不一定。
有时候不要过于注重形式,没有意义,可以解决人们迫切问题的设计模式及编程模型才是有价值与意义的,个人多年经验之谈。
Actors 基于消息驱动的异步编程模型,本文在标题上就已经明确提出了 Actors 异步编程模型的核心要点:“基于消息驱动”
每个线程可以运行一个或多个 Actors 消息驱动器,有些类似 Windows 窗体编程中的消息队列(WndProc),当某个消息到达,人们对感兴趣的消息逐个进行处理。
Actors 异步编程模型里面要求,所有的线程或进程之间都通过消息的方式进行合作,所以采用 Actors 异步编程模型的结构大约如下图所示:
上图为简化的理解图,Actors 是一个消息生产 + 消费的驱动模型,设A与B两个端点,A为生产Actors Action,B为消费处理 Actors Action 端点
A与B之间传递消息(Actor Action)的传输层介质,可以是进程内存、共享内存、Socket套接字、Pipe 匿名/命名管道,Actor 本身不限制消息传递的介质层。
Actors 模型本身不解决传输介质层出现故障而导致的 Actor Actions 消息丢失的问题,例如:A节点生产一个消息经Socket套接字推送到B节点,但Socket套接字发生故障导致该生产的消息被丢失。
如何解决此问题?对于异步编程而言,一些行为并不需要ACK确认已经处理该消息,但如果确定需要对方处理该消息,则需要实现一种名为 “停等ARQ”【停等超时自动重传】ACK确认的机制以确保消息被传递到目标节点上被处理,但这属于传输介质层设计时需要考虑的东西,与 Actors 模型并没有任何关系。
所以,人们从此处不需要参考 Actors 更多博客/书籍/文献就可以自行推导出,Actors 从设计之初就是为了解决多线程编程的一些问题,而不是为了分布式而设计的,只是发展到分布式的时代仍然有大量的解决方案工程采用 Actors 异步编程编程模型而已。
那么什么是本文提出的 “Actors Action”?
比如A节点向B节点 Publish(生产)一个 Actor Action,那么它至少携带以下信息:
1、Action ID(动作ID)
2、进阶:Sequence ID(序列ID)用于ACK确认,注意:它与 Actor 模型本身无关
3、Action 载荷的数据模型(贫血模型)
4、Origin(来源信息若不需要应答则不需要)
B节点从消息队列中 Peek 弹出顶部入列消息(FIFO先入先出原则),调度派发器则基于 Action ID 来派发消息到对应的 Actor Action Handler(Actor 动作处理器)来消费处理该动作的行为。
那么 Action 载荷的数据模型,如何在调度派发器内解决呢?
很容易:
C# 工程语言可以编写工程内消息模型的序列化算法,根据ID来映射对应的 “贫血数据模型”,管理的方法有两种:
1、基于反射检索元数据的方式来自动构建及建立映射,例如在数据模型头上声明特性,静态标记:Action ID。
2、手动注册 Action ID 跟数据模型的映射关系
C/C++ 工程语言可以编写工程内消息模型的静态序列化算法,如采用静态编译的序列化 google protobuf,解决方案仍是手动注册 Action ID 跟数据模型之间的映射关系。
当我们为 Action 增加进阶的序列ID,那么则可以实现 “停等ARQ” 的机制,那么也为RPC远过程调用的实现提供可能性,如果我们在 C/C++ 语言中,最好实现的编程模型为APM(异步编程模型)
例如:
修改玩家状态
参考一:ChangePlayerStatusAsync( args..., lambda... )
参考二:BeginChangePlayerStatus( args...., [...](args...) {
auto result = EndChangePlayerStatus(...);
// TO:DO Your are code here.
})
但当我们在 Actors 异步编程模型的基础上,增加了RPC远过程调用,那么则可能带来一个全新的潜在问题,“deadlock” 逻辑层的死锁,如:A远过程调用B,B远过程调用A,调用上下层级关系不明确,或许大多数开发人员皆曾犯过该错误。
注意:此类错误跟 Actors 异步编程模式并没有直接关系,将其归纳于 Actors 异步编程模式本身的缺点,理解上有问题的。
Actors 异步编程模型总结:
重点(一):
Actors 异步编程模式,本身是没有多线程下的安全问题的,原因很明显,它是只是把消息生产并推送到消息消费者的消息队列,这个过程甚至不需要加锁(临界区)
这取决于底层传输媒介,若同个进程内存传递的需要上锁,消息队列本身并非必是线程安全,例如:C/C++ 工程语言常常适用STL标准库中的 std::queue、std::list 泛型模板BCL基础类库。
重点(二):
Actors 异步编程模式,本身不提供停等ARQ,消息确认机制,因为实现类似的机制导致的逻辑死锁是开发人员的问题,不是 Actors 模式本身的缺点,扣锅还是的要点脸,没有的东西不要给别人加头上。
重点(三):
Actors 异步编程模式,是最初是用于解决并缓解 “单进程多线程” 内共享资源(share-resource)竞争导致的死锁、内存安全、多核编程编码复杂度的问题。
例如:
A线程访问共享资源竞争锁,B线程访问共享资源竞争到锁,但A线程访问另外一个共享资源竞争到锁,B线程尝试访问另外一个共享资源竞争锁,那么就会发生相互竞争死锁现象。
当人采用:
Actors 模式时,我们将该共享资源按需要自行划分挂在到一个具体的 Actors 调度器负责驱动处理,当其它线程/进程需要访问该共享资源时,交付请求到目的 Actors 调度器上负责的 Actors Action Handler 进行处理,那么操作该共享的资源都在单个工作线程上,所以不需要额外的增加互斥锁。
Actors 模型系统执行非常高效,但其效能表现仍无法比拟多线程内存之间相互访问的效率,但并非绝对的,某些情况下 Actors 模型系统的效能表现优于多线程模型的系统,当然不说大家也明白不是,这么对比没有意义。
重点(四):
Actors 异步编程模式,基于消息驱动,势必会带来大量碎片化的消息报文数据,这必定造成严重内存碎片问题,致内存分配的效率下跌,C/C++ 开发人员应注意对内存碎片问题进行优化处理,由更先进的 “现代工业高级编程语言” 特性来解决,个人建议人们适用:“Microsoft C# by dotNET.”
C/C++ 可以采用以下几类解决方案:
1、定长/对齐数据报文,尽量避免随机颗粒化分配
2、分配固定大小区块,划分若干个小区块,每个小区块缓存一个消息
3、自行实现碎片整理(移动内存)
4、适用开源内存池,掩耳盗铃把问题丢向内存池(例:jemalloc)
重点(五):
Actors 异步编程模式驱动的执行单元是一个线程(线程是程序执行最小单元),如果单个线程负载过多性能会成为瓶颈的,但这并不算 Actors 模式本身的缺点,如果需要承担的负载过重,开发人员应当思考如何优化并解决
例如:更加细化的拆分非本职业务到其它的 Actors 的驱动器/线程进行负责,而不是说这就是 Actors 模式本身的缺点,这不是一个好的解决问题的态度。
如果学过 “领域驱动模型” 的童靴们,应该很明白大多数某个具体 Actors 产生性能瓶颈,均是上层模型领域职责过于宽泛,负责的事务远远超出它本身应当承担的事务导致的,但凡是不是绝对的,对多数软件工程项目而言,它却是问题的本质原因。
是的为每个模块/接口的粒度划分存在一些问题,就像人们在进行多线程(多核)编程时,需要注重锁(lock)的粒度问题,如果人们粒度太大那么多线程系统的效率可能还不如单线程的效能,如果粒度太小,编程复杂性就会直线提高,因为通常锁粒度应用越小越容易遇到死锁的问题,但相对的代码效能通常就越高(指综合并发效能指数,对硬件负载及利用率越高)。