Python的并发与并行

简介

综合转载于:

Thread

整体看下Thread类的内容:模拟的是Java的线程模型

  • name/getName/setName是线程名字有关的

  • isDaemon是否是守护进程

  • setDaemon设置为守护进程,如果把调用线程设置为守护线程,那么等调用线程结束后,被调用的子线程结束与否都会随着守护线程结束

  • isAlive线程是否是活动状态

  • start方法开启一个新线程。把需要并行处理的代码放在run()方法中,start()方法启动线程将自动调用 run()方法

  • run线程实际在运行的内容,可以被子类继承和重写

  • join阻塞调用它的线程,直到等待被调用的线程运行结束,其实就变成了单线程。参数timeout的作用是,当前线程等待被调用的子线程的时间,如果时间到了,不管子线程是否结束,当前线程都进入就绪状态,重新等待CPU调度。

  • 什么时候用join()方法?

    在很多情况下,主线程生成并起动了子线程,如果子线程里要进行大量的耗时的运算,主线程往往将于子线程之前结束,但是如果主线程处理完其他的事务后,需要用到子线程的处理结果,也就是主线程需要等待子线程执行完成之后再结束,这个时候就要用到join()方法了。

    用start方法来启动线程,真正实现了多线程运行,这时无需等待run方法体代码执行完毕而直接继续执行下面的代码。通过调用Thread类的start()方法来启动一个线程,这时此线程处于就绪(可运行)状态,并没有运行,一旦得到cpu时间片,就开始执行run()方法,这里方法 run()称为线程体,它包含了要执行的这个线程的内容,run方法运行结束,此线程随即终止。

    run()方法只是类的一个普通方法而已,如果直接调用Run方法,程序中依然只有主线程这一个线程,其程序执行路径还是只有一条,还是要顺序执行,还是要等待run方法体执行完毕后才可继续执行下面的代码,这样就没有达到写线程的目的。

总结:调用start方法方可启动线程,而run方法只是thread的一个普通方法调用,还是在主线程里执行。把需要并行处理的代码放在run()方法中,start()方法启动线程将自动调用 run()方法。可见join和setDaemon作用是相反的,一个是等待子线程结束,一个是不等到子线程结束,有可能把子线程强制结束。如果两个都不设置的时候,那么主线程和子线程各自运行各自的,互不干扰。

GIL

GIL全称global interpreter lock,全局解释器锁,是 Python 解释器中的一个布尔值,受到互斥保护。这个锁被 CPython 中的核心字节码用来评估循环,并调节用来执行语句的当前线程

每个线程在执行的时候都需要先获取GIL,保证同一时刻只有一个线程可以执行代码,即同一时刻只有一个线程使用CPU。执行单线程程序的开发人员看不到GIL的影响,但它可能是CPU绑定多线程代码中的性能瓶颈。

由于即使在具有多个CPU核心的多线程体系结构中,GIL一次只允许一个线程执行,因此GIL已经称为Python的“臭名昭著”的特性。

GIL为Python解决了什么问题?

Python使用引用计数来进行内存管理。这意味着在Python中创建的对象具有引用计数变量,该变量用于跟踪指向该对象的引用数。当此技术达到0的时候,释放对象占用的内存。示例:

1
2
3
4
import  sys
a = []
b = a
sys.getrefcount(a) # 查看空列表的引用次数。

在上面的示例中,空列表对象的引用计数为3,a和b参数引用各一次,在调用sys.getrefcount()的时候也引用一次。

回到GIL:

问题是这个引用计数变量需要防止竞争条件,如果其中两个线程同时增加或减少其值。发生这种情况,它可能导致从未释放的内存泄漏,或者更糟糕的是,在对该对象的引用仍然存在时错误地释放内存。这可能会导致Python程序中出现崩溃或其他“怪异”错误。

通过向跨线程共享的所有数据结构添加,可以保持此引用计数变量的安全性,从而保证不会对它们进行不一致的修改。但是为每个对象或对象组添加一个锁意味着将存在多个锁,这可能导致另一个问题 - 死锁(死锁只有在有多个锁时才会发生)。另一个问题是由于重复获取和释放锁而导致的性能下降

