背景

语言交互接口(FFI)

一般操作系统会提供系统调用的C API。这种说法其实并不正确,但是我们假设这个说法是正确的。毕竟很多Linux发行版都是自带了GCC编译器的,我们就简单的认为提供了系统调用的C API。然后我们思考一下这么一个问题,一个非C语言是怎么实现和操作系统交互的?主要有两种方式实现,一种是使用中断来实现系统调用,另外一种方式是间接使用C语言的系统调用API。

间接调用C语言的API涉及到了语言交互接口(FFI, Foreign Function Interface)的概念。不少编程语言就通过使用FFI来实现系统调用。很多语言把FFI叫做"Language Bindings”,比如Python Bindings。还有一些语言有自己的叫法,比如Java的JNI。通过使用FFI,像Python这些语言就可以比较简单的实现系统调用。

Python Bindings

Python Bindings可以让Python代码调用C API,或者在C程序中运行Python脚本。实现Python Bindings有两种基本的方式,分别如下:

  • 使用Python的标准库ctypes
  • 使用CPython提供的库Python/C API

和很多基础库一样,这两个库都很底层,在业务代码中使用起来会比较复杂。我们可以用一些封装好的三方库来实现Python Bindings,比如使用Cython。

使用Cython实现Python Bindings

Cython简介

Cython是一种能够方便为Python编写C扩展的编程语言,其编译器可以将Cython代码编译为C代码。Cython兼容Python的所有语法且能编译成C,故Cython可以允许开发者通过Python式的代码来手动控制GIL。Cython其语法也允许开发者方便的使用C/C++代码库。通过以下命令即可安装Cython。

1
pip install Cython

Cython基础使用方法

Hello, World!

所有合法的Python语法都是合法的Cython语法,我们先写一个简单的例子保存到hello.pyx

1
2
3
# hello.pyx
def say_hello_to(name):
    print("Hello %s" % name)

接着写一下setup文件来编译和构建这个扩展。

1
2
3
4
5
6
7
# setup.py
from setuptools import setup
from Cython.Build import cythonize

setup(
    ext_modules = cythonize("hello.pyx")
)

使用如下命令构建这个Cython文件。

1
python setup.py build_ext --inplace

接下来我们就可以打开Python解释器并引入和使用这个Cython扩展了。

