Đôi khi bạn phải gọi mã C từ Go: một thư viện mã hóa đã kiểm định, một driver phần cứng, SQLite, libjpeg. Go cho phép điều này qua cgo — cầu nối gọi hàm C ngay trong mã Go. Nghe tiện, và đúng là tiện. Nhưng cgo mang một cái giá mà nhiều người đánh giá thấp: mỗi lần vượt biên giới Go↔C tốn một khoản phí cố định, không phụ thuộc hàm C làm gì. Bài này đo thật khoản phí đó, giải thích vì sao nó tồn tại, và chỉ ra cách duy nhất để nó không giết hiệu năng.

Gọi C từ Go trông thế nào

cgo dùng một quy ước đặc biệt: viết mã C trong một khối comment ngay trên dòng import "C". Mọi hàm, kiểu, biến C khai trong đó truy cập được qua namespace giả C:

/*
#include <stdint.h>
static int64_t cong_c(int64_t a, int64_t b) { return a + b; }
*/
import "C"

r := C.cong_c(C.int64_t(20), C.int64_t(22)) // = 42

import "C" phải đứng ngay dưới khối comment, không có dòng trống xen giữa. C.cong_c gọi hàm C; C.int64_t(...) chuyển giá trị Go sang kiểu C tương ứng. Đo thật trong go-lab (Go 1.23, gcc 12.2): C.cong_c(20,22) trả về 42 đúng như mong đợi. Nhưng có một tác dụng phụ quan trọng: bật cgo khiến binary link động tới libc — không còn là binary tĩnh thuần túy nữa (ta sẽ nói ở phần đánh đổi).

Ảnh chụp đoạn mã Go nền tối minh hoạ cgo gọi C từ Go và cái giá cố định mỗi lần vượt biên giới, gọi C từ Go khối comment cộng import C, khối comment chứa include stdint h static int64_t cong_c int64_t a int64_t b return a cộng b, import C phải ngay dưới khối comment C, r bằng C chấm cong_c C int64_t 20 C int64_t 22 bằng 42, C tên gọi hàm C C int64_t là kiểu C tương ứng bật cgo kéo theo binary link động tới libc không tĩnh, mỗi lần gọi C phải vượt biên giới Go C vì sao đắt runtime Go phải cho mỗi lần gọi chuyển từ goroutine stack nhỏ co giãn sang system stack báo scheduler goroutine đang chạy mã ngoài tầm kiểm soát chặn preemption và một số tương tác với GC trong lúc ở C chi phí cố định mỗi lần không phụ thuộc C làm gì nhiều hay ít, cách sửa gộp việc gọi C một lần cho cả lô tệ N lần cgo call mỗi phần tử một lần vượt biên for range xs s cộng int64 C add1 tốt một cgo call xử lý cả mảng bên trong C C sum_arr unsafe Pointer trả biên giới về 1 lần thay vì N lần khấu hao hết phí

Hình 1: cgo dùng khối comment C ngay trên import "C". C.cong_c(...) gọi hàm C. Mỗi lần gọi phải vượt biên giới Go↔C — chi phí cố định. Cách sửa: gộp việc, gọi C một lần cho cả lô thay vì mỗi phần tử một lần.

Vì sao mỗi lần gọi lại đắt

Một lời gọi hàm Go thường gần như miễn phí — compiler thường inline nó. Nhưng gọi qua cgo, runtime Go phải làm một loạt việc cho mỗi lần gọi:

  • Chuyển từ goroutine stack (nhỏ, co giãn được) sang system stack (cố định, kiểu C) — vì mã C không hiểu stack co giãn của Go.
  • Báo cho scheduler biết goroutine này đang chạy mã ngoài tầm kiểm soát của runtime, để nó không bị preempt hay dời sang thread khác giữa chừng.
  • Xử lý tương tác với GC: trong lúc ở C, con trỏ Go truyền vào phải được pin để GC không dời chúng.

Tất cả những việc này là chi phí cố định mỗi lần gọi, không phụ thuộc hàm C bên trong làm nhiều hay ít. Đo thật để thấy điều đó:

Ảnh chụp bảng kết quả đo thật nền tối mỗi cgo call tốn 22 ns cố định gấp 95x gọi Go gộp lô cứu 76x, go test bench benchmem Go 1.23 gcc 12.2 arm64 10 core, chi phí biên giới cố định không phụ thuộc C làm gì cgo call cong_c có tính toán 22,30 ns 0 alloc cgo call rỗng noop_c chỉ vượt biên 21,80 ns 0 alloc hàm Go tương đương congGo nội tuyến 0,2304 ns 0 alloc, noop_c 21,80 gần bằng cong_c 22,30 chi phí gần như toàn bộ là vượt biên giới không phải phép cộng cgo đắt hơn gọi Go 95x, gộp lô N cgo call vs 1 cgo call mảng 1000 phần tử 1000 cgo call mỗi phần tử một lần 21.378 ns 0 alloc 1 cgo call xử lý cả mảng trong C 281,4 ns 0 alloc 1000 nhân 21 ns biên giới bằng 21.378 ns gộp thành 1 lần vượt biên rồi để C lặp bên trong 281 ns nhanh hơn 76x khấu hao phí, chạy thật C cong_c 20 22 bằng 42 gọi C thành công binary link động libc so 6 cgo buộc dynamic link, cốt lõi chi phí biên 22 ns mỗi lần cố định đổi stack báo scheduler GC so Go gọi Go 0,23 ns cgo đắt hơn 95x mỗi lần cách sửa gộp việc gọi C 1 lần cho cả lô cứu 76x kéo theo link động libc mất một phần tính di động của Go

