python多线程

什么是多线程

多进程和多线程都可以执行多个任务,线程是进程的一部分。线程的特点是线程之间可以共享内存和变量,资源消耗少(不过在Unix环境中,多进程和多线程资源调度消耗差距不明显,Unix调度较快),缺点是线程之间的同步和加锁比较麻烦。

Python中多线程实现

在Python中,对于多线程的实现,有两个标准模块thread和threading,我们在此主要使用较为高级的threading模块。

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

def target():
print '%s running' % threading.current_thread().name
time.sleep(1)
print '%s ended' % threading.current_thread().name

print '%s running' % threading.current_thread().name
t = threading.Thread(target=target)

t.start()
t.join()
print '%s ended' % threading.current_thread().name

# Output:
MainThread running
Thread-1 running
Thread-1 ended
MainThread ended

在上面的例子中,start是启动线程,join是阻塞当前线程。从结果可以看到,主线程直到Thread-1结束之后才结束。

线程锁和ThreadLocal

线程锁

对于多线程来说,最大的特点就是线程之间可以共享数据,那么共享数据的过程中就会出现多个线程同时更改一个变量,使用同样的资源,从而出现死锁、数据错乱的情况。

假设有两个全局资源,a和b,有两个线程thread1,thread2。thread1占用a,想访问b,但此时thread2占用b,想访问a,两个线程都不释放此时拥有的资源,那么就会造成死锁。

为了解决上述问题,出现了Lock。在访问某个资源之前,使用Lock.acquire()锁住资源;在访问之后,通过Lock.release()释放资源。

1
2
3
4
5
6
7
8
9
10
11
12
a = 3
lock = threading.Lock()
def target():
print 'the curent threading %s is running' % threading.current_thread().name
time.sleep(4)
global a
lock.acquire()
try:
a += 3
finally:
lock.release()
print '%s ended' % threading.current_thread().name

这里使用finally的目的是防止当前进程无限占用资源。

ThreadLocal

介绍完线程锁,接下来我们介绍ThreadLocal。当不想将变量传递给其他线程时,可以使用局部变量,但是在函数中定义局部变量会使得在函数之间传递非常麻烦。ThreadLocal是非常牛逼的东西,它解决了全局变量需要加锁,局部变量传递麻烦的问题。

通过在线程中定义local = threading.local(),此时local就变成了一个全局变量,但是这个local只是在该线程中为全局变量,对于其他的线程来说是局部变量,也即别的线程不可更改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
local = threading.local()
def func(name):
print 'current thread: %s' % threading.currentThread().name
local.name = name
print "%s in %s" % (local.name,threading.currentThread().name)

t1 = threading.Thread(target=func,args=('haibo',))
t2 = threading.Thread(target=func,args=('lina',))
t1.start()
t2.start()
t1.join()
t2.join()

Output:
current thread: t1
haibo in t1
current thread: t2
lina in t2

ThreadLocal使用最多的地方就是每个线程处理一个HTTP请求。

使用Map实现多线程

对于多线程的使用,我们通常是用thread或者threading来创建,比较麻烦。如果要创建更多的线程,那就要一一加到里面,不仅操作麻烦,而且代码可读性也很差。

在Python中,我们可以使用map来简化代码。map可以实现多任务的并发,简单示例如下:

1
2
urls = ['http://www.baidu.com','http://www.sina.com','http://www.qq.com']
results = map(urllib2.urlopen, urls)

map将urls列表中每一个元素当做参数分别传给urllib2.urlopen函数,并且把结果放到result列表中。map的原理:

python_map

由此可见,map函数负责将线程分给不同的CPU。

在Python中有两个库包含了map函数:multiprocessing和它鲜为人知的子库multiprocessing.dummy。dummy是multiprocessing模块的完整克隆,唯一的不同在于multiprocessing作用于进程,而dummy模块作用于线程

1
2
3
4
5
6
7
8
9
10
11
12
import urllib2

from multiprocessing.dummy import Pool as ThreadPool

urls = ['http://www.baidu.com','http://www.sina.com','http://www.qq.com']

pool = ThreadPool()

results = pool.map(urllib2.urlopen, urls)
print results
pool.close()
pool.join()
  • pool = ThreadPool()创建了线程池,默认值为当前机器CPU的核数,我们也可以指定线程池的大小,但不是越多越好,因为过多的话,线程之间的切换也是很消耗资源的。
  • results = pool.map(urllib2.urlopen, urls)语句将不同的url传给各自的线程,并且把执行结果返回到result中。

Python多线程的缺陷 —— GIL

上面说了那么多关于多线程的用法,但是在Python中多线程并不能真正的发挥作用,因为在Python中有一个GIL,即全局解释锁,该锁的存在保证在同一时间只能有一个线程执行任务,也就是说多线程并不是真正的并发,而是交替的进行。

什么是GIL

首先需要明确一点,GIL并不是Python的特性,它是在实现Python解释器(CPython)时所引入的一个概念。类似C++是一套语言标准,但是可以通过GCC、Visual C++等不同的编译器来编译成可执行代码。Python也是一样,同样一段代码可以通过CPython、PyPy、Psyco等不同的Python执行环境来执行。像其中的JPython就没有GIL,然而大部分环境下CPython是Python的默认执行环境,所以在很多人的概念中CPython就是Python,也就是想当然的将GIL归结为Python语言的缺陷。所以这里要先明确:GIL并不是Python的特性,Python完全可以不依赖于GIL

