本篇介绍Python多线程,以及其与多进程的比较。

一、Python多线程

一个进程由若干个线程组成。在Python标准库中,有两个模块 thread 和 threading 提供调度线程的接口。鉴于thread是低级模块,很多功能还不完善,我们一般只会用到threading 这个比较完善的高级模块。因此,这里我们只讨论 threading 模块的使用。

1. threading

要启动一个线程,我们只需要把一个函数传入Thread实例,然后调用start()运行。

#!/usr/bin/env python
# -*- coding: utf-8 -*-
import random
from threading import Thread
import threading
import time


def run():
    print('tread:', threading.current_thread().name, 'is running...')
    for i in range(5):
        print('tread:', threading.current_thread().name, ', number:', i)
        start = time.time()
        time.sleep(random.random()*10)
        end = time.time()
        print('tread:', threading.current_thread().name, ', number:', i, ', run for', (end - start)/1000, 'seconds')


if __name__ == '__main__':
    print('tread:', threading.current_thread().name, 'is running...')
    t = Thread(target=run, name='func_run')
    t.start()
    t.join()
    print('tread:', threading.current_thread().name, 'is end.')

运行结果为:

tread: MainThread is running...
tread: func_run is running...
tread: func_run , number: 0
tread: func_run , number: 0 , run for 0.007302240133285522 seconds
tread: func_run , number: 1
tread: func_run , number: 1 , run for 0.006630177736282349 seconds
tread: func_run , number: 2
tread: func_run , number: 2 , run for 0.0010141320228576661 seconds
tread: func_run , number: 3
tread: func_run , number: 3 , run for 0.0007971792221069336 seconds
tread: func_run , number: 4
tread: func_run , number: 4 , run for 0.0030803031921386717 seconds
tread: MainThread is end.

current_thread()函数用于返回当前线程的实例,主线程实例的名字为MainThread,子线程的名字可以在创建时给予,或者被默认给予Thread-1、Thread-2这样的名字。

二、Lock线程锁

多进程和多线程最大的区别就在于:对于多进程,同一个变量各自有一份拷贝存在于每个进程,互不影响;对于多线程,所有的线程共用所有的变量。因此,在多线程应用中,任何一个变量都可以被任意一个线程修改,我们要尽量避免多个线程同时修改同一个变量。

2.1 未使用线程锁

首先,我们要了解,对于“多个线程同时修改一个变量”,这种情况是怎么出现的。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import random
from threading import Thread
import threading
import time


lock = threading.Lock()
a = 0


def change():
    global a
    a = a + 1
    a = a - 1


def loop_change():
    for i in range(1000000):
        change()


if __name__ == '__main__':
    print('tread:', threading.current_thread().name, 'is running...')
    t1 = Thread(target=loop_change(), args=())
    t2 = Thread(target=loop_change(), args=())
    t1.start()
    t2.start()
    t1.join()
    t2.join()
    print(a)
    print('tread:', threading.current_thread().name, 'is end.')

理论上来说,不论我们如何调用函数change(),共享变量a的值都应该为0。但实际上,因为两个线程t1、t2之间交替运行的次数过多,导致a的结果可能不是0。CPU在工作时的情况:首先,将值a和1分别存入两个寄存器;然后,将两个寄存器的值进行加法运算,并将结果存入第三个寄存器;然后,将第三个寄存器的值存入并覆盖原本保存a的值的寄存器内。由于两个线程都调用了各自的寄存器,或者说都有各自的临时变量,那么当t1和t2交替运行时,就可能出现结果值错乱的情况。

为了避免这种情况的发生,我们就需要提供线程锁,确保:当一个线程获得了change()的调用权时,另一个线程就不能同时执行change()方法,直到锁被释放之后,“重新获得了该锁的线程”才能继续进行修改。

2.2 使用threading.lock()线程锁

使用线程锁,在临界区之前进行加锁,在临界区之后释放锁。注意:在加锁动作和临界区代码之间,不要有任何代码;在临界区之后,务必保证释放锁。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import random
from threading import Thread
import threading
import time



