Contents

GoGameActor-103 对比golang 的两种actor实现———protoactor-go 和 gonet

introduction

actor 模型的介绍我就不赘述了可以去看我之前的博客GoGameActor-101 actor intruduction这里说一下我最近在看的两个go语言的实现 protoactor-go 和 gonet

protoactor-go

protoactor-go 由瑞士团队Asynkron出品,虽然api还不稳定,但是已经有团队在生产环境使用了。看protoactor-go给我的感觉是,代码解耦非常彻底,所以略有些吃力。因为还没完全理清抽象的概念,所以经常被interface{}搞得晕头转向。

而且protoactor-go有许多非常有意思的特性比如deadletter,persistence,behaviour,middleware,futures,等等等等。由于特性很多,所以实现也比较复杂。protoactor-go只是Asynckron的protoactor的go语言版本,实际上还有c#版本。

没在大型团队待过,所以不知道有这些特性是为什么,虽然花时间把文档看了个七七八八。但是奈何没有经验,作为nerd实在没发评价。不过我感觉挺diao的, 专业团队设计的东西就是要设计成我不太能看懂的样子😄。但是protoactor-go的文档非常全面专业,真正使用的时候即使照着c#版本的文档,也不应该有无法理解的地方。

gonet

相比之下,gonet我就比较能看懂了。但是特性也比较简单,是为mmo游戏设计的一个框架。这里我只讨论他的actor实现部分。不过gonet的文档并不全面,能拿来说的地方并不多。

有关actor模型实现

protoactor-go 和 gonet关于actor模型的实现走的是两种不同的路线。gonet需要actor自己实现receive方法,以及和actor方法相对应的参数类型.actor的receive方法根据参数类型调用不同的函数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
type HelloActor struct{} //先定义具体actor
type Hello struct{ Who string } //再定义消息

func (state *HelloActor) Receive(context actor.Context) { //receive函数负责消息分发和调用
    switch msg := context.Message().(type) {
    case Hello:
        fmt.Printf("Hello %v\n", msg.Who) //这里没有调用HelloActor自己的函数,而是做一个打印
    }
}

func main() {
    context := actor.EmptyRootContext
    props := actor.PropsFromProducer(func() actor.Actor { return &HelloActor{} })
    pid, err := context.Spawn(props) //到这你可以理解为都是为了将一个actor注册到系统中
    if err != nil {
        panic(err)
    }
    context.Send(pid, Hello{Who: "Roger"}) //向这个actor发送消息
    console.ReadLine()
}

而gonet就比较简单了,由于gonet内部使用反射实现,他和lua+c 实现的skynet是非常相似的。你不需要实现消息分发,gonet会自动帮你解决。但是缺点是,过度依赖golang 反射的函数调用,性能会差很多,不如去用动态语言(对比对象是common lisp)这里比较复杂,我贴一下有关反射函数调用那边的代码,你们自己看吧。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
func (a *Actor) call(io CallIO) {
    rpcPacket := io.RpcPacket
    head := io.RpcHead
    funcName := rpcPacket.FuncName
    if !a.HasRpc(funcName) {
        log.Printf("func [%s] has no method", funcName)
        return
    }
    m, _ := a.rType.MethodByName(funcName)
    rpcPacket.RpcHead.SocketId = io.SocketId
    params := rpc.UnmarshalBody(rpcPacket, m.Type)
    if len(params) >= 1 {
        in := make([]reflect.Value, len(params))
        in[0] = a.rVal
        for i, param := range params {
            if i == 0 {
                continue
            }
            in[i] = reflect.ValueOf(param) //收集需要调用的函数的参数
        }

        a.Trace(funcName)
        ret := m.Func.Call(in) //反射函数调用 并收集返回值
        a.Trace("")
        if ret != nil && head.Reply != "" {
            ret = append([]reflect.Value{reflect.ValueOf(&head)}, ret...)
            rpc.MGR.Call(ret)
        }
    } else {
        log.Printf("func [%s] params at least one context", funcName)
        //f.Call([]reflect.Value{reflect.ValueOf(ctx)})
    }
}

性能

这里我不直接使用两个框架,但是我用两者实现actor的基本方法模拟下测试,再和golang直接调用以及common lisp直接调用add函数做对比,因为我不是特别关心这两个框架的使用,或者实际使用的时候性能的好坏(我想写一个自己的嘛😁)所以简化一下哈哈哈。

1
2
3
func add(a, b int) int {
    return a + b
}

我会分别用两种方式去调用这个add方法,以期对比他们的性能。

protoactor的实现思路的性能测试

测试代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
type HelloActor struct {
}

type CallAdd struct {
    word string
}

// Receive ...
func (this *HelloActor) Receive(param interface{}) {
    switch param.(type) {
    case CallAdd:
        add(0, 1000)
    }
}

func BenchmarkActor(b *testing.B) {
    actor := HelloActor{}
    for i := 0; i < b.N; i++ {
        actor.Receive(CallAdd{word: "hello"})
    }
}

结果

1
2
3
4
5
6
7
 go test -bench="Actor" .
goos: darwin
goarch: arm64
pkg: test
BenchmarkActor-8   	1000000000	         0.3178 ns/op
PASS
ok  	test	2.150s

这个速度很快了哦,接近直接调用的性能了

gonent基于反射实现思路的性能

测试代码

1
2
3
4
5
6
7
8
9
func BenchmarkReflect(b *testing.B) {

    funcValue := reflect.ValueOf(add)
    paramList := []reflect.Value{reflect.ValueOf(0), reflect.ValueOf(1000)}
    for i := 0; i < b.N; i++ {

        funcValue.Call(paramList)
    }
}

结果

1
2
3
4
5
6
7
❯ go test -bench="Reflect" .
goos: darwin
goarch: arm64
pkg: test
BenchmarkReflect-8   	 5031619	       204.8 ns/op
PASS
ok  	test	2.458s

这个性能和上面的对比下,属实太拉胯的哈哈哈

直接调用add函数

测试代码

1
2
3
4
5
func BenchmarkDirect(b *testing.B) {
    for i := 0; i < b.N; i++ {
        add(0, 1000)
    }
}

结果

1
2
3
4
5
6
7
8
~/test/center
❯ go test -bench="Direct" .
goos: darwin
goarch: arm64
pkg: test
BenchmarkDirect-8   	1000000000	         0.3184 ns/op
PASS
ok  	test	1.836s

common lisp 直接调用add函数

测试代码(使用sbcl 解释器)

1
2
3
4
5
6
7
8
(defun add (a b)
  (+ a b))

(defun test1000000000()
  (loop for i from 0 to 1000000000
        do (add 1 i)))

(time (test1000000000))
1
2
3
4
5
Evaluation took:
  2.534 seconds of real time
  2.534737 seconds of total run time (2.532272 user, 0.002465 system)
  100.04% CPU
  0 bytes consed

性能也不错,虽然比不上静态语言的golang但是这个成绩已经很能打了,即使和静态语言对比也有一拼了。动态语言里面,怕是难逢敌手了。

总结

gonet胜过protoactor-go的地方可能就是他写起来会容易很多,但是性能和特性上,与protoactor比只能说是贫瘠了。即使和动态语言common lisp比,也属实拉胯了,当然common lisp是能和c++这种性能级语言battle一下的,性能上属于第一梯队的。

gonent会慢的主要原因是golang的反射函数调用拖了后腿。而protoactor的实现方式,让它几乎没有性能上的损失。当然写法上,会有些一点怪异吧。函数调用比较密集的话,并不推荐使用反射函数调用。