GIL是解释器本身的一个锁,它增加了一条规则,即执行任何Python字节码都需要获取解释器锁。这可以防止死锁(因为只有一个锁)并且不会引入太多的性能开销。但它也让任何受CPU限制的Python程序都是单线程的。GIL虽然被解释器用于其他语言(如Ruby),但并不是解决此问题的唯一方法。有些语言通过使用除引用计数之外的方法(例如垃圾收集)来避免需要GIL对线程安全内存管理。另一方面,这意味着这些语言通常需要通过添加其他性能提升特性(如 JIT编译器 )来弥补GIL单线程性能优势的损失。

为什么选择GIL作为解决方案

那么为什么要在Python中使用GIL呢?

自从操作系统没有线程概念以来,Python就已存在。Python的设计易于使用,以便更快地开发,越来越多的开发人员开始使用它。有许多扩展正在为那些Python中需要其特性的C语言库而编写、服务。为了防止不一致的更改,这些C扩展需要GIL提供的线程安全内存管理。GIL易于实现,很容易添加到Python中。它为单线程程序提供了性能提升,因为只需要管理一个锁。非线程安全的C库变得更容易集成。这些C扩展成为不同社区容易采用Python的原因之一。正如您所看到的,GIL是一个实用的解决方案,可以解决CPython开发人员在Python生命中早期面临的一个难题。

对多线程Python程序影响

当你在查看一个典型的Python程序——或任何计算机程序时,它们在性能上受CPU限制与受I/O限制是存在区别的。(这里的意思是说,不同的程序,限制它们的性能的原因是不一定相同的,有可能受到CPU限制,有可能受到I/O限制)

  • CPU绑定程序是那些将CPU推向极限的程序。这包括进行数学计算的程序,如矩阵乘法,搜索,图像处理等。
  • I/O绑定程序是花费时间等待输入/输出的程序,它可以来自用户,文件,数据库,网络等。I/O绑定程序有时需要等待很长时间才能从源头获取他们需要的东西,因为源可能需要在输入/输出准备好之前进行自己的处理,例如,用户考虑输入什么,或者在运行的数据库查询的过程。

让我们来看一个执行倒计时的简单CPU绑定程序:

1
2
3
4
5
6
7
8
9
10
11
12
import time
from threading import Thread

COUNT = 50000000
def countdown(n):
while n>0:
n -= 1

start = time.time()
countdown(COUNT)
end = time.time()
print('Time taken in seconds -',end-start)

运行这段代码输出:

1
Time taken in seconds - 3.368496894836426

现在使用两个并行线程将代码修改为相同的倒计时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import time
from threading import Thread

COUNT = 50000000

def countdown(n):
while n>0:
n -= 1