lock = threading.Lock()
a = 0


def change():
    global a
    a = a + 1
    a = a - 1


def loop_change():
    for i in range(1000000):
        lock.acquire()
        try:
            change()
        finally:
            lock.release()


if __name__ == '__main__':
    print('tread:', threading.current_thread().name, 'is running...')
    t1 = Thread(target=loop_change(), args=())
    t2 = Thread(target=loop_change(), args=())
    t1.start()
    t2.start()
    t1.join()
    t2.join()
    print(a)
    print('tread:', threading.current_thread().name, 'is end.')

如此,无论如何运行,结果都将是我们预期的0。

当多个线程同时执行lock.acquire()时,只有一个线程能够成功地获得线程锁,然后继续执行代码,其它线程只能等待锁的释放。同时,获得锁的线程一定要记得释放锁;否则,其会成为死线程。因此,我们用try...finally...来确保锁的释放。

锁的问题就是,一方面,让原本多线程的任务实际上又变成了单线程的运行方式(尽管对于Python的伪多线程而言,这并不会造成什么性能的下降)。另一方面,由于可以存在多个锁,不同的线程可能会持有不同的锁,在试图获取对方的锁时,可能会造成死锁,导致多个线程全部挂起。这时,只能通过操作系统来强行终止。

三、Python的GIL锁

对于一个多核CPU,它可以同时执行多个线程。通过Windows或Linux操作系统提供的任务管理器,我们可以看到CPU的资源占用率。

理论上,当我们提供一个无限循环的死线程时,CPU一核的占用率就会提升到100%;若是提供两个无限循环的线程,就又会有一核的占用率到100%。如果在java或者C中这么做,那么确实会发生这种情况。但是,我们在Python中这样尝试,发现情况却并非如此,即使我们启用更多的线程,CPU的占用率也不会提高多少。这是因为尽管Python使用的是真正的线程,但Python的解释器在执行代码时有一个GIL锁(Gloabal Interpreter Lock)。不论是什么Python代码,一旦要执行,必然会先获得GIL锁;然后,每执行100行代码,就会释放GIL锁,使得其它线程有机会执行。GIL锁实际上给一个Python进程的所有线程都上了锁。因此,哪怕是再多的线程,在一个Python进程中,也只能交替执行,即只能使用一个核。

#!/usr/bin/env python
# -*- coding: utf-8 -*-
import multiprocessing
import random
from threading import Thread
import threading
import time


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


if __name__ == '__main__':
    for i in range(multiprocessing.cpu_count()):
        print(multiprocessing.cpu_count())
        t = threading.Thread(target=loop)
        t.start()
    print('tread:', threading.current_thread().name, 'is end.')

从 multiprocessing.cpu_count()可以得知CPU个数,这段代码运行时,可以观察到 CPU 占用率并不是100%,甚至并不高。

四、ThreadLocal

我们已经知道,一个全局变量会受到所有线程的影响。那么,我们应该如何构建一个独属于这个线程的“全局变量”?换言之,我们既希望这个变量在这个线程中拥有类似于全局变量的功能,又不希望其它线程能够调用它,以防止出现上面所述的问题,该怎么做?

一种方式是将该变量作为参数,传递到线程调用的方法中。这种方式比较繁琐,不推荐。

另一种方式是使用ThreadLocal对象,既能解决这个问题,又免于繁琐的操作。ThreadLocal对象由threading.local()方法创建:

#!/usr/bin/python
# -*- coding:utf-8 -*-
import threading
from threading import Thread

local_variant = threading.local()


def variant_print():
    print('Thread', threading.current_thread().name, '\'s variant is ', local_variant.a)


def thread_run(a):
    local_variant.a = a
    local_variant.b = a + 1
    variant_print()


if __name__ == '__main__':
    t1 = Thread(target=thread_run, args=(5,))
    t2 = Thread(target=thread_run, args=(6,))

    t1.start()
    t2.start()
    t1.join()
    t2.join()

运行结果为:

Thread Thread-1 's variant is  5 6
Thread Thread-2 's variant is  6 7