Hình 2: cgo call có tính toán (22,30 ns) và cgo call rỗng (21,80 ns) gần bằng nhau — chi phí gần như toàn bộ là vượt biên giới, không phải phép cộng. Hàm Go tương đương chỉ 0,2304 ns. Gộp lô: 1000 cgo call riêng lẻ (21.378 ns) so với 1 cgo call cho cả mảng (281,4 ns) — nhanh hơn ~76x.

Bằng chứng nằm ở hai dòng đầu: noop_c (hàm C rỗng, chỉ return 0) mất 21,80 ns, còn cong_c (có phép cộng) mất 22,30 ns — gần như bằng nhau. Nghĩa là ~22 ns đó không phải chi phí tính toán mà gần như toàn bộ là chi phí vượt biên giới. So với hàm Go tương đương chạy 0,2304 ns, một cgo call đắt hơn khoảng 95 lần. Con số này nhất quán với những gì cộng đồng Go đo được qua nhiều năm: cgo call cỡ vài chục nano giây, gấp cả trăm lần gọi Go thường.

Cách sửa duy nhất: gộp lô

Vì phí là mỗi lần vượt biên, cách duy nhất để nó không giết hiệu năng là vượt biên ít lần hơn — gộp nhiều việc vào một cgo call. So sánh hai cách xử lý mảng 1.000 phần tử:

// TỆ: 1000 cgo call, mỗi phần tử một lần vượt biên
for _, x := range xs { s += int64(C.add1(C.int64_t(x))) }

// TỐT: MỘT cgo call, C tự lặp bên trong
C.sum_arr((*C.int64_t)(unsafe.Pointer(&xs[0])), C.int(len(xs)))

Đo thật: cách per-element mất 21.378 ns (1000 × ~21 ns biên giới), còn cách gộp lô chỉ 281,4 ns — nhanh hơn ~76 lần. Toàn bộ chênh lệch là chi phí biên giới bị nhân lên 1000 lần ở cách đầu. Nguyên tắc rút ra: nếu phải dùng cgo, hãy thiết kế API C thô (coarse-grained) — mỗi cgo call làm thật nhiều việc — thay vì API mịn (fine-grained) gọi qua lại liên tục. Truyền cả mảng, cả buffer, cả struct sang một lần, để C xử lý trong vòng lặp của nó.

Đánh đổi cần cân nhắc

cgo phá vỡ binary tĩnh và một phần tính di động. Binary có cgo link động tới libc (đo thật: ldd cho thấy libc.so.6), nên không còn "copy một file là chạy mọi nơi" — máy đích phải có libc tương thích. Cross-compile cũng phức tạp hơn nhiều vì cần toolchain C cho nền tảng đích. Nếu ưu thế lớn nhất của dự án là triển khai một-binary, cân nhắc kỹ trước khi thêm cgo.

Chỉ dùng cgo khi thật sự cần thư viện C. Với thư viện lớn đã kiểm định (OpenSSL, SQLite, ffmpeg) mà viết lại bằng Go là phi thực tế, cgo là lựa chọn đúng. Nhưng nếu chỉ cần một thuật toán nhỏ, viết thẳng bằng Go thường thắng — vừa tránh phí biên giới, vừa giữ binary tĩnh, vừa dễ debug (stack trace xuyên biên giới C rất khó đọc).

Đừng gọi cgo trong vòng lặp nóng. Đây là hệ quả trực tiếp của phép đo. Một cgo call trong vòng lặp chạy hàng triệu lần biến ~22 ns thành hàng chục mili giây phí thuần. Nếu buộc phải xử lý nhiều phần tử qua C, gộp chúng thành một call. Và luôn benchmark — đôi khi phiên bản Go thuần hóa ra nhanh hơn cgo dù C nhanh hơn, đơn giản vì phí biên giới lớn hơn phần C tiết kiệm được.

Ba ý mang về

  1. Mỗi cgo call tốn một khoản phí cố định ~22 ns để vượt biên giới Go↔C (đổi stack, báo scheduler, pin con trỏ cho GC) — đo thật, hàm C rỗng (21,80 ns) gần bằng hàm C có tính toán (22,30 ns), chứng tỏ phí nằm ở biên giới chứ không ở phần C; đắt hơn một lời gọi Go (~0,23 ns) khoảng 95 lần.
  2. Cách sửa là gộp lô — vượt biên ít lần hơn: đo thật, 1000 cgo call riêng lẻ (21.378 ns) so với một cgo call xử lý cả mảng trong C (281,4 ns) nhanh hơn ~76 lần; thiết kế API C thô, truyền cả buffer/mảng một lần thay vì gọi qua lại.
  3. cgo đánh đổi tính di động: binary link động tới libc (mất binary tĩnh), cross-compile phức tạp, stack trace khó đọc — chỉ dùng khi thật sự cần thư viện C đã kiểm định, và đừng bao giờ gọi cgo trong vòng lặp nóng.

Phần sau ta đi tới một đích biên dịch hiện đại mở ra thế giới trình duyệt cho Go: Phần sau mổ xẻ WebAssembly — biên dịch Go với GOOS=js GOARCH=wasm, cách gọi qua lại JavaScript, và kích thước binary wasm.