最近在给一个 HTTP 服务写 SDK,这个 HTTP 服务的响应是下面这种经典格式:

{
    "cdoe": 0,
    "msg": "ok",
    "data": {}
}

那么在 Go 里定义结构体的时候就会长这样:

type Custom struct {
    // ...
}

type SomeResponse struct {
    Code int    `json:"code"`
    Msg  string `json:"msg"`
    Data Custom `json:"data"`
}

相应的方法会长这样:

SomeService(ctx context.Context, param any) (*Custom, error)

因为 Custom 的内部可能会有很多数据,所以是以指针作为返回值的,尽量避免数据拷贝;而 Code != 0 这种情况就通过 error 返回。

内部实现大概会是这样:

func (s SDK) SomeService(ctx context.Context, param any) (*Custom, error) {
    var resp SomeResponse
    err := s.request(ctx, "/someservice", param, &resp)
    if err != nil {
        return nil, err
    }

    if resp.Code != 0 {
        return nil, fmt.Errorf("code: %d, err: %s", resp.Code, resp.Msg)
    }

    return &resp.Data, nil
}

通常我们到这一步就算是完成任务了,但是这时候我想了下:return 之后,GC 是怎么回收 resp 的呢?是会把 resp.Coderesp.Msg 的内存回收了,只让 resp.Data 逃逸吗?

这个问题不算复杂,动手做个实验就知道了。先把上面的代码简化一下:

package main

type Custom struct {
    // ...
}

type SomeResponse struct {
    Code int
    Msg  string
    Data Custom
}

func SomeService() *Custom {
    var resp SomeResponse

    // ...

    return &resp.Data
}

func main() {
    SomeService()
}

然后执行 go run -gcflags="-m" main.go,其中 -gcflags="-m" 可以让编译器打印出逃逸分析的过程。执行之后会得到如下输出:

# command-line-arguments
./main.go:13:6: can inline SomeService
./main.go:21:6: can inline main
./main.go:22:13: inlining call to SomeService
./main.go:14:6: moved to heap: resp

可以看到,整个 resp 都逃逸到堆了,也就是说 SomeService() 结束之后 GC 没有回收 resp,也不是前面猜想的「把 resp.Coderesp.Msg 的内存回收了,只让 resp.Data 逃逸」。

编译器不这么“精准 GC”其实也很好理解,内存是整片一起申请的,如果只释放一部分会变得很复杂(尤其是假如 Data 在结构体中的顺序不是在末尾)。

那么像这种情况可以优化吗?可以,而且方法也挺简单的,只需要改两行就行:

type SomeResponse struct {
    Code int
    Msg  string
    Data *Custom // 改成指针
}

func SomeService() *Custom {
    var resp SomeResponse

    // ...

    return resp.Data
}

这么改的话 Data 本身就只是个指针变量,它存的是地址,至于这个地址指向的内存与 SomeResponse 就无关了,所以在 SomeService() 结束之后整个 resp 就会 GC 回收了:

$ go run -gcflags="-m" main.go
# command-line-arguments
./main.go:13:6: can inline SomeService
./main.go:21:6: can inline main
./main.go:22:13: inlining call to SomeService

这么做有什么好处呢,在业务中能节省一些内存,尤其是当不需要返回的数据多的情况下,让 GC 尽早回收掉不再使用的内存,假如调用方是个常驻进程,它也不会长时间持有这一片用不上的内存了。

当然这样改之后也可能会导致多了一些处理指针的逻辑,所以应该根据实际情况来编码,像我这个 SDK 的逻辑不复杂、调用方是个黑盒,用指针就挺合适的 :-P

标签: go

添加新评论