Go 語言使用 cgo 時的內存管理

【導讀】本文中作者介紹了使用 cgo 時的關注點和使用 pprof 對 cgo 程序進行排查時的注意點。

先放結論

使用 cgo 時:

  1. 和日常 Go 對象被 gc 管理釋放的表現略有不同的是,Go 和 c 代碼的類型相互轉化傳遞時,有時需要在調用結束後手動釋放內存。

  2. 有時類型轉換伴隨着內存拷貝的開銷。

  3. 如果想消除拷貝開銷,可以通過unsafe.Pointer獲取原始指針進行傳遞。

  4. c 代碼中的內存泄漏,依然可以使用 valgrind 檢查。但是需要注意,像C.CString這種泄漏,valgrind 無法給出泄漏的準確位置。

  5. go pprof無法檢查 c 代碼中的內存泄漏。

引子

Go 的 cgo 介紹頁面源碼中的註釋文檔 有如下例子:

package main

// #include <stdio.h>
// #include <stdlib.h>
//
// static void myprint(char* s) {
//   printf("%s\n", s);
// }
import "C"
import "unsafe"

func main() {
  cs := C.CString("Hello from stdio")
  C.myprint(cs)
  C.free(unsafe.Pointer(cs)) // yoko注,去除這行將發生內存泄漏
}

從上面例子可以看到,Go 代碼中的cs變量在傳遞給 c 代碼使用完成之後,需要調用C.free進行釋放。

文檔中也對C.CString的釋放做了如下強調說明:

// Go string to C string
// The C string is allocated in the C heap using malloc.
// It is the caller's responsibility to arrange for it to be
// freed, such as by calling C.free (be sure to include stdlib.h
// if C.free is needed).
func C.CString(string) *C.char {}

// 翻譯成中文:
// C string在C的堆上使用malloc申請。
// 調用者有責任在合適的時候對該字符串進行釋放,釋放方式可以是調用C.free(調用C.free需包含stdlib.h)

另外值得說明的是,以下幾種類型轉換,都會發生內存拷貝

// Go string to C string
func C.CString(string) *C.char {}

// Go []byte slice to C array
// 這個和C.CString一樣,也需要手動釋放申請的內存
func C.CBytes([]byte) unsafe.Pointer {}

// C string to Go string
func C.GoString(*C.char) string {}

// C data with explicit length to Go string
func C.GoStringN(*C.char, C.int) string {}

// C data with explicit length to Go []byte
func C.GoBytes(unsafe.Pointer, C.int) []byte {}

就想減少那一次拷貝

假設我們可以確定在 c 模塊中不會修改 Go 傳入的內存,並且 c 函數調用結束之後,c 模塊不會再持有這塊內存,我們出於性能考慮,想避免這種拷貝,可以這樣做:

package main

// #include <stdio.h>
// #include <stdlib.h>
//
// static void myprint(char* s, int len) {
//   int i = 0;
//   for (; i < len; i++) {
//     printf("%c", *(s+i));
//   }
//   printf("\n");
// }
import "C"
import "unsafe"

type StringStruct struct {
  str unsafe.Pointer
  len int
}

func main() {
  str := "Hello from stdio"
  ss := (*StringStruct)(unsafe.Pointer(&str))
  c := (*C.char)(unsafe.Pointer(ss.str))
  C.myprint(c, C.int(len(str)))
}

這裏多說兩句,我對於 cgo 在 buffer 傳遞時,默認使用拷貝方式的理解。

首先,Go 中 string 是 immutable 語義的,我們無法確保 c 模塊中不會對傳入內存進行修改。相關的內容還可以看看這篇 [Go 語言中]byte 和 string 類型相互轉換時的性能分析和優化

更爲重要的是,Go 自身的堆內存管理使用了垃圾回收器。那麼和 c 語言模塊進行交互時,Go 將內存傳入 c 模塊後,Go 無法知道 c 模塊會持有這塊內存多久(有可能函數調用結束後,c 模塊依然持有這塊內存),所以 Go 的解決方案是對內存進行拷貝後傳入。一般來說,c 語言裏都是秉承哪個模塊申請就由哪個模塊釋放的原則,因爲跨庫申請釋放可能由於各自鏈接的內存管理庫不一致導致出現難以排查的 bug。並且換個角度來說,被調用模塊也無法知道傳入的內存是在堆上申請還是棧上申請的,是否需要釋放。所以 Go 傳入 c 模塊的內存,c 模塊也許會對這塊內存再次進行拷貝,但是 c 模塊肯定不會釋放(即free)傳入的這份內存。所以,一般來說,Go 在調用完 c 函數之後,Go 需要釋放拷貝生成的這塊內存。

很多時候,拷貝體現的是一種解耦的思想,用性能消耗、內存佔用量換取可讀性和可維護性。

使用 cgo 時如何定位內存泄漏問題

以下我們故意製造幾種內存泄漏的場景,看 valgrind 和Go pprof是否能檢查出來。

測試一,我們註釋掉本文第一個例子中的free,使用 valgrind 跑內存泄漏檢查,輸出信息如下:

$valgrind --leak-check=full ./demo