t1 = Thread(target=countdown, args=(COUNT//2,))
t2 = Thread(target=countdown, args=(COUNT//2,))
start = time.time()
t1.start()
t2.start()
t1.join()
t2.join()
end = time.time()
print('Time taken in seconds -', end - start)

再次运行:

1
Time taken in seconds - 3.0929627418518066

可以看出,两个版本的完成时间几乎相同。在多线程版本中,GIL防止了CPU所绑定的线程并行执行。GIL对I/O绑定的多线程程序的性能影响不大,因为线程在等待I/O时是共享锁的。

但是线程完全受CPU约束的程序,例如,使用线程处理部分图像的程序,不仅会因锁而成为单线程,而且与编写为完全单线程的场景相比,还会导致执行时间的增加,如上例所示。

这种增加是锁的获取和释放开销的结果。

为什么还没有删除GIL?

Python有许多的由C语言编写的扩展库,这些扩展库在很大程度上依赖于由GIL提供的解决方案,如果删除GIL,会导致现有的C扩展被破坏,许多地方需要重写甚至完全重写。当然,还有其他解决方案可以解决GIL解决的问题,但有些解决方案是会降低单线程和多线程或者I/O绑定的程序的性能的。

Python3中对GIL的改进

Python 3为现有的GIL带来了重大改进 - GIL会对 “仅CPU限制” 和 “仅I/O绑定” 的多线程程序造成影响,但是如果一个多线程程序,其中有些线程受I/O约束和有些线程受CPU约束会怎么样呢?

在这样的程序中,已知Python的GIL会使I/O绑定的线程匮乏,因为它们没有机会从CPU绑定的线程中获取GIL。这是因为Python内置了一种机制,强制线程在连续使用固定间隔后释放GIL ,如果没有其他人获得GIL,则相同的线程可以继续使用它。

1
2
3
>>>import sys
>>>sys.getchectinterval() # 返回检查间隔值
100

这种机制的问题在于,大多数情况下,CPU绑定的线程会在其他线程获取GIL之前重新获取GIL。在Python 3.2中修复了这个问题,并且添加了一种机制,可以查看被删除的其他线程的GIL获取请求数,并且在其他线程有机会运行之前不允许当前线程重新获取GIL。

如何处理Python的GIL

多进程与多线程:最流行的方法是使用多进程方法,使用多个进程而不是线程。每个Python进程都有自己的Python解释器和内存空间,因此GIL不会成为问题。Python有一个multiprocessing模块,可以轻松地创建进程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from multiprocessing import Pool
import time
COUNT = 50000000
def countdown(n):
while n>0:
n -= 1
if __name__ == '__main__':
pool = Pool(processes=2)
start = time.time()
r1 = pool.apply_async(countdown, [COUNT//2])
r2 = pool.apply_async(countdown, [COUNT//2])
pool.close()
pool.join()
end = time.time()
print('Time taken in seconds -', end - start)

运行结果:

1
Time taken in seconds - 2.558570623397827

与多线程版本相比,性能有了不错的提升。耗费时间没有下降到上面看到的一半,是因为流程管理有自己的开销。多进程要比多线程的开销更多,这也可能会成为一个扩展瓶颈。

替代Python解释器: Python有多个解释器实现。分别用C,Java,C#和Python编写的CPython,Jython,IronPython和PyPy是最受欢迎的。GIL仅存在于CPython的原始Python实现中。如果程序及其库可用于其他实现之一,那么也可以尝试其他的Python解释器。

并发(concurrency)与并行(parallelism)

  • 并发:单处理器同时处理多个任务。计算机似乎在同一时间做了多个任务,但实际上只是在多个任务间快速切换。比如一个单核CPU上在1分钟处理了4个任务,实际上只是每个任务执行1s后就换另外一个任务。
  • 并行:多处理器同时处理多个任务。计算机确实在同一时间做着多个任务。比如在4核CPU上,每个核心处理一个任务,1分钟过后,每个任务都做了1分钟。而上面并发的例子中,每个任务只做了1/4分钟。

因此Python中的并发只不过任务之间的互相切换,直到所有任务完成,如下图。

而并行,指的是同一时刻、同时发生,如下图。

img

在Python中,并行是multi-processing 。比如你的电脑是8核处理器,那么可以强制Python开8个进程运行程序,加快运行速度。

Effective Python 并行与并发_数据

场景

  • 并发通常应用于 I/O 操作频繁的场景

    比如你在实现一个爬虫时,从网页爬取多个图片/文件,读写操作的时间远大于 CPU 运行处理时间

  • 并行则更多应用于 CPU heavy 的场景,

    比如 MapReduce 中的并行计算,通常为加快运行速度,使用多台服务器或者处理器

注意点:

  • 使用并发过程中,需要确切知道某个任务的哪个具体环节会造成I/O阻塞,在该处设置中断。从而让Event Loop对队列中的下一个任务进行调度。
  • 如果当前任务不涉及较为耗时的I/O操作,协程效果相比单线程会差,因为Event Loop在调度过程中会损失一定的资源。

多线程的数据共享

比如有个程序,它做的操作只有一条cnt = cnt + 1,如果将这个程序写成多线程(假设两个),那么可能最后的输出是1,而不是2。

要理解背后的原因,需要将cnt = cnt + 1写成汇编形式

1
2
3
4
5
6
// 将共享变了cnt加载到accumulator register
movq cnt(%rip), %rdx
// 加1操作
addq %eax
// 将更行的值给回共享变量cnt
movq %eax, cnt(%rip)

如果执行顺序为:线程1执行step1,线程2执行step1,线程1执行step2,线程2执行step2,线程1执行step3,线程2执行step3,结果显然为1。为了解决多线程的数据竞争,需要对数据合理加锁。

对于上述多线程中的race condition问题,可以阅读CSAPP 12.5节《Synchronizing Threads with Semaphores》

实操一下《Effective Python》中的例子

一分一毛,也是心意。