【Python】线程 - Python 的单线程

Posted by 西维蜀黍 on 2019-08-06, Last Modified on 2024-01-09

Python 中的伪多线程

实验

如果你不幸拥有一个多核 CPU,你肯定在想,多核应该可以同时执行多个线程。

如果写一个死循环的话,会出现什么情况呢?

打开 Mac OS X 的 Activity Monitor,或者 Windows 的 Task Manager,都可以监控某个进程的 CPU 使用率。

我们可以监控到一个死循环线程会 100% 占用一个 CPU。

如果有两个死循环线程,在多核 CPU 中,可以监控到会占用 200% 的 CPU,也就是占用两个 CPU 核心。

要想把 N 核 CPU 的核心全部跑满,就必须启动 N 个死循环线程。

实验 1

试试用 Python 写个死循环:

import threading, multiprocessing

def loop():
    x = 0
    while True:
        x = x ^ 1

for i in range(multiprocessing.cpu_count()):
    t = threading.Thread(target=loop)
    t.start()

启动与 CPU 核心数量相同的 N 个线程,在 4 核 CPU 上可以监控到 CPU 占用率仅有 102%,也就是仅使用了一核。

实验 2

而如果我们变成进程呢?

我们改一下代码:

#coding=utf-8
from multiprocessing import Pool
from threading import Thread

from multiprocessing import Process


def loop():
    while True:
        pass

if __name__ == '__main__':

    for i in range(3):
        t = Process(target=loop)
        t.start()

    while True:
        pass

结果 CPU 利用率直接飙到了 100%,说明进程是可以利用多核的!

实验 3

为了验证这是 Python 中的 GIL 搞得鬼,我试着用 Java 写相同的代码,开启线程,我们观察一下:

public class TestThread {
    public static void main(String[] args) {
        for (int i = 0; i < 3; i++) {
            new Thread(new Runnable() {

                @Override
                public void run() {
                    while (true) {

                    }
                }
            }).start();
        }
        while(true){

        }
    }
}

由此可见,Java 中的多线程是可以利用多核的,这是真正的多线程!而 Python 中的多线程只能利用单核,这是假的多线程!

难道就如此?我们没有办法在 Python 中利用多核?当然可以!刚才的多进程算是一种解决方案,还有一种就是调用 C 语言的链接库。对所有面向 I/O 的(会调用内建的操作系统 C 代码的)程序来说,GIL 会在这个 I/O 调用之前被释放,以允许其他线程在这个线程等待 I/O 的时候运行。我们可以把一些计算密集型任务用 C 语言编写,然后把.so 链接库内容加载到 Python 中,因为执行 C 代码,GIL 锁会释放,这样一来,就可以做到每个核都跑一个线程的目的!


可能有的小伙伴不太理解什么是计算密集型任务,什么是 I/O 密集型任务

计算密集型任务的特点是要进行大量的计算,消耗 CPU 资源,比如计算圆周率、对视频进行高清解码等等,全靠 CPU 的运算能力。这种计算密集型任务虽然也可以用多任务完成,但是任务越多,花在任务切换的时间就越多,CPU 执行任务的效率就越低,所以,要最高效地利用 CPU,计算密集型任务同时进行的数量应当等于 CPU 的核心数。

计算密集型任务由于主要消耗 CPU 资源,因此,代码运行效率至关重要。Python 这样的脚本语言运行效率很低,完全不适合计算密集型任务。对于计算密集型任务,最好用 C 语言编写。

第二种任务的类型是 IO 密集型,涉及到网络、磁盘 IO 的任务都是 IO 密集型任务,这类任务的特点是 CPU 消耗很少,任务的大部分时间都在等待 IO 操作完成(因为 IO 的速度远远低于 CPU 和内存的速度)。对于 IO 密集型任务,任务越多,CPU 效率越高,但也有一个限度。常见的大部分任务都是 IO 密集型任务,比如 Web 应用。

IO 密集型任务执行期间,99% 的时间都花在 I/O 上,花在 CPU 上的时间很少,因此,用运行速度极快的 C 语言替换用 Python 这样运行速度极低的脚本语言,完全无法提升运行效率。对于 I/O 密集型任务,最合适的语言就是开发效率最高(代码量最少)的语言,脚本语言是首选,C 语言最差。

综上,Python 多线程相当于单核多线程,多线程有两个好处:CPU 并行,IO 并行,单核多线程相当于自断一臂。所以,在 Python 中,可以使用多线程,但不要指望能有效利用多核。如果一定要通过多线程利用多核,那只能通过 C 扩展来实现,不过这样就失去了 Python 简单易用的特点。不过,也不用过于担心,Python 虽然不能利用多线程实现多核任务,但可以通过多进程实现多核任务。多个 Python 进程有各自独立的 GIL 锁,互不影响。

分析

GIL 是 Python 解释器设计的历史遗留问题,通常我们用的解释器是官方实现的 CPython,要真正利用多核,除非重写一个不带 GIL 的解释器。

因为,任何 Python 线程执行前,必须先获得 GIL 锁,然后,每执行 100 条字节码,解释器就自动释放 GIL 锁,让别的线程有机会执行。这个 GIL 全局锁实际上把所有线程的执行代码都给上了锁,所以,多线程在 Python 中只能交替执行,即使 100 个线程跑在 100 核 CPU 上,也只能用到 1 个核。

所以,在 Python 中,可以使用多线程,但不要指望能有效利用多核。如果一定要通过多线程利用多核,那只能通过 C 扩展来实现,不过这样就失去了 Python 简单易用的特点。

