我是靠谱客的博主 自由未来,最近开发中收集的这篇文章主要介绍Go 内存管理之三:CGO,觉得挺不错的,现在分享给大家,希望可以做个参考。

概述

之前在 povilasv.me[1] 上,我们一起探讨了 Go 内存管理 和 Go 内存管理之二。在上篇博文中,我们发现使用 cgo 会占用更多的虚拟内存。现在我们来深入研究一下 cgo。

本文是 Go语言中文网组织的 GCTT 翻译,发布在 Go语言中文网公众号,转载请联系我们授权。

CGO 揭秘

正如之前所见,cgo 会使虚拟内存膨胀。此外,对于大部分用户而言,一旦他们导入了 net 包或者其子包(比如 http),就会自动的使用 cgo。

我在标准库的代码里发现很多描述 cgo 调用工作机制的文档。比如,你在 cgocall.go[2] 文件里,就能看到非常有用的注释:

为了在 Go 代码中调用 C 函数 f,cgo 会生成代码调用 runtime.cgocall(_cgo_Cfunc_f, frame),这里 _cgo_Cfunc_f 是由 cgo 自动生成、gcc 编译的函数。

为了不阻塞其他的 Go 例程或垃圾收集器,runtime.cgocall(如下)先调用 entersyscall,接着再调用 runtime.asmcgocall(_cgo_Cfunc_f, frame)

runtime.asmcgocall(见 asm_$GOARCH.s)切换到 m->go 栈上(被认为是由操作系统分配的栈,可以在其上安全的运行 gcc 编译的代码),并且调用 _cgo_Cfunc_f(frame)

_cgo_Cfunc_f 调用真正的 C 函数 f,并将 frame 结构里的参数传递给它,将结果记录到 frame 结构里,最后返回 runtime.asmcgocall

runtime.asmcgocall 重获控制后,再切回原来的 gm->curg)的栈,然后返回 runtime.cgocall

然后, runtime.cgocall 会调用 exitsyscall,它会阻塞直到 m 可以运行 Go 代码而不会违反 $GOMAXPROCS 限制,接着将 gm 上解锁。

以上的描述跳过了由 gcc 编译的函数 f 再调用 Go 函数的可能性。如果发生这种情况,我们会在执行 f 的过程中沿着兔子洞走下去。