官方给出的CPython中的GIL(Global Interpreter Lock)解释如下:

In CPython, the global interpreter lock, or GIL, is a mutex that prevents multiple native threads from executing Python bytecodes at once. This lock is necessary mainly because CPython’s memory management is not thread-safe. (However, since the GIL exists, other features have grown to depend on the guarantees that it enforces.)

乍一看,GIL就是CPython中的全局锁。

为什么会有GIL

由于硬件的提升,CPU逐渐发展成多核,为了更加有效的利用多核处理器的性能,就出现了多线程的编程方式,而随之而来的就是线程间数据一致性和状态同步的困难。即使在CPU内部的Cache也不例外,为了有效解决多份缓存之间的数据同步,各厂商花费了不少心思,同时也不可避免的带来了一定的性能损失。

Python也是为了利用多核,开始支持多线程。而解决多线程之间数据完整性状态同步的最简单方法自然就是加锁。于是有了GIL这把超级大锁,而当越来越多的开发者接受了这种设定后,他们开始大量的依赖这种特性(即默认python内部对象是thread-safe的,无需在实现时考虑额外的内存锁和同步操作)。

当前GIL设计的缺陷

伪代码:

1
2
3
4
5
6
7
while True:
# acquire GIL
for i in 1000:
do something
# release GIL

# Give Operating System a chance to do thread scheduling

这种模式在只有一个CPU核心的情况下毫无问题。任何一个线程被唤起时都能够成功的获得GIL(因为只有释放了GIL才会引发线程调度)。但是当CPU有多个核的时候,问题就来了。从伪代码中可以看到,从release GILacquire GIL之间几乎是没有间隙的。所以当在其他核上的线程被唤起时,大部分情况下主线程已经又再一次获取到GIL了。这个时候被唤起的线程只能白白的浪费CPU时间,看着另一个线程拿着GIL欢快的执行着。然后达到切换时间后进入待调度状态,再被唤醒,再等待,由此往复恶性循环。

当然这种实现方式是原始而丑陋的,Python的每个版本中也在逐渐改进GIL和线程调度之间的互动关系。例如先尝试持有GIL再做上下文切换,在IO等待时释放GIL等尝试。但是无法改变的是GIL的存在使得操作系统线程调度这个本来就昂贵的操作变得更加奢侈了。

为了直观的理解GIL对于多线程带来的性能影响,这里直接借用一张测试结果图。图中表示的是两个线程在双核CPU上的执行情况。两个线程均为CPU密集型运算线程。绿色部分表示该线程正在运行,且在执行有用的计算,红色部分表示线程被调度唤醒,但是无法获取GIL,导致无法进行有效运算所等待的时间。

CPU_crowded

由此可见,GIL的存在导致多线程无法很好的利用多核CPU的并发处理能力。

那么Python的IO密集型线程能否从多线程中收益呢?我们来看下面这张测试结果。颜色代表的含义同上图一致。白色部分表示IO线程 处于等待。可见,当IO线程收到数据包引起终端切换后,仍然由于一个CPU密集型线程的存在,导致无法获取GIL锁,从而进行无尽的循环等待。

IO_crowded

简单总结下就是:Python的多线程在多核CPU上,只对于IO密集型计算产生正面效果;而当有至少一个CPU密集型线程存在时,多线程的效率会由于GIL的存在而大幅下降

如何避免GIL产生的影响

说了这么多,如果不说解决方案就仅仅是个科普贴。GIL这么烂,有没有办法绕过呢?我们来看一下有哪些现成的方案。

用multiprocessing替代Thread

multiprocessing库的出现很大程度上是为了弥补thread库因为GIL而低效的缺陷。正如我们上面所说,它完整的复制了一套thread所提供的接口方便迁移。唯一的不同就是它使用了多进程而不是多线程。每个进程有自己独立的GIL,因此不会出现进程之间的GIL争抢。

当然multiprocessing也不是万能良药。它的引入会增加程序实现时线程间数据通讯和同步的困难。就拿计数器举例子,如果我们要多个线程累加同一个变量,对于thread来说,申明一个global变量,用thread.Lock的context包裹住三行就搞定了。而multiprocessing由于进程之间无法看到对方的数据,只能通过在主线程声明一个Queue,put再get或者用share memory的方式。这个额外的实现成本使得本来就非常痛苦的多线程编码变得更加痛苦了。

使用其他的解析器

之前也提到了既然GIL只是CPython的产物,那么其他解析器是不是更好呢?像JPython和IronPython这样的解析器由于实现语言的特性,他们不需要GIL的帮助。然而由于用了Java/C#作为解析器的实现,他们也失去了利用社区众多C语言模块有用特性的机会。所以这些解析器也因此一直都比较小众,毕竟功能和性能大家在初期都会选择前者。

GIL总结

Python GIL其实是功能和性能之间权衡后的产物,它有其存在的合理性,也有比较难改变的客观因素。从本文的分析中,我们可以做以下简单的总结:

  • 因为GIL的存在,只有IO Bound场景下的多线程会得到较好的性能
  • 如果对并行计算性能较高的程序可以考虑把核心部分也变为C模块,或者索性用其他语言实现
  • GIL在较长一段时间内将会继续存在,但是会不断改进

总结

一句话来总结本文的分析结果:Python多线程在IO密集型任务中还是很有用处的,而对于计算密集型任务,应该使用Python多进程