【Python随笔】python多线程什么情况下可以并行?什么情况下不能?

python面试里,多线程是避不开的话题,其中一个经典问题就是,多线程threading.Thread是不是并行运行。这个问题的答案,说是也不对,说不是也不对,得分情况讨论。本文就带领大家,分析并回答这个问题。

我们用一段代码来做实验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import subprocess
from threading import Thread, Lock
import datetime
import time


_lock = Lock()


def log(*args, **kwargs):
with _lock:
print(*args, **kwargs)


def _io_task():
# 单个task执行约3s
subprocess.run(['ping', '127.0.0.1'], capture_output=True)


def _code_task():
# 单个task执行约0.8s
tms = 0
while tms < 50000000:
tms += 1


def testfunc(num):
st = datetime.datetime.now()
# _io_task()
_code_task()
et = datetime.datetime.now()
dt = et - st
log(f'{num} -> {dt.total_seconds() * 1000}ms')


def main():
num_threads = 5
threads = []
st = datetime.datetime.now()
for i in range(num_threads):
t = Thread(target=testfunc, args=(i,))
threads.append(t)
for t in threads:
t.start()
time.sleep(0)
for t in threads:
t.join()
et = datetime.datetime.now()
dt = et - st
log(f'overall {dt.total_seconds() * 1000}ms')


if __name__ == '__main__':
main()

这段代码意思很容易懂,起了5个testfunctarget的线程,然后主线程等待这些线程全部执行结束join,最后统计每个线程的开始结束时间间隔和开始结束时间间隔。

执行了之后我们会发现如果test_func里设置的是code_task的话,那么总时间相当于一个线程执行时间的num_threads倍,这个可以通过设置num_threads为1以及更高的数字来实验到,而更为蹊跷的是,每个线程执行的时间间隔和总的执行时间间隔几乎相差无几。可以看下面的输出例子:

1
2
3
4
5
6
7
8
9
10
11
# num_threads = 1
0 -> 873.087ms
overall 874.0830000000001ms

# num_threads = 5
0 -> 4278.944ms
1 -> 4412.494ms
2 -> 4488.241ms
4 -> 4243.062ms
3 -> 4882.43ms
overall 5156.513ms

然后,如果test_func里设置的是io_task的话,num_threads就算设置三五个,用的时间基本都一样:

1
2
3
4
5
6
7
8
9
10
11
12
13
# num_threads = 3
0 -> 3061.7909999999997ms
2 -> 3061.7909999999997ms
1 -> 3061.7909999999997ms
overall 3062.788ms

# num_threads = 5
2 -> 3036.288ms
4 -> 3035.291ms
1 -> 3036.288ms
0 -> 3549.756ms
3 -> 3549.756ms
overall 3549.756ms

所以两种情况下,一个是节省时间并行的,一个是没节省时间不并行的。这是为什么呢?

很多同学会提到GIL这个概念,没错,GIL就是关键。GIL是全局解释器锁,它的意义就在于,单个python运行时环境里,同一时刻,保证只有一个操作系统线程能够解释执行python代码。python多线程,实质是起了多个操作系统线程,竞争GIL锁解释执行python代码,在多线程执行code_task的情况下,不同线程需要获取GIL,才能执行while+=之类的语句,所以这些线程全部执行完的话,总的时间是累加的。而在io_task的情况下,等待ping命令返回的期间是阻塞在操作系统的io接口,并不需要解释执行python代码,因此单个任务线程不需要每时每刻都拥有GIL,各个线程间也没有什么竞争关系了,所以最后呈现了并行的状态。这就是上述问题的答案了。

当然,这个问题还有引申的空间——为什么code_task的场景下,每个线程执行的时间间隔和总的执行时间间隔几乎相差无几,而不是线程一个接一个串行执行完毕呢?这是因为在python运行时环境里GIL的竞争是时分复用的,每个时间间隔sys.getswitchinterval()都会触发一次GIL的释放和竞争,这样呈现的结果就是一个线程执行一点字节码,之后另一个线程抢到机会也执行一点字节码,循环往复,直到所有线程执行结束。所以这种机制下,多线程之间是并发的,最终结束的时刻,不会相差太多时间。

python多线程的机制,如果要深入了解,可以参考笔者以前在Medium Python系列写的这篇文章,里面已经清楚讲述了python线程启动的逻辑以及GIL竞争的逻辑。

版权声明
本文为博客HiKariのTechLab原创文章,转载请标明出处,谢谢~~~