不过,也不用过于担心,Python 虽然不能利用多线程实现多核任务,但可以通过多进程实现多核任务。多个 Python 进程有各自独立的 GIL 锁,互不影响。

全局解释器锁(Global Interpreter Lock,GIL)

Python 代码的执行由 Python 虚拟机(解释器)来控制。Python 在设计之初就考虑要在主循环中,同时只有一个线程在执行,就像单 CPU 的系统中运行多个进程那样,内存中可以存放多个程序,但任意时刻,只有一个程序在 CPU 中运行。同样地,** 虽然 Python 解释器可以运行多个线程,只有一个线程在解释器中运行。** 因为,任何 Python 线程执行前,必须先获得 GIL 锁 hold the control of the Python interpreter。

对 Python 虚拟机的访问由全局解释器锁(GIL)来控制,正是这个锁能保证同时只有一个线程在运行。在多线程环境中,Python 虚拟机按照以下方式执行:

  1. 设置 GIL。
  2. 切换到一个线程去执行。
  3. 运行。
  4. 把线程设置为睡眠状态。
  5. 解锁 GIL。
  6. 再次重复以上步骤。

对所有面向 I/O 的(会调用内建的操作系统 C 代码的)程序来说,GIL 会在这个 I/O 调用之前被释放,以允许其他线程在这个线程等待 I/O 的时候运行。如果某线程并未使用很多 I/O 操作,它会在自己的时间片内一直占用处理器和 GIL。也就是说,I/O 密集型的 Python 程序比计算密集型的 Python 程序更能充分利用多线程的好处

我们都知道,比方我有一个 4 核的 CPU,那么这样一来,在单位时间内每个核只能跑一个线程,然后时间片轮转切换。但是 Python 不一样,它不管你有几个核,单位时间多个核只能跑一个线程,然后时间片轮转。看起来很不可思议?但是这就是 GIL 搞的鬼。任何 Python 线程执行前,必须先获得 GIL 锁,然后,每执行 100 条字节码,解释器就自动释放 GIL 锁,让别的线程有机会执行。这个 GIL 全局锁实际上把所有线程的执行代码都给上了锁,所以,多线程在 Python 中只能交替执行,即使 100 个线程跑在 100 核 CPU 上,也只能用到 1 个核。通常我们用的解释器是官方实现的 CPython,要真正利用多核,除非重写一个不带 GIL 的解释器。

效率对比 threading & multiprocessing

创建多进程 multiprocessing

首先 import multiprocessing,并定义要实现的 job(),同时为了容易比较,我们将计算的次数增加到 1000000 次。

import multiprocessing as mp

def job():
    res = 0
    for i in range(1000000):
        res += i + i**2 + i**3

def multicore():
    p1 = mp.Process(target=job)
    p2 = mp.Process(target=job)
    p1.start()
    p2.start()
    p1.join()
    p2.join()

创建多线程 multithread

接下来创建多线程程序,创建多线程和多进程有很多相似的地方。首先 import threading 然后定义 multithread() 完成同样的任务

import threading as td

def multithread():
    t1 = td.Thread(target=job)
    t2 = td.Thread(target=job)
    t1.start()
    t2.start()
    t1.join()
    t2.join()

创建单线程

最后我们定义只在一个线程中执行计算的函数。

def normal():
    res = 0
    for _ in range(2):
        job()
    print('normal:', res)

运行时间

最后,为了对比各函数运行时间,我们需要 import time, 然后依次运行定义好函数:

import time

if __name__ == '__main__':
    st = time.time()
    normal()
    st1 = time.time()
    print('normal time:', st1 - st)
    multithread()
    st2 = time.time()
    print('multithread time:', st2 - st1)
    multicore()
    print('multicore time:', time.time() - st2)

大功告成,下面我们来看下实际运行对比。

结果对比

"""
# range(1000000)
('normal:', 499999666667166666000000L)
('normal time:', 1.1306169033050537)
('thread:', 499999666667166666000000L)
('multithread time:', 1.3054230213165283)
('multicore:', 499999666667166666000000L)
('multicore time:', 0.646507978439331)
"""

单线程 / 多线程 / 多进程的运行时间分别是 1.131.30.64 秒。

我们发现:

  • 多进程最快,这说明在开启多个进程后,执行计算的过程可以充分利用 CPU 的多个核。
  • 而开启多线程比只用单线程还慢,这其实印证了前面 GIL 的分析,即 Python 的多线程是伪多线程,开启的多个线程,在任意时刻只有一个线程在运行。而比单线程还慢是因为不断的切换线程还需要耗费额外的资源。

我们将运算次数加十倍,再来看看三种方法的运行时间:

"""
# range(10000000)
('normal:', 4999999666666716666660000000L)
('normal time:', 40.041773080825806)
('thread:', 4999999666666716666660000000L)
('multithread time:', 41.777158975601196)
('multicore:', 4999999666666716666660000000L)
('multicore time:', 22.4337899684906)
"""

这次运行时间依然是 多进程 < 普通 < 多线程,由此我们可以清晰地看出哪种方法更有效率。

协程

Python 中的多线程不能真正的利用多核,不能解决 CPU bound 的问题,但是在一些 I/O bound 的程序上却可以有很好的提升。

但是目前的情况是 我们有了协程啊,在 2.x 系列里我们可以使用 gevent 啊,在 3.x 系列的标准库里又有了 asyncio。IO bound 的问题完全可以用协程解决。而且我们可以自主的控制协程的调度了。为什么还要使用由 OS 调度的不太可控的线程呢?

所以我认为线程在 Python 里就是个鸡肋。尤其实在 3.x 系列里。

Reference