Avatar
😀

Organizations

  • 简介

    skynet-x是基于actor 消息的服务框架,那么我们需要定义一套标准且高效的消息结构

    Processor

    一个伪线程的逻辑处理器概念,它分为独占和负载两种模式。

    • 独占Processor是为了更好的处理实时性更高的业务,它不会被其他任务抢占

    • 负载Processor又可分为两种运行态,均匀的处理业务以及从其他Processor上偷窃任务,尽量保证Processor不会过于闲置,除此之外,负载Processor可随着任务的变动而增加(不会超过最大设定值),特别的当某个任务陷入”死循环”或者是超出设定运行阈值的时候会重新创建一个Processor,并让之前的挂起(在C版本中将会被强制关闭)。

    C版本和Go版本调度和设计上差异不大,但一些细节上的处理可能不同,因为C可以提供更多的底层控制

    PID

    一个 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

    Created Fri, 22 Jul 2022 00:00:00 +0000
  • 工作中曾经开发了一个cobweb的分布式服务器框架(基于golang,c),但是在实际开发过程中代码难以维护以及更新,主要是每次都需要跨平台进行编译,特别是cgo 往往需要指定平台的系统库,而且一些不规范的使用方式造成无法充分发挥多核的优势,可以参见 关于Go协程的思考 虽然1.16 支持抢占式,但错误的使用方式依然造成了cpu过高的问题。,后续重新设计了skynet-x 是一个actor模型分布式服务框架,使用go编写。

    尽管Actor模型和CSP模型各有所有长,为什么不采用CSP主要有两方面考虑。

    1. CSP模式使用尽管很简单,但是一个致命的问题是无法控制消息的优先级,当然若只处理一个Channel那可以规避,那么为啥还需要使用CSP,而且像go channel 本身是基于互斥锁(1.16)实现,且无法进行优化和更加精细的控制,只能依赖于runtime的调度。(网上所说什么时候触发调度,我认为channel不能包含其中,它本质也是加锁导致切换)
    2. 隔离性太弱,后续一些新的channel引入也会造成破坏性修改,而且 select-case模式等待的channel会随着数量的增加性能会慢慢减弱。

    它是一个年轻的框架,仅仅经历了两款项目的迭代 现在版本为 v1.6.0 2023-05-28

    与skynet的差异

    1. 增加了独占进程的概念,对于一些性能敏感的服务可以绕过公平调度的原则。(公平调度是一个很普遍但并非最优解的调度策略,但对于需要占用资源较多的进程就显得无力)

    2. 使用协程而非线程,一个好处是对于一些假死服务我们可以重新启动它,其它代价远小于线程(尽管协程的开销很低,但我们尽量保证不会被滥用)

    3. 一个简单的二进制文件,skynet修改了lua部分虚拟机源码,而且大部分实现都是基于lua实现,而我设计的是一个将脚本语言作为可选项的插件。

    4. 所有库都是底层语言的实现方式,可控制力和性能更好,完全将业务和底层区分方便同时进行维护

    5. 无感的集群交互方式,调用其他服务(无论在不在本地)就像普通消息那样简单,不需要像skynet需要显示调用cluster

    6. 进程支持错误重启且消息不会丢失(beta)

    7. 支持后续的DSL

    8. 在2024/03我正计划重新用C实现了一版以提供更好的性能和更底层的控制

    Created Mon, 20 Jun 2022 00:00:00 +0000
  • 简介

    sktpmd模块是skynet-x底层集群模块,它承担了skynet-x网络节点之间的通讯职能。全名为(skynet port managment daemon)

    架构

    1. sktpmd 为了满足对等网络的性质,所以每次和其他节点建立连接是有两条连接, 当A节点于B节点建立连接,首先A节点发送握手等待B确认,B确认完成之后重复走A的流程,这样一个双向连接就被建立了起来,1.6.0改变了个行为,对于像存在类似缓存,或者数据中心的业务而言的单向节点而言,只需要一条连接即可,节省资源。

    2. sktpmd现在支持原始的tcp,udp,unix协议,后续规划可能由reliable udp实现,降低集群通讯延时并提供更好的性能和时延性。

    3. 远程命名服务,通过内置命令生成唯一的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亦可
    

    tunnel

    既然节点之间是双向连接,所以连接数量为 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.6.0集群建立

    v1.5.0 之前节点之间都是双向链接,但考虑到一个单向服务器,如 dns server,conf server 等,大部分是 request/response 模式,惰性连接的收益很大,所以去除之前的一些设计。

    网络底层

    参考 Go协程的思考,在linux下,使用了 epoll。所以尽量部署到 linux 下以发挥更好的性能

    Created Mon, 30 May 2022 00:00:00 +0000
  • 无论是对于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

    Zmalloc

    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

    API

    • skynet.zalloc(n) 用以分配指定大小的内存块,考虑到 64在go中为tiny-size,直接会从P上分配,所以zmalloc分配块从128开始

    • skynet.zrealloc(buf,n) realloc函数会先检查buf,确保是否需要重新分配内存

    Created Thu, 21 Apr 2022 00:00:00 +0000