Python CAPI 的简单使用

环境配置

Usage

常用函数

内存相关

Python CAPI 的简单使用

之前开发一个模块,需要调用 Python 的 CAPI。主要功能是:在 cpp 编写的程序中,将数据转成 Py 对象 PyObject,传入 Python 脚本,再将返回值写回程序中。其实就是间接调用 python 脚本,有输入和输出。在这里做一下记录。

Python 版本:3.6
clang 版本:3.4.2
本机:MacOS
本机 Python 头文件目录:/XX/anaconda3/include/python3.6m
安装 python 以后,在安装目录下的 include/python3.6m 中即可找到。

环境配置

如果想用自定义路径的 python:export CPLUS_INCLUDE_PATH=/xxx/include/python3.7m/

  1. 设置默认 python 路径,在~/.bashrc 中配置:
alias python2='/YourLib/bin/python2.7'
alias python3='/YourLib/bin/python3.6'
alias python=python3
  1. ~/.bashrc 中配置unset python:使用默认环境。

编译环境

由于需要 python 的库,当使用 CMAKE 时,需要添加如下内容,其中,${PROJECT_SOURCE_DIR}/your_path就是 python 文件夹路径,可以用sys库找到具体位置:

set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -I${PROJECT_SOURCE_DIR}/your_path/python3.6/include/python3.6m -I /your_path/python3.6.include/python3.6m -Wno-unused-result -Wsign-compare -DNDEBUG -g -fwrapv -03 -Wall -Wstrict-prototypes")
set(CMAKE_EXE_LINKER_FLAGS "-L /your_path/python3.6/lib -L /your_path/python3.6/lib -lpython3.6m -lpthread -ldl -lutil -lm -Xlinker -export-dynamic")
TARGET_LINK_LIBRARIES(your_exe_file python3.6m)

修改 bashrc,增加动态链接库路径
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:$JAVA_HOME/jre/lib/amd64/server:$HADOOP_HDFS_HOME/lib:/usr/lib:$MLF_TEST_DEP_HOME/crf++/lib:$MLF_TEST_DEP_HOME/python3.6/lib/

Docker 镜像封装

由于需要将可执行文件放到 docker 中,这一步骤也花费了一些时间。无此需求的盆友可以略过本小节。

FROM busybox:1.29.3
RUN set -ex \
  \
  && wget http://ftp.gnu.org/gnu/gcc/gcc-4.5.1/gcc-4.5.1.tar.bz2 \
  && wget https://www.python.org/ftp/python/3.6.5/Python-3.6.5.tar.xz
  && wget https://www.python.org/ftp/python/2.7.2/Python-2.7.2.tar.xz
  && tar -xvf Python-2.7.2.tar.xz
  && tar -xvf Python-3.6.5.tar.xz
  && cd Python-2.7.2.tar.xz
  && cd Python-3.6.5.tar.xz
  && ./configure prefix=/usr/local/python3

发现无法处理中文,会出现编码问题。

FROM alpine:3.8
RUN apk add --no-cache python \
  && python -m ensurepip \
  && rm -r /usr/lib/python*/ensurepip \
  && pip install --upgrade pip setuptools \
  && rm -r /root/.cache \
  && apk add --no-cache python3 \
  && rm -r /usr/lib/python*/ensurepip \
  && pip3 install --upgrade pip setuptools && \
  rm -r /root/.cache

发现无法使用 locale 指令,仍然无法解决。最后,放弃 python2,使用 Ubuntu+Python3.6 环境:尝试在 dockerhub 上找到使用 ubuntu 系统、已经封装好的 Python 镜像python:3.6.10-buster

FROM python:3.6.10-buster
RUN apt-get update \
      && apt-get install wget locales
