python

Python中的线程安全

2017-04-25  本文已影响1250人  862aa6df68e4

线程,一个计算机的基本词汇。但是要准确地理解它,并在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

上一篇下一篇

猜你喜欢

热点阅读