想象一家生意火爆的陕西面馆:主厨老 Python 手艺全面,凉皮、肉夹馍样样都会,唯独店里最火的招牌油泼面一忙起来就出餐慢。客人催单,后厨排长队,原因很简单——老 Python 每做一个动作都要停下来翻一遍菜谱(解释器逐行解释),动作一多就慢。老板掐表一看(性能分析),发现 80% 的客人都在等油泼面这一道菜。于是老板请来一位只做油泼面的关中老师傅 C:他手速极快,但不认识店里的点单系统,只听关中方言(C 语法)。要让这位老师傅上岗,得先签一份「后厨协作协议」(C API):约定食材怎么递给他(参数解析)、成品怎么端出去(返回值)、用过的碗由谁洗(引用计数)、面糊了怎么退单(错误处理),以及厨房唯一的燃气总阀归谁管(GIL)。这份协议,就是 Python C 扩展。本文带你从签约到开张走完全流程:先看值不值得请人,再认识协议模板,然后开业注册模块、写菜单、管碗、退单,最后请来 Cython 这位「代书先生」帮你批量起草合同。

岗位关联

学会 C 扩展,等于给 Python 装上原生引擎:你能亲手把耗时瓶颈改写为毫秒级,是性能优化、底层库封装、量化与推理服务调优等岗位的核心加分技能。

目录
  1. 可视化演示
  2. 为什么写C扩展
  3. C API 基本盘
  4. 模块定义初始化
  5. 函数注册解析
  6. 引用计数管理
  7. 错误处理机制
  8. GIL 与线程
  9. Cython 加速
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

展示C扩展模块生命周期(Python→C API→模块初始化→函数调用)、引用计数管理(Py_INCREF/Py_DECREF)及性能对比(Python vs C),切换模式掌握C扩展核心技术。

本节配有交互动效(进入视口后自动加载,约 3KB)。

C扩展模块生命周期 PyModuleDef定义模块结构(名称/文档/方法表),PyInit_xxx在import时调用完成模块初始化并注册到解释器 PyArg_ParseTuple解析Python参数为C类型,函数返回PyObject*给解释器,NULL表示异常 import → PyInit → PyModuleDef → PyMethod → PyArg_Parse → return Py* C扩展通过C API桥接Python解释器与原生C代码实现性能跃升

为什么写C扩展

面馆里,油泼面是招牌,可一到饭点就出餐慢。老 Python 什么都做:擀面、煮面、浇油都是他,但每做一个动作都要停下来看一遍菜谱(解释器逐行解释),动作一多就排队。老板用秒表一掐(profile),发现 80% 的客人都在等油泼面这一道。这道菜就是「热代码路径」:程序里被反复执行的那一小段。它通常只占源码的一小块,却吃掉绝大部分运行时间。