RUN pip3 install numpy -i http://pypi.douban.com/simple/ --trusted-host pypi.douban.com
RUN pip3 install scipy -i http://pypi.douban.com/simple/ --trusted-host pypi.douban.com
RUN pip3 install numpy hanziconv pandas scipy blaze nltk snownlp -i http://pypi.douban.com/simple/ --trusted-host pypi.douban.com
## pandas scipy scikit-learn
RUN locale-gen zh_CN.UTF-8
RUN chmod 0755 /etc/default/locale
ENV LC_ALL=zh_CN.UTF-8
ENV LANG=zh_CN.UTF-8
ENV LANGUAGE=zh_CN.UTF-8
RUN ln -fs /bin/bash /bin/sh
RUN  rm -rf /var/lib/apt/lists/*
CMD ["bash"]
FROM python3_ubuntu:v0.0.1
ENV DEBIAN_FRONTEND = noninteractive
RUN pip3 install numpy hanziconv pandas scipy blaze nltk snownlp -i http://pypi.douban.com/simple/ --trusted-host pypi.douban.com
CMD ["bash"]

Usage

引用头文件

#include <Python.h>
最好不要用#include <pythonx.y/Python.h>这种形式,为了可移植性。
调用 PYTHON 的 C API 时,必须引用这个头文件,由于有一些预处理的定义会影响标准库,所以必须优先于其他的标准头文件。
如果编译时提示no such file,说明环境还有些问题。可参考 stackoverflow
或者像上文中, export 目标 path 即可。
编译时可使用参数 -framework Python:gcc(或g++) sample.cpp -o sample -framework Python

常用函数

初始化

Py_Initialize()和Py_Finalize(void);定义在 pylifecycle.h 中:

PyAPI_FUNC(void) Py_Initialize(void);
PyAPI_FUNC(void) Py_Finalize(void);

用于初始化 Python 解释器、加载 sys.modules 还有些其他的功能。先调用这个函数,再调用其他 Python/C API 函数。如果是嵌套在类中,可以在构造函数或者写一个init()函数中调用Py_Initialize(),然后在西析构函数中调用Py_Finalize()
以下三个函数也定义在 pylifecycle.h 中。如果需要处理中文,遇到wchar_t*变量的时候,可以用 Py_DecodeLocale() 做编码转换。

PyAPI_FUNC(void) Py_SetProgramName(const wchar_t *);
PyAPI_FUNC(void) Py_SetPythonHome(const wchar_t *);
PyAPI_FUNC(void)      Py_SetPath(const wchar_t *);

这三个函数应先于 Initialize()调用。
如果遇到:Fatal Python error: Py_Initialize: Unable to get the locale encoding
把前面的三个设置去掉就可以了。

线程相关

GIL

  1. PyGILState_STATE PyGILState_Ensure()
    确保当前线程准备好调用 CAPI,可以跳过当前 python 的状态和 GIL 锁。
  2. void PyGILState_Release(PyGILState_STATE)函数:
    释放当前获取的所有资源,调用后,Python 状态将保持不变。
  3. int PyGILState_Check()如果当前线程拥有 GIL 锁,返回 1。
    可以封装在类中,避免忘记手动销毁。
class EnsureGilState {
public:
    EnsureGilState() {
        _state = PyGILState_Ensure();
    }
    ~EnsureGilState() {
        PyGILState_Release(_state);
    }
private:
    PyGILState_STATE _state;
}

多线程

  1. PyThreadState对象,记录 Python 线程状态。
  2. PyThreadState* PyEval_SaveThread()如果 GIL 锁已经创建,并且将线程状态变成 NULL,释放 GIL 锁。
  3. void PyEval_RestoreThread(PyThreadState *tstate)
  4. PyEval_InitThreads();初始化并且获取 GIL 锁。应该在主线程调用,在调用其他线程之前。
class EnableThreads {
public:
    EnableThreads() {
        _state = PyEval_SaveThread();
    }
    ~EnableThreads() {
        PyEval_RestoreThread(_state);
    }
private:
    PyThreadState* _state;
};

运行 python 脚本

PyRun_SimpleString()函数用来运行脚本,定义在pythonrun.h中。

PyAPI_FUNC(int) PyRun_SimpleStringFlags(const char *, PyCompilerFlags *);
## define PyRun_SimpleString(s) PyRun_SimpleStringFlags(s, NULL)

比如

PyRun_SimpleString("import sys");
// or
PyRun_SimpleString("import sys;import csv;");

模块导入

PyImport_ImportModule定义在import.h

PyAPI_FUNC(PyObject *) PyImport_ImportModule(
     const char *name            /* UTF-8 encoded string */
     );

eg: 写一个 test_add.py(这里写在了同目录下)

def func(a, t_str):
    if (t_str == "" or t_str == None):
        return func_1(a)
    else:
        return func_2(a, int(t_str))
def func_1(a):
    return a+1
def func_2(a, b):
    return a+b
print(func(1,""))
print(func(1,2))

然后调用

std::string module_name = "test_add"; // test_add.py
PyObject* pModule = PyImport_ImportModule(module_name.c_str());

ps: 其中遇到个问题:Undefined symbols for architecture x86_64 ,后面是__basic_string blah blah,把 gcc 改成 g++解决。(clang 同理) *注意,只获得 pModule,未必会成功。不成功的原因可能有以下几种:

  1. 名字不对,比如用 main/test 这类名字;
  2. 脚本内有错误:这种情况想要排查的话,使用 PyErr_Print()即可。
    所以,要在获取 pModule 之后加 check,否则容易引起后续的很多未知问题。
if (!_pModule) {
    PyErr_Print();
    std::cout << "Fatal" << std::endl;
    exit(1);
}

python 的 C API 有一些关于异常的处理,后面详细讲。

获取 py 脚本中的函数对象

PyObject_GetAttrString()
例如:

PyObject* pTestFunc = PyObject_GetAttrString(pModule, "func");
// 同上,为了安全,加一个check。
if!PyCallable_Check(pTestFunc)) {
    PyErr_Print();
    std::cout << "Fatal: not callable" << std::endl;
    exit(1);
}

