消息订阅,发布是每个服务器都必备的机制,当然 skynet
也不例外,而且更加优雅。
绕不开传统的事件订阅流程,skynet
的事件总线是一个process
,意味着
所以事件订阅发布都是异步的,
所有消息的发布是可调度的(参见上节 scheduler
),
可以通过控制独占来解决事件总线的调度频率
emm… 似乎也没什么好些的,eventbus
更像底层某些机制的封装,了解即可
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,确保是否需要重新分配内存