橋接 Rust 和原生 Go

你好,大家好,我是 yuchanns!

最近我做了一些有趣的事情,想和你分享:介紹 OpenDAL 作爲 Go 語言的原生綁定。

TLDR;  我將向你展示一種可行的方法,利用 purego 和 libffi 的魔力,從 Rust 和 C 組件構建原生 Go 綁定。

什麼是 OpenDAL?

Apache OpenDAL[1] 是一個 Rust 庫,提供了統一的數據訪問層。它爲訪問各種存儲服務(如 S3、Google Drive 和 Dropbox)提供了一致的 API。

OpenDAL 有一個願景,幫助用戶以任何語言、任何方法和任何集成自由訪問數據。這一願景推動社區構建了許多其他語言綁定。

我們已經發布了 Java、NodeJS 和 Python 的綁定。但是我們還沒有 Go 的綁定。

CGo 不是 Go

那麼問題是什麼?

@Suyan[2] 告訴我,Go 綁定因涉及構建和使用 CGo 而變得複雜而停滯不前。

讓我們快速回顧 9ef494d[3],這是在我們更新了 Go 綁定以完全支持原生功能之前所做的提交。

由於 Go 綁定是建立在 C 綁定之上的,讓我們首先構建一個 C 綁定工件:

cd bindings/c
make build

然後我們需要添加一個名爲 opendal_c.pc 的文件,其中包含以下內容:

libdir=/path/to/opendal/target/debug/
includedir=/path/to/opendal/bindings/c/include/

Name: opendal_c
Description: opendal c binding
Version:

Libs: -L${libdir} -lopendal_c
Cflags: -I${includedir}

在這之後,我們可以使用以下命令構建 Go 綁定:

export PKG_CONFIG_PATH=/dir/of/opendal_c.pc
cd bindings/go
go build -tags dynamic .

最後,我們使用以下命令運行測試

expose LD_LIBRARY_PATH=/path/to/opendal/bindings/c/target/debug/
go test -tags dynamic .

如你所見,在我們與 OpenDAL 集成之前,需要進行 4 項繁瑣的手動操作,這與 Go 的包管理方法相悖。

無法做出這樣的權衡是不值得的。以這種方式無法在 Go 原生社區中推廣 OpenDAL。沒有人對其進行維護感興趣,即使構建了,用戶實際上也無法 go get 綁定!畢竟,他們已經說過:

CGo 不是 Go。

調用 Rust 而不使用 CGo

我開始思考:難道沒有一種方法可以直接從 Go 中純粹地調用 Rust 嗎?

答案是肯定的。我進行了大量的在線搜索,然後從這篇文章 RustGo: Calling Rust from Go with near-zero overhead[4] 中得到了一個有趣的想法。

簡而言之,這個想法是將受限制的 Rust 代碼鏈接起來,並在 Go 的粘合層中調用它。

這太棒了,如果你喜歡的話可以去看一下。主要問題是它依賴於一些彙編語言的粘合劑,對我來說太複雜了。想象一下,你必須爲每個方法寫大量的彙編代碼,並在每個平臺上都進行適配,那簡直是瘋狂。

TEXT ·ScalarBaseMult(SB), 0, $16384-16
    MOVQ dst+0(FP), DI
    MOVQ in+8(FP), SI

    MOVQ SP, BX
    ADDQ $16384, SP
    ANDQ $~15, SP

    MOVQ ·_scalar_base_mult(SB), AX
    CALL AX

    MOVQ BX, SP
    RET

忘了吧。

第二個想法是 purego[5]。它是由 Ebitengine 創建的,旨在支持 Linux、macOS、Windows、FreeBSD 和包括 amd64 和 arm64 在內的架構。

它聲稱我們可以在不使用 CGo 的情況下從 Go 調用 C 函數,這意味着交叉編譯非常簡單,我們的用戶只需一個指令 go get 就可以輕鬆獲取綁定。

把這個例子快速回顧一下:

package main

import (
    "fmt"
    "runtime"

    "github.com/ebitengine/purego"
)

func getSystemLibrary() string {
    switch runtime.GOOS {
    case "darwin":
        return "/usr/lib/libSystem.B.dylib"
    case "linux":
        return "libc.so.6"
    default:
        panic(fmt.Errorf("GOOS=%s is not supported", runtime.GOOS))
    }
}

