Python多线程

180 阅读8分钟

「这是我参与2022首次更文挑战的第18天,活动详情查看:2022首次更文挑战」。

前言

多线程是提高效率的一种有效方式,但是由于 CPython 解释器中存在 GIL 锁,因此 CPython 中的多线程只能使用单核。也就是说 Python 的多线程实际上是宏观的多线程,而微观上依旧是单线程。那多线程就没有任何作用吗?我们先来看看 Python 中的多线程吧。

多线程

线程和进程之间有很多相似的地方,它们都是一个独立的任务。但是相比进程,线程要小的多。我们运行线程需要在进程中进行,而且线程和线程之间是共享内存的。相比进程的数据隔离,线程的安全性要更差一些。

我们可以吧一个应用程序当作一个进程,而这个程序又运行着一些其它东西。比如 QQ 有时钟功能、天气功能等。这些小功能可以互补影响的运行,它们就是一个个线程。下面我们来看看 Python 中线程的创建。

线程的创建

Python 中线程的操作需要通过 threading.Thread 类实现,该类就是线程类。创建线程的操作如下:

from threading import Thread
t = Thread(target=func, args(a, ))

其中 Thread 接收了两个参数,分别是线程要执行的函数和函数需要的参数。因此我们需要准备一个函数:

import time

def func():
	time.sleep(1)
	print("我在运行")

t = Thread(target=func)
t1.start()

因为我们的函数没有参数,所以 args 参数我们可以不给。上面的代码运行效果如下:

你好

这样看不出线程运行的效果,我们可以在主线程中添加一条输出:

import time
from threading import Thread


def func():
    time.sleep(1)
    print("你好")


t = Thread(target=func)
t.start()
print("我是主线程")

输出结果如下:

我是主线程
你好

可以看到主线程的输出先执行了,这说明我们开启了一个线程。

面向对象方式创建线程

除了用函数的方式,我们还可以用面向对象的方式来创建线程。这就需要我们手动继承 Thread 类,而且还需要实现其中的 run 方法,代码如下:

import time
from threading import Thread


class MyThread(Thread):

    def __init__(self):
        super().__init__()

    def run(self) -> None:
        time.sleep(1)
        print("我在运行")


t = MyThread()
t.start()
print("我是主线程")

在上面我们创建了一个 MyThread 类继承了 Thread,并且实现了 run 方法。我们在 run 中写了之前 target 函数的内容,后面代码和之前相似。当我们调用 start 方法后,程序就会帮我们开启线程自动执行 run 方法。下面是运行的结果:

我是主线程
我在运行

和第二节中的一样。

线程中的一些方法

在线程中提供了几个方法,可以供我们使用。

import time
from threading import Thread


class MyThread(Thread):

    def __init__(self):
        super().__init__()

    def run(self) -> None:
        time.sleep(1)
        print("我在运行")


t = MyThread()
t.start()
print(t.is_alive())
print(t.getName())
print("我是主线程")

其中 is_alive 方法是判断一个线程是否停止的方法。而 get_name 则是获取线程名称的方法。在线程中还有一个方法非常常用,那就是我们的 join 方法。它的作用是让主线程等待知道该线程执行完。假如我们想要在所以子线程执行完成后执行一些代码,我们就可以通过 join 方法来实现,代码如下:

import time
from threading import Thread


class MyThread(Thread):

    def __init__(self):
        super().__init__()

    def run(self) -> None:
        time.sleep(1)
        print("我在运行")


t_l = []
for i in range(10):
    t = MyThread()
    t_l.append(t)
    t.start()
for t in t_l:
    t.join()
print("所有子线程都执行完了")

我们先用列表将所有线程都装进去,如何逐个调用 join 方法。这样我们就能让主线程等所有子线程执行完后再继续执行,执行效果如下:

我在运行
我在运行我在运行
我在运行
我在运行我在运行
我在运行
我在运行
我在运行

我在运行

所有子线程都执行完了

可以看到主线程中输出的语句在子线程之后。

线程锁

虽然在 CPython 解释器中有 GIL 锁,但是多线程还是存在数据不安全的问题。比如在我们对一个数进行 += 的操作时,会有一个赋值回原变量的操作,这时可能就会存在数据不安全的情况。我们看看下面这段代码:

from threading import Thread
num = 0


def add():
    global num
    for i in range(1000000):
        num += 1


def sub():
    global num
    for i in range(1000000):
        num -= 1


t1 = Thread(target=add)
t1.start()
t2 = Thread(target=sub)
t2.start()
t1.join()
t2.join()
print(num)

我们使用两个线程分别进行加减操作,它们操作的次数是一样的。所有按理最后的结果应该是 0,但是实际结果却不是,下面是我环境下的一次运行结果:

235818

和预期的相差还是比较大的,这时我们的数据就不安全了。解决这个问题我们需要使用到锁,在 threading 模块中有一个 Lock 类可以用于添加锁,我们改进后的代码如下:

from threading import Thread, Lock
num = 0


