上一篇文章中用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動態庫的簡單說明
在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 FFI調用go
## 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_test
和make 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可以預加載,鳥哥的博客寫的很詳細了,大家可以去看看。