func main() {
    libc, err := purego.Dlopen(getSystemLibrary(), purego.RTLD_NOW|purego.RTLD_GLOBAL)
    if err != nil {
        panic(err)
    }
    var puts func(string)
    purego.RegisterLibFunc(&puts, libc, "puts")
    puts("Calling C from Go without Cgo!")
}

嗯,雖然不是直接的,但仍然是純粹的。我們所需要的只是基於我們的 C 綁定工件的共享對象( *.so )。

我現在迫不及待地想要實現它了!

結構體的麻煩

很快我遇到了問題。

現實世界告訴我們,purego 不支持結構作爲返回值,而我需要調用的首個 C 函數是 opendal_operator_new ,它返回一個結構 opendal_operator

我反覆查看了示例,包括一個稍微複雜的窗口演示。結果顯示,結構值僅受 Darwin 平臺支持,並作爲實驗性功能。

看起來我們又陷入了僵局。

突然,一個略微不太理智的想法閃現在我的腦海中。

衆所周知,高級語言的編譯器生成遵循特定調用約定的代碼,以便程序可以通過接口橋調用外部函數。

那就是 libffi[6] 的目標。

libffi 已經移植到許多平臺,並覆蓋了我們所需的所有內容。

把 libffi 用 purego 封裝起來,然後通過所謂的 purego-libffi 調用我們的 C 綁定,怎麼樣?

是的,這是可能的。而且已經有人做到了。

嗨社區,讓我向你們介紹  JupiterRider/ffi[7]。

橋接 Rust 和 Go 世界

我在不到半天的時間內建立了一個 概念演示 [8]。後來我向 OpenDAL 社區提交了一個議題 [9] 。

使用 purego + libbfi 的神奇組合,我們可以根據 C 綁定函數的簽名輕鬆地勾勒調用方法。以 opendal_operator_new  爲例:

// C-binding signature
struct opendal_result_operator_new opendal_operator_new(const char *scheme,
                                                        const struct opendal_operator_options *options);
struct opendal_operator_options *opendal_operator_options_new(void);

typedef struct opendal_operator_options {
  struct HashMap_String__String *inner;
} opendal_operator_options;

typedef struct opendal_result_operator_new {
  struct opendal_operator *op;
  struct opendal_error *error;
} opendal_result_operator_new;

該函數返回一個名爲 opendal_result_operator_new 的結構。因此,我們可以使用 ffi.Type 構造一個表示它的 Go 變量

var (
    typeResultOperatorNew = ffi.Type{
        Type: ffi.Struct,
        Elements: &[]*ffi.Type{
            &ffi.TypePointer,
            &ffi.TypePointer,
            nil,
        }[0],
    }
)

你可能會注意到 C 結構內有兩個字段,但我們不會爲它們構造類型化變量,因爲它們只是指針。

我們打算把這個結構應用於下面這個函數:

func NewOperator(name string, opts *OperatorOptions) (*Operator, error) {
    var cif ffi.Cif
    if status := ffi.PrepCif(&cif, ffi.DefaultAbi, 2, &TypeResultOperatorNew, &ffi.TypePointer, &ffi.TypePointer); status != ffi.OK {
        return nil, errors.New(status.String())
    }
    sym, _ := purego.Dlsym(libopendal, "opendal_operator_new")
    fn := func(name string, opts OperatorOptions) (*ResultOperatorNew, error) {
        byteName, err := unix.BytePtrFromString(name)
        if err != nil {
            return nil, err
        }
        var result ResultOperatorNew
        ffi.Call(&cif, sym, unsafe.Pointer(&result), unsafe.Pointer(&byteName), unsafe.Pointer(&opts))
        return &result, nil
    }
    result, _ := fn(name, *opts)
    return result.op, nil
}

type ResultOperatorNew struct {
    op    *Operator
    error *Error
}

type Operator struct {
    ptr uintptr
}

儘管我們忽略了一些錯誤處理,但大致上應該是這樣。

只要我們正確指定變量定義了相應的 C 結構,我們將使用類似的 Go 結構獲取返回值 ResultOperatorNew

現在我們可以與 CGO_ENABLED=0 一起使用。

這就是全部。這樣就建立了 Rust 和原生 Go 之間的橋樑。其餘的工作仍然是直截了當和簡單的。

