美文网首页python
Python中的线程安全

Python中的线程安全

作者: 862aa6df68e4 | 来源:发表于2017-04-25 10:38 被阅读1250次

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

    相关文章

      网友评论

        本文标题:Python中的线程安全

        本文链接:https://www.haomeiwen.com/subject/ywfrzttx.html