在上一篇文章中,我们了解了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对象,它包含三个参数exporter
、view
和flags
。exporter
是生产者对象,view
是Py_Buffer
类型的请求,一般是未经初始化的Py_Buffer
对象,flags
是标识获取的buffer对象类型,和上述的readonly
、ndim
等密切相关。这个方法调用成功以后,用户就可以根据view
获取对应的buffer对象。PyBuffer_Release
:是上述操作的逆操作。接受一个参数,即需要被释放的view
。PyBuffer_FillInfo
:填写一个Py_Buffer
一般用在生产者模型里,即bf_getbuffer
函数内。它包含一个exporter
,对应被填充的view
以及用来填充的buf
。
操作实践
在上一篇文章中我们实现了datetimecpy
的time
模块,这次我们实现timedelta
对象,它是用来表示两个时间之间的差。
代码仓库在这里,本章对应的代码在Ch-6分支上。
模拟第三方C库
由于本章内容是PyBuffer,涉及到第三方C语言库的调用。这里我简单实现了一个C语言类作为被调用的接口,用来存放时间差。
这个时间差用三个单位来刻画,分别是日、秒和微秒。然而为了节约内存空间,在自定义的结构体对象中我用一个定长unsigned char
数组实现。考虑到日的范围为[-999999999, 999999999],以一个字节8位来算,只需要四个字节就可以表示这么大范围。同理,秒的范围为[0, 86399]占三个字节,微秒的范围为[0, 999999]也占三个字节,所以一共10个字节。
所以这样定义——
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_RawMalloc
和PyMem_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_GetBuffer
和PyBuffer_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最重要的部分之一PyBuffer,并完善了datetimecpy
项目。至此,datetimecpy
项目的开发就告一段落了,它是完全参考官方的datetime
模块开发的。如果学有余力可以直接阅读CPython项目中对应的C版本的datetime模块源码。这个版本的datetime并不是我们平常用Python开发时候所用的datetime模块,它是C语言版本写的实现,需要在构建时修改同级的SetUp文件,将下面一行去掉注释再按照文档编译CPython项目才可以使用。
#_datetime _datetimemodule.c
好了,接下来我会开始讲一些遗留的问题,比如这章提到的内存分配机制和GIL。