TL;DR
文字列をctypes.c_char_pではなく、ctypes.POINTER(ctypes.c_char)で受け取り、ctypes.string_atで文字列を取得した後メモリを適切に開放する。
lib = ctypes.CDLL("./mydll.dll")
lib.GetString.restype = ctypes.POINTER(ctypes.c_char)
#コールバックの引数の場合も同様
CALLBACKFUNC = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.POINTER(ctypes.c_char))
result_pointer = lib.GetString()
result_str = ctypes.string_at(result_pointer)
print(result_str) # -> hogehoge
ctypes.cdll.msvcrt.free(result_pointer) #メモリを解放
経緯
諸事情があり、受け取ったデータを適切に処理してJSON形式で返すプログラムをGoで記述し、dll化してPythonから呼び出すような実装をした。Python側で繰り返しdllの関数を呼び出していると、Pythonプログラムのメモリ使用量が際限なく(GB単位)上昇していっていることに気づいた。メモリリークが発生しているとすればdll周りであろうと考え、対応を行うことにした。
対応
まずGoogleで検索してみると、GoでC.CStringを用いて返した文字列のポインタは関数を呼び出した側で解放すべきであることが分かった(参考記事)。
今回問題に遭遇したGo側のプログラムは以下のようなもの。
//export GetString
func GetString(arg C.int) *C.char {
res := someFunc(int(arg))
bytes, err := json.Marshal(res)
if err != nil {
return C.CString("")
}
return C.CString(string(bytes))
}
C.CStringで文字列を返している。そして、Python側はというと
lib = ctypes.CDLL("./mydll.dll")
lib.GetString.restype = ctypes.c_char_p
result_pointer = lib.GetString()
result = json.loads(result_pointer)
print(result)
特にメモリの開放を意識していなかったためメモリリークが発生したようだ。そこで、以下のようにコードを変更した。
lib = ctypes.CDLL("./mydll.dll")
lib.GetString.restype = ctypes.c_char_p
result_pointer = lib.GetString()
result = json.loads(result_pointer)
ctypes.cdll.msvcrt.free(result_pointer) #メモリを解放
print(result)
この変更ですべてうまくいくはずだった。しかし実際に実行してみると、0xC0000374
というエラーが発生する。これはメモリの開放に失敗したことを意味する。よくよく調べてみると、c_char_p型として読み込むと自動的にバイト配列へと変換されポインタとして扱えなくなってしまうらしい。どうしたものかと思っていた所たどり着いたのがこちら.ちょうど必要としていたことが全部書いてあった。冒頭に示した通り、ctypes.POINTERを使う形に書き直したところ、無事にメモリ使用量が爆発することはなくなった。めでたしめでたし。