PHP FFI呼叫go,居然比go還快

半山發表於2020-08-12

上一篇文章中用PHP的FFI成功了呼叫了cjieba,但是速度實在是慢,4個函式迴圈呼叫20次,用了居然1分50多秒,而且C版本只比PHP快一點點,看來是cjieba本身慢了。

這次發現了一個golang的分詞庫gse,試試匯出為動態庫,用FFI載入。

不能匯出go指標

由於之前對cgo不熟悉,以為go可以很方便的匯出到C,沒想到一開始就把我難倒。

panic: runtime error: cgo result has Go pointer

不能匯出go結構體

一開始直接在go裡返回了[]string,沒想到報錯了,原來go不允許匯出含有指標的資料結構

Go type not supported in export: struct

後來想,要不匯出[]string 的指標,但是如果只有指標地址,沒有長度,遍歷肯定會出錯,於是構造了一個結構體,儲存指標地址和長度,沒想到還是不行。

這期間,由於工作忙(主要是懶),斷斷續續的看了一下cgo相關的內容,先跑通了C呼叫go,於是再試著用FFI,很快也跑通了。

在go裡,匯出一個函式到C動態庫,其實非常簡單,需要在import C 包,並在匯出的函式加上export 函式名 ,加上一個空的main 函式即可,如:

package main

import (
    "C"
)
//必須和函式同名
//export PlusOne
func PlusOne(num int) int {
    return num + 1
}
func main() {

}

編譯方法如下:

go build -buildmode=c-shared -o libdemo.so demo.go

就會自動生成 so 和 libdemo.h 標頭檔案,開啟libdemo.h,可以看到裡面是各種go 資料型別的定義,摘除部分如下:

typedef signed char GoInt8;
typedef unsigned char GoUint8;
typedef short GoInt16;
typedef unsigned short GoUint16;
typedef int GoInt32;
typedef unsigned int GoUint32;
typedef long long GoInt64;
typedef unsigned long long GoUint64;

可以看到還包含了一個另外的標頭檔案#include <stddef.h> ,可以使用gcc -E -P libdemo.h -o libdemo_unfold.h 展開stddef.h合併到一個到標頭檔案,然後複製我們需要的型別定義即可。

PHP如何初始化go型別變數

由於go的string,slice匯出後,都是一個結構體,不是一個簡單型別,這裡我們先看看string。

typedef struct { const char *p; ptrdiff_t n; } GoString;

可以看到string有一個char* 指標,和一個表示長度的n,可以說明go的string是不帶’\n’的,和C的字串不同。然而一開始,我居然還特意加了’\n’,然後給n也加1,結果發現不對,在go那邊加上輸出後,才發現出錯了。

對於這種結構體,用載入動態庫的FFI例項呼叫 new 方法。即$goStr = $ffi->new('GoString',0),注意new的第二個引數要傳0,表示這個物件PHP不用管理記憶體。在這個地方,我又掉坑裡了。

然後要給p和n賦值,對於n,比較簡單,直接給字串長度,但是對於p,就比較麻煩。

翻看PHP文件,發現有個memcpy方法,於是試了一下,成功的實現了PHP和go之間傳string。

完整的程式碼如下:

function makeGoStr(FFI $ffi, string $str): FFI\CData
{
    $goStr = $ffi->new('GoString', 0);
    $size = strlen($str);
    $cStr = FFI::new("char[$size]", 0);

    FFI::memcpy($cStr, $str, $size);
    $goStr->p = $cStr;
    $goStr->n = strlen($str);
    return $goStr;
}

FFI 靜態方法和FFI例項方法的區別

在上面的程式碼裡,既有FFI的靜態方法,也有例項方法,它們之間的區別在於,靜態方法只有常用的資料型別,如果int,char;例項方法,才能呼叫載入的so裡面的型別。

FFI的三種呼叫思路

下面我說一下三種呼叫思路,建議第一種,這裡就不貼程式碼了,完整的程式碼看github

1 透過 C.char

由於go不能返回 slice string,那麼換個思路,把陣列拼接成字串,然後返回C.char。這種方式最簡單,而且在後面的跑分測試裡發現,也是最有效率的。
複雜的資料結構,可以序列化為string 然後返回C.char

2 透過slice 指標傳引數

既然不能返回,那麼我們修改傳入的引數是否可以呢。透過測試發現確實可行。

3 返回指標的地址

這就是一開始我的想法,這種方法有點麻煩,而且速度也不佔優。

可以下載我github的程式碼,對於go需要開啟go mod。

make lib,生成go的動態庫,然後make php_testmake go_test 檢視對比。

go的

TestCut: goseg_test.go:18 CutChar 2000 次用時:41.511794ms
TestCut: goseg_test.go:26 CutPointer 2000 次用時:45.24684ms
TestCut: goseg_test.go:34 CutSlice 2000 次用時:42.537337ms

php的

CutChar 2000 次用時:0.027052 s
CutSlice 2000 次用時:0.038451 s
CutPointer 2000 次用時:0.038257 s

可以發現php居然比go的還快,比cjieba快了不知道多少倍,看來以後一些耗CPU的方式,可以用go來開發動態庫,給PHP用,比透過介面呼叫可以快很多。
假如go和php呼叫需要5ms,這樣2000次就是 10s了。可以發現FFI是介面呼叫的0.04/10 = 0.004,是幾百倍數量級的提升。當然實際情況更復雜,但是效能提升可是顯而易見的。

當然前提是選擇一個效能高的FFI外部庫才行,如果比PHP還慢,那就不必了。

另外FFI可以預載入,鳥哥的部落格寫的很詳細了,大家可以去看看

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章