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,确保是否需要重新分配内存