Avatar
😀

Organizations

  • Zen 是一个基于 Unity 引擎的GamePlay框架,脱离 Monobehaviour 开发,致力简化开发流程。内部提供了一个类ECS的架构满足开发,你也可以使用自定义的上层,比如自己实现像MVCC,或者是MVC的上层封装。让开发聚焦在游戏玩法而非一些底层架构上。

    Zen的一些设计思想不算是纯粹的OOP,它有ECS的概念,也有type embedding的概念,而且设计概念大部分是参考面过过程和内嵌的设计思想,所以理解曲线会比较困难

    设计目标

    1. 无框架化,它之所有不提供是为了更好的设计出不同品类的游戏,而我在近10年的游戏开发生涯中,我始终觉得框架的约束即使最大的约束,因为业务的多样性和非明确性的特点,一般游戏后期的一些奇奇怪怪的需求总是会迫使你绕过框架的约束从而形成屎山code,所以我希望Zen框架本身可以尽可能的简单,让开发者可以自由的去选择框架的约束。你可使用Zen的一部分,或者全部,甚至是都不需要。

    2. MonoBehaviour编程设计,解除引擎原始的约束,更自由的编程方式,像之前开发游戏,一个角色身上可能挂在各式各样的组件,一旦后期业务变动很容易出现引用丢失或者维护起来更为困难,而且一些特殊的时候可能还需要设置一下脚本的执行顺序,给维护带来巨大的不便(如我之前所呆的项目各种口口相传的细节规范,让开发痛不欲生)

    3. 模块化,Zen的一大特色,以像C library的方式来组织模块,让模块之间可以互相调用,并且可以互相替换,让开发者可以自由的去选择模块的约束。选择何种内置模块,或者是自定义模块由开发者决定,这也是使用 Zen 唯一的约束,你的模块可以是框架,也可以是Module

    4. 简单化,Zen 本身只提最必要的一些基础组件,你可以重新实现,而并非是必要的

    5. 自由化,游戏开发是自由的,是创造性的,Zen 不会约束你干什么,你只需要关注你的想法,怎么做取决于你的点子。

    6. 非文档约束性组件控制器绑定,面向对象的模式必然导致代码变得复杂,因使用内嵌代替OOP,但显然C#做不到,需要额外的封装,但过于麻烦不符合Zen的设计哲学,故通过静态泛型约束实现。

    7. 无任何反射调度,提高代码的运行速度。

    8. 高度继承的构建管线,非常完善且易使用基建设计(配置,资源,本地化,网络等)

    9. ZenRpc现在可用了,无关乎网络,无缝与 skynet-go

    Zen不鼓励继承,其内部设计也是符合此规范,所以整个拓扑架构更平整

    Created Sat, 01 Apr 2023 00:00:00 +0000
  • Redis

    Remote Dictionary Server,采用 ANSI C 编写的 K-V数据库

    Redis命令

    Redis下载

    类型

    • string 最大存储值为256mb,底层由SDS(simple dynamic string)实现,优势是访问长度仅需O(1)

    • hash

    • list 存储有序字符串,最大2^32-1个元素

    • set

    同list,但不允许重复

    • sorted set 已排序的都字符串集合,但不允许重复

    – 其它

    1. GEO 地理位置
    2. HyperLogLog 基数统计
    3. Bitsmap bit数组,类似boolean filter

    redis设计架构

    1. 单线程业务,多线程存储,redis6.0引入多线程也仅仅是为了提高解析命令的速度

    2. 虚拟内存

    虚拟内存机制就是暂时把不经常访问的数据(冷数据)从内存交换到磁盘中,从而腾出宝贵的内存空间用于其它需要访问的数据(热数据)。通过VM功能可以实现冷热数据分离,使热数据仍在内存中、冷数据保存到磁盘。这样就可以避免因为内存不足而造成访问速度下降的问题。

    击穿,穿透,雪崩

    击穿

    某个key在过期点的时候,突然出现大量请求查找这个key

    穿透

    访问一个不存在的key的时候

    雪崩

    指缓存中数据大批量到过期时间,访问落到db上,造成db压力过大

    持久化机制

    RDB

    RDB持久化,是指在指定的时间间隔内,执行指定次数的写操作,将内存中的数据集快照写入磁盘中,它是Redis默认的持久化方式。执行完操作后,在指定目录下会生成一个dump.rdb文件,Redis 重启的时候,通过加载dump.rdb文件来恢复数据

    分为手动触发和自动触发

    优点 适合大规模的数据恢复场景,如备份,全量复制等

    缺点 没办法做到实时持久化/秒级持久化。

    AOF

    采用日志的形式来记录每个写操作,追加到文件中,重启时再重新执行AOF文件中的命令来恢复数据。它主要解决数据持久化的实时性问题

    优点 数据一致性和完整性更高 缺点 内容越多,文件越大,恢复变慢,它需要将所有命令执行一遍

    高可用

    主从

    类似mysql主从,master负责写,slave负责读

    哨兵

    监视其他节点的状态

    集群

    Gossip,HashSlot 16384

    View

    分布式锁

    setnx

    setnx nx [expired]

    Created Thu, 24 Nov 2022 00:00:00 +0000
  • iptables

    iptables 基于 netfilter 采用一条条规则链表,时间复杂度为O(n),最主要的是 iptables 专为防火墙设计

    ipvs

    ipvs 同样基于 netfilter,但底层采用的是hash表,索引复杂度为O(1)

    Created Sat, 19 Nov 2022 00:00:00 +0000
  • 前言

    其实在cobweb之初就设计了一种编码协议(kproto),用于内部消息的编码,但因为公司项目长期需要维护以及开发(两款线上,一款开发中),所以一直未对此库进行维护, 而后期在研发的时候,发现需要与多种语言交互,显然 json,xml 不是一个很好的选择,而 protobuf 对弱类型语言支持不友好。

    Benchmark

    • cpu: Intel(R) Core(TM) i9-9900K CPU @ 3.60GHz
    • os: windows11
    • arch: amd64
    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)
    Created Wed, 21 Sep 2022 00:00:00 +0000
  • 简介

    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
  • 结构分析

    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

    其实很好理解,正好是一次二级缓冲模型,第一次gc会将local放入 victim,第二gc victim不为空才会真正清理,local不会参与gc

    sync.pool Created Fri, 15 Apr 2022 00:00:00 +0000
  • Cgo

    cgo 一种go与c交互的技术

    开启cgo

    要求系统安装C/C++工具链,macos和linux(gcc 自带),windows(mingw),并确保环境变量CGO_ENAVBLE=on,最后单个源码需要导入 import "C"

    cgo类型映射

    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的函数指针比较特别

    1. 官方给出的Example

    2. 我这里给出另外一种,通过c wrap 这个函数指针成一个普通函数,然后go调用它

    Created Wed, 06 Apr 2022 00:00:00 +0000