Python 线程同步(一) -- 竞争条件与线程锁

2019-05-09 14:10:03   最后更新: 2019-05-09 14:10:03   访问数量:104




上一篇文章中我们介绍了 Python 中的线程与用法

python 的线程

 

一旦引入并发,就有可能会出现竞争条件,有时会出现意想不到的状况

 

 

上图中,线程A读取变量然后给变量赋予一个新值,然后写入内存,但是,与此同时,B从内存中读取相同变量,此时可能A尚未将改变后的变量写入内存,导致B读到的是原值,也有可能A已经写入导致B读取到的是新的值,由此程序运行出现了不确定性

本文我们就来讨论如何解决上述问题

 

单例模式

此前在介绍装饰器时,我们看到过一种单例模式的实现

python 魔术方法(二) 对象的创建与单例模式的实现

 

class SingleTon: _instance = {} def __new__(cls, *args, **kwargs): if cls not in cls._instance: cls._instance[cls] = super(SingleTon, cls).__new__(cls, *args, **kwargs) return cls._instance[cls] class TechTest(SingleTon): testfield = 12

 

 

多线程下的单例

下面我们将上面单例模式的代码改造成多线程模式,并且加入 time.sleep(1) 来模拟创建时有一些 IO 操作的场景

import time from threading import Thread class SingleTon: _instance = {} def __new__(cls, *args, **kwargs): if cls not in cls._instance: time.sleep(1) cls._instance[cls] = super(SingleTon, cls).__new__(cls, *args, **kwargs) return cls._instance[cls] class TechTest(SingleTon): testfield = 12 def createTechTest(): print(TechTest()) if __name__ == '__main__': threads = list() for i in range(5): t = Thread(target=createTechTest) threads.append(t) for thread in threads: thread.start()

 

 

打印出了:

<__main__.TechTest object at 0x000001F5D7E8EEF0>

<__main__.TechTest object at 0x000001F5D60830B8>

<__main__.TechTest object at 0x000001F5D60830F0>

<__main__.TechTest object at 0x000001F5D6066048>

<__main__.TechTest object at 0x000001F5D6083240>

 

从运行结果看,我们的单例模式类不止创建出了一个对象,这已经不是单例了

这是为什么呢?

在我们的单例类 __new__ 方法中,先检查了字典中是否存在对象,如果不存在则创建,当多个线程同时执行到判断,而均没有执行到创建的语句,则结果是多个线程均判断需要创建单例的对象,于是多个对象就被这样创建出来了,这就构成了竞争条件

 

解决上述问题最简单的方法就是加锁

 

 

上图中,线程A将读取变量、写入变量、写入内存的一系列操作锁定,而线程B则必须在线程A完成所有操作释放锁以前一直阻塞等待,直到获取到锁,读取到完成一系列操作后的值

 

threading.Lock

threading.Lock 使用的是 _thread 模块实现的锁机制,从本质上,他实际返回的是操作系统所提供的锁

锁对象创建后不属于任何特定线程,他只有两个状态 -- 锁定与未锁定,同时他有两个方法用来在这两个状态之间切换

 

获取锁 -- acquire

acquire(blocking=True, timeout=-1)

 

这个方法尝试获取锁,如果锁的状态是未锁定状态,则立即返回,否则,根据 blocking 参数决定是否阻塞等待

一旦 blocking 参数为 True,且锁是锁定状态,那么该方法会一直阻塞,直到达到 timeout 秒数,timeout 为 -1 表示不限制超时

如果获取成功则返回 True,如果因为超时或非阻塞获取锁失败等原因没有获取成功,则返回 False

 

释放锁 -- release

release()

 

这个方法用来释放锁,无论当前线程是否持有锁,他都可以调用这个方法来释放锁

但如果一个锁并没有处于锁定状态,那么该方法会抛出 RuntimeError 异常

 

实例

有了锁机制,我们的单例模式类可以改造为下面的样子:

from threading import Thread, Lock class SingleTon: _instance_lock = Lock() _instance = {} def __new__(cls, *args, **kwargs): cls._instance_lock.acquire() try: if cls not in cls._instance: time.sleep(1) cls._instance[cls] = super(SingleTon, cls).__new__(cls, *args, **kwargs) return cls._instance[cls] finally: cls._instance_lock.release()

 

