前两日帮同学解决的问题中涉及到 python 的线程, 协程概念及其调度过程, 加上之前总听说同学们去面试的时候会被问到 python 的多线程问题. 所以想写一篇总结. 本篇文章假定读者已经有一些操作系统知识的基础, 并且几乎不涉及到具体编程, 主要研究总结 python 独特的线程切换调度问题, 以及最近用的越来越多的协程的概念和协程切换调度问题.
一, 进程, 线程概念回顾
操作系统课上我们都学过, 进程是资源的分配单位, 而线程是 CPU 调度运行的基本单位. 也就是说, 即使是多进程程序, 调度依然是按照多个线程去进行调度, 由于 CPU 时间片分配给每个独立调度的线程, 拥有四个线程的进程比拥有一个线程的进程拥有更多的 CPU 时间片. 如果一个有四个线程的进程运行在一个四核的 CPU 机器上, 那么核的利用率可以达到 100%, 即所有的核都可以调度运行一个线程, 不会出现一方有难, 八方围观的情况. 同样, 四个单线程进程也能使四核的 CPU 机器计算资源利用率达到 100%, 因为每个进程中的线程被独立调度执行.
一核有难, 多核围观
二, CPython 的多线程困境
当我们被问到 python 多线程的时候, 回答一般都会涉及到 GIL, 但是 GIL 其实不是 python 本身的特性, 而是 CPython 实现时引入的一种机制, 而 JPython 的实现里面里就没有 GIL. 这里我们主要研究 CPython 中由于 GIL 的存在而导致的独特的多线程困境, 我们可以先看下 GIL 的官方说明:
InCPython, the global interpreter lock, or GIL, is a mutex that prevents multiple native threads from executing Python bytecodes at once. This lock is necessary mainly because CPython's memory management is not thread-safe. (However, since the GIL exists, other features have grown to depend on the guarantees that it enforces.)
GIL 的存在本身就是为了阻止多个原生的线程同时执行 python 的字节码, 我们可以看下锁的实现数据结构
GIL 锁数据结构
NRMUTEX 中的 thread_id 就表明 GIL 锁目前被哪个 thread 拥有, 只有一个线程拥有了 GIL 锁, 他才能被解释器解释执行, 同一个 python 进程里面的其他线程就需要等待 NRMUTEX 的释放. 这就会导致如下这个场景的问题出现:
python 多线程困境
比如一个拥有 2 个线程的 python 进程运行在 2 核的 CPU 上, 我们假设每个线程都只涉及到纯 CPU 计算, 不会被阻塞, 只有线程运行的时间片到达才会进行线程切换, 每个线程任务完成需要运行 4s. 我们编号 2 个线程为 T1,T2, 编号 2 个核为 C1,C2. 如果是两个个非 python 线程, 是可以上做到上图所示的 C1 调度执行 T1,C2 调度执行 T2, 2 个线程并行执行, 那么上述进程执行结束共需要 4s.
但是由于 CPython 中 GIL 锁的存在, C1 调度执行 T1 的时候, GIL 锁被 T1 占着, T2 拿不到 GIL 锁, 处于阻塞的状态, 等到 T1 执行结束或者执行的字节码行数到了设定的阈值, T1 就会释放 GIL 锁, 然后 T2 获得 GIL 锁之后再继续执行. 这样的结果就是, 这个拥有 2 个纯 CPU 计算线程的 python 程序进程运行结束需要 8s, 因为每个时刻, python 进程中永远只有一个线程再被运行. 那这就很胃疼了, 这么看似乎 python 的多线程就没用了? 也不是的, 上述情况下多线程没用, 是因为我们假定的是每个线程运行代码都是纯 CPU 计算过程, 不会遇到 IO 等阻塞操作, 只在执行结束或者 "轮转时间片" 到了之后才会被切换,( 之所以打引号, 是因为 python 的多线程调度的轮转时间片并不是常规 CPU 时间片, 而是按照字节码来算的). 但是如果 T1 线程有 IO 操作会被阻塞, 会在 IO 操作前提前释放 GIL 锁, 进而 T2 线程获得 GIL, 可以正常被 CPU 调度执行, 这样 Python 程序进程仍然处于继续运行的状态, 而不会像单线程的时候遇到 IO 会被阻塞等待. 话虽如此, 除了少部分高端玩家, 大部分情况下, 我们用 python 的多线程时, 多线程只是发挥了类似于异步处理的功能, 不但没有发挥出多线程的并行威力, 反而还承受了多线程的高昂的切换开销以及应对复杂的锁同步的问题.
三, Talk is cheap, show me the code
这个例子我是从 [1] 中的文章直接拿过来的, 觉得还比较好的能说明在计算密集的时候 python 的多线程切换开销的影响. my_counter()就是一个纯 CPU 计算代码段, 不会被阻塞. 当线程运行 my_counter()的时候只有在线程结束或者线程轮转时间片到达之后才会释放 GIL 锁, 进行线程切换.
顺序执行的单个子线程, 运行时间 10.5s
并发执行的多线程, 运行时间 17s
我们可以看到, 顺序执行的过程中, 只有一个子线程在执行 my_counter(), 主线程由于在等待子线程执行结束, 所以每次获得 GIL 锁之后又会立马释放锁. 运行时间为 10.5s, 在第二个程序中, 我们同时创建两个子线程,"同时运行"my_counter(),python 程序进程运行过程中, 会有三个线程被调度切换, 两个子线程 "同时运行" 程序, 时间非但没有缩短, 反而长了近一倍, 这就是 python 线程切换带来的开销.
这个例子中, 我们看到频繁的线程切换开销还是很高昂的, 这样的话, 我们就干脆用 python 的单线程好了, 但是单线程进程运行过程中当线程被阻塞时任务就停滞了, 有没有一种办法, 既能让单线程进程即使运行到阻塞操作如读取文件时, 线程能不被阻塞, 继续完成一些其他的任务, 同时还不用承担这么高昂的切换代价呢? 有的, 那就是协程该登场的时候了.
四, 引用
[1] https://cloud.tencent.com/developer/article/1489753
来源: https://www.qcloud.com/developer/article/1538086