「这是我参与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 单核的利用率。
关于协程的详细操作不作为本文的主要内容,感兴趣的读者可以自行研究。