likes
comments
collection
share

Python C语言API系列教程(六、C语言API灵魂——PyBuffer)

作者站长头像
站长
· 阅读数 20

上一篇文章中,我们了解了Python虚拟机对模块和方法的实现,以及它们的API用法。同时,我们也完善了我们的datetimecpy模块。在这篇文章中,我们会接触到一个重要的概念PyBuffer

本系列代码datetimecpy对应的代码详见repo

前置知识

早在第一篇文章中,我们就了解到Python的C语言API的用处,一方面是加速,另一方面是调用C开发的模块(本地化适配)。而通过C语言API调用其他模块必须通过一个协议——Buffer协议。

这个协议本质上是生产者-消费者模型,功能提供方(C开发的模块)是生产者,用户使用的Python的API是消费者。官方文档是这样描述的——

  • 在生产者方面是一个可以提供buffer给外界使用的接口的类型。
  • 在消费者方面是有不同的方法可以获取到原始buffer指针的模型。

这么说有点费解,我的理解是这样的——

  • 首先不管生产者还是消费者都是一个PyObject*
  • 其次,作为生产者,它必须包含一个指针指向原始Buffer,且提供相应的方法可以获取这个Buffer。
  • 作为消费者,它必须调用上面的方法来获取Buffer内容给我们最终用户。

举个例子就好理解了,PIL模块是常见的Python绘图库,常见于数据分析、可视化等领域。但是Python本身是没有能力直接绘图的,因此它必须通过操作系统提供的API进行绘图,比如GDI、OpenGL等。而提供这些能力的PyObject*就是生产者。反之,则是消费者。

要点详解

生产者模型

生产者不是自己随意定义的,它必须遵守C语言API的规定的方式,也就是遵守Buffer协议。这个作为生产者的PyObject*的type必须填写tp_as_buffer插槽。这个插槽是一个PyBufferProcs对象,它包含两个函数指针类型的成员,一个是bf_getbuffer函数指针,其签名为int (PyObject *exporter, Py_buffer *view, int flags),另一个是bf_releasebuffer函数指针,其签名为void (PyObject *exporter, Py_buffer *view)

具体说说这两个函数指针。顾名思义,bf_getbuffer是为了获取buffer。第一个参数exporter即是作为生产者的PyObject*,第二个view是生产者与消费者之间传递的对象,即请求,flags是标志位,暂时不用去管。在实现这个方法的时候也不是乱来的,它经过以下几个步骤——

  • 检查请求是否被满足,即检查view参数是否符合要求。如果符合则继续,否则抛出PyExc_BufferError异常,并将view->obj赋值NULL,函数返回-1。
  • 填写view字段
  • 增加exporter中的计数器
  • view->obj指向exporter
  • 返回0

同理,bf_releasebuffer是释放buffer。第一个参数还是exporter,第二个是需要被释放的view。它的过程只有两步。

  • 减少exporter中的计数器
  • 当计数器为0则释放内存

消费者模型

相比而言,消费者模型比较随意点。用户可以以任意的方式消费生产者产生的PyBuffer对象,一般生产者会封装一些原生buffer的操作方法。

PyBuffer及其相关API

在生产者和消费者之间,Python虚拟机是通过Py_buffer传递的。它的结构如下——

typedef struct {
    void *buf;
    PyObject *obj;
    Py_ssize_t len;
    Py_ssize_t itemsize; 
    int readonly;
    int ndim;
    ...
} Py_buffer;

来自CPython 3.9

  • buf指针指向原生的buffer对象,是我们需要直接使用的对象。
  • obj指向当前Py_buffer的拥有者(有点像rust的概念),一般指向生产者对象。
  • len是总长度,一般用来分配内存。也就是说如果存放一个数组的话就是itemsize * size。
  • itemsize是单个元素的内存,如果不涉及数组则为内存大小。
  • readonly是只读标识,代表这个buffer是否是只读的。可以填PyBUF_WRITABLE代表可写,否则不可写。
  • ndim代表数组的维度,只有当这个buffer是一个数组才用到,比如三维数组的话这个值为3。

与之相关的API也有很多,选取部分有用的API介绍一下——

  • PyObject_GetBuffer:获取一个buffer对象,它包含三个参数exporterviewflagsexporter是生产者对象,viewPy_Buffer类型的请求,一般是未经初始化的Py_Buffer对象,flags是标识获取的buffer对象类型,和上述的readonlyndim等密切相关。这个方法调用成功以后,用户就可以根据view获取对应的buffer对象。
  • PyBuffer_Release:是上述操作的逆操作。接受一个参数,即需要被释放的view
  • PyBuffer_FillInfo:填写一个Py_Buffer一般用在生产者模型里,即bf_getbuffer函数内。它包含一个exporter,对应被填充的view以及用来填充的buf

操作实践

在上一篇文章中我们实现了datetimecpytime模块,这次我们实现timedelta对象,它是用来表示两个时间之间的差。

代码仓库在这里,本章对应的代码在Ch-6分支上。

模拟第三方C库

由于本章内容是PyBuffer,涉及到第三方C语言库的调用。这里我简单实现了一个C语言类作为被调用的接口,用来存放时间差。

这个时间差用三个单位来刻画,分别是日、秒和微秒。然而为了节约内存空间,在自定义的结构体对象中我用一个定长unsigned char数组实现。考虑到日的范围为[-999999999, 999999999],以一个字节8位来算,只需要四个字节就可以表示这么大范围。同理,秒的范围为[0, 86399]占三个字节,微秒的范围为[0, 999999]也占三个字节,所以一共10个字节。

Python C语言API系列教程(六、C语言API灵魂——PyBuffer)

所以这样定义——

struct timedelta_buf {
    unsigned char buf[10];
};

