Python 面试与工程实践:性能、并发和 C/C++ 扩展
本文定位
本文面向 3 年以上后端工程师,保留原有 Python 性能优化、OpenCV/ViSP、C++ 和 SWIG 实践内容,重点回答以下面试与工程问题:
- Python/CPython 为什么在不同负载下表现不同?
- GIL 对线程、多进程和异步 I/O 的影响边界是什么?
- 性能问题应该先做 profile,还是先换并发模型?
- 什么时候应该优化算法,什么时候应该使用矩阵化、C/C++ 扩展或 GPU?
- C/C++ 扩展如何处理编译、链接、部署和故障定位?
本文中的“Python 行为”和“CPython 实现”分开讨论。GIL、对象布局、解释器调度和免费线程化等内容都需要结合 Python/CPython 版本理解。
速查导航
Python/CPython 为什么慢
不同的场景下,代码是有不同的要求,大体有三个等级,“管用、更好、更快”。相比C/C++,Python具有较好的开发系效率,但是程序的性能运行速度会差一些。究其原因是Python为了灵活性,牺牲了效率。
动态类型。对于C/C++等静态类型语言,由于变量的类型固定,变量之间的运算很容易指定特定的函数。而动态类型在运行的时间需要大量if else判断处理,直到找到符合条件的函数。动态类型增加语言的易用性,但是牺牲了程序的运行效率。
GIL(Global Interpreter Lock)全局解释锁,CPython在解释执行任何Python代码的时候,首先都需要they acquire GIL when running,release GIL when blocking for I/O。如果没有涉及I/O操作,只有CPU密集型操作时,解释器每隔100一段时间(100ticks)就会释放GIL。GIL是实现Python解释器的(Cython)时所引入的一个概念,不是Python的特性。
由于GIL的存在,使得Python对于计算密集型任务,多线程threading模块形同虚设,因为线程在实际运行的时候必须获得GIL,而GIL只有一个,因此无法发挥多核的优势。为了绕过GIL的限制,只能使用multiprocessing等多进程模块,每个进程各有一个CPython解释器,也就各有一个GIL。CPython不支持JIT(Just-In-Time Compiler),JIT 能充分利用程序运行时信息,进行类型推导等优化,对于重复执行的代码段来说加速效果明显。对于CPython如果想使用JIT对计算密集型任务进行优化,可以尝试使用JIT包numba,它能使得相应的函数变成JIT编译。
性能优化的正确顺序
最近在做一些算法优化方面的工作,简单总结一下思路:
- 熟悉算法的整体流程,对于算法代码,最开始尽可能不要使用多线程和多进程方法,
- 在1的基础上跑出算法的CPU profile,整体了解算法耗时分布和瓶颈。Python提供的cProfile模块灵活的针对特定函数或者文件产生profile文件,根据profile数据进行代码性能优化。
- 可以直接将生成profile代码写在Python脚本
- 使用命名行方式生成profile
- 分析工具pstats
- 图形化工具Gprof2Dot。python gprof2dot.py -f pstats result.out | dot -Tpng -o result.png
https://blog.csdn.net/asukasmallriver/article/details/74356771
- 程序(算法)本身的剪枝。比如视频追踪中,考虑是否让每个像素点都参与计算?优化后选择梯度变化最大的1w个像素点参与计算,能提高分辨率大的视频追踪效率。
- 使用矩阵操作代替循环操作。(get_values())
- 任务分解,在理解算法的基础上寻找并行机会,利用多线程或者多进程充分利用机器资源。生产者消费者模型,专门的线程负责图像获取和图形变换,专门的线程负责特征提取和追踪。
- 使用C/C++重写效率低的瓶颈部分
- 使用GPU计算
最近项目中需要用到visp库中的模板追踪算法,visp库用C++编写的,代码多,功能丰富。但是,对于项目来说直接调visp库并不方便,因此我们摘取visp库中的所需代码,提供python调用的接口,并根据项目需求进行优化和扩展。开源项目越来越多,以后工作也可能会遇到提取复杂库中部分功能,然后提供python调用的接口,因此这里总结一下,过程并不复杂,但是也遇到一些坑。主要注意以下几点:
- 依赖库采用静态方式编译。最开始的时候采用默认的动态编译,导致项目依赖复杂,部署起来非常不方便。要注意的是,visp库依赖opencv库,两个库都要采用静态方式编译。
- 提取所需代码,封装成类。提取visp库中的模板追踪算法,封装成类。为了便于后续的优化工作,接口扩展性尽可能好。
- **swig实现python调用C++**。有很多方法实现python调用C++,我这里采用swig,适合懒人。
静态编译opencv库
opencv采用cmake项目管理,通过ccmake可以很方便的设置静态编译选项。BUILD_SHARED_LIBS设置为OFF即为静态编译。另外,为了保持系统整洁,避免安装到系统路径,设置了安装路径,CMAKE_INSTALL_PREFIX=/home/terse/code/terse-visp/opencv-3.4.6/build
- git clone https://github.com/opencv/opencv.git
- cd opencv-3.4.6
- mkdir build && cd build
- ccmake ..(关闭动态编译选项,设置安装路径),cmake ..
- make -j4
- make install
静态编译visp库
ViSP 官方仓库 和 OpenCV 库一样都采用 CMake 管理,编译过程类似,这里只需要设置静态编译和设置安装路径:
关闭动态编译选项:BUILD_SHARED_LIBS=OFF
设置安装路径: CMAKE_INSTALL_PREFIX=/home/terse/code/terse-visp/visp/build
- git clone https://github.com/lagadic/visp.git
- cd visp
- mkdir build && cd build
- ccmake ..(关闭动态编译选项,设置安装路径),cmake ..
- make -j4
- make install
提取模板追踪算法,封装成C++类
visp库中提供了模板追踪算法,但是它不能解决遮挡的情况,参考区域很大的时候,追踪速度也很慢,因此在项目中针对这些问题做了一些优化,这个不是本文的重点就不赘述了。下面从visp中摘取的代码,封装成C++类,say_hello成员函数,没有实际用途,只是为了后续的验证python代码的正确性。
1 |
|
1 |
|
采用swig实现python调用C++
python调用C++的方法有很多,例如ctypes、PyObject、Boost.python,采用了swig方法,使用之后感觉确挺方便的。为了给追踪功能提供numpy参数的输入和输出,这里需要引入numpy.i文件。
参考:SWIG Python 文档
1. 定义接口文件:visp.i
1 | /* File: visp.i */ |
2. swig 编译visp.i 文件生成C++和py代码,生成visp_wrap.cxx,visp.py
1 | swig -c++ -python -py3 visp.i //python3 |
3. 分别编译visp.cc和visp_wrap.cxx代码
1 | g++ -O2 -fPIC -c visp.cc -I/home/terse/code/terse-visp/VispSource/build/include |
4. 链接生成_visp.so文件
1 | g++ -shared visp_wrap.o visp.o -L/home/terse/code/terse-visp/VispSource/build/lib -lvisp_ar -lvisp_blob -lvisp_core -lvisp_detection -lvisp_core -lvisp_gui -lvisp_imgproc -lvisp_io -lvisp_klt -lvisp_mbt -lvisp_me -lvisp_robot -lvisp_sensor -lvisp_tt -lvisp_tt_mi -lvisp_vision -lvisp_visual_features -lvisp_vs -lvisp_tt -lvisp_ar -lvisp_blob -lvisp_core -lvisp_detection -lvisp_core -lvisp_gui -lvisp_imgproc -lvisp_io -lvisp_klt -lvisp_mbt -lvisp_me -lvisp_robot -lvisp_sensor -lvisp_tt -lvisp_tt_mi -lvisp_vision -lvisp_visual_features -lvisp_vs -Wl,-Bstatic -L/home/terse/code/terse-visp/opencv-3.4.6/build/lib -lopencv_dnn -lopencv_ml -lopencv_objdetect -lopencv_shape -lopencv_stitching -lopencv_superres -lopencv_videostab -lopencv_calib3d -lopencv_features2d -lopencv_highgui -lopencv_videoio -lopencv_imgcodecs -lopencv_video -lopencv_photo -lopencv_imgproc -lopencv_flann -lopencv_core -Wl,-Bstatic -L/home/terse/code/terse-visp/opencv-3.4.6/build/share/OpenCV/3rdparty/lib -littnotify -llibprotobuf -llibjasper -lquirc -lippiw -lippicv -Wl,-Bdynamic -lpython3.7m -Wl,-Bdynamic -llapack -fopenmp -ldl -lz -lrt -ltiff -o _visp.so |
这里有个坑纠结了挺久了,最开始生成的_visp.so文件中通过ldd -r 查看一直有几个未定义的符号。排查后发现来自opencv_core库中,通过nm查看,发现libopencv_core.a中是未定义的,而libopencv_sore.so是正常的。最后发现那几个未定义的符号在/home/terse/code/terse-visp/opencv-3.4.6/build/3rdparty/lib/libittnotify.a库中,链接时加入这个库就将未定义的符号解决了。这个链接文件中有许多依赖的库是不需要的,这里就没有仔细排查了,只要没少就能链接成功。
简单测试
通过ldd -r 检查_visp.so文件没有问题,理论上就没什么问题里,这里通过代码中故意遗留的函数测试一下。
1 | import visp |
最近在做一些算法优化的工作,由于对Python认识不够,开始的入坑使用了多线程。发现在一个四核机器,即使使用多线程,CPU使用率始终在100%左右(一个核)。后来发现Python中并行计算要使用多进程,改成多进程模式后,CPU使用率达到340%,也提升了算法的效率。另外multiprocessing对多线程和多进程做了很好的封装,需要掌握。这里总结下面两个问题:
- Python中的并行计算为什么要使用多进程?
- Python多线程和多进程简单测试
- multiprocessing库的使用
Python中的并行计算为什么要使用多进程?
Python在并行计算中必须使用多进程的原因是GIL(Global Interpreter Lock,全局解释器锁)。GIL使得在解释执行Python代码时,会产生互斥锁来限制线程对共享资源的访问,直到解释器遇到I/O操作或者操作次数达到一定数目时才会释放GIL。这使得Python一个进程内同一时间只能允许一个线程进行运算”,也就是说多线程无法利用多核CPU。因此:
- 对于CPU密集型任务(循环、计算等),由于多线程触发GIL的释放与在竞争,多个线程来回切换损耗资源,因此多线程不但不会提高效率,反而会降低效率。所以计算密集型程序,要使用多进程。
- 对于I/O密集型代码(文件处理、网络爬虫、sleep等待),开启多线程实际上是**并发(不是并行)**,线程A在IO等待时,会切换到线程B,从而提升效率。
- 大多数程序包含CPU和IO操作,但不考虑进程的资源开销,多进程通常都是优于多线程的。
- 由于Python多线程的问题,因此通常情况下都使用多进程,使用多进程需要注意进程间变量的共享。
Python多线程和多进程简单测试
- job1是一个完成CPU没有任务IO的死循环,观察CPU使用率,无论使用多少线程数量num,CPU使用率始终在100%左右,也就是说只能利用核的资源。而多进程则可以使用多核资源,num为1时CPU使用率为100%,num为2时CPU使用率接近200%。
- job2是一个IO密集型的程序,主要的耗时在print系统调用。num=4时,多线程跑了10.81s,cpu使用率93%;多进程只用了3.23s,CPU使用率130%。
1 | import multiprocessing |
multiprocessing的使用
- 单个进程multiprocessing.Process对象,和threading.Thread的API完全一样,start(),join(),参考上文中的测试代码。
- 进程池
- 进程间对象共享队列multiprocessing.Queue()
- 进程同步multiprocessing.Lock()
- 进程间状态共享multiprocessing.Value,multiprocessing.Array
- multiprocessing.Manager()
- 进程池:multiprocessing.Pool()
- pool.map
- pool.imap_unordered
- pool.apply_async
Python多线程和多进程的使用非常方面,因为multiprocessing提供了非常好的封装。为了方便设置线程和进程的数量,通常都会使用池pool技术。
1 | from multiprocessing.dummy import Pool as DummyPool # thread pool |
multilprocessing包的使用可参考:
并发模型与生产排障
如何选择线程、进程和协程
| 场景 | 优先考虑 | 主要代价 |
|---|---|---|
| CPU 密集型纯 Python 代码 | 多进程、算法优化或原生扩展 | 进程内存、序列化和进程间通信 |
| I/O 密集型任务 | 线程或异步 I/O | 共享状态、取消、超时和连接管理 |
| C 扩展释放 GIL 的计算 | 线程也可能有效 | 需要确认扩展是否真的释放 GIL |
| 大规模独立任务 | 进程池或任务队列 | 调度、排队、重试和结果汇聚 |
| 高并发网络服务 | 异步 I/O 或成熟线程模型 | 事件循环阻塞和调用链可观测性 |
“CPU 密集型任务必须多进程”是 CPython 传统 GIL 语境下的工程经验,不是 Python 语言规范。具体方案还要看任务是否执行原生代码、是否释放 GIL、数据是否需要跨进程复制,以及运行的 Python 版本。
性能优化的验证闭环
- 先建立可重复的基准和正确性测试,明确输入规模、机器配置和指标。
- 使用 profile 找到主要耗时,不要依据直觉先加线程。
- 优先优化算法复杂度、数据访问和不必要的对象分配。
- 再考虑矩阵化、批处理、缓存、并发、C/C++ 扩展或 GPU。
- 比较吞吐、延迟、CPU、内存和尾延迟,并确认优化没有改变结果语义。
高频面试追问
- GIL 是 Python 语言特性还是 CPython 实现细节?
- 为什么 I/O 密集型任务可以从线程或异步中受益?
- 为什么多进程能利用多核,但代价是什么?
- 如何证明一次优化确实有效?
- NumPy 矩阵化为什么可能比 Python 循环快?
- C/C++ 扩展如何影响 GIL、内存所有权和异常传播?
- 进程池中的任务为什么会因为序列化和数据复制变慢?
- Python 服务 CPU 飙高、内存增长或事件循环阻塞时如何排查?
面试追问与结论边界
- 动态类型会增加运行时工作,但“Python 慢”不能归结为单一原因;算法、对象分配、解释器、系统调用和第三方库都可能是瓶颈。
- GIL 是 CPython 历史实现中的核心约束,不应泛化为所有 Python 实现;即使在 CPython 中,执行原生扩展、I/O 或不同版本时行为也不同。
threading、multiprocessing和asyncio解决的问题不同,不能仅按“线程慢、进程快、协程最快”排序。- 文章中的静态链接和 SWIG 命令依赖旧的 Python、OpenCV、ViSP 和本机路径,适合作为工程案例,不应直接复制到现代环境;实际使用前应重新确认 ABI、编译器、依赖版本和许可证。
- GIL、JIT、免费线程化和解释器实现会随 Python 版本演进,关键结论必须带版本范围。