构建输入对象

PyTuple_SetItem()
在上面的脚本中,调用 func 可能有两种执行结果:如果第二个参数是空或者 None,则调用 func_1,否则调用 func_2。
比如,需要把一个值,或者两个值输入到 py 脚本的 func 函数中。这里的num是一个参数,按奇数偶数采取不同的含参数。

PyObject* args = nullptr;
PyObject* arg1 = PyInt_FromLong(100);
if (num%2 == 0) {
    args = PyTuple_New(2);
    std::string test_str = "20";
    PyObject* arg2 = Py_BuildValue("s", test_str.c_str());
    PyTuple_SetItem(args, 0, arg1);
    PyTuple_SetItem(args, 1, arg2);
} else {
    args = PyTuple_New(1);
    PyTuple_SetItem(args, 0, arg1);
}

Py_BuildValue(X, Y)这个函数,参数 X 为格式字符串,代表属性的类型。一般有"s" "i"等等。可参考这里

构建输出对象

PyObject_CallObject()

PyObject* pRet = PyObject_CallObject(pFunc, args);

获取结果

int iRet = 0;
PyArg_Parse(pRet, "i", &iRet);
std::cout << iRet << std::endl;
Py_XDECREF(pRet);
Py_XDECREF(args);

Py_XDECREF用来对引用计数执行减一操作。与Py_DECREF的区别是:会对 NULL 进行处理。
如果不执行减一操作,很可能会发生内存泄露。

错误和异常处理

PyErr_Print();如果调用之前发生错误,则会发出一些错误信息。
清除错误信息:PyErr_Clear();
在调试多线程时,发现 PyErr_Print()不能正常调用。如果需要打印 python 脚本的报错信息,可以用 traceback 记录的内容。

void print_err() {
    PyObject* ex = PyErr_Occurred();
    PYObject *ptype, *pvalue, *ptraceback;
    PyErr_Fetch(&type, &pvalue, &ptraceback);
    if (ex) {
        PyTracebackObject* tb = (PyTracebackObject *)ptraceback;
        std::cout << "Traceback: " << std::endl;
        while (tb != nullptr) {
            PyObject *line = PyUnicode_FromFormat("File \"%U\", line %d, in %U\n",
            tb->tb_frame->f_code->co_filename, tb->tb_lineno, tb->tb_frame->f_code->co_name);
            std::cout << PyUnicode_1BYTE_DATA(line);
            tb = tb->tb_next;
        }
    PyObject* ptypeStr = PyObject_Str(ptype);
    PyObject* pvalueStr = PyObject_Str(pvalue);
    std::cout << "ERROR IS" << PyUnicode_AsUTF8(pvalueStr) << std::endl;
    std::cout << "ERROR TYPE" << PyUnicode_AsUTF8(ptypeStr) << std::endl;
    }
}

内存相关

引用计数

void Py_DECREF(PyObject *o);
void Py_XDECREF(PyObject *o);
void Py_CLEAR(PyObject *o)

引用计数用于内存管理,可以统计对象被引用了多少次并且给出一些处理。当引用计数为 0 的时候,将执行 deallcoate 操作(对象中的一个函数指针)。除了可以控制内存以外,还可以帮助判断一个对象是否存在。如果引用计数大于 0,那么说明对象的生存周期还没有结束。

关于引用计数的变化情况,文档中是这样介绍的:

In theory, the object’s reference count goes up by one when the variable is made to point to it and it goes down by one when the variable goes out of scope.

Ownership of reference: 这里的“所有权”,意味着有权限执行 Py_DECREF。

传递对象的引用给函数的时候有两种情形:可能“偷走”引用或者不。“偷走”引用意味着当传递引用给函数的时候,函数假设其“拥有”了引用。比如PyList_SetItem()PyTuple_SetItem()

Py_INCREF(PyObject *o);
增加引用计数

文档中关于安全性的一些建议:

A safe approach is to always use the generic operations (functions whose name begins with PyObject*, PyNumber*, PySequence* or PyMapping*). These operations always increment the reference count of the object they return.

但是实际测试中,发现内存泄漏的情况还是很严重,而且很难定位。后来妥协了,采用了 pybind 这个库,是在 python CAPI 之上进行了一层封装,这个库比 Python C API 更友好。

https://github.com/pybind/pybind11

Reference

https://docs.python.org/3.6/c-api/index.html
https://docs.python.org/3/c-api/memory.html
ftp://ftp.netapp.com/frm-ntap/opensource/ClusterViewer/1.0/package_sources/Python-3.4.4/Include/
https://www.medin.name/blog/2012/02/12/embedding-python-inside-a-multithreaded-c-program/

Python CAPI 的简单使用

环境配置

Usage

常用函数

内存相关