EntityManager
是一个很重要的模块,像游戏开发本身是基于视觉感知,而EntityManager
是对所有场景物体的统一抽象封装, 并提供一系列通用操作,EntityManager
本身是一个抽象类,提供了一定程度的通用性操作,但针对一些特殊情况我们还是需要针对特定问题特定实现(千万不想要想着做平,来自某家公司的教训:))
EntityManager
附带一个默认的 EntityController
,提供一些常规的实体控制,像 Zen
的UI框架就是基于 EntityManager
的一个具体实现。
抽象描述一个物体的实体,它的生命周期函数定义类似于GameComponent
,但是它的调度不由GameEntry
而是 GameComponent
。
简单来说,要创建一个物体首先我们需要定义它的逻辑模板(骨架)以及它的数据(描述)
举个例子
public struct EnemyData:IEntityData{
// 对于实体数据接口,资源名必不可少
public string Assets {get;set;}
public void OnCtor(){
// 有些时候,实体的数据更关卡等级或这地图这类外部数据挂钩,可能需要在这里动态设置一次
}
}
// 定义Entity的逻辑模板
public struct Enemy:Entity {
protected EnemyData userdata;
protected void OnCtor(){
// init setup...
}
protected void OnUpdate(float delta,float unscaleDelta){
// loop logic.
}
}
void dosomething(){
// 直接在默认的EntityManager中创建一个实体
GameEntry.GetComponent<EntityManager>().Load<Enemy>(new EnemyData());
}
对于实体的销毁,像敌人死亡,特效消失之类的,仅仅只需要设置一个 Alive
属性,即可完成,生命周期由EntityManger
自行决断。
需要注意的是 继承IEntityData
的数据模板是会一直复用的,它与 Entity
的复用规则不同,后者复用的是Entity
所持有的实体,因为它本身只是一系列函数集合且很少会带有数据并不会占用太多的内存,而前者大部分情况下是都是通过读表获取,复杂的实体数据可能会导致内存异常大,所以保留实体数据是EntityManager
的默认行为。
EntityManager
可能需要在某些大量对象时使用对象池(Entity
),或者内存池(EntityData
),但在大多数情况下,框架并不清楚是否需要对象池,或者是需要一个定长周期的物体(子弹或者特效)。
这个时候需要设置就非常有必要了,可以决断出是否需要且生命周期(keepalive)或者是需要多大的对象池,以及自动孵化的频率了。
Zen
是一个基于 Unity 引擎的GamePlay
框架,脱离 Monobehaviour
开发,致力简化开发流程。内部提供了一个类ECS
的架构满足开发,你也可以使用自定义的上层,比如自己实现像MVCC
,或者是MVC
的上层封装。让开发聚焦在游戏玩法而非一些底层架构上。同时也
提供一些自定义性,不至于给予开发者太强约束性
Zen的一些设计思想不算是纯粹的
OOP
,它有ECS的概念,也有type embedding
的概念,而且设计概念大部分是参考面过过程和内嵌的设计思想,所以理解曲线会比较困难,但是用起来还是挺 easy。
其实在cobweb
之初就设计了一种编码协议(kproto),用于内部服务消息之间的编码,但因为项目长期需要维护以及开发(两款线上,一款开发中),所以一直未对此库进行维护,
而后期在研发的时候,发现需要与多种语言交互,显然 json
,xml
不是一个很好的选择,而 protobuf
对弱类型语言支持不友好。所以诞生了它 (kproto,kpb )
format | compress rate | encode rate | decode rate |
---|---|---|---|
json std | 0% | 0%( 213.8 ns/op) | 0%(1204ns/op) |
proto v3 | -40% | -51%(98.36 ns/op) | -84%(190.1ns/op) |
kproto | -40% | -76% (65.21 ns/op) | -95%(62.18ns/op) |
skynet
实现了一套调度器机制,用以调度 process
, 与云风大佬不同的是,sched
支持公平模式以及独占模式,独占模式主要是为了解决特性场景下业务的响应速度,从而提升体验感,
其次,不一定需要按照每个 process
对应一条协程,减少runtime
调度以及内存的开销
一个伪线程的逻辑处理器概念,它分为独占和负载两种模式。
独占是为了更好的处理实时性更高的业务,它不会被其他任务抢占
负载又可分为两种运行态,均匀的处理业务以及从其他
process上偷窃任务,尽量保证
Processor不会过于闲置,除此之外,负载
Processor可随着任务的变动而增加(不会超过最大设定值),特别的当某个任务陷入”死循环”或者是超出设定运行阈值的时候会重新创建一个
Processor`,并让之前的挂起(在C版本中将会被强制关闭)。
多数时候协程过多会造成系统压力。
C版本和Go版本调度和设计上差异不大,但一些细节上的处理可能不同,因为C可以提供更多的底层控制
process
是一种用户态的伪概念,用于描述一个 actor
的具体实现方式,也是 skynet
最小调度单元,这种概念对于分布式而言更友好,无关乎process在哪,只需要知道 PID
或者 Alias
即可向其投送消息。
主要是对业务几乎无侵入
message
是 skynet
基础消息承载结构
Actor
模型最重要的的概念是 mailbox
,它代表了一个实体需要处理的队列容器,
得益于go
的简单性,可以使用 channel
来实现,但这种方式的实现性能不高,因为 channel
底层的结构使用的是互斥锁,
所以我采用了mpsc
实现了无锁队列,性能更优于 channel
TODO: 吞吐量对比
用户不需要构建这个结构体,仅仅需要指定 dest
以及需要发送的数据,而且 skynet
消息投递被设计成不允许发送 nil
因为这是无任何意义的,相反它还会消耗服务投递的性能,如果确实有这种需求,可以发送 struct{}{}
。
至于这个节点是再本地,还是其他地方并不重要
接受回调只包含5
个关键参数 context
,addr
,session
,mtype
,msg
,size
context
其实就是创建process
指定的结构指针,用于表示处理上下文
addr
即为投递者的Pid
,(需要注意的是,skynet
支持redirect
以及 fake
模式,所以这地址需要在发送的时候明确)
session
主要的作用是用以区分这条消息是否是同步请求, 如若大于0,则其值就是请求序列号,只需要通过 skynet.ret(msg)
返回即可
工作中曾经开发了一个cobweb
的分布式服务器框架(基于golang
,c
),但是在实际开发过程中代码难以维护以及更新,主要是每次都需要跨平台进行编译,特别是cgo
往往需要指定平台的系统库,而且一些不规范的使用方式造成无法充分发挥多核的优势,可以参见 关于Go协程的思考
虽然1.16 支持抢占式,但错误的使用方式依然造成了cpu过高的问题。,后续重新设计了skynet
是一个actor
模型分布式服务框架,使用go
编写。
尽管Actor
模型和CSP
模型各有所有长,为什么不采用CSP
主要有以下方面考虑。
CSP
模式使用尽管很简单,但是一个致命的问题是无法控制消息的优先级,当然若只处理一个Channel
那可以规避,那么为啥还需要使用CSP
,而且像go channel 本身是基于互斥锁(1.16)实现,且无法进行优化和更加精细的控制,只能依赖于runtime
的调度。(网上所说什么时候触发调度,我认为channel不能包含其中,它本质也是加锁导致切换)
隔离性太弱,后续一些新的channel
引入也会造成破坏性修改。
select-case
模式会随着等待数量的增加性能会慢慢减弱。
channel
多大合适?
它是一个年轻的框架,仅仅经历了两款项目的迭代
现在版本为重启了 v2 版本v1.6.0 2023-05-28
sktpmd
模块是skynet
底层集群模块,它承担了skynet
网络节点之间的通讯职能。全名为(skynet port managment daemon
)
sktpmd
为了满足对等网络的性质,所以每次和其他节点建立连接是有两条连接,
当A节点于B节点建立连接,首先A节点发送握手等待B确认,B确认完成之后重复走A的流程,这样一个双向连接就被建立了起来,1.6.0改变了个行为,对于像存在类似缓存,或者数据中心的业务而言的单向节点而言,只需要一条连接即可,节省资源。
sktpmd
现在支持原始的tcp,udp,unix
协议,后续规划可能由reliable udp
实现,降低集群通讯延时并提供更好的性能和时延性。
远程命名服务,通过内置命令生成唯一的Name,通过Name来与其他节点通讯是友好的。
启动也非常简单,无须任何代码,仅仅只需要在 conf.conf
中配置一下即可,使用的时候跟节点内通讯无任何区别。因为我已经作平了本地和节点之间的差异。
内部均由kproto进行编码,提供更快的序列化方式。
example call
skynet.send("host:port@name","rpc"...) -- 通过域名或者地址+端口的形式和其他节点进行通讯
skynet.send("alias","rpc",...) -- 通过别名
skynet.send(pid, "rpc",...) -- 通过pid亦可
既然节点之间是双向连接,所以连接数量为 f(n) = n²-n
,如果节点过的时候,势必造成 socket fd
消耗殆尽,
基于这个问题,可以通过内置的tun
,来设置代理,这么一来,tun
的作用相当于这个集群节点的网关。因为其内部的节点相对于其他 tun
代理是不可见的,
通过配置tun
的规则开启多个,则可以实现业务拆分。
2022-10-07 此模块被弃用,可以用多节点转接的方式或代理的方式做到,如
send("n1.n2.n3@name")
sktpmd
提供了一套服务发现机制,但其运作原理是不同于 etcd
或者 consul
,它本身是一个惰性发现,它不需要一个中心服维持它们的关系。
sktpmd整个发现流程是基于
gossip
算法来发现的,但一些api依然可以主动触发,v1.6.0
这个模块将保留,因为集群模式的逻辑改变了
v1.5.0
之前节点之间都是双向链接,但考虑到一个单向服务器,如 dns server
,conf server
等,大部分是 request/response
模式,惰性连接的收益很大,所以去除之前的一些设计。
参考 Go协程的思考,在linux
下,使用了 epoll
。所以尽量部署到 linux
下以发挥更好的性能
无论是对于C版本还是Go版本的skynet
而言,一个高效的内存分配器可以提高内存的使用效率,这里效率无论是对于内存碎片亦或是GC而言,都是一种更高效的手段
SLAB
算法的分段内存分配器SLAB
最开始是阅读 linux
源码学习的算法,在skynet
中它确实有更优秀的性能,因为它直接分配了一块大共用内存,所以不会产生任何GC和真实分配,但在业务开发过程中,一旦忘记释放 那么这段内存将不能再被使用和获取了(也就是野指针),直到程序结束。最后的保守策略依然会向runtime
申请内存,将会导致内存占用过高。
而且内置的Debug
模式也无法定位到这个指针,原因在于 golang 堆栈伸缩会导致指针地址变动,所以 Debug
只能定位到存在 memory-leak
,而无法知道具体位置。若需要具体位置则需要hook这个调用栈,性能方面得不偿失
sync.Pool
的分段内存分配器将不同size的buffer放入不同的池中,按需进行分配,减少race的开销,这个方法虽然简单,但是性能是低于 slab
。
但它确实能减轻心智负担,代价就是牺牲了部分性能以及gc压力,但这也是skynet
默认使用的策略。如果需要使用可以在编译指令中指明slab
zmalloc
本身也是 slab
的升级版本,增加可伸缩链表实现对于预分配内存额外部分的缓冲池。
在24个线程的cpu条件测试结果如下,zmalloc
保持了一种稳定的时间复杂度,额外产生的内存分配也很少
- | time(ns) | alloc/op(B) |
---|---|---|
slab-128 | 23.5 | 1 |
sync.Pool-128 | 2490 | 65562 |
zmalloc-128 | 43.31 | 16 |
slab-256 | 24 | 256 |
sync.Pool-256 | 2204 | 65562 |
zmalloc-256 | 44 | 16 |
slab-1024 | 92 | 1024 |
sync.Pool-1024 | 2490 | 65562 |
zmalloc-1024 | 49 | 17 |
slab-4096 | 365 | 4096 |
sync.Pool-4096 | 2210 | 65562 |
zmalloc-4096 | 45 | 23 |
skynet.zalloc(n)
用以分配指定大小的内存块,考虑到 64在go中为tiny-size,直接会从P上分配,所以zmalloc分配块从128开始
skynet.zrealloc(buf,n)
realloc函数会先检查buf,确保是否需要重新分配内存
type Pool struct {
noCopy noCopy
local unsafe.Pointer // P 本地池,固定尺寸,实际结构 [P]poolLocal,类似 void* 并附加长度构成了一个数组
localSize uintptr // size of the local array
victim unsafe.Pointer // local from previous cycle
victimSize uintptr // size of victims array
New func() any
}
type poolChain struct{
head *poolChainElt
tail *poolChainElt
}
type poolChainElt struct{ // 一个双向链表
poolDequeue
next,prev *poolChainElt
}
type poolDequeue struct{
headtail uint64
vals []eface
}
type eface struct{ // 数据的真实内存分配,包括一个类型描述和实际数据
typ,val unsafe.Pointer
}
type poolLocalInternal struct{
private any // p的私有缓冲区
shared poolChain // 公共缓冲区
}
type poolLocal struct{
poolLocalInternal
pad [128-unsafe.Sizeof(poolLocalInternal{})%128]byte // 应该是补位,可以确保刚好占满一个 cache line 加速访问
}
func (p *Pool) pin() (*poolLocal, int) {
pid := runtime_procPin() // 将当前G和P绑定,并返回P的id
s := runtime_LoadAcquintptr(&p.localSize) // load-acquire
l := p.local // load-consume
if uintptr(pid) < s { // 主要是P的数量可能会变动 重新找一个
return indexLocal(l, pid), pid
}
return p.pinSlow()
}
func (p *Pool) pinSlow() (*poolLocal, int) {
runtime_procUnpin() // 禁止 P 抢占,否则当前G会被放回本地或者全局队列,当时之后G不一定在现在这个P上
allPoolsMu.Lock()
defer allPoolsMu.Unlock()
pid := runtime_procPin()
s := p.localSize
l := p.local
if uintptr(pid) < s { // double check
return indexLocal(l, pid), pid
}
if p.local == nil { // 如果本地队列为空,那么此时Pool没被初始化,加入全局池引用
allPools = append(allPools, p)
}
size := runtime.GOMAXPROCS(0) // 查看现在P的个数
local := make([]poolLocal, size) // 为这个Pool分配跟P数量相同的本地池
atomic.StorePointer(&p.local, unsafe.Pointer(&local[0])) // store-release
runtime_StoreReluintptr(&p.localSize, uintptr(size)) // store-release
return &local[pid], pid // 返回当前和P绑定的本地池
}
func (p *Pool) Get() any {
if race.Enabled {
race.Disable()
}
l, pid := p.pin() // 先找本地池
x := l.private
l.private = nil
if x == nil { // 如果没有,那么从全局池找
// Try to pop the head of the local shard. We prefer
// the head over the tail for temporal locality of
// reuse.
x, _ = l.shared.popHead()
if x == nil {
x = p.getSlow(pid)
}
}
runtime_procUnpin() //释放P,让其可以被抢占
if race.Enabled {
race.Enable()
if x != nil {
race.Acquire(poolRaceAddr(x))
}
}
if x == nil && p.New != nil {
x = p.New()
}
return x
}
// Put adds x to the pool.
func (p *Pool) Put(x any) {
if x == nil {
return
}
if race.Enabled {
if fastrandn(4) == 0 {
// Randomly drop x on floor.
return
}
race.ReleaseMerge(poolRaceAddr(x))
race.Disable()
}
l, _ := p.pin() // 老规矩,先禁止P被抢占
if l.private == nil { // 本地没有 则先放入本地
l.private = x
} else {
l.shared.pushHead(x) // 否则放入全局
}
runtime_procUnpin()
if race.Enabled {
race.Enable()
}
}
其实很好理解,正好是一次二级缓冲模型,第一次gc会将local放入 victim,第二gc victim不为空才会真正清理,local不会参与gc
要求系统安装C/C++工具链,macos和linux(gcc 自带),windows(mingw),并确保环境变量
CGO_ENAVBLE=on
,最后单个源码需要导入import "C"
C type | Cgo type | Go type |
---|---|---|
char | C.char | byte |
signed char | C.schar | int8 |
unsigned char | C.uchar | uint8 |
short | C.short | int16 |
unsigned short | C.ushort | uint16 |
int | C.int | int32 |
unsigned int | C.uint | uint32 |
long | C.long | int32 |
unsigned long | C.ulong | uint32 |
long long int | C.longlong | int64 |
unsigned long long int | C.ulonglong | uint64 |
float | C.float | float32 |
double | C.double | double |
size_t | C.size_t | uint |
go引用c的函数指针比较特别
官方给出的Example