Cơ bản về CGO

Alex Nguyen
ZaloPay Engineering
7 min readAug 29, 2019

CGO là một tính năng thú vị giúp ta có thể sử dụng lại code C từ trong phần code Go của mình.

Vậy khi nào cần sử dụng CGO?

  • Khi một thư viện cần sử dụng mà không có code Go tương ứng hoặc không thể viết trong Go.
  • Khi cần kế thừa logic nghiệp vụ trong C mà khó hoặc tốn nhiều chi phí để viết lại.
  • Hay khi sử dụng thư viện độc quyền hoặc SDK.

Trong bài viết này chúng ta sẽ cùng tìm hiểu một số điểm cần lưu ý trong sử dụng CGO. Bài viết sẽ làm rõ:

  • Các Setup để sử dụng CGO.
  • Xây dụng một chương trình CGO đơn giản.
  • Sử dụng Interface của C trong Go.
  • Lệnh #cgo, tag để build.
  • Nhược điểm của CGO.

Setup để sử dụng CGO

Windows

CGO không tương thích với Cygwin. Để dùng được CGO bạn phải cài đặt MingGW và sử dụng cmd của Windows.

Linux, MacOS

CGO được tích hợp sẵn, người dùng Linux và Mac chỉ cần cài đặt Go.

1. Chương trình CGO đơn giản

Hãy xem cách viết CGO trong code sau:

Cần lưu ý rằng câu lệnh import "C" yêu cầu một dòng riêng và không thể được import cùng với các package khác. Phần code trong cặp /* và */ ngay phía trên import "C" sẽ được xem như thuộc một package C ảo và có thể gọi ra để sử dụng thông qua package này.

Trình biên dịch sẽ tự động chia ra, gọi gcc (hoặc trình biên dịch C default của máy tính) để biên dịch đoạn code C, sau đó go compiler sẽ làm nhiệm vụ liên kết nó vào chương trình.

Một lưu ý nữa, Go là ngôn ngữ ràng buộc kiểu mạnh, do đó tham số được truyền phải đúng kiểu khai báo, và phải được chuyển đổi sang kiểu trong C bằng các hàm chuyển đổi trước khi truyền, không thể truyền trực tiếp bằng kiểu của Go.

Tiếp theo chúng ta sẽ tìm hiểu chi tiết hơn về cách gọi hàm kiểu này.

2. Gọi hàm C từ Go

Đối với hàm của C có giá trị trả về, chúng ta có thể nhận giá trị trả về bình thường.

Hàm div ở trên thực hiện một phép toán chia số nguyên và trả về kết quả của phép chia.

Tuy nhiên, không có cách xử lý đặc biệt nào cho trường hợp số chia là 0. Vì ngôn ngữ C không hỗ trợ trả về nhiều kết quả nên nếu bạn muốn trả về lỗi khi số chia là 0 thì có thể xem errno là một biến toàn cục thread-safe có thể được sử dụng để ghi lại mã trạng thái của lỗi gần đây nhất.

CGO hỗ trợ các macro errno thuộc thư viện tiêu chuẩn <errno.h>: nếu có hai giá trị trả về khi CGO gọi hàm C thì giá trị trả về thứ hai sẽ tương ứng với trạng thái lỗi errno.

Từ đó ta cải tiến hàm div như sau:

Thực thi đoạn code trên sẽ cho output như sau:

2 <nil>
0 invalid argument

Chúng ta có thể xem hàm `div` tương ứng với một hàm trong Go như sau:

func C.div(a, b C.int) (C.int, error)

Trong đó tham số thứ hai trả về (giá trị error) có thể bỏ qua, được hiện thực bên dưới là kiểu syscall.Errno.

3. Giá trị trả về của hàm void

Trong C cũng có hàm không trả về kiểu giá trị (thay vào đó trả về void). Chúng ta không thể nhận được giá trị trả về của hàm kiểu void vì đó thực sự không phải giá trị!?.

Như đã đề cập trong ví dụ trước, CGO hiện thực một phương pháp đặc biệt cho errno và có thể nhận về trạng thái lỗi của ngôn ngữ C thông qua giá trị trả về thứ hai. Tính năng này vẫn hợp lệ cho các hàm kiểu void. Đoạn code sau để lấy mã trạng thái lỗi của hàm không có giá trị trả về:

Lúc này, chúng ta bỏ qua giá trị trả về đầu tiên và chỉ nhận được mã lỗi tương ứng với giá trị trả về thứ hai.

Chúng ta cũng có thể thử lấy giá trị trả về đầu tiên, cũng chính là kiểu tương ứng với kiểu void trong ngôn ngữ C:

Chạy code này sẽ thu được kết quả:

main._Ctype_void{}

Chúng ta có thể thấy rằng kiểu void của ngôn ngữ C tương ứng với kiểu trong package main _Ctype_void. Trong thực tế, hàm noreturn của ngôn ngữ C cũng được coi là một hàm với kiểu trả về _Ctype_void, do đó bạn có thể trực tiếp nhận giá trị trả về của hàm kiểu void:

Chạy code này sẽ cho ra kết quả sau:

[]

Trong thực tế, trong code được CGO tạo ra, kiểu _Ctype_void tương ứng với kiểu mảng có độ dài 0 ([0]byte), do đó output fmt.Println là một cặp dấu ngoặc vuông biểu thị một giá trị null.

4. Sử dụng Interface của C trong Go