那么怎么获取对应的数据呢?比如说取timedelta的days呢?这个很简单,只要获取对应下标的数据再根据位置偏移就好了,比如这样获取days数据——

long timedelta_buf_get_days(struct timedelta_buf* td_buf) {
    return (long)(td_buf->buf[0] << 24 | td_buf->buf[1] << 16 | td_buf->buf[2] << 8 | td_buf->buf[3]);
}

如果要写数据呢?也很简单,只要“与”上对应位置的数据并右移相应的位数就好了——

int timedelta_buf_set_days(struct timedelta_buf* td_buf, long days) {
    if (td_buf == NULL) {
        return -1;
    }
    td_buf->buf[0] = (days & 0xff000000) >> 24;
    td_buf->buf[1] = (days & 0x00ff0000) >> 16;
    td_buf->buf[2] = (days & 0x0000ff00) >> 8;
    td_buf->buf[3] = (days & 0x000000ff) >> 0;
    return 0;
}

其他两个单位也按照这种方法读取即可。另外,需要注意一下这个unsigned char的初始化和释放,必须要用\0来填充——

struct timedelta_buf* timedelta_buf_new() {
    struct timedelta_buf* td_buf = (struct timedelta_buf*)PyMem_RawMalloc(10 * sizeof(char));
    if (td_buf == NULL) {
        return NULL;
    }
    memset(td_buf->buf, '\0', 10 * sizeof(char));
    return td_buf;
}

void timedelta_buf_delete(struct timedelta_buf* td_buf) {
    if (td_buf == NULL) {
        return;
    }
     PyMem_RawFree(td_buf);
}

这里面用到了Python内存相关API(PyMem_RawMallocPyMem_RawFree),下一章就会讲解。

好了,有了这部分基础知识就可以往下走了。

生产者对象设计

上文一直提到的exporter就是生产者对象,按照要求其包含一个原生buffer和计数器——

typedef struct {
    PyObject_HEAD

    struct timedelta_buf* timedelta;

    Py_ssize_t exports;

} TimedeltaExporter;

并且在实现type时需要填写tp_as_buffer来表明它是一个生产者对象——

int TimedeltaExporter_getbuffer(TimedeltaExporter* exporter, Py_buffer* view, int flag);

void TimedeltaExporter_releasebuffer(TimedeltaExporter* exporter, Py_buffer* view);

static PyBufferProcs TimedeltaExporter_as_buffer = {
    (getbufferproc) TimedeltaExporter_getbuffer,
    (releasebufferproc) TimedeltaExporter_releasebuffer
};

static PyTypeObject TimedeltaExporter_type = {
    PyVarObject_HEAD_INIT(NULL, 0)
    .tp_name = "datetimecpy._timedelta_exporter",
    .tp_as_buffer = &TimedeltaExporter_as_buffer
    ...
};

然后实现PyObject_GetBufferPyBuffer_Release两个方法,注意前文提供的步骤——

int TimedeltaExporter_getbuffer(TimedeltaExporter* exporter, Py_buffer* view, int flag) {
    struct timedelta_buf* buf = (struct timedelta_buf*)exporter->timedelta;
    if (buf == NULL || exporter->exports == 0) {
        buf = timedelta_buf_new();
        if (buf == NULL) {
            return -1;
        }
    }
    if (view == NULL) {
        return -1;
    }
    PyBuffer_FillInfo(view, exporter, buf, sizeof(buf), 0, flag);
    if (PyErr_Occurred()) {
        PyErr_Print();
    }
    exporter->exports ++;
    return 0;
}

void TimedeltaExporter_releasebuffer(TimedeltaExporter* exporter, Py_buffer* view) {
    timedelta_buf_delete(exporter->timedelta);
    exporter->exports --;
}

到此为止,生产者对象写好了。

消费者对象设计

消费者对象比较随意,只要能消费上述的exporter即可。在本例中,消费者对象就是timedelta对象。为了方便,我直接将exporter定义在消费者对象里面——

typedef struct {
    PyObject_HEAD

    PyObject* exporter;

} Timedelta;

其成员方法即可通过Buffer协议(PyObject_GetBuffer方法)获取struct timedelta_buf*进行操作。比如它的__repr__方法——

PyObject* Timedelta_repr(PyObject* self) {
    TimedeltaExporter* exporter = ((Timedelta*)self)->exporter;
    Py_buffer buffer = {NULL, NULL};
    if (PyObject_GetBuffer(exporter, &buffer, PyBUF_WRITABLE) < 0) {
        return NULL;
    }
    struct timedelta_buf* _buf = (struct timedelta_buf*)buffer.buf;
    long days = timedelta_buf_get_days(_buf);
    long seconds = timedelta_buf_get_seconds(_buf);
    long microseconds = timedelta_buf_get_microseconds(_buf);
    return PyUnicode_FromFormat("%s(days=%ld, seconds=%ld, microseconds=%ld)", 
        Py_TYPE(self)->tp_name, days, seconds, microseconds);
}

现在测试一下,运行项目并初始化timedelta如果出现下图则说明成功——

Python C语言API系列教程(六、C语言API灵魂——PyBuffer)

小结

本章内容讲述了Python的C语言API最重要的部分之一PyBuffer,并完善了datetimecpy项目。至此,datetimecpy项目的开发就告一段落了,它是完全参考官方的datetime模块开发的。如果学有余力可以直接阅读CPython项目中对应的C版本的datetime模块源码。这个版本的datetime并不是我们平常用Python开发时候所用的datetime模块,它是C语言版本写的实现,需要在构建时修改同级的SetUp文件,将下面一行去掉注释再按照文档编译CPython项目才可以使用。

#_datetime _datetimemodule.c

好了,接下来我会开始讲一些遗留的问题,比如这章提到的内存分配机制和GIL。