13.4. 通過cgo調用C代碼

Go程序可能會遇到要訪問C語言的某些硬件驅動函數的場景,或者是從一個C++語言實現的嵌入式數據庫查詢記錄的場景,或者是使用Fortran語言實現的一些線性代數庫的場景。C語言作為一個通用語言,很多庫會選擇提供一個C兼容的API,然後用其他不同的編程語言實現(譯者:Go語言需要也應該擁抱這些巨大的代碼遺產)。

在本節中,我們將構建一個簡易的數據壓縮程序,使用了一個Go語言自帶的叫cgo的用於支援C語言函數調用的工具。這類工具一般被稱為 foreign-function interfaces (簡稱ffi),並且在類似工具中cgo也不是唯一的。SWIG(http://swig.org)是另一個類似的且被廣泛使用的工具,SWIG提供了很多複雜特性以支援C++的特性,但SWIG並不是我們要討論的主題。

在標準庫的compress/...子包有很多流行的壓縮算法的編碼和解碼實現,包括流行的LZW壓縮算法(Unix的compress命令用的算法)和DEFLATE壓縮算法(GNU gzip命令用的算法)。這些包的API的細節雖然有些差異,但是它們都提供了針對 io.Writer類型輸出的壓縮接口和提供了針對io.Reader類型輸入的解壓縮接口。例如:

package gzip // compress/gzip
func NewWriter(w io.Writer) io.WriteCloser
func NewReader(r io.Reader) (io.ReadCloser, error)

bzip2壓縮算法,是基於優雅的Burrows-Wheeler變換算法,運行速度比gzip要慢,但是可以提供更高的壓縮比。標準庫的compress/bzip2包目前還沒有提供bzip2壓縮算法的實現。完全從頭開始實現一個壓縮算法是一件繁瑣的工作,而且 http://bzip.org 已經有現成的libbzip2的開源實現,不僅文檔齊全而且性能又好。

如果是比較小的C語言庫,我們完全可以用純Go語言重新實現一遍。如果我們對性能也沒有特殊要求的話,我們還可以用os/exec包的方法將C編寫的應用程序作為一個子進程運行。只有當你需要使用複雜而且性能更高的底層C接口時,就是使用cgo的場景了(譯註:用os/exec包調用子進程的方法會導致程序運行時依賴那個應用程序)。下面我們將通過一個例子講述cgo的具體用法。

譯註:本章採用的代碼都是最新的。因為之前已經出版的書中包含的代碼只能在Go1.5之前使用。從Go1.6開始,Go語言已經明確規定了哪些Go語言指針可以直接傳入C語言函數。新代碼重點是增加了bz2alloc和bz2free的兩個函數,用於bz_stream對象空間的申請和釋放操作。下面是新代碼中增加的註釋,說明這個問題:

// The version of this program that appeared in the first and second
// printings did not comply with the proposed rules for passing
// pointers between Go and C, described here:
// https://github.com/golang/proposal/blob/master/design/12416-cgo-pointers.md
//
// The rules forbid a C function like bz2compress from storing 'in'
// and 'out' (pointers to variables allocated by Go) into the Go
// variable 's', even temporarily.
//
// The version below, which appears in the third printing, has been
// corrected.  To comply with the rules, the bz_stream variable must
// be allocated by C code.  We have introduced two C functions,
// bz2alloc and bz2free, to allocate and free instances of the
// bz_stream type.  Also, we have changed bz2compress so that before
// it returns, it clears the fields of the bz_stream that contain
// pointers to Go variables.

要使用libbzip2,我們需要先構建一個bz_stream結構體,用於保持輸入和輸出緩存。然後有三個函數:BZ2_bzCompressInit用於初始化緩存,BZ2_bzCompress用於將輸入緩存的數據壓縮到輸出緩存,BZ2_bzCompressEnd用於釋放不需要的緩存。(目前不要擔心包的具體結構,這個例子的目的就是演示各個部分如何組合在一起的。)

我們可以在Go代碼中直接調用BZ2_bzCompressInit和BZ2_bzCompressEnd,但是對於BZ2_bzCompress,我們將定義一個C語言的包裝函數,用它完成真正的工作。下面是C代碼,對應一個獨立的文件。

gopl.io/ch13/bzip

/* This file is gopl.io/ch13/bzip/bzip2.c,         */
/* a simple wrapper for libbzip2 suitable for cgo. */
#include <bzlib.h>

int bz2compress(bz_stream *s, int action,
                char *in, unsigned *inlen, char *out, unsigned *outlen) {
    s->next_in = in;
    s->avail_in = *inlen;
    s->next_out = out;
    s->avail_out = *outlen;
    int r = BZ2_bzCompress(s, action);
    *inlen -= s->avail_in;
    *outlen -= s->avail_out;
    s->next_in = s->next_out = NULL;
    return r;
}

現在讓我們轉到Go語言部分,第一部分如下所示。其中import "C"的語句是比較特別的。其實並沒有一個叫C的包,但是這行語句會讓Go編譯程序在編譯之前先運行cgo工具。

// Package bzip provides a writer that uses bzip2 compression (bzip.org).
package bzip

/*
#cgo CFLAGS: -I/usr/include
#cgo LDFLAGS: -L/usr/lib -lbz2
#include <bzlib.h>
#include <stdlib.h>
bz_stream* bz2alloc() { return calloc(1, sizeof(bz_stream)); }
int bz2compress(bz_stream *s, int action,
                char *in, unsigned *inlen, char *out, unsigned *outlen);
void bz2free(bz_stream* s) { free(s); }
*/
import "C"

import (
    "io"
    "unsafe"
)

type writer struct {
    w      io.Writer // underlying output stream
    stream *C.bz_stream
    outbuf [64 * 1024]byte
}

// NewWriter returns a writer for bzip2-compressed streams.
func NewWriter(out io.Writer) io.WriteCloser {
    const blockSize = 9
    const verbosity = 0
    const workFactor = 30
    w := &writer{w: out, stream: C.bz2alloc()}
    C.BZ2_bzCompressInit(w.stream, blockSize, verbosity, workFactor)
    return w
}

在預處理過程中,cgo工具生成一個臨時包用於包含所有在Go語言中訪問的C語言的函數或類型。例如C.bz_stream和C.BZ2_bzCompressInit。cgo工具通過以某種特殊的方式調用本地的C編譯器來發現在Go源文件導入聲明前的註釋中包含的C頭文件中的內容(譯註:import "C"語句前緊挨著的註釋是對應cgo的特殊語法,對應必要的構建參數選項和C語言代碼)。

在cgo註釋中還可以包含#cgo指令,用於給C語言工具鏈指定特殊的參數。例如CFLAGS和LDFLAGS分別對應傳給C語言編譯器的編譯參數和鏈接器參數,使它們可以從特定目錄找到bzlib.h頭文件和libbz2.a庫文件。這個例子假設你已經在/usr目錄成功安裝了bzip2庫。如果bzip2庫是安裝在不同的位置,你需要更新這些參數(譯註:這裡有一個從純C代碼生成的cgo綁定,不依賴bzip2靜態庫和操作系統的具體環境,具體請訪問 https://github.com/chai2010/bzip2 )。

NewWriter函數通過調用C語言的BZ2_bzCompressInit函數來初始化stream中的緩存。在writer結構中還包括了另一個buffer,用於輸出緩存。

下面是Write方法的實現,返回成功壓縮數據的大小,主體是一個循環中調用C語言的bz2compress函數實現的。從代碼可以看到,Go程序可以訪問C語言的bz_stream、char和uint類型,還可以訪問bz2compress等函數,甚至可以訪問C語言中像BZ_RUN那樣的宏定義,全部都是以C.x語法訪問。其中C.uint類型和Go語言的uint類型並不相同,即使它們具有相同的大小也是不同的類型。

func (w *writer) Write(data []byte) (int, error) {
    if w.stream == nil {
        panic("closed")
    }
    var total int // uncompressed bytes written

    for len(data) > 0 {
        inlen, outlen := C.uint(len(data)), C.uint(cap(w.outbuf))
        C.bz2compress(w.stream, C.BZ_RUN,
            (*C.char)(unsafe.Pointer(&data[0])), &inlen,
            (*C.char)(unsafe.Pointer(&w.outbuf)), &outlen)
        total += int(inlen)
        data = data[inlen:]
        if _, err := w.w.Write(w.outbuf[:outlen]); err != nil {
            return total, err
        }
    }
    return total, nil
}

在循環的每次迭代中,向bz2compress傳入數據的地址和剩餘部分的長度,還有輸出緩存w.outbuf的地址和容量。這兩個長度信息通過它們的地址傳入而不是值傳入,因為bz2compress函數可能會根據已經壓縮的數據和壓縮後數據的大小來更新這兩個值。每個塊壓縮後的數據被寫入到底層的io.Writer。

Close方法和Write方法有著類似的結構,通過一個循環將剩餘的壓縮數據刷新到輸出緩存。

// Close flushes the compressed data and closes the stream.
// It does not close the underlying io.Writer.
func (w *writer) Close() error {
    if w.stream == nil {
        panic("closed")
    }
    defer func() {
        C.BZ2_bzCompressEnd(w.stream)
        C.bz2free(w.stream)
        w.stream = nil
    }()
    for {
        inlen, outlen := C.uint(0), C.uint(cap(w.outbuf))
        r := C.bz2compress(w.stream, C.BZ_FINISH, nil, &inlen,
            (*C.char)(unsafe.Pointer(&w.outbuf)), &outlen)
        if _, err := w.w.Write(w.outbuf[:outlen]); err != nil {
            return err
        }
        if r == C.BZ_STREAM_END {
            return nil
        }
    }
}

壓縮完成後,Close方法用了defer函數確保函數退出前調用C.BZ2_bzCompressEnd和C.bz2free釋放相關的C語言運行時資源。此刻w.stream指針將不再有效,我們將它設置為nil以保證安全,然後在每個方法中增加了nil檢測,以防止用戶在關閉後依然錯誤使用相關方法。

上面的實現中,不僅僅寫是非併發安全的,甚至併發調用Close和Write方法也可能導致程序的的崩潰。修復這個問題是練習13.3的內容。

下面的bzipper程序,使用我們自己包實現的bzip2壓縮命令。它的行為和許多Unix系統的bzip2命令類似。

gopl.io/ch13/bzipper

// Bzipper reads input, bzip2-compresses it, and writes it out.
package main

import (
    "io"
    "log"
    "os"
    "gopl.io/ch13/bzip"
)

func main() {
    w := bzip.NewWriter(os.Stdout)
    if _, err := io.Copy(w, os.Stdin); err != nil {
        log.Fatalf("bzipper: %v\n", err)
    }
    if err := w.Close(); err != nil {
        log.Fatalf("bzipper: close: %v\n", err)
    }
}

在上面的場景中,我們使用bzipper壓縮了/usr/share/dict/words系統自帶的詞典,從938,848字節壓縮到335,405字節。大約是原始數據大小的三分之一。然後使用系統自帶的bunzip2命令進行解壓。壓縮前後文件的SHA256哈希碼是相同了,這也說明了我們的壓縮工具是正確的。(如果你的系統沒有sha256sum命令,那麼請先按照練習4.2實現一個類似的工具)

$ go build gopl.io/ch13/bzipper
$ wc -c < /usr/share/dict/words
938848
$ sha256sum < /usr/share/dict/words
126a4ef38493313edc50b86f90dfdaf7c59ec6c948451eac228f2f3a8ab1a6ed -
$ ./bzipper < /usr/share/dict/words | wc -c
335405
$ ./bzipper < /usr/share/dict/words | bunzip2 | sha256sum
126a4ef38493313edc50b86f90dfdaf7c59ec6c948451eac228f2f3a8ab1a6ed -

我們演示瞭如何將一個C語言庫鏈接到Go語言程序。相反,將Go編譯為靜態庫然後鏈接到C程序,或者將Go程序編譯為動態庫然後在C程序中動態加載也都是可行的(譯註:在Go1.5中,Windows系統的Go語言實現並不支持生成C語言動態庫或靜態庫的特性。不過好消息是,目前已經有人在嘗試解決這個問題,具體請訪問 Issue11058 )。這裡我們只展示的cgo很小的一些方面,更多的關於內存管理、指針、回調函數、中斷信號處理、字符串、errno處理、終結器,以及goroutines和系統線程的關係等,有很多細節可以討論。特別是如何將Go語言的指針傳入C函數的規則也是異常複雜的(譯註:簡單來說,要傳入C函數的Go指針指向的數據本身不能包含指針或其他引用類型;並且C函數在返回後不能繼續持有Go指針;並且在C函數返回之前,Go指針是被鎖定的,不能導致對應指針數據被移動或棧的調整),部分的原因在13.2節有討論到,但是在Go1.5中還沒有被明確(譯註:Go1.6將會明確cgo中的指針使用規則)。如果要進一步閱讀,可以從 https://golang.org/cmd/cgo 開始。

練習 13.3: 使用sync.Mutex以保證bzip2.writer在多個goroutines中被併發調用是安全的。

練習 13.4: 因為C庫依賴的限制。 使用os/exec包啟動/bin/bzip2命令作為一個子進程,提供一個純Go的bzip.NewWriter的替代實現(譯註:雖然是純Go實現,但是運行時將依賴/bin/bzip2命令,其他操作系統可能無法運行)。