1
2
3
4
5
6
Python 3.6.9 (default, Oct  8 2020, 12:12:24)
[GCC 8.4.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import hello
>>> hello.sayHelloTo("Cython")
Hello Cython

静态类型

Cython可以允许我们在代码中定义静态来类型来提升程序的性能。我们可以使用cdef关键字来定义静态类型。cdef还可以用于定义struct, union, enum以及指针类型等。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
def fibo(n):
    cdef int i = 2
    cdef int a = 0
    cdef int b = 1
    cdef int c = 0

    if n == 0:
        return a
    if n == 1:
        return b

    while i <= n:
        c = a + b
        a = b
        b = c
        i += 1
    return b

定义函数

我们可以在Cython代码中使用def, cdef和cpdef这三个关键字来定义函数。通过def定义的函数是Python函数,这类函数的参数和返回类型都是Python对象。通过cdef定义的函数是C函数,这类函数的参数和返回类型可以是Python对象,也可以是C变量。cpdef是cdef和def的混合体,Cython代码在调用这类函数的时候会使用比较快的C调用约定(calling convention)。另外需要注意的是只有def和cpdef定义的函数可以被Python代码调用。下表展示了这几种关键字的不同特性。

关键字 Python objects为参数和返回值 C 变量为参数和返回值 Python代码可访问
def ×
cdef ×
cpdef

下方代码展示了三种关键字的用法。当我们需要使用Python代码访问C函数的时候,我们可以使用def或者cpdef关键字定义一个包装函数来调用C函数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
cdef double calcExponent(int a, int x):
    cdef int i = 0 
    cdef int flag = 0
    cdef double ans = 1.0

    if x == 0:
        return 1
    
    if x < 0:
        flag = 1
        x = -x

    while i < x:
        ans = ans * a
        i += 1
    
    if flag > 0:
        ans = 1 / ans
    else:
        return ans

def pyCalcExponent(int a, int x):
    return calcExponent(a, x)

cpdef double cpCalcExponent(a, x):
    return calcExponent(a, x)

调用C标准库函数示例

Cython包中预定义了libc, libcpp和posix的函数可以直接使用。完整的列表可以戳这里。下面的代码展示了如何使用这些预定义的模块。

1
2
3
4
5
# foolib.pyx
from libc.math cimport cos

def pyCos(x):
    return cos(x)

libc的math库在某些类Unix系统上默认没有链接,所以需要手动在构建脚本里明确连接共享库m。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# setup.py
from setuptools import setup, Extension
from Cython.Build import cythonize

setup(
    ext_modules=cythonize([
        Extension("foolib", sources=["foolib.pyx"], libraries=["m"])
        ]),
    zip_safe=False,
)

构建命令如下

1
2
# build & install command
python setup.py build_ext --inplace
1
2
3
4
5
6
Python 3.6.9 (default, Oct  8 2020, 12:12:24)
[GCC 8.4.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import foolib as f
>>> f.pyCos(3.14159265357589)
-1.0

构建及调用C代码示例

前面介绍了直接使用Cython已经封装好的标准库,接下来的例子会介绍使用我们自己写的C/C++库。本例会实现一个C函数fibo来计算斐波那契数。另外实现一个函数叫calcDistance,用来计算平面直角坐标系中两点的距离。

C源代码

点和距离的结构体以及函数的定义放在foolib.h头文件中,两个函数的实现放在foolib.c文件中。

下面是头文件和源文件的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// foolib.h
#ifndef __FOOLIB_H
#define __FOOLIB_H
typedef struct _Point {
	char name[50];    // 这里使用数组来存储名字避免内存管理.
    int x;
    int y;
} Point;

typedef struct _Distance {
    char start_name[50];
    char end_name[50];
    double distance;
} Distance;

extern int fibo(int n);

extern Distance calcDistance(Point a, Point b);
#endif
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// foolib.c
#include <string.h>
#include <math.h>

#include "foolib.h"

int fibo(int n) {
    int a = 0;
    int b = 1;
    if (n == 0) {
        return a;
    }
    if (n == 1) {
        return b;
    }
    int i;
    int c;
    for (i = 2; i <= n; i++) {
        c = a + b;
        a = b;
        b = c;
    }
    return b;
}

Distance calcDistance(Point a, Point b) {
    Distance distance;
    strcpy(distance.start_name, a.name);
    strcpy(distance.end_name, b.name);
    int x = b.x - a.x;
    int y = b.y - a.y;
    double d2 = (double) (x * x + y * y);
    distance.distance = sqrt(d2);
    return distance;
}

接下来我们使用下方的命令把源码编译成共享库。

1
2
	gcc -c -Wall -Werror -fpic foolib.c
	gcc -shared -o libfoolib.so foolib.o -lm

第一条命令会把源码编译成位置无关代码(PIC, position-independent code)pic 并且会生成一个叫foolib.o的object文件。第二行命令会将object文件进行链接生成共享库。需要注意的是,共享库需要按lib{name}.sosoname的格式命名。本例中,我们需要将共享库命名为_libfoolib.so_.

Cython代码

接下来是Cython代码。我们先创建一个pxd文件pfoolib.pxd。在pxd文件中,我们引入foolib.h头文件,然后以Cython语法声明一遍头文件中的结构体和函数。因为Python代码没法直接访问pxd文件中定义的结构体和函数,我们还需要定义一些包装函数来访问C函数,定义一些包装类供Python代码使用。我们在pyx文件pfoolib.pyx中定义这些包装函数和包装类。我们可以简单的将pyd文件看作Cython的头文件,只是声明C代码实现的内容即可。接下来的代码块为相关的Cython代码。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# pfoolib.pxd
cdef extern from "foolib.h":
    ctypedef struct Point:
        char name[50]
        int x
        int y
    
    ctypedef struct Distance:
        char start_name[50]
        char end_name[50]
        double distance
    
    int fibo(int n)

    Distance calcDistance(Point a, Point b)

在pxd文件中,我们可以只声明一些我们需要的字段即可。如果我们一个字段都不需要,那么写个pass就是了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# pfoolib.pyx
cimport pfoolib

from libc.string cimport strcpy, strlen

# The class name can't be same as Point defined in pfoolib.pxd, so we name it PyPoint
cdef class PyPoint:
    cdef public bytes name
    cdef public int x
    cdef public int y

    def __init__(self, str name, int x, int y):
		# We need to encode the string into byte array.
        self.name = name.encode("utf-8")
        self.x = x
        self.y = y
    
    cdef pfoolib.Point toCPoint(self):
        cdef pfoolib.Point p
        # Copy the byte array to the char array. 
        strcpy(p.name, self.name)
        p.x = self.x
        p.y = self.y
        return p

cdef class PyDistance:
    cdef public str start_name
    cdef public str end_name
    cdef public double distance

    def __init__(self, char* start_name, char* end_name, float distance):
        self.start_name = start_name[:strlen(start_name)].decode("utf-8")
        self.end_name = end_name[:strlen(end_name)].decode("utf-8")
        self.distance = distance
    
    cdef pfoolib.Distance toCDistance(self):
        cdef pfoolib.Distance d
        d.start_name = self.start_name
        d.end_name = self.end_name
        d.distance = self.distance

def pyFibo(n):
		return fibo(n)

cpdef PyDistance pyCalcDistance(PyPoint a, PyPoint b):
	# Transfer the python class to c struct.
    cdef pfoolib.Point cpa = a.toCPoint()
    cdef pfoolib.Point cpb = b.toCPoint()
    cdef pfoolib.Distance dis = pfoolib.calcDistance(cpa, cpb)
    d = PyDistance(dis.start_name, dis.end_name, dis.distance)
    return d

在pyx文件中,我们需要注意一下几点:

  • 类名和pxd中的结构体名称需要不同,否则会收获一个重命名的错误(官方示例使用了同样的名字,但是我试了一下报错了)。
  • 为了避免管理内存,我们将name字段声明为了char数组,所以我们只能用strcpy函数来拷贝name字符串。如果直接使用赋值操作的话会遇到"runtime index error”。如果name被声明为char*, 那么我们就可以直接使用赋值操作。
  • C中的字符串类型对应Python3中的bytes类型。所以我们需要对字符串进行编解码操作。
  • Cython的编译器在编译的时候会为pyx文件产生一个同名的c文件。如果我们的C代码和pyx文件在同一个文件夹下,那么我们需要给他们不同的文件名。

下面的代码块为setup文件。注意libraries参数,这个参数告诉Cython构建这个扩展时我们所需要的共享库。这里我们指明了foolib,C编译器会在链接阶段寻找libfoolib.so这个共享库。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# setup.py
from setuptools import setup, Extension
from Cython.Build import cythonize

setup(
    name = "pfoolib",
    ext_modules=cythonize([
        Extension("pfoolib", ["pfoolib.pyx"], libraries=["foolib"])
    ]),
    zip_safe=False
)

使用如下命令构建扩展:

1
CFLAGS="-I/path/to/headers" LDFLAGS="-L/path/to/library" python3 setup.py build_ext --inplace

接下来就可以在Python中使用这个共享库了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
Python 3.6.9 (default, Oct  8 2020, 12:12:24)
[GCC 8.4.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import pfoolib
>>> a = pfoolib.PyPoint("aaa", 1, 3)
>>> b = pfoolib.PyPoint("ccc", 2, 3)
>>> c = pfoolib.pyCalcDistance(a, b)
>>> c.start_name
'aaa'
>>> c.end_name
'bbb'
>>> c.distance
1.0

Makefile代码

我们可以写要给简单的Makefile来避免敲命令行来编译这么多东西。Makefile内容如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Makefile
pwd:=$(shell pwd)

build: sharelib
	CFLAGS="-I$(pwd)" LDFLAGS="-L$(pwd)" python3 setup.py build_ext --inplace

sharelib:
	gcc -c -Wall -Werror -fpic foolib.c
	gcc -shared -o libfoolib.so foolib.o -lm

clean:
	rm -f foolib.o libfoolib.so pfoolib.*.so
	rm -rf build

然后我们就可以使用下面的命令来编译C共享库和Cython模块了。Makefile的用法可以参考引用里面的Makefile tutorial

1
2
3
make sharelib    # compile the share library with out building Cython module
make build       # compile the share library and build Cython module
make clean       # remove the compiling and building results.

回调函数示例

接下来的例子会展示为C库提供回调函数的用法。在这个例子里面,我们会提供一个函数来过滤掉数组中的某些元素,该函数会以数组和回调函数作为输入, 根据回调函数的返回结果来选择是否过滤某个元素。

C源代码

下面的代码展示了C代码的头文件和源文件:

1
2
3
4
5
6
7
// foolib.h
#ifndef __FOOLIB_H
#define __FOOLIB_H
typedef int (*predicate)(int, void*);

extern int filter(int [], int, predicate, void*);
#endif

在头文件中,我们声明了回调函数的类型。回调函数会通过一个void*参数来获取上下文,该上下文会获取真实的Python回调函数。如果不这样干的话,我们是没法使用对象的成员方法或者使用闭包的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// foolib.c
int filter(int arr[], int length, predicate check, void* p) {
    int i = 0;
    int j = 0;
    for (i = 0; i < length; i++) {
        if (check(arr[i], p) > 0) {
            arr[j] = arr[i];
            j++;
        }
    }
    return j;
}

Cython代码

接下来是pxd文件和pyx文件。

1
2
3
# pfoolib.pxd
cdef extern from "foolib.h":
	int filter(int [], int, predicate, void*)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# pfoolib.pyx
cimport pfoolib
from libc.stdlib cimport malloc, free

cdef int cCallBack(int a, void *p):
    try:
        func = <object>p
        if func(a):
            return 1
        else:
            return 0
    except:
        return -1

def pyFilter(list arr, judge):
    cdef int* cArr = <int*> malloc(len(arr) * sizeof(int))
    if not cArr:
        raise MemoryError()

    try:
        for i in range(len(arr)):
            cArr[i] = arr[i]

        newLength = filter(cArr, len(arr), cCallBack, <void*> judge)

        for i in range(newLength):
            arr[i] = cArr[i]
        return arr[:newLength]
    finally:
        free(cArr)

cCallBack是提供给filter函数的回调函数。cCallBack会从void*参数中获取Python代码提供的回调函数并进行调用。filter函数会以一个整数数组(和整数指针等效)作为入参。如果我们的C代码没有指定数组的长度,那么Cython不能自动的将Python list转换成数组。所以我们需要在堆中创建一个数组,把list中的元素拷贝进数组中,过滤完以后再把内存释放掉。

构建代码及命令和前例相同就不做展示了。使用方法如下:

1
2
3
4
5
6
Python 3.6.9 (default, Oct  8 2020, 12:12:24)
[GCC 8.4.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import pfoolib as p
>>> p.pyFilter([1, 3, -3, 0, 9, -8, 2], lambda x: x > 0)
[1, 3, 9, 2]

总结

本文中的例子都是Cython的一些基本用法。但是这些用法可以涵盖日常使用中的很多场景。日后我还会继续介绍一些Cython的用法。

名词解释

  1. Position independent code (PIC):PIC 是一种和内存位置无关的代码。因为不同的程序需要使用相同的共享库,但是共享库在不同进程中的内存位置并不相通。所以,共享库中的变量及函数地址不能固定,只能在程序运行时确认。
  2. soname: 类Unix系统中每个共享库都会有一个"so-name”。so-name以"lib"为文件名前缀,".so"为后缀。所以foo共享库的so-name为libfoo.so。当在编译时传入”-l foo"参数给GCC的时候,编译器会在链接阶段需寻找libfoo.so文件。但是某些基础的C/C++库没有这种限制。

引用

  1. Cython official document
  2. Shared libraries with GCC on Linux
  3. Makefile tutorial