def add(lock):
    global num
    for i in range(1000000):
        lock.acquire()
        num += 1
        lock.release()


def sub(lock):
    global num
    for i in range(1000000):
        lock.acquire()
        num -= 1
        lock.release()


lock = Lock()
t1 = Thread(target=add, args=(lock, ))
t1.start()
t2 = Thread(target=sub, args=(lock, ))
t2.start()
t1.join()
t2.join()
print(num)

在可能出现数据不安全的地方,即加减操作的位置我们添加了锁,这次不管我们怎么运行得到的结果都是 0。这里需要注意一点,我们两个函数/进程使用的是同一把锁,如果我们使用不同的锁还是会出现数据不安全的问题。

线程池

因为开启线程需要消耗一些时间,所以有时候我们会使用线程池来减少开启线程花费的时间。线程池的操作定义在 concurrent.futures.ThreadPoolExecutor 类中,下面我们来看看线程池如何使用:

import time
import threading
from concurrent.futures import ThreadPoolExecutor


def func1():
    print(threading.current_thread().name, 'is running')


def func2():
    for i in range(10):
        print(threading.current_thread().name, 'is running')


pool = ThreadPoolExecutor(max_workers=2)
t1 = pool.submit(func1)
t2 = pool.submit(func2)


在代码中我们创建了一个容量为 2 的线程池,我们调用 pool.submit 函数就能使用线程池中的线程了。我们使用两个线程分别执行 func1 和 func2,我们来看看执行效果:

ThreadPoolExecutor-0_0 is running
ThreadPoolExecutor-0_0 is running
ThreadPoolExecutor-0_0 is running
ThreadPoolExecutor-0_0 is running
ThreadPoolExecutor-0_0 is running
ThreadPoolExecutor-0_0 is running
ThreadPoolExecutor-0_0 is running
ThreadPoolExecutor-0_0 is running
ThreadPoolExecutor-0_0 is running
ThreadPoolExecutor-0_0 is running
ThreadPoolExecutor-0_0 is running

可以看到程序只使用了一个线程。这是因为 func1 内容很少,马上就执行完了。此时第一个线程空闲,线程池使用第一个线程执行 func2。我们可以把代码修改一下:

import time
import threading
from concurrent.futures import ThreadPoolExecutor


def func1():
    print(threading.current_thread().name, 'is running')


def func2():
    for i in range(10):
        print(threading.current_thread().name, 'is running')


pool = ThreadPoolExecutor(max_workers=2)
t1 = pool.submit(func2)
t2 = pool.submit(func1)


这次我们先执行 func2,再执行 func1。这时我们执行 func1 时第一个线程并为空闲,因此会调用第二个线程:

ThreadPoolExecutor-0_0 is running
ThreadPoolExecutor-0_0 is running
ThreadPoolExecutor-0_0 is running
ThreadPoolExecutor-0_0 is running
ThreadPoolExecutor-0_0 ThreadPoolExecutor-0_1is running
ThreadPoolExecutor-0_0 is running
 ThreadPoolExecutor-0_0 is running
is running
ThreadPoolExecutor-0_0 is running
ThreadPoolExecutor-0_0 is running
ThreadPoolExecutor-0_0 is running

结果中确实使用了 thread0-1。

线程和协程

线程的切换是操作系统控制的,如果你执行下面的代码:

import time
from threading import Thread

num = 0


def func():
    global num
    for i in range(1000000):
        num += 1


tl = []
start = time.time()
for i in range(10):
    t = Thread(target=func)
    tl.append(t)
    t.start()
for t in tl:
    t.join()
print("一共花了", time.time()-start)

发现输出结果如下:

一共花了 0.6873388290405273

执行下面代码:

import time
from threading import Thread

num = 0


def func():
    global num
    for i in range(1000000):
        num += 1


tl = []
start = time.time()
for i in range(10):
    func()
print("一共花了", time.time()-start)

运行结果如下:

一共花了 0.6560418605804443

会发现它们的速度特别接近,而且不用多线程要比用多线程更快。如果进行的操作比较耗时,那么两种方式的运行速度会特别接近。这是因为两种方式都是在使用单核操作。那多线程在 Python 中的意义在哪呢?

因为 CPython 没有真正意义上的多线程,因此协程在 Python 中起了非常重要的作用。协程可以理解执行每一条 Python 语句,协程的操作是由我们程序员来控制的。如果我们运用的好,可以极大的提升 Python 程序的速度。

但是在我们实际开发中,会有许多无法察觉的 IO 操作。这些操作可能只有几毫秒,这时我们程序员就会对这些操作视而不见,如果这种情况比较多,那我们的程序依然会出现许多不必要的耗时。因此我们需要结合线程和协程,因为线程是由操作系统分配切换的,操作系统可以察觉这些程序员无法察觉的 IO 操作,因此使用线程结合协程可以极大的利用 CPU 单核的利用率。

关于协程的详细操作不作为本文的主要内容,感兴趣的读者可以自行研究。