Python快速而美丽[v1.0.0][线程同步]

Lara ·
更新时间:2024-11-14
· 991 次阅读

线程安全

系统的线程调度是随机的,当多个线程可以同时修改某一资源的时候,就会产生线程安全问题,最后会导致达不到预期结果,但也因为线程调度有随机性,可能我们运行很多次或者很久的程序都没有出过错,但并不等于不存在问题

例如一个取钱的场景,一个账户有一定的余额,当取钱的量大于余额的时候,会取款失败,小于余额的时候则取款成功,这个逻辑在单线程情况下没有任何问题,但是放在多线程场景下就会出现混乱,例如两个线程取钱,第一个线程取钱可能小于账户余额可以取款成功,但是第二个线程也取款,恰巧在第一个线程还没完成流程,余额没有发生变动的时候,第二个线程开始取钱也判断了是否小于余额,恰巧也小于余额,也能取款成功,如果这两个线程的取款总额是大于余额的,但是每个线程的取款都是小于余额的,两个都能成功,但账面剩余的余额将会是负数,这显然是不符合实际的
这就是所谓的线程不安全

该场景代码如下 先定义一个账户 class Account: # 定义构造器 def __init__(self, account_no, balance): # 封装账户编号、账户余额的两个成员变量 self.account_no = account_no self.balance = balance 在定义一个取钱的线程 import threading import time import Account # 定义一个函数来模拟取钱操作 def draw(account, draw_amount): # 账户余额大于取钱数目 if account.balance >= draw_amount: # 吐出钞票 print(threading.current_thread().name + "取钱成功!吐出钞票:" + str(draw_amount)) # time.sleep(1) # 修改余额 account.balance -= draw_amount print("\t余额为: " + str(account.balance)) else: print(threading.current_thread().name + "取钱失败!余额不足!") # 创建一个账户 acct = Account.Account("1234567" , 1000) # 模拟两个线程对同一个账户取钱 threading.Thread(name='甲', target=draw , args=(acct , 800)).start() threading.Thread(name='乙', target=draw , args=(acct , 800)).start()

这段代码只要执行次数够多,一定会出现线程不安全的情况,导致账面余额为负数,因为系统调度线程有随机性,总会碰到偶然的错误的
也可以将time.sleep(0.001)的注释放开,则必然导致上述情形出现,因为一个线程等待进入阻塞状态,则另一个线程便会继续工作,最终两个线程都能够取钱成功,账户以供1000余额,取出去的是1600

同步锁(Lock)

总结上述代码,实际上就是存在两个并发线程在修改Accout对象,而系统恰好在time.sleep(1)时候切换到另一个修改Account对象的线程,所以除了线程不安全的现象
为了解决线程不安全,python的threading模块引入了锁(Lock),threading模块提供了Lock和Rlock两个类,他们提供了如下方法:

acquire(blocking=True, timeout=1):请求对Lock或RLock加锁,timeout指定加锁的时间,单位为秒 release():释放锁 Lock和RLock的区别 threading.Lock:它是一个基本的锁对象,每次只能锁定一次,其余的锁请求,需要待锁释放后才能获取 threading.RLock:它代表重入锁(Reentrant Lock),对于可重入锁,在同一个线程中可以对它进行多次锁定,也可以释放多次,如果使用RLock,那么acquire()和release()方法必须同时出现,且调用了N次acquire()则必须调用N次release()才能释放锁 RLock锁具有可重入性,也就是说同一个线程可以对已被加锁的RLock锁再次加锁,RLock对象会维持一个计数器来追踪acquire()方法的嵌套调用,线程每次调用acquire()方法加锁后,都必须显示调用release()方法释放锁,因此被锁保护的方法可以调用另一个被相同锁保护的方法 Lock是控制多线程对共享资源进行访问的工具,通常情况下,锁提供了对共享资源的独占访问,每次只能有一个线程对Lock对象加锁,线程在开始访问共享资源之前应先请求获得Lock对象,当对共享资源访问完成后,程序释放对Lock对象的锁

在实际场景中,RLock是比较常用的,其结构如下

Class Demo_RLock: # 定义需要保证线程安全的方法 def account(): # 加锁 self.lock.acquire() try: # 需要线程安全的代码 # 方法体 # 使用finally块来保证释放锁 finally: # 修改完成,释放锁 self.lock.release()

== 不可变类都是线程安全的,因为他的对象状态不可改变==

加了锁的代码 import threading import time class Account: # 定义构造器 def __init__(self, account_no, balance): # 封装账户编号、账户余额的两个成员变量 self.account_no = account_no self._balance = balance self.lock = threading.RLock() # 因为账户余额不允许随便修改,所以只为self._balance提供getter方法 def getBalance(self): return self._balance # 提供一个线程安全的draw()方法来完成取钱操作 def draw(self, draw_amount): # 加锁 self.lock.acquire() try: # 账户余额大于取钱数目 if self._balance >= draw_amount: # 吐出钞票 print(threading.current_thread().name + "取钱成功!吐出钞票:" + str(draw_amount)) time.sleep(0.001) # 修改余额 self._balance -= draw_amount print("\t余额为: " + str(self._balance)) else: print(threading.current_thread().name + "取钱失败!余额不足!") finally: # 修改完成,释放锁 self.lock.release() import threading import Account # 定义一个函数来模拟取钱操作 def draw(account, draw_amount): # 直接调用account对象的draw()方法来执行取钱操作 account.draw(draw_amount) # 创建一个账户 acct = Account.Account("1234567" , 1000) # 模拟两个线程对同一个账户取钱 threading.Thread(name='甲', target=draw , args=(acct , 800)).start() threading.Thread(name='乙', target=draw , args=(acct , 800)).start() 线程同步解析

通过使用Lock对象可以非常方便的实现线程安全的类,线程安全的类有几个特点:该类的对象可以被多个线程安全地访问,每个线程在调用该对象的任意方法之后,都将得到正确的结果;每个线程在调用该对象的任意方法之后,该对象都依然保持合理的状态

这样就实现了安全访问逻辑加锁===》修改===》释放锁,通过这样的方式保证并发线程在任何一个时刻只有一个线程可以进入修改共享资源的代码区也称为临界区,同一时刻只有一个线程处于临界区

程序在Account中定义了draw()方法完成取钱流程,而不是在线程的执行体里实现,这种方式更符合面向对象的思想,更体现了领域驱动设计的设计模式,这个模式认为每个类都应该是完备的领域对象,比如说Account代表用户账户,它就应该提供账户相关的函数,通过draw()来完成取钱,而不是将setBanlance()暴露出来,这也保证了Account对象的完成性和一致性

不可变类是线程安全的,因为他的对象状态是不可改变的;可变类的线程安全是以降低程序的运行效率作为代价的,为了减少线程安全所带来的负面影响,程序可以采用如下策略:

不要对线程安全类的所有方法都进行同步,只对那些会改变竞争资源关系的的方法进行同步,例如Account类的accountNo实例变量就无需同步,只需要对draw()方法进行同步 如果可变类有两种运行环境:单线程环境和多线程环境,则该可变类应提供两个版本,即线程不安全版本和线程安全版本
作者:Davieyang.D.Y



线程 线程同步 Python

需要 登录 后方可回复, 如果你还没有账号请 注册新账号