==31055== 17 bytes in 1 blocks are definitely lost in loss record 1 of 4
==31055==    at 0x4C29BC3: malloc (vg_replace_malloc.c:299)
==31055==    by 0x737433: _cgo_d0ada72ffd0d_Cfunc__Cmalloc (_cgo_export.c:30)
==31055==    by 0x45C9DF: runtime.asmcgocall (/usr/local/go/src/runtime/asm_amd64.s:635)
==31055==    by 0xD1A6BF: ???
==31055==    by 0x1FFF00030F: ???
==31055==    by 0x45A057: runtime.goready.func1 (/usr/local/go/src/runtime/proc.go:312)
==31055==    by 0x45B205: runtime.systemstack (/usr/local/go/src/runtime/asm_amd64.s:351)
==31055==    by 0x4331BF: ??? (/usr/local/go/src/runtime/proc.go:1082)
==31055==    by 0x45B098: runtime.rt0_go (/usr/local/go/src/runtime/asm_amd64.s:201)

可以看到,雖然 valgrind 給出了definitely lost的結果,但是幾乎無法直接找到泄漏的位置。

測試二,我們再來測試在 c 代碼中製造泄漏,看 valgrind 是否能查到,測試代碼如下:

package main

// #include <stdio.h>
// #include <stdlib.h>
// #include <string.h>
//
// static void myprint(char* s) {
//   printf("%s\n", s);
// }
//
// static void f1() {
//   void *p = malloc(128 * 1024 * 1024); // 這裏故意申請不釋放
//   memset(p, '0', 128 * 1024 * 1024);
// }
import "C"
//import "unsafe"
import _ "net/http/pprof"

func main() {
  cs := C.CString("Hello from stdio")
  C.myprint(cs)
  C.f1()
  //C.free(unsafe.Pointer(cs))
}

輸出如下:

==31701== 134,217,728 bytes in 1 blocks are possibly lost in loss record 5 of 5
==31701==    at 0x4C29BC3: malloc (vg_replace_malloc.c:299)
==31701==    by 0x73754D: f1 (main.go:12)
==31701==    by 0x73754D: _cgo_3679cecbf840_Cfunc_f1 (cgo-gcc-prolog:48)
==31701==    by 0x45CA5F: runtime.asmcgocall (/usr/local/go/src/runtime/asm_amd64.s:635)
==31701==    by 0xD1A6BF: ???
==31701==    by 0x1FFF00031F: ???
==31701==    by 0x45A0D7: runtime.goready.func1 (/usr/local/go/src/runtime/proc.go:312)
==31701==    by 0x45B285: runtime.systemstack (/usr/local/go/src/runtime/asm_amd64.s:351)
==31701==    by 0x43323F: ??? (/usr/local/go/src/runtime/proc.go:1082)
==31701==    by 0x45B118: runtime.rt0_go (/usr/local/go/src/runtime/asm_amd64.s:201)

可以看到,valgrind 給出了possibly lost的結果,並且有具體的函數和行號。說明 valgrind 在這種情況可以起作用。

測試三,申請的代碼稍微複雜一點,在 c 代碼中創建一個新的線程,在線程中製造內存泄漏,代碼如下:

package main

// #include <stdio.h>
// #include <stdlib.h>
// #include <string.h>
// #include <pthread.h>
//
// static void myprint(char* s) {
//   printf("%s\n", s);
// }
//
// static void *f1(void *q) {
//   void *p = malloc(128 * 1024 * 1024);
//   memset(p, '0', 128 * 1024 * 1024);
//   return NULL;
// }
//
// static void f2() {
//   pthread_t t;
//   pthread_create(&t, NULL, f1, NULL);
// }
import "C"
//import "unsafe"

func main() {
  cs := C.CString("Hello from stdio")
  C.myprint(cs)
  C.f2()
  //C.free(unsafe.Pointer(cs))
}

valgrind輸出如下:

==31858== 134,217,728 bytes in 1 blocks are possibly lost in loss record 6 of 6
==31858==    at 0x4C29BC3: malloc (vg_replace_malloc.c:299)
==31858==    by 0x45197D: f1 (main.go:13)
==31858==    by 0x4E3DDD4: start_thread (in /usr/lib64/libpthread-2.17.so)
==31858==    by 0x514FEAC: clone (in /usr/lib64/libc-2.17.so)

可以看到,這種情況 valgrind 也可以檢查出來。

測試四,用go pprof分析,分析方法見 Go 語言 pprof 備忘錄,代碼如下:

package main

// #include <stdio.h>
// #include <stdlib.h>
// #include <string.h>
//
// static void myprint(char* s) {
//   printf("%s\n", s);
// }
//
// static void f1() {
//   void *p = malloc(128 * 1024 * 1024);
//   memset(p, '0', 128 * 1024 * 1024);
// }
import "C"
//import "unsafe"
import _ "net/http/pprof"
import "net/http"

func main() {
  cs := C.CString("Hello from stdio")
  C.myprint(cs)
  C.f1()
  //C.free(unsafe.Pointer(cs))
  http.ListenAndServe("0.0.0.0:10001", nil)
}

go pprof的結果是,它並不記錄C.CString申請的內存,也不記錄 c 代碼中申請的內存。

其他

我個人猜測go pprof只監測通過 Go 垃圾回收器申請和釋放的內存,C.CString以及 c 代碼中的內存申請都沒有經過 gc,所以無法監測。

文檔中也有相應的描述,如下:

As a special case, C.malloc does not call the C library malloc directly but instead calls a Go helper function that wraps the C library malloc but guarantees never to return nil. If C's malloc indicates out of memory, the helper function crashes the program, like when Go itself runs out of memory. Because C.malloc cannot fail, it has no two-result form that returns errno.

轉自:

github.com/q191201771

Go 開發大全

參與維護一個非常全面的 Go 開源技術資源庫。日常分享 Go, 雲原生、k8s、Docker 和微服務方面的技術文章和行業動態。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/VtlcT2AlfTPVNRQJN8-toQ