C 扩展的价值就是把这段热代码用 C 重写。C 编译后直接跑机器码,不需要逐行解释,也没有 Python 那套动态类型检查,所以常常能快几十倍。但这不是「全重写就全快」——收益有上限,而这个上限由阿姆达尔定律(Amdahl's Law)决定。

S总=1(1−p)+pSC\displaystyle S_{\text{总}} = \frac{1}{(1 - p) + \frac{p}{S_C}}

1. 分子:把原来的总耗时规定为 1,加速比就看新耗时剩多少。

11

2. 未重写部分:$(1-p)$ 是仍然留在 Python 里、没有被优化的那部分耗时占比。

(1−p)(1 - p)

3. 重写后部分:$\frac{p}{S_C}$ 是热代码被 C 提速之后的新耗时占比,p 越大、S_C 越高,这一项越小。

pSC\frac{p}{S_C}

4. 取倒数:分母越小,整体加速比越大,这就是整体加速比公式。

1(1−p)+pSC\frac{1}{(1-p)+\frac{p}{S_C}}

所以聪明的老板只外包油泼面,凉皮肉夹馍仍让老 Python 掌勺。工程上也是一样:先 profile 定位热点,再重写那一小段,而不是把整个厨房换一遍。

C API 基本盘

要请 C 师傅上岗,先签协议。协议第一页写着:本店所有食材统一装进一种保鲜盒(PyObject)——不管是面、肉还是菜,师傅不必认识店里的点单系统,只要会开这种盒子。在 C 扩展里,一切 Python 对象(整数、字符串、列表、字典)在 C 侧都表现为 PyObject* 指针,形态完全统一,盒子里只有两个固定字段:引用计数和类型指针。

任何扩展源文件第一行都要 #include <Python.h>;编译时需要 Python 的开发头文件,并在 setup.py 里用 Extension 声明源码。值得注意的是,每次从 Python 侧调用 C 函数都要「跨界」,而跨界有固定开销:参数要打包、C 侧要解析类型、结果还要重新装箱成 Python 对象。

t调用=N×(t类型检查+tC执行+t装箱)\displaystyle t_{\text{调用}} = N \times (t_{\text{类型检查}} + t_{\text{C执行}} + t_{\text{装箱}})

1. 调用次数 N:从 Python 侧每调用一次 C 函数,就要付一次「过路费」。

NN

2. 类型检查:参数要经过格式串校验,确认是整数还是字符串之后才能进入 C 世界。

t类型检查t_{\text{类型检查}}

3. C 执行:真正的原生计算,通常是最快的一段。

tC执行t_{\text{C执行}}

4. 装箱:C 整数要包成 Python 对象才能交还给调用方。

t装箱t_{\text{装箱}}

保鲜盒接口统一,师傅就能用同一套手法处理任何食材;这也是为什么 C 扩展能跟 Python 对象系统天然兼容。但盒子也不是白拿的——每次跨界都要拆盒、装盒,所以不要让 C 函数只做一件芝麻大的事。

模块定义初始化

新店开张前要挂招牌、贴菜单、定店规。C 师傅上岗第一天,老板先给他一块招牌(模块对象),再把会做的菜写进菜单(方法表),最后在门口喊一声「开业啦」(初始化函数)。这套开业流程在 C API 里对应三件套:PyModuleDef 描述模块元信息、PyMethodDef 数组描述函数表、PyInit_模块名 作为导入入口。

PyInit_fast_math 是 Python 导入模块时自动调用的函数,它调用 PyModule_Create 生成模块对象,把方法表注册上去,最后返回给解释器。方法表数组必须以 {NULL, NULL, 0, NULL} 结尾,像菜单最后画一条结束线。

PyInit_mod→PyModule_Create模块对象→注册方法表可导入模块\displaystyle \text{PyInit\_mod} \xrightarrow{\text{PyModule\_Create}} \text{模块对象} \xrightarrow{\text{注册方法表}} \text{可导入模块}

1. 入口函数:模块名固定为 PyInit_实际模块名,解释器 import 时自动调用它。

PyInit_mod\text{PyInit\_mod}

2. 创建模块对象:PyModule_Create 按 PyModuleDef 的配方生成一个模块对象。

→PyModule_Create模块对象\xrightarrow{\text{PyModule\_Create}} \text{模块对象}

3. 注册方法表:把函数指针表挂到模块上,之后 Python 里就能直接调用这些函数。

→注册方法表可导入模块\xrightarrow{\text{注册方法表}} \text{可导入模块}

开业流程固定:挂招牌、贴菜单、喊开业,一样都不能少。实际写代码时最常见的报错就是「模块名对不上」——PyInit_ 后面的名字必须和最终 .so 文件名严格一致,否则解释器找不到入口,招牌就挂歪了。

函数注册解析

菜单贴好后,客人点菜,食材要从传菜口递进后厨。C 师傅不懂 Python 的点菜方式,所有位置参数都被打包成一个「参数元组」(args)递进来。师傅得按菜谱规格一件件检查:这桌要几两盐、几斤面。在 C API 里,这个「点菜单」就是格式串。

任何一个导出函数的签名都是 static PyObject* func(PyObject* self, PyObject* args):self 是模块对象,args 是参数元组。PyArg_ParseTuple 按格式串校验并提取参数:"i" 对应 C 的 int,"L" 对应 long long,"s" 对应字符串,"O" 对应任意 PyObject。返回值则用 PyLong_FromLongLong 这类构建函数装箱。

PyArg_ParseTuple(args,"is",&i,&s)⇒(i∈Z, s∈char*)\displaystyle \text{PyArg\_ParseTuple}(\text{args}, \text{"is"}, \&i, \&s) \Rightarrow (i \in \mathbb{Z},\ s \in \text{char*})

1. 参数元组:Python 调用传进来的位置参数被打包成一个元组对象。

args\text{args}

2. 格式串 "is":告诉 C 语法:「第一个必须是整数,第二个必须是字符串」。

"is"\text{"is"}

3. 写回地址:解析成功就把值写进 &i、&s 指向的 C 变量,失败则返回 0。

&i, &s\&i,\ \&s

有了格式串这张点菜单,大厨不用猜食材规格;写错格式串,运行时直接退单(抛 TypeError)。这也是 C 扩展「稳」的来源——错误在边界就被拦住,而不是让类型错误悄悄溜进 C 代码里。

引用计数管理

后厨最重要的规矩是「谁洗碗」。C 师傅从保鲜盒里取食材,有些碗是他自己端出来的(新引用),用完全得自己洗(Py_DECREF);有些只是瞄一眼别人灶台上的碗(借来的引用),绝对不能顺手洗——否则原主人还在用,碗就被砸了。引用计数管理,就是明确「这个对象归谁持有、谁负责减计数」。

对象一出生,引用计数就是 1。新增一份持有时调用 Py_INCREF 加一;放弃持有调用 Py_DECREF 减一;Py_XDECREF 是允许传 NULL 的安全版。计数减到 0,对象才真正销毁。最常见的坑有两个:一是把新对象塞进缓存时忘了 INCREF,导致对象被提前释放;二是对借来的引用乱 DECREF,导致计数提前归零。

Py_INCREF(o): N←N+1;Py_DECREF(o): N←N−1;N=0⇒释放对象\displaystyle Py\_INCREF(o):\ N \leftarrow N + 1; \quad Py\_DECREF(o):\ N \leftarrow N - 1; \quad N = 0 \Rightarrow \text{释放对象}

1. 初始计数:对象刚创建时计数为 1,表示创建者持有它。

N=1N = 1

2. 新引用加一:每次有人新增持有,计数加 1,对应 Py_INCREF。

N←N+1N \leftarrow N + 1

3. 放弃引用减一:用完就减 1,对应 Py_DECREF 或 Py_XDECREF。

N←N−1N \leftarrow N - 1

4. 归零释放:计数减到 0 才真正销毁对象、归还内存。

N=0⇒释放对象N = 0 \Rightarrow \text{释放对象}

洗碗规矩定清楚,后厨才不会出现「碗还被人用着就碎了」的崩溃(悬垂指针),也不会有「碗堆到水池发臭」的内存泄漏。写 C 扩展时,每次看到 PyObject*,先问一句:这碗是我的,还是借的?

错误处理机制

面煮糊了不能硬端上桌。C 师傅的处理方式很统一:先在账本上记一笔「这单糊了」(设置异常),然后端个空盘子回去(返回 NULL)。前台接到空盘子,立刻知道这单要退给客人。这套退单流程,就是 C 扩展的错误处理约定。

出错时,函数调用 PyErr_SetString(或 PyErr_NoMemory 等)设置异常类型和消息,然后返回 NULL——NULL 是 C 侧「抛异常」的统一信号。有两个铁律:绝不能在没设置异常时返回 NULL,否则 Python 侧会报 SystemError: NULL result without error;返回前要记得释放自己持有的引用,否则异常路径本身就会泄漏。

出错→PyErr_SetString异常对象→return NULLPython 调用方\displaystyle \text{出错} \xrightarrow{\text{PyErr\_SetString}} \text{异常对象} \xrightarrow{\text{return } NULL} \text{Python 调用方}

1. 出错现场:C 函数发现输入非法或资源分配失败,进入错误分支。

出错\text{出错}

2. 设置异常:PyErr_SetString 把异常类型和消息挂到当前线程状态上。

→PyErr_SetString异常对象\xrightarrow{\text{PyErr\_SetString}} \text{异常对象}

3. 返回空指针:函数返回 NULL,解释器看到空盘子就知道该抛异常。

→return NULLPython 调用方\xrightarrow{\text{return } NULL} \text{Python 调用方}

退单流程统一后,客人(上层 Python 代码)总能收到明确原因,而不是「菜莫名消失」。这也是为什么 C 扩展能和 Python 的 try/except 无缝配合:你在 C 侧退的单,Python 侧照样能接住。

GIL 与线程

面馆只有一个燃气总阀(GIL),同一时刻只能有一个人开火。老 Python 做事时,总阀必须在他手上;C 师傅揉面(纯 C 计算)不需要用阀,就可以先把总阀交出去让别的灶台开工,揉完面再拿回来。这套「交阀—干活—拿回」的流程,在 C API 里是 Py_BEGIN_ALLOW_THREADS 和 Py_END_ALLOW_THREADS 这一对宏。

GIL 保证同一时刻只有一个线程执行 Python 字节码,所以 CPU 密集型的纯 Python 多线程无法利用多核。C 扩展这时就有优势:只要某段计算不碰任何 Python 对象,就可以释放 GIL,让多个线程的纯 C 计算真正并行。但注意,释放 GIL 期间绝不能调用任何 Python C API;重新获取后才能继续操作对象。而且,你自己管理的共享数组、计数器仍需加锁。

S线程=M⋅tctc+t重获\displaystyle S_{\text{线程}} = \frac{M \cdot t_c}{t_c + t_{\text{重获}}}

1. 线程数 M:同时排队做纯 C 任务的线程数量。

MM

2. 串行总耗时:握着 GIL 时只能一个一个来,M 个任务一共耗时 M 倍单任务时长。

M⋅tcM \cdot t_c

3. 并行新耗时:释放 GIL 后大家同时干,只需 t_c,但最后都要排队重获 GIL。

tc+t重获t_c + t_{\text{重获}}

4. 加速比:两者相除,M 越大、重获开销越小,收益越接近 M 倍。

M⋅tctc+t重获\frac{M \cdot t_c}{t_c + t_{\text{重获}}}

总阀交出去能并行,但回来时要排队;交了阀又偷偷碰 Python 对象,厨房必出事故。记住一句话:释放 GIL 只代表「其他灶台可以开火」,不代表你的调料罐子没人偷吃——共享数据该上锁还是得上锁。

Cython 加速

委托合同一张张手写太累,老板请来「代书先生」Cython。你只需用近似 Python 的感觉描述需求,加点类型标注,它自动起草完整的 C API 合同,生成的代码交给编译器就变成 C 扩展。手写 C 里最繁琐的格式串解析、模块样板、异常传播,Cython 全包了。

Cython 是带 C 类型注解的 Python 方言:.pyx 文件里,def 会把函数导出给 Python,cdef int 声明 C 类型变量,再用 cythonize 编译成 C。把局部变量声明成 cdef int、cdef double 之后,性能可以接近手写 C。代价是:一旦涉及引用计数和 GIL,底层语义没有消失,只是被工具隐藏了。

L手写C≈3×LCython\displaystyle L_{\text{手写C}} \approx 3 \times L_{\text{Cython}}

1. 手写 C 行数:一个带参数解析、错误处理、引用计数的完整函数,代码量大。

L手写CL_{\text{手写C}}

2. Cython 行数:同样的函数用近似 Python 语法写,通常只有三分之一。

LCythonL_{\text{Cython}}

3. 比例关系:三倍差距主要省在胶水代码上,核心算法一行都省不了。

≈3×\approx 3 \times

代书先生省的是「抄合同」的力气,不是「懂合同」的功夫。真出了引用计数、GIL 相关的怪问题,你还是得回来理解这套厨房规矩——工具帮你写代码,但不会帮你背锅。

AI 应用场景

量化行情特征计算加速

阶段:特征工程与实时行情计算

解决的问题:逐笔行情数据量巨大,纯 Python 计算波动率、买卖压力等特征时每次都要解释执行,延迟高且 CPU 占用大。把热路径用 C 扩展重写后,特征可以在 tick 级实时算出,回测与实盘共用一套接口。

具体案例:某量化团队将 order book 特征函数用 C 扩展重写,单笔数据处理的延迟从 5ms 降到 0.3ms,策略信号能多抢出好几毫秒的窗口。

给 AI 服务封装底层检索库

阶段:模型部署与服务化

解决的问题:向量检索、图像编解码等底层 C/C++ 库用 ctypes 绑定时签名繁琐、跨平台麻烦,而且错误处理散落各处。C 扩展能提供干净的 Python 接口,同时保留完整的异常传播和引用计数安全。

具体案例:推理服务中自研 HNSW 索引通过 C 扩展暴露 search(query, top_k) 接口,上层用 asyncio 并发调用,检索延迟和召回率双双达标。

数据预处理公共库提速

阶段:数据清洗与预处理

解决的问题:清洗管线反复跑在大量 CSV 和日志上,字符串处理和哈希去重是瓶颈。用 Cython 给公共函数加静态类型并编译成扩展,团队无需改动任何调用代码就能获得接近 C 的速度。

具体案例:数据平台组把去重哈希和字段切分用 Cython 重写,日均数据处理量提升了 8 倍,且代码量只有原手写 C 方案的三分之一。

Python 演示

python67 行
import subprocessimport sysimport time C_SRC = r'''#define PY_SSIZE_T_CLEAN#include <Python.h> static long long fib_ll(long long n) {    if (n < 2) return n;    return fib_ll(n - 1) + fib_ll(n - 2);} static PyObject *fib(PyObject *self, PyObject *args) {    long long n;    if (!PyArg_ParseTuple(args, "L", &n)) return NULL;    return PyLong_FromLongLong(fib_ll(n));} static PyMethodDef FastMethods[] = {    {"fib", fib, METH_VARARGS, "fast fibonacci"},    {NULL, NULL, 0, NULL}}; static struct PyModuleDef fastmod = {    PyModuleDef_HEAD_INIT, "fast_math", "C extension demo", -1, FastMethods}; PyMODINIT_FUNC PyInit_fast_math(void) {    return PyModule_Create(&fastmod);}''' SETUP_SRC = '''from setuptools import setup, Extension setup(    name="fast_math",    ext_modules=[Extension("fast_math", ["fast_math.c"])],)''' with open("fast_math.c", "w", encoding="utf-8") as f:    f.write(C_SRC) with open("setup_fast_math.py", "w", encoding="utf-8") as f:    f.write(SETUP_SRC) subprocess.check_call([sys.executable, "setup_fast_math.py", "build_ext", "--inplace"]) import fast_math  # noqa: E402  def fib_py(n):    if n < 2:        return n    return fib_py(n - 1) + fib_py(n - 2)  for n in (28, 30):    t0 = time.perf_counter()    a = fast_math.fib(n)    t1 = time.perf_counter()    b = fib_py(n)    t2 = time.perf_counter()    print("n=%d C=%.4fs Python=%.4fs speedup=%.1fx" %          (n, t1 - t0, t2 - t1, (t2 - t1) / (t1 - t0)))
目的:一键把最小 C 扩展编译进当前目录,然后对比斐波那契数列在 Python 和 C 下的速度差异。运行前提是本机有 C 编译器(Linux 用 gcc,macOS 用 clang,Windows 需 Visual Studio Build Tools)。
  1. C_SRC = r'''把整套 C 源码放进原始字符串,脚本运行时一次性写盘。
  2. static PyObject *fib(PyObject *self, PyObject *args) {C 导出函数的统一签名:self 是模块对象,args 是参数元组。
  3. if (!PyArg_ParseTuple(args, "L", &n)) return NULL;用格式串 "L" 校验参数并写回 long long 变量,失败立即返回 NULL 退单。
  4. return PyLong_FromLongLong(fib_ll(n));把 C 整数装箱成 Python 整数返回,这是「新引用」。
  5. static PyMethodDef FastMethods[] = {方法表:把 fib 这个名字和 C 函数指针绑定。
  6. PyMODINIT_FUNC PyInit_fast_math(void) {初始化函数:模块被 import 时解释器首先调用它。
  7. subprocess.check_call([sys.executable, "setup_fast_math.py", "build_ext", "--inplace"])调用 setuptools 编译 C 源码,并把生成的 .so 放到当前目录。
  8. import fast_math # noqa: E402编译完成后,C 扩展像普通模块一样被导入。
  9. def fib_py(n):纯 Python 版斐波那契,用来和 C 版对比跑分。
  10. a = fast_math.fib(n)调用 C 版;两版耗时差就是热代码重写的收益。

常见误区

误区我以为写 C 扩展就像写单片机程序,所有对象用完都要手动 free,于是在函数里对每个 PyObject 都调用 free。

正解C 扩展里的 PyObject 不能也不该手动 free。Python 对象由引用计数统一管理:用 Py_DECREF 把计数减到 0,解释器才真正释放内存;直接 free 会破坏内存管理器的记账结构。后厨的碗由洗碗工(引用计数)统一洗,你直接砸碗,整个厨房都崩。该洗几次,取决于你的引用是「新引用」还是「借来的」。

为何常见:C 语言的肌肉记忆是 malloc/free 配对,很容易把 Python 对象当成裸内存。

误区把借来的引用直接返回给调用方,能省一次 Py_INCREF,速度更快,应该没问题。

正解借来的引用所有权在别人手里,可能在你想用时已被释放。函数返回的是「新引用」,调用方会负责 DECREF;如果你返回借来的引用,调用方一减就可能归零,出现 use-after-free 崩溃。正确做法是先 Py_INCREF 再返回,或构建一个新对象。把别人的碗直接端给客人,客人洗完碗,原主人再拿就碎了。

为何常见:「少做事等于更快」的直觉没错,但忽略了所有权契约。

误区我猜释放 GIL 之后 C 代码就自动线程安全了,所以我不再加锁。

正解GIL 只保护 Python 解释器内部状态;你自己 C 代码里的全局缓存、数组、计数器,GIL 管不着。释放 GIL 后两个线程同时写同一块缓冲区照样崩。总阀交出去只代表「其他灶台能开火」,不代表你的调料罐子没人偷吃。共享数据仍要自己加锁或用原子操作。

为何常见:把「解释器安全」误解为「应用数据安全」。

误区Cython 能自动生成 C 扩展,那我完全不用学引用计数和 GIL 了。

正解Cython 自动写胶水,但底层语义没变:对象生命周期、内存所有权、线程边界依然存在。在 Cython 里混用 Python 对象一样有引用计数问题,with nogil 块内同样禁止调用 Python API。代书先生帮你起草合同,但合同里每一条责任还是你的;出了事不能赖「字是代笔的」。

为何常见:工具降低了门槛,让人误以为底层规则消失了。

面试题

Q1. CPython 对象的内存释放由谁决定?

单选题 · easy

  • A.程序员手动调用 free
  • B.引用计数归零触发← 正确答案
  • C.GC垃圾回收器全权决定
  • D.由操作系统自动回收

答案:B。CPython 用引用计数决定对象生死,像后厨里「碗还剩几个人要用」决定它洗不洗;GC 只负责处理循环引用这类特殊情况,操作系统更不会替 Python 兜底。所以只有当最后一个持有者放弃时,内存才被回收。

Q2. 下面哪种 PyObject 引用是「借来的」?

单选题 · easy

  • A.自己新建的字典对象
  • B.从参数里解析出的对象← 正确答案
  • C.函数返回值构建对象
  • D.模块创建的返回值

答案:B。从参数元组里解析出的对象是「借来的碗」,所有权在调用方,用完不要 DECREF;自己新建的字典、返回值构建的对象、模块创建的返回值都不是借来的。把借来的碗拿去洗,原主人再用就崩了。

Q3. 关于 CPython 的 GIL,描述正确的是?

单选题 · mid

  • A.同时仅一线程执行字节码← 正确答案
  • B.让多核并行跑 C 扩展
  • C.保证所有线程同时执行
  • D.防止引用计数产生泄漏

答案:A。GIL 是「燃气总阀」:同一时刻只有一个线程执行字节码,所以 CPU 密集型纯 Python 多线程无法并行;它不能让多核并行跑 C,更不负责防泄漏。C 扩展要并行,必须主动释放 GIL 并保证纯 C 计算。

Q4. C 函数出错并设置异常后,应该返回什么?

单选题 · mid

  • A.返回整数常量0
  • B.返回整数常量1
  • C.返回整数常量-1
  • D.返回NULL空指针← 正确答案

答案:D。C API 的错误协议是:先 PyErr_SetString 记异常,再返回 NULL。返回 0、1、-1 都不在协议里,解释器只认空盘子。如果没设异常就返回 NULL,Python 侧会报 SystemError。

Q5. 引用计数「加一配减一」的正确含义是?

单选题 · hard

  • A.只做递减操作计数
  • B.新引用加一放弃减一← 正确答案
  • C.每次函数调用都加一
  • D.模块销毁时批量归零

答案:B。每次新增持有就 Py_INCREF 加一,每次放弃持有就 Py_DECREF 减一,配对出现才不会泄漏或提前释放。只减不加会提前销毁,每调用都加是过度计数,等模块销毁再批量归零则太迟。

Q6. 下列哪种做法会导致内存泄漏?

单选题 · hard

  • A.新引用用完做 DECREF
  • B.模块级缓存只增不减← 正确答案
  • C.借来的引用直接使用
  • D.XDECREF 检查空指针

答案:B。模块级缓存每次存入对象都 INCREF 却从不 DECREF,计数只升不降,对象永远释放不了,像碗只进不出的水池。新引用用完 DECREF 是对的,借来的引用不用操作,Py_XDECREF 判空也是对的。

Q7. PyArg_ParseTuple 的格式串 "L" 表示什么?

单选题 · mid

  • A.双精度浮点数
  • B.长长整型数值← 正确答案
  • C.任意类型对象
  • D.字节串类型

答案:B。格式串就是点菜单:“L“ 对应 C 的 long long 长长整型,”d“ 才是双精度浮点,”s“ 是字符串,”O” 是任意对象。写错规格,食材就进不了后厨。

Q8. 哪些情况下适合写 C 扩展?

多选题 · easy

  • A.热点循环体计算← 正确答案
  • B.调用现成 C 库← 正确答案
  • C.所有业务逻辑代码
  • D.频繁拆装箱结构← 正确答案

答案:ABD。热点循环、调用现成 C 库、频繁拆装箱都是 C 扩展的用武之地;「所有业务逻辑」重写是过度工程——老板不会为了凉皮也去请 C 大厨。重写的对象应该是被反复执行、开销又大的那一小段。

Q9. 释放 GIL 的正确前提有哪些?

多选题 · mid

  • A.不调用任何 C API← 正确答案
  • B.共享数据已加锁保护← 正确答案
  • C.完成后需重新获取← 正确答案
  • D.可对所有代码释放

答案:ABC。释放 GIL 的区块必须不碰任何 Python C API,共享数据仍需自备锁,干完活必须重新获取才能继续操作对象。「可对所有代码释放」是危险误解——总阀不能随便交,交出去还得拿回来。

Q10. 关于 Py_XDECREF 与 Py_DECREF,正确的是?

多选题 · extreme

  • A.X 版本会先判空← 正确答案
  • B.两者都递减引用计数← 正确答案
  • C.两者对空指针都安全
  • D.归零时都会释放对象← 正确答案

答案:ABD。Py_XDECREF 递减前会检查空指针,Py_DECREF 不允许传 NULL;两者都会递减计数,并在归零时释放对象。C 错在「两者对空指针都安全」——Py_DECREF(NULL) 会直接崩溃,只有 X 版本能接住空指针。