这样就再也不会出现前文所说的问题了

但是,这样的实现因为加锁的粒度太大而存在性能的问题,这不在我们本文讨论范围内,会单独抽出一篇文章来介绍单例模式的优化

 

上下文管理器

每次都必须执行 acquire 和 release 两个方法看上去非常繁琐,也十分容易出错,因为一旦由于疏忽,线程没有 release 就退出,那么其他线程将永远无法获取到锁而引发严重的问题

好在 python 有一个非常易用的特性 -- 上下文管理协议,threading.Lock 是支持上下文管理协议的,上面的代码可以改造为:

from threading import Thread, Lock class SingleTon: _instance_lock = Lock() _instance = {} def __new__(cls, *args, **kwargs): with cls._instance_lock: if cls not in cls._instance: time.sleep(1) cls._instance[cls] = super(SingleTon, cls).__new__(cls, *args, **kwargs) return cls._instance[cls]

 

 

可重入锁 -- threading.RLock

为什么需要可重入锁

对于 threading.Lock,同一个线程两次获取锁就会发生死锁,因为前一个锁被自己占用,而自己又去等待锁的释放,陷入了死循环中

这种死锁的情况看上去很容易避免,但事实上,在面向对象的程序中,这却很容易发生

from threading import Lock class TechlogTest: def __init__(self): self.lock = Lock() def lockAndPrint(self): with self.lock: print('[%s] locked' % 'TechlogTest') class TechlogTestSon(TechlogTest): def lockAndPrint(self): with self.lock: print('[%s] locked' % 'TechlogTestSon') super(TechlogTestSon, self).lockAndPrint() if __name__ == '__main__': son = TechlogTestSon() son.lockAndPrint()

 

 

上面的例子中,子类尝试调用父类的同名方法,打印出 “[TechlogTestSon] locked” 后就一直阻塞等待,而实际上,父类与子类一样对方法进行了锁定,而根据多态性,父类与子类获取到的锁对象实际上都是子类创建的对象,于是死锁发生了

为了避免这样的情况,就需要使用可重入锁

 

threading.RLock

与 threading.Lock 一样,RLock 也提供两个方法分别用于加锁与解锁,而其加锁方法也同样是一个工厂方法,返回操作系统中可重入锁的实例

此前我们研究过 Java 中可重入锁 ReentrantLock 的源码

ReentrantLock 用法详解

 

实际上,操作系统中可重入锁的实现与上文中 Java 可重入锁的实现非常类似,通常在锁对象中维护当前加锁线程标识与一个数字用来表示加锁次数,同一线程每次调用加锁方法则让加锁次数 + 1,解锁则 - 1,只有变为 0 才释放锁

 

加锁与解锁

acquire(blocking=True, timeout=-1)

release()

 

可以看到,这两个方法的参数与 threading.Lock 中的同名方法是完全一致的,用法也完全相同,这里就不再赘述了

 

上下文管理器

threading.RLock 也完全实现了上下文管理协议,上面那个死锁的例子,我们稍加改造就可以解决死锁问题了

from threading import RLock class TechlogTest: def __init__(self): self.lock = RLock() def lockAndPrint(self): with self.lock: print('[%s] locked' % 'TechlogTest') class TechlogTestSon(TechlogTest): def lockAndPrint(self): with self.lock: print('[%s] locked' % 'TechlogTestSon') super(TechlogTestSon, self).lockAndPrint() if __name__ == '__main__': son = TechlogTestSon() son.lockAndPrint()

 

 

打印出了:

[TechlogTestSon] locked

[TechlogTest] locked

 

在多线程环境中,性能提升的同时会出现许多棘手的新问题,上述问题只是冰山一角,加锁也只能解决其中一些最基本的场景,还有更多复杂的场景需要更为合适的工具来处理

敬请期待下一篇日志,我们来详细介绍 python 线程同步的其他工具

 

欢迎关注微信公众号,以技术为主,涉及历史、人文等多领域的学习与感悟,每周三到七篇推文,全部原创,只有干货没有鸡汤

 

 

https://docs.python.org/zh-cn/3.6/library/threading.html

 






线程            线程同步      死锁      并发      同步      current      可重入锁     


京ICP备15018585号