13.4. 通過cgo調用C代碼

Go程序可能會遇到要訪問C語言的某些硬件驅動的場景, 或者是從一個C++實現的嵌入式數據庫査詢記録的場景, 或者是使用Fortran實現的一些線性代數庫的場景. C作爲一個通用語言, 很多庫會選擇提供一個C兼容的API, 然後用其他語言實現.

在本節中, 我們將構建一個簡易的數據壓縮程序, 通過使用一個Go語言自帶的叫cgo的用於支援C語言函數調用的工具. 這類工具被稱爲外圍函數接口(ffi), 併且cgo也不是Go中唯一的類似工具. SWIG(swig.org) 是類似的另一個被廣泛使用的工具, 它提供了很多複雜特性以支援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 算法的壓縮實現. 完全從頭實現是一個繁瑣的工作, 而且 bzip.org 有現成的 libbzip2 開源實現, 文檔齊全而且性能較好,

如果C庫比較小, 我們可以用純Go重新實現一遍. 如果我們對性能沒有特殊要求, 我們可以用 os/exec 包的方法將C編寫的應用程序作爲一個子進行運行. 隻有當你需要使用複雜但是性能更高的底層C接口時, 就是使用cgo的場景了. 下面我們將通過一個例子講述cgo的用法.

要使用 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;
    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>
int bz2compress(bz_stream *s, int action,
                char *in, unsigned *inlen, char *out, unsigned *outlen);
*/
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
        verbosity = 0
        workFactor = 30
    )
    w := &writer{w: out, stream: new(C.bz_stream)}
    C.BZ2_bzCompressInit(w.stream, blockSize, verbosity, workFactor)
    return w
}

在循環的每次迭代中, 向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)
        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 釋放輸入和輸齣流的緩存. 此刻 w.stream 指針將不在有效, 我們將它設置爲 nil 以保證安全, 然後在每個方法中增加 nil 檢測, 以防止用戶在關閉後依然錯誤使用相關方法.

不僅僅寫是非併發安全的, 甚至併發調用 Close 和 Write 也可能導致C代碼的崩潰. 脩複這個問題是 練習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程序中動態加載也都是可行的. 這里我們隻展示的cgo很小的一些方面, 更多的關於內存管理, 指針, 迴調函數, 信號處理, 字符串, errno處理, 終結器, 以及 goroutines 和繫統線程的關繫等, 有很多細節可以討論. 特别是如何將Go的指針傳入C函數的規則也是異常複雜的, 部分的原因在 13.2節 有討論到, 但是在Go1.5中還沒有被明確. 如果要進一步閲讀, 可以從 https://golang.org/cmd/cgo 開始.

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

練習13.4: 因爲C庫依賴的限製. 使用 os/exec 包啟動 /bin/bzip2 命令作爲一個子進程, 提供一個純Go的 bzip.NewWriter 的替代實現.