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是可重入的版本。
(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的。同时,读时本身就是原子操作,那就更不用加锁了。但是,如果读时,存在中断的可能,而且读时如果中断,会导致结果产生歧义,那么就必须加锁了。
我构造了一个简单的例子来说明这一点。
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中的线程安全,就是通过加锁,来实现原子操作(不可中断),避免不确定的线程切换导致逻辑错误。
作者:Marx7
链接:https://www.jianshu.com/p/4097b7a5a1bf
來源:简书
简书著作权归作者所有,任何形式的转载都请联系作者获得授权并注明出处。