Zen
是一个基于 Unity 引擎的GamePlay
框架,脱离 Monobehaviour
开发,致力简化开发流程。内部提供了一个类ECS
的架构满足开发,你也可以使用自定义的上层,比如自己实现像MVCC
,或者是MVC
的上层封装。让开发聚焦在游戏玩法而非一些底层架构上。
Zen的一些设计思想不算是纯粹的
OOP
,它有ECS的概念,也有type embedding
的概念,而且设计概念大部分是参考面过过程和内嵌的设计思想,所以理解曲线会比较困难
无框架化,它之所有不提供是为了更好的设计出不同品类的游戏,而我在近10年的游戏开发生涯中,我始终觉得框架的约束即使最大的约束,因为业务的多样性和非明确性的特点,一般游戏后期的一些奇奇怪怪的需求总是会迫使你绕过框架的约束从而形成屎山code,所以我希望Zen
框架本身可以尽可能的简单,让开发者可以自由的去选择框架的约束。你可使用Zen
的一部分,或者全部,甚至是都不需要。
无MonoBehaviour
编程设计,解除引擎原始的约束,更自由的编程方式,像之前开发游戏,一个角色身上可能挂在各式各样的组件,一旦后期业务变动很容易出现引用丢失或者维护起来更为困难,而且一些特殊的时候可能还需要设置一下脚本的执行顺序,给维护带来巨大的不便(如我之前所呆的项目各种口口相传的细节规范,让开发痛不欲生)
模块化,Zen
的一大特色,以像C library
的方式来组织模块,让模块之间可以互相调用,并且可以互相替换,让开发者可以自由的去选择模块的约束。选择何种内置模块,或者是自定义模块由开发者决定,这也是使用 Zen
唯一的约束,你的模块可以是框架,也可以是Module
。
简单化,Zen
本身只提最必要的一些基础组件,你可以重新实现,而并非是必要的
自由化,游戏开发是自由的,是创造性的,Zen
不会约束你干什么,你只需要关注你的想法,怎么做取决于你的点子。
非文档约束性组件控制器绑定,面向对象的模式必然导致代码变得复杂,因使用内嵌代替OOP
,但显然C#做不到,需要额外的封装,但过于麻烦不符合Zen
的设计哲学,故通过静态泛型约束实现。
无任何反射调度,提高代码的运行速度。
高度继承的构建管线,非常完善且易使用基建设计(配置,资源,本地化,网络等)
ZenRpc
现在可用了,无关乎网络,无缝与 skynet-go
Zen
不鼓励继承,其内部设计也是符合此规范,所以整个拓扑架构更平整
Remote Dictionary Server,采用 ANSI C 编写的 K-V数据库
string 最大存储值为256mb,底层由SDS(simple dynamic string)实现,优势是访问长度仅需O(1)
hash
list 存储有序字符串,最大2^32-1个元素
set
同list,但不允许重复
– 其它
单线程业务,多线程存储,redis6.0引入多线程也仅仅是为了提高解析命令的速度
虚拟内存
虚拟内存机制就是暂时把不经常访问的数据(冷数据)从内存交换到磁盘中,从而腾出宝贵的内存空间用于其它需要访问的数据(热数据)。通过VM功能可以实现冷热数据分离,使热数据仍在内存中、冷数据保存到磁盘。这样就可以避免因为内存不足而造成访问速度下降的问题。
某个key在过期点的时候,突然出现大量请求查找这个key
访问一个不存在的key的时候
指缓存中数据大批量到过期时间,访问落到db上,造成db压力过大
RDB持久化,是指在指定的时间间隔内,执行指定次数的写操作,将内存中的数据集快照写入磁盘中,它是Redis默认的持久化方式。执行完操作后,在指定目录下会生成一个dump.rdb文件,Redis 重启的时候,通过加载dump.rdb文件来恢复数据
分为手动触发和自动触发
优点 适合大规模的数据恢复场景,如备份,全量复制等
缺点 没办法做到实时持久化/秒级持久化。
采用日志的形式来记录每个写操作,追加到文件中,重启时再重新执行AOF文件中的命令来恢复数据。它主要解决数据持久化的实时性问题
优点 数据一致性和完整性更高 缺点 内容越多,文件越大,恢复变慢,它需要将所有命令执行一遍
类似mysql主从,master负责写,slave负责读
监视其他节点的状态
Gossip,HashSlot 16384
setnx nx [expired]
iptables
基于 netfilter
采用一条条规则链表,时间复杂度为O(n),最主要的是 iptables
专为防火墙设计
ipvs
同样基于 netfilter
,但底层采用的是hash表,索引复杂度为O(1)
其实在cobweb
之初就设计了一种编码协议(kproto),用于内部消息的编码,但因为公司项目长期需要维护以及开发(两款线上,一款开发中),所以一直未对此库进行维护,
而后期在研发的时候,发现需要与多种语言交互,显然 json
,xml
不是一个很好的选择,而 protobuf
对弱类型语言支持不友好。
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-x
是基于actor
消息的服务框架,那么我们需要定义一套标准且高效的消息结构
一个伪线程的逻辑处理器概念,它分为独占和负载两种模式。
独占Processor
是为了更好的处理实时性更高的业务,它不会被其他任务抢占
负载Processor
又可分为两种运行态,均匀的处理业务以及从其他Processor
上偷窃任务,尽量保证Processor
不会过于闲置,除此之外,负载Processor
可随着任务的变动而增加(不会超过最大设定值),特别的当某个任务陷入”死循环”或者是超出设定运行阈值的时候会重新创建一个Processor
,并让之前的挂起(在C版本中将会被强制关闭)。
C版本和Go版本调度和设计上差异不大,但一些细节上的处理可能不同,因为C可以提供更多的底层控制
一个 message
最重要的是消息地址,如果一个消息没有地址的话我们称为 dead-letter
。 那么我们通过Pid
标定一个地址类型,
它表示该服务的唯一id (本质上是一个uint64
)的类习惯,它一定能确保在当前节点以及集群中唯一的。
在服务本身未被关闭的时候,pid
一定不会产生变动,但重新启动节点之后,它的值可能会发生改变,因为所有服务默认都是并发启动,除非手动指定了关系(这也是它与skynet-x
的区别),所以不要尝试保存这个pid
一旦能确定了一个pid
的话,就可以通过 skynet.send(pid,cmd,...) or skynet.call(ti,pid,cmd,...)
将其发送出去了。
Actor
模型最重要的的概念是 mailbox
,它代表了一个实体需要处理的队列容器,
得益于go
的简单性,可以使用 channel
来实现,但这种方式的实现性能不高,因为 channel
底层的结构使用的是互斥锁,
所以我采用了mpsc
实现了无锁队列,性能更优于 channel
TODO: 吞吐量对比
用户不需要构建这个结构体,仅仅需要指定 destination
以及需要发送的数据,而且 skynet-x
消息投递被设计成不允许发送 nil
因为这是无任何意义的,相反它还会消耗服务投递的性能,如果确实有这种需求,可以发送 struct{}{}
。
而且消息发送成功只能代表被 mailbox
接受了,不代表会被立即处理,而不会一定处理成功,所以需要正确理解这种方式。
如果发送失败,那么一定失败,并返回一个错误
接受回调只包含5
个关键参数 context
,addr
,session
,mtype
,argument
context
其实就是创建服务用户指定的结构指针,用于数据传递和状态修改
session
主要的作用是用以区分这条消息是否是同步请求, 如若大于0,则其值就是请求序列号,只需要通过 skynet.ret(msg)
返回即可
mtype
仅仅是一个消息类别的区分,类似于消息号,用户可自行定义,可作为rpc
消息类型
argument
才是真实的数据,它可以是任意值,特别的,在lua
中这个值是会被解构,在跨节点通讯这个值恒为 []byte
,当不需要时记得 skynet.free 1.4.0 这个由底层回收,用户不用关心
异步消息通过 skynet.send
的方式进行投递,它只在乎这个消息有没有正确到达到对点服务,而不关心是否能被对点服务正确处理,并返回一个 error
工作中曾经开发了一个cobweb
的分布式服务器框架(基于golang
,c
),但是在实际开发过程中代码难以维护以及更新,主要是每次都需要跨平台进行编译,特别是cgo
往往需要指定平台的系统库,而且一些不规范的使用方式造成无法充分发挥多核的优势,可以参见 关于Go协程的思考
虽然1.16 支持抢占式,但错误的使用方式依然造成了cpu过高的问题。,后续重新设计了skynet-x
是一个actor
模型分布式服务框架,使用go
编写。
尽管Actor
模型和CSP
模型各有所有长,为什么不采用CSP
主要有两方面考虑。
CSP
模式使用尽管很简单,但是一个致命的问题是无法控制消息的优先级,当然若只处理一个Channel
那可以规避,那么为啥还需要使用CSP
,而且像go channel 本身是基于互斥锁(1.16)实现,且无法进行优化和更加精细的控制,只能依赖于runtime
的调度。(网上所说什么时候触发调度,我认为channel不能包含其中,它本质也是加锁导致切换)channel
引入也会造成破坏性修改,而且 select-case
模式等待的channel
会随着数量的增加性能会慢慢减弱。它是一个年轻的框架,仅仅经历了两款项目的迭代 现在版本为 v1.6.0 2023-05-28
增加了独占进程的概念,对于一些性能敏感的服务可以绕过公平调度的原则。(公平调度是一个很普遍但并非最优解的调度策略,但对于需要占用资源较多的进程就显得无力)
使用协程而非线程,一个好处是对于一些假死服务我们可以重新启动它,其它代价远小于线程(尽管协程的开销很低,但我们尽量保证不会被滥用)
一个简单的二进制文件,skynet
修改了lua部分虚拟机源码,而且大部分实现都是基于lua
实现,而我设计的是一个将脚本语言作为可选项的插件。
所有库都是底层语言的实现方式,可控制力和性能更好,完全将业务和底层区分方便同时进行维护
无感的集群交互方式,调用其他服务(无论在不在本地)就像普通消息那样简单,不需要像skynet
需要显示调用cluster
。
进程支持错误重启且消息不会丢失(beta)
支持后续的DSL
在2024/03我正计划重新用C实现了一版以提供更好的性能和更底层的控制
sktpmd
模块是skynet-x
底层集群模块,它承担了skynet-x
网络节点之间的通讯职能。全名为(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-x.send("host:port@name","rpc"...) -- 通过域名或者地址+端口的形式和其他节点进行通讯
skynet-x.send("alias","rpc",...) -- 通过别名
skynet-x.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-x
而言,一个高效的内存分配器可以提高内存的使用效率,这里效率无论是对于内存碎片亦或是GC而言,都是一种更高效的手段
SLAB
算法的分段内存分配器SLAB
最开始是阅读 linux
源码学习的算法,在skynet-x
中它确实有更优秀的性能,因为它直接分配了一块大共用内存,所以不会产生任何GC和真实分配,但在业务开发过程中,一旦忘记释放 那么这段内存将不能再被使用和获取了(也就是野指针),直到程序结束。最后的保守策略依然会向runtime
申请内存,将会导致内存占用过高。
而且内置的Debug
模式也无法定位到这个指针,原因在于 golang 堆栈伸缩会导致指针地址变动,所以 Debug
只能定位到存在 memory-leak
,而无法知道具体位置。若需要具体位置则需要hook这个调用栈,性能方面得不偿失
sync.Pool
的分段内存分配器将不同size的buffer放入不同的池中,按需进行分配,减少race的开销,这个方法虽然简单,但是性能是低于 slab-allocator
。
但它确实能减轻心智负担,代价就是牺牲了部分性能以及gc压力,但这也是skynet-x
默认使用的策略。如果需要使用可以在编译指令中指明slab
zmlloc
本身也是 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
我这里给出另外一种,通过c wrap 这个函数指针成一个普通函数,然后go调用它