-- cgocall.go 源码(https://golang.org/src/runtime/cgocall.go )

注释甚至还深入的讲解了 Go 如何实现 cgo 到 Go 的调用。我强烈建议你研究一下这些代码和注释。透过现象看本质让我学到了很多。从这些注释我们可以看到,是否调用 C 代码会让 Go 程序的行为完全不同。

运行时追踪

探索 Go 程序行为的一种很酷的方法是使用 Go 运行时追踪。要想进一步了解 Go 程序追踪方面的知识,可以参阅 Go 运行时追踪器[3]这篇博文。现在,我们来修改一下代码加入追踪功能:

func main() {
    trace.Start(os.Stderr)
    cs := C.CString("Hello from stdio")

    time.Sleep(10 * time.Second)

    C.puts(cs)
    C.free(unsafe.Pointer(cs))

    trace.Stop()
}

编译这个程序并且将标准错误输出保存到文件里:

/ex7 2> trace.out

最后,来看看追踪结果:

go tool trace trace.out

就是这样。下次如果再遇到表现怪异的命令行程序,我就知道如何去追踪了????。顺便说一下,如果要追踪网页服务的话,可以使用 httptrace 包,用起来更简单。想要了解更多的话,可以参阅 HTTP 追踪[4]这篇博文。

我还编写了一个相似的但没有任何 C 代码调用的程序,以便使用 go tool trace 比较它们的追踪结果。这就是这个 Go 原生程序的代码:

func main() {
   trace.Start(os.Stderr)
   str := "Hello from stdio"
   time.Sleep(10 * time.Second)
   fmt.Println(str)

   trace.Stop()
}

cgo 程序和 Go 原生程序的追踪结果并没有太大差异。当然,我注意到有一些统计还是有点差别的。比如,cgo 程序没有包含堆的统计信息。

Image

cgo 程序的追踪信息统计

Image

Go 原生程序的追踪信息统计

我试图使用不同的视图去观察,但是并没有发现更多的显著差异。我猜测这可能是由于 Go 不会为已编译的 C 代码添加追踪指令。

因此,我决定使用 strace 来继续探索它们之间的差异。

使用 strace 探索 cgo

首先明确一下,我们将要探索的两个程序有着相同的行为。我们只是简单的从上述的程序中去除了追踪语句。

cgo 程序:

func main() {
    cs := C.CString("Hello from stdio")

    time.Sleep(10 * time.Second)

    C.puts(cs)
    C.free(unsafe.Pointer(cs))
}

go 原生程序:

package main

import (
    "fmt"
    "time"
)

func main() {
    str := "Hello from stdio"
    time.Sleep(10 * time.Second)

    fmt.Println(str)
}

编译,然后使用 strace 运行它们:

sudo strace -f ./program_name

我加了 -f 标志让 strace 也追踪线程。

-f 追踪当前被追踪进程由于执行 fork(2),vfork(2)和 clone(2) 系统调用而生成的子进程

cgo 结果

如前所见,为了完成工作,cgo 程序会加载 libc 和 pthreads 这两个 C 代码库。而且,事实证明,cgo 程序以不同的方式创建线程。创建线程的时候,你可以看到有一个函数调用为线程栈分配了 8 MB 的内存:

mmap(NULL, 8392704, PROT_NONE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0) = 0x7f1990629000
// 我们为栈分配了 8 MB 内存
mprotect(0x7f199062a000, 8388608, PROT_READ|PROT_WRITE) = 0
// 允许读写,但禁止执行位于该内存区域的代码

设置完栈之后,你会看到一个系统调用 clone,但是传递的参数与一个典型的 Go 原生程序不同:

clone( child_stack=0x7f1990e28fb0, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID, parent_tidptr=0x7f1990e299d0, tls=0x7f1990e29700, child_tidptr=0x7f1990e299d0) = 3600

如果你对这些参数的含义感兴趣,请参阅下面的描述(摘自 clone 手册):

CLONE_VM – 调用进程和子进程运行于同一内存空间。
CLONE_FS – 调用者和子进程共享相同的文件系统信息。
CLONE_FILES – 调用进程和子进程共享相同的文件描述符表。
CLONE_SIGHAND – 调用进程和子进程共享相同的信号处理函数表。 译注 1[5]
CLONE_THREAD – 把子进程与调用进程置于同一个线程组中。
CLONE_SYSVSEM – 子进程和调用进程共享同一个 System V 信号量调整值的列表。
CLONE_SETTLS – 将 TLS (线程本地存储)描述符设置成 nettls
CLONE_PARENT_SETTID – 将子线程 ID 存储在 ptid 在父线程内存中的位置。
CLONE_CHILD_CLEARTID – 将子线程 ID 存储在 ctid 在子线程内存中的位置。

–- clone 系统调用手册

clone 调用之后,线程会首先保留 128 MB 内存,然后再取消保留 57.8 MB 和 8 MB。看一下下面这段 strace 的输出:

mmap(NULL, 134217728, PROT_NONE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_NORESERVE, -1, 0) = 0x7f1988629000
//134217728 / 1024 / 1024 = 128 MiB
munmap(0x7f1988629000, 60649472 )
// 取消从 0x7f1988629000 起始的 57.8 MiB 的内存映射
munmap(0x7f1990000000, 6459392)
// 取消从 0x7f1990000000 起始的 8 MiB 的内存映射
mprotect(0x7f198c000000, 135168, PROT_READ|PROT_WRITE

现在,一切都能说通了。在 cgo 程序中,我们看到分配了大约 373.25 MB 的虚拟内存,这可以从上面的输出中获得完全的解释。甚至,它还解释了为什么我在本文第一部分[6]中的 /proc/PID/maps 里面没有看到这些内存映射,因为保留内存的线程有自己的 PID。另外,虽然线程调用了 mmap,由于并没有实际的使用那些内存区域,因而它们并不会被算到常驻内存里,而是被算到了虚拟内存里。

让我们来做一些随手计算:

strace 的输出中有 5 个 clone 系统调用。各自保留了 8 MB(栈)+ 128 MB,接着又取消保留 57.8 MB 和 8 MB,这样最终每个线程保留了约 70 MB。但实际上,有一个线程并没有取消映射任何内存,还有一个没有取消映射那 8 MB。所以,最终的算式应该像下面这样:

4 * 70 + 8 + 1 * 128 = ~ 416 MB

此外,不要忘了程序初始化的时候会额外保留一些内存,因而应该再加上一些常量。

显然,要想弄清楚我们到底是在哪个时刻对内存做的采样(执行 ps 命令)是非常困难的;比如,我们可能在只有 2 或 3 个线程在运行的时候执行的 ps,内存已经被 mmap 但还没有被释放等。在我看来,这就是我最初写作 Go 内存管理[7]这篇博文想要寻找的解答。

如果你对 mmap 的参数含义感兴趣,这里是它们的定义:

  • MAP_ANONYMOUS – 不映射到任何文件,将内存初始化成 0。

  • MAP_NORESERVE – 不为这个映射保留交换空间。

  • MAP_PRIVATE – 创建一个私有的写时复制映射。对于映射了同一个文件的多个进程而言,一个进程对内存的更新对其他进程不可见,并且不会写回文件。

mmap 系统调用手册

最后,我们来看看 Go 原生程序是怎样创建线程的。

Go 原生结果

go 原生程序只会进行 4 次 clone 系统调用,新线程不会分配内存(没有 mmap 调用),也不为栈保留 8 MB 的内存空间。go 原生程序创建线程的调用大致如下:

clone( child_stack=0xc420042000, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM) = 3935

注意 Go 和 cgo 调用 clone 时传递参数的差异。

此外,在 Go 原生代码产生的 strace 输出里,你可以清楚的看到 Println 语句对应的系统调用:

[pid  3934] write(1, "Hello from stdion", 17Hello from stdio
) = 17

译注 2[8]

然而,在 cgo 版本里,我没有找到 fputs() 对应的类似系统调用。

Go 原生程序让我喜爱的一点是,它的 strace 输出更小、更易于理解。这意味着发生了更少的事情。比如,go 原生程序的 strace 输出仅有 281 行,而 cgo 版的输出有 342 行。

结语

你可以从我的探索中得到的有:

  • 如果使用的包里引用了 C 代码,Go 可能自动神奇的切换到 cgo。比如使用了 netnet/http 包。

  • Go 有两个 DNS 解析器的实现:netgo 版和 netcgo 版。

  • 通过设置环境变量 export GODEBUG=netdns=1,你可以了解你在使用哪个 DNS 客户端。

  • 你可以在运行时切换 DNS 解析器,只需将环境变量设置成 export GODEBUG=netdns=goexport GODEBUG=netdns=cgo。 译注 3[9]

  • 你可以通过 Go 构建标签在编译时指定使用的 DNS 解析实现:go build -tags netcgogo build -tags netgo

  • /proc 文件系统很有用,但是不要被线程误导!

  • /prod/PID/status/proc/PID/maps 对于快速了解正在发生什么会很有用。

  • Go 运行时追踪器能够帮助你调试程序。

  • 当你不知道如何下手时,不要忘了还有 strace -f

最后:

  • cgo 不是 Go。

  • 大的虚拟内存占用不是坏事。

  • 不同版本的 Go 编译出来的程序会有不同的表现,Go 1.10 上正确的事情并不一定在 Go 1.12 上同样正确。

如果这篇博文让你有所收获,希望不仅仅是在编译 Go 程序的时候需要设置 CGO_ENABLED=0。Go 的作者不是凭空决定了 Go 现在的运行机制。你现在看到的这些行为在将来也许会改变,就像 Go 1.12 带来的变化那样。

这就是今天的内容。如果你想第一时间看到我的博客文章,请订阅简报[10]。如果你愿意支持我的写作,我这里还有一个愿望清单[11],你可以为我买一本书或是随便一个什么东西????。

感谢您的阅读,下次再见!

译注

  1. 此处原文中对 CLONE_SIGHAND 的说明实为 CLONE_FILES 的说明,应为拷贝粘贴错误。译文已纠正。

  2. 行末的 Hello from stdio 和换行符应为程序运行时打印到标准输出所致。

  3. 此处原文中两处皆为:export GODEBUG=netdns=go,应为笔误。译文已纠正。


via: https://povilasv.me/go-memory-management-part-3/

作者:Povilas[12]译者:Stonelgh[13]校对:polaris1119[14]

本文由 GCTT[15] 原创编译,Go 中文网[16] 荣誉推出,发布在 Go语言中文网公众号,转载请联系我们授权。

参考资料

[1]

povilasv.me: https://povilasv.me/

[2]

cgocall.go: https://golang.org/src/runtime/cgocall.go

[3]

Go 运行时追踪器: https://blog.gopheracademy.com/advent-2017/go-execution-tracer/

[4]

HTTP 追踪: https://blog.golang.org/http-tracing

[5]

译注 1: #note1

[6]

本文第一部分: https://povilasv.me/go-memory-management/

[7]

Go 内存管理: https://povilasv.me/go-memory-management/

[8]

译注 2: #note2

[9]

译注 3: #note3

[10]

简报: https://povilasv.me/newsletter

[11]

愿望清单: https://www.amazon.com/hz/wishlist/ls/2NLKE1Z1SND3W?ref_=wl_share

[12]

Povilas: https://povilasv.me/about/

[13]

Stonelgh: https://github.com/stonglgh

[14]

polaris1119: https://github.com/polaris1119

[15]

GCTT: https://github.com/studygolang/GCTT

[16]

Go 中文网: https://studygolang.com/

最后

以上就是自由未来为你收集整理的Go 内存管理之三:CGO的全部内容,希望文章能够帮你解决Go 内存管理之三:CGO所遇到的程序开发问题。

如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。

本图文内容来源于网友提供,作为学习参考使用,或来自网络收集整理,版权属于原作者所有。
点赞(39)

评论列表共有 0 条评论

立即
投稿
返回
顶部