很快,我們將擁有一個完全功能的 Go 綁定,以利用 OpenDAL 的強大功能。敬請關注!

分發和基準測試

事情還沒有做完。

就我而言,分發共享對象對於維護者來說仍然是一個重大挑戰。雖然我們可以將共享對象嵌入到 Go 文件中,但 C 綁定的構件對於 Go 庫來說太大了。默認功能包含 15 個服務,在發佈後達到 12.4M!

我們設法將默認服務減少到僅一個,並使用 zstd 進行壓縮。現在每個服務的大小已經縮小到 400K〜2M。

除此之外,我們創建了一個倉庫來提供這些預構建的 Go 綁定服務。

請記住,apache/opendal-go-services[10] 是可選的,用戶可以根據自己的條件和特性構建自己的構件。

此外,我創建了一個基準測試 [11] 來滿足一些好奇心。它比較了原生 Go ( github.com/apache/opendal/bindings/go ) 和 CGo ( pkg: opendal.apache.org/go ) 在讀寫方面使用內存服務的性能。

benchstat old.txt new.txt
goos: linux
goarch: arm64
pkg: github.com/apache/opendal/bindings/go
               │   new.txt    │
               │    sec/op    │
Write4KiB-10     2.844µ ± ∞ ¹
Write256KiB-10   10.09µ ± ∞ ¹
Write4MiB-10     99.16µ ± ∞ ¹
Write16MiB-10    658.2µ ± ∞ ¹
Read4KiB-10      6.387µ ± ∞ ¹
Read256KiB-10    82.70µ ± ∞ ¹
Read4MiB-10      1.228m ± ∞ ¹
Read16MiB-10     3.617m ± ∞ ¹
geomean          90.23µ
¹ need >= 6 samples for confidence interval at level 0.95

pkg: opendal.apache.org/go
               │   old.txt    │
               │    sec/op    │
Write4KiB-10     4.240µ ± ∞ ¹
Write256KiB-10   10.11µ ± ∞ ¹
Write4MiB-10     89.58µ ± ∞ ¹
Write16MiB-10    646.2µ ± ∞ ¹
Read4KiB-10      20.94µ ± ∞ ¹
Read256KiB-10    132.7µ ± ∞ ¹
Read4MiB-10      1.847m ± ∞ ¹
Read16MiB-10     6.305m ± ∞ ¹
geomean          129.7µ
¹ need >= 6 samples for confidence interval at level 0.95

哇,我必須說這真是個驚喜!

接下來是什麼

我已經爲 Go 綁定創建了一個跟蹤問題 [12],你可以隨意可以選擇一個關注點。我們很快將發佈 opendal-go 的第一個版本。即使現在,你也可以嘗試一下 go get

go get github.com/apache/opendal/bindings/go
go get github.com/apache/opendal-go-services/memory

libffi 的純 Go 封裝現在只支持 Linux 和 BSD,但 @Xuanwo[13] 和我已經與作者 @JupiterRider[14] 討論了對 Windows 和 macOS 的支持 [15]。

這就是開源的美妙之處!通過與上游和下游密切合作,我們可以共同推動事情發生!

引用鏈接

[1] Apache OpenDAL: https://opendal.apache.org/
[2] @Suyan: https://github.com/suyanhanx
[3] 9ef494d: https://github.com/apache/opendal/commit/9ef494d6df2e9a13c4e5b9b03bcb36ec30c0a7c0
[4] RustGo: Calling Rust from Go with near-zero overhead: https://words.filippo.io/rustgo/
[5] purego: https://github.com/ebitengine/purego
[6] libffi: https://github.com/libffi/libffi
[7] JupiterRider/ffi: https://github.com/JupiterRider/ffi
[8] 概念演示: https://github.com/yuchanns/opendal
[9] 議題: https://github.com/apache/opendal/issues/4848
[10] apache/opendal-go-services: https://github.com/apache/opendal-go-services
[11] 基準測試: https://github.com/apache/opendal/blob/main/bindings/go/tests/behavior_tests/benchmark_test.go
[12] 跟蹤問題: https://github.com/apache/opendal/issues/4892
[13] @Xuanwo: https://github.com/Xuanwo
[14] @JupiterRider: https://github.com/JupiterRider
[15] 對 Windows 和 macOS 的支持: https://github.com/JupiterRider/ffi/issues/3

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