Trừu tượng và module hóa là cách để đơn giản hóa các vấn đề trong lập trình:

  • Khi code quá dài, ta có thể đưa các lệnh tương tự nhau vào chung một hàm.
  • Khi có nhiều hàm hơn, ta chia chúng vào các file hoặc module.

Trong phần này, ta trừu tượng hóa một module tên là hello và tất cả các interface của module đó được khai báo trong file header hello.h:

Và hiện thực hàm SayHello trong file hello.c:

Ngoài ra ta có thể hiện thực hàm này bằng C++ cũng được:

Trong hàm main của Go ta gọi file header như sau:

Với việc lập trình C thông qua interface, ta có thể hiện thực module bằng nhiều ngôn ngữ khác nhau, miễn là đáp ứng được interface SayHello có thể được viết bằng C, C++, Go hoặc kể cả Assembly.

5. Lệnh #cgo

Trước dòng import "C" ta có thể đặt các tham số cho quá trình biên dịch (compile phase) và quá trình liên kết (link phase) thông qua các lệnh #cgo.

  • Các tham số của quá trình biên dịch chủ yếu được sử dụng để xác định các macro liên quan và đường dẫn truy xuất file header đã chỉ định.
  • Các tham số của quá trình liên kết chủ yếu là để xác định đường dẫn truy xuất file thư viện và file thư viện sẽ được liên kết.

Trong đoạn mã trên:

  • Phần CFLAGS: -D định nghĩa macro PNG_DEBUG, giá trị là 1
  • -I xác định thư mục tìm kiếm có trong file header.
  • Phần DFLAGS: -L chỉ ra thư mục truy xuất các file thư viện, -l chỉ định thư viện png là bắt buộc.

Do các vấn đề mà C/C ++ để lại, đường dẫn truy xuất file header C có thể là relative path, nhưng đường dẫn truy xuất file thư viện bắt buộc phải là absolute path. Absolute path của thư mục package hiện tại có thể được biểu diễn bằng biến ${SRCDIR} trong thư mục truy xuất các file thư viện:

// #cgo LDFLAGS: -L${SRCDIR}/libs -lfoo

Đoạn code trên sẽ được phân giải trong link phase và trở thành:

// #cgo LDFLAGS: -L/go/src/foo/libs -lfoo

Lệnh #cgo chủ yếu ảnh hưởng đến một số biến môi trường của trình biên dịch như CFLAGS, CPPFLAGS, CXXFLAGS, FFLAGS và LDFLAGS. LDFLAGS được sử dụng để đặt tham số của liên kết, CFLAGS được sử dụng để đặt tham số biên dịch cho code ngôn ngữ C.

Đối với người dùng sử dụng C và C++ trong môi trường CGO, có thể có ba tùy chọn biên dịch khác nhau:

  • CFLAGS cho các tùy chọn biên dịch theo ngôn ngữ C.
  • CPPFLAGS cho các tùy chọn biên dịch cụ thể C++.
  • CXXFLAGS cho các biên dịch C và C++.

Các lệnh #cgo cũng hỗ trợ tùy chọn biên dịch hoặc liên kết với các hệ điều hành hoặc một kiểu kiến trúc CPU khác nhau:

// tuỳ chọn cho Windows
// #cgo windows CFLAGS: -DX86=1
// tuỳ chọn cho non-windows platforms
// #cgo !windows LDFLAGS: -lm

Một ví dụ để xác định hệ thống nào đang chạy CGO:

Bằng cách này, chúng ta có thể biết được hệ thống mà code đang vận hành, nhờ đó áp dụng các kĩ thuật riêng cho các nền tảng khác nhau.

Nhược điểm của CGO

Bên cạnh các điểm tiện lợi mà CGO mang lại, công cụ này cũng tồn tại khá nhiều điểm trừ, đến mức các developer đều sẽ xem như việc sử dụng nó là cách cuối cùng trong trường hợp bất khả kháng. Một số điểm có thể kể đến:

  • CGO build rất chậm, dĩ nhiên khi bây giờ trong build process phải chạy đến 2 compiler (GCC và Go compiler) biên dịch 2 lần code và link chúng lại với nhau. Ngoài ra lời gọi hàm CGO cũng mất nhiều thời gian thực thi hơn so với Go native.
  • Nguy cơ memory leak: Go có garbage collector (GC) nhưng C thì không nên khi khởi tạo các object bên trong code C, bạn phải quản lý chúng bằng tay (tạo ra thì nhớ hủy nó đi), nếu không thì memleak sẽ là một vấn lớn.
  • Nguy cơ lỗi vùng nhớ: do khác biệt về tổ chức bộ nhớ của C (static) và Go (dynamic scaling), nếu một hàm của C cần làm việc với object trên vùng nhớ của Go khả năng cao sẽ dẫn tới lỗi.

Lời kết

Trên đây là một số vấn đề cơ bản trong sử dụng CGO được trích một phần từ cuốn sách Advanced Go Book do nhóm tác giả ZaloPay biên soạn. Để đọc chi tiết hơn và xem các code ví dụ cụ thể, mời bạn đọc vào repo của nhóm để tham khảo.

Trong bài viết tiếp theo, chúng ta sẽ đi tìm hiểu sâu hơn về cơ chế tổ chức bộ nhớ, chuyển đổi kiểu cũng như cách Go làm việc với object của C.

Cảm ơn các bạn đã đọc.

--

--