Python中的线程安全
线程,一个计算机的基本词汇。但是要准确地理解它,并在Python的语义下正确地去应用它,保证线程安全,需要一些思考和学习。
内核级线程 or 用户级线程
我们知道,线程,在计算机里面通常的分类是内核级线程和用户级线程。内核级线程的调度是由系统完成的,而用户级线程的调度是由用户来控制的。那么Python标准库提供的线程是那一类呢?如果我们了解或者使用过gevent和eventlet,进行下对比,我们就很容易回答出来了。Python提供的线程是内核级的,而gevent和eventlet提供的则是用户级的线程。这类用户级的线程,我们叫它协程,也可以叫green thread。本文中的线程,主要针对Python标准库提供的线程。下文提到的线程一词,也都是指Python标准库提供的线程。
什么叫线程安全
当多个线程同时运行时,保证运行结果符合预期,就是线程安全的。由于多线程执行时,存在线程的切换,而python线程的切换时机是不确定的。既有cooperative multitasking的调度,也有preemptive multitasking的调度。
python线程什么时候切换呢?当一个线程开始sleep或者进行I/O操作时,另一个线程就有机会拿到GIL锁,开始执行它的代码。这就是
cooperative multitasking
。同时,CPython也有preemptive multitasking
的机制:在Python2,当一个线程无中断地运行了1000个字节码,或者在Python3中,运行了15毫秒,那么它就会放弃GIL锁,另一个线程就可能开始运行。
既然线程的切换是不可控的,那么如何保证在线程切换时,不会影响逻辑?同时,在某些场景下,我们还要主动协调各线程的执行顺序,也就是要解决多个线程同步的问题。
如何实现线程安全
对于线程安全问题,我的理解包含下面三种解决方案:
(1)天生线程安全
所谓天生线程安全,就是线程代码中只对全局对象进行读操作,而不存在写操作。这种情况下,不论线程在何处中断,都不会影响各个线程本来的执行逻辑。这时,不需要做任何额外的事情。线程本身就是安全的。
(2)实现原子操作
在一个线程中,有时,需要保证某一行或者某一段代码的逻辑是不可中断的,也就是说要保证这段代码执行的原子性,即,实现原子操作。如何实现原子操作呢?
其实,很简单,就是在执行代码的前后加互斥锁,放互斥锁就可以了。标准库里面为我们提供的互斥锁有两种。一种是Lock,一种是RLock。RLock是可重入的版本。实现原子操作的代码如下:
mylock = threading.Lock()
with mylock:
do_something()
由于python GIL的存在和最小执行单元是字节码,很多python built-in的类型的读写操作本身都是原子操作的。但是有时候,python中的一行代码是被解释成了多条字节码,也就是非原子操作的。这时,是必须加锁的。对于python原子操作的更多叙述,请见[python中的原子操作]
(3)实现线程同步
线程同步是在锁的基础来实现的。通过锁来对各个线程的执行顺序进行控制。虽然在一定意义上,实现原子操作也是一种线程同步,但它更多是保证单个线程中的操作不被中断。而我理解的线程同步,是一个线程需要等待其它线程完成特定任务之后,才能执行。多个线程之间有依赖关系。
线程安全举例
对于python常见的框架类代码,它们都是线程安全的。它们的实现都属于上文中实现原子操作
这一场景。下面举1个例子。
Django中的signal实现
我们知道,一个信号对象有两个基本的功能。一个是注册处理函数,在Django的signal中,叫connect
,另一个是触发处理函数,在Django的signal中,叫send
。在进行connect
时,需要将处理函数按照一定规则存放到signal对象的receivers
列表中。在进行send
时, 需要读取signal对象的receivers
列表,依次调用处理函数。很明显,send
是一个简单的读操作。而connect
是一个写操作。阅读代码,我们会发现,connect
中进行了加锁,如下所示,而send
没有加锁。
下面是connect
中加锁的代码段:在这段加锁的代码段中,将receivers
列表的弱引用对象清理,receivers
列表的新元素添加和全局缓存字典sender_receivers_cache
的清理封装成了一个原子操作。
with self.lock:
self._clear_dead_receivers()
for r_key, _ in self.receivers:
if r_key == lookup_key:
break
else:
self.receivers.append((lookup_key, receiver))
self.sender_receivers_cache.clear()
下面是send的代码:
def send(self, sender, **named):
responses = []
if not self.receivers or self.sender_receivers_cache.get(sender) is NO_RECEIVERS:
return responses
for receiver in self._live_receivers(sender):
response = receiver(signal=self, sender=sender, **named)
responses.append((receiver, response))
return responses
其实这个地方,还会有一个疑问:既然signal对象的属性(比如receivers
列表)存在读,也存在写,照理说,写时需要加锁,读时,也应该加锁呀?
其实,读时,要不要加锁,要分情况看。如果读的代码段,随时被中断,但不会影响结果,那么不加锁也是OK的。同时,读时本身就是原子操作,那就更不用加锁了。但是,如果读时,存在中断的可能,而且读时如果中断,会导致结果产生歧义,那么就必须加锁了。
我构造了一个简单的例子来说明这一点。
import threading
class Student(object):
def __init__(self, name, age):
self.name = name
self.age = age
self.lock = threading.Lock()
def update(self, name, age): # 写操作(加锁)
with self.lock:
self.name = name
self.age = age
def get_info(self): # 读操作(加锁)
with self.lock:
name, age = self.name, self.age
return name, age
def get_name(self): # 读操作(不用加锁)
return self.name
假设我们在主线程里面初始化了一个Student对象,存在多个线程对该 对象进行读写,调用update进行写,调用get_info, get_name进行读。这里的update是不可中断的, get_info也是不可中断。要保证他们的不可中断性,必须要通过加锁来实现。而get_name方法本身就是原子操作。不需要加锁。
回到signal的例子中看,send并不是一个原子操作。而且如果在send的过程中,在读取缓存,或者receicers列表时,发生中断,会造成结果不准确的情况。一个极端的例子,读线程在send时读取receivers列表之后,被一个写线程中断,写线程此时新注册了一个处理函数F。后切回到读线程,处理刚才读出的处理函数。此时,F并不会执行。后来,又有一个写线程把F弹出来了。读线程再次去运行。此时F也不会执行。这样,由于send的中断,导致F并没有被执行过。这里,没有加锁的唯一原因,就我理解来看,应该是由使用场景来决定的。
一个signal对象的写操作,应该是在module去运行的,也就是在import这个module时就会运行。这个运行往往是在主线程中。而send方法是在多个子线程中。也就说在多线程环境中,都是只读的,并没有写的动作。所以,send处不加锁,也是OK的。而且,我也觉得,connect处的锁不加也没事。因为connect并不存在并发的情况。
当然,从严格意义上来说,要保证signal的connect和send都是线程安全的,是都应该加锁的。而且,官方也鼓励加锁。When in doubt,use mtux.
总结
最后,总结一下。Python中的线程安全,就是通过加锁,来实现原子操作(不可中断),避免不确定的线程切换导致逻辑错误。
参考链接
[1]Grok the GIL: How to write fast and thread-safe Python
[2]Understanding the Python GIL