ThreadLocal的原理类似于创建了一个词典。当我们创建一个变量local_varient.a的时候,实际上在local_varient这个词典中创建了一个“以threading.current_thread()为关键字(当前线程),不同线程中的a为值”的键值对组成的dict。

五、进程和线程的比较

在初步了解进程和线程以及它们在Python中的运用方式之后,我们现在来讨论一下二者的区别与利弊。

5.1 执行特点

首先,我们简单了解一下多任务的工作模式:通常,我们会将其设计为Master-Worker 模式,Master负责分配任务,Worker负责执行任务;在多任务环境下,通常是一个Master对应多个Worker。

那么,多进程任务实现Master-Worker,主进程就是Master,其它进程是Worker。而多线程任务,主线程Master,子线程Worker。

多进程的优点就在于:稳定性高。一个子进程的崩溃不会影响到其它子进程和主进程(主进程挂了还是会全崩的)。但是,多进程的问题在于:其创建进程的开销过大,特别是Windows系统,其多进程的开销要比使用fork()的Unix/Linux系统大的多。并且,对于一个操作系统本身而言,它能够同时运行的进程数也是有限的。

多线程模式占用的资源消耗,没有多进程那么多。因此,它也往往会更快一些(至少在Windows下多线程的效率往往要比多进程要高)。多线程模式的问题在于:一个线程挂掉,会直接让进程内包括主线程的所有的线程都崩溃,因为所有线程共享进程的内存。在Windows系统中,如果我们看到了这样的提示“该程序执行了非法操作,即将关闭”,那往往就是因为某个线程出现问题,导致整个进程崩溃。

5.2 切换

在使用多进程或多线程的时候,都应该考虑线程数或者进程数切换的开销。无论是进程、还是线程,如果数量太多,那么效率是肯定上不去的。

因为操作系统在切换进程和线程时,需要先保存当前执行的现场环境(包括CPU寄存器的状态,内存页等);然后,再准备另一个任务的执行环境(恢复上次的寄存器状态,切换内存页等),才能开始执行新任务。这个过程虽然很快,但也是需要耗时的。一旦任务数量过于庞大,浪费在准备环境的时间也会非常巨大。

5.3 计算密集型和IO密集型

多任务的类型,也是我们判断如何构建工作模式的一个重要点。我们可以将任务简单分为两类:计算密集型和IO密集型。

计算密集型任务的特点是:进行大量的运算,消耗CPU资源,例如一些复杂的数学运算,或者是一些视频的高清解码运算,纯靠CPU的计算能力来执行的任务。这种任务虽然也可以用多任务模式来完成,但任务之间切换的消耗往往比较大。因此,若是要高效进行这类任务的运算,“计算密集型任务同时进行的数量”最好不要超过CPU的核心数。

就语言本身而言,代码运行的效率对于计算密集型任务也是至关重要。因此,类似于Python这样的高级语言往往不适合,而像C这样的底层语言的执行效率就会更高。好在Python处理这类任务时,用的往往是“用C编写的库”。若是要自己实现这类任务的底层计算功能,还是以C为主比较好。

IO密集型的特点则是:进行大量的输入输出,涉及到网络、磁盘IO的任务往往都是IO密集型任务。这类任务消耗CPU的资源并不高,往往时间都是花在等待IO操作完成,因为IO操作的速度往往都比CPU和内存运行的速度要慢很多。对于IO密集型任务,多任务执行提升的效率就会很高。当然,任务数量还是有一个限度的。对于这类任务,Python这类开发效率高的语言就会更适合,因为能减少代码量;而C语言效果就很差,因为写起来很麻烦。

现代操作系统对IO操作进行了巨大的改进,其提供了异步IO操作,来实现单进程、单线程执行多任务的方式。在单核CPU上采用单进程模型,可以高效地支持多任务。而在多核CPU上,也可以运行多个进程(数量与CPU核心数相同)来充分地利用多核CPU。通过异步IO编程模型来实现多任务,是目前的主流趋势。而在Python中,单进程的异步编程模型称为协程。