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

线程安全

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

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

该场景代码如下

先定义一个账户

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()方法进行同步
  • 如果可变类有两种运行环境:单线程环境和多线程环境,则该可变类应提供两个版本,即线程不安全版本和线程安全版本

死锁

当两个线程互相等待对方释放同步监视器时,就会发生死锁,Python解释器没有监测也没有采取措施来处理死锁,需要在编程过程中实现对于死锁的处理,一旦出现死锁程序不会发生异常,也不会有提示,只是所有线程处于阻塞状态,无法继续,即便Ctrl+C都无法停止

import threading
import time

class A:
    def __init__(self):
        self.lock = threading.RLock()
    def foo(self, b):
        try:
            self.lock.acquire()
            print("当前线程名: " + threading.current_thread().name + " 进入了A实例的foo()方法" )     # ①
            time.sleep(0.2)
            print("当前线程名: " + threading.current_thread().name + " 企图调用B实例的last()方法")   # ③
            b.last()
        finally:
            self.lock.release()
    def last(self):
        try:
            self.lock.acquire()
            print("进入了A类的last()方法内部")
        finally:
            self.lock.release()
class B:
    def __init__(self):
        self.lock = threading.RLock()
    def bar(self, a):
        try:
            self.lock.acquire()
            print("当前线程名: " + threading.current_thread().name + " 进入了B实例的bar()方法" )   # ②
            time.sleep(0.2)
            print("当前线程名: " + threading.current_thread().name + " 企图调用A实例的last()方法")  # ④
            a.last()
        finally:
            self.lock.release()
    def last(self):
        try:
            self.lock.acquire()
            print("进入了B类的last()方法内部")
        finally:
            self.lock.release()
a = A()
b = B()
def init():
    threading.current_thread().name = "主线程"
    # 调用a对象的foo()方法
    a.foo(b)
    print("进入了主线程之后")
def action():
    threading.current_thread().name = "副线程"
    # 调用b对象的bar()方法
    b.bar(a)
    print("进入了副线程之后")
# 以action为target启动新线程
threading.Thread(target=action).start()
# 调用init()函数
init()

执行结果如下

D:\PythonPrograms\CrazyCode\14\14.5\DeadLock>python dead_lock.py
当前线程名: 副线程 进入了B实例的bar()方法
当前线程名: 主线程 进入了A实例的foo()方法
当前线程名: 主线程 企图调用B实例的last()方法
当前线程名: 副线程 企图调用A实例的last()方法
  • 程序中有两个线程执行,副线程的执行体是action()函数,主线程的执行体是init()函数,在action()中B对象调用bar()函数,而init()函数中A对象调用foo()函数,action()函数先执行,B对象调用bar()函数,在进入bar()之前该线程对B对象的Lock加锁,然后该线程暂停0.2S,CPU切换到另一个线程;
  • A对象执行foo()方法,进入foo()方法之前,该线程对A对象的Lock加锁,然后再等待0.2S,CPU切换到副线程继续向下执行,当程序要调用A对象的last()方法时,在执行该方法前,必须先对A对象的Lock加锁,但此时主线程正保持着A对象的Lock锁定,所以副线程无法对A对象的Lock加锁,被阻塞,CPU在此切换线程
  • 主线程醒过来,继续向下执行,程序希望调用B对象的last()方法时,在执行该方法之前,必须对B对象的Lock加锁,但此时副线程没有释放B对象的Lock锁定,主线程也被阻塞,互相等待对方释放锁,就出现了死锁

避免死锁

  • 避免多次锁定:尽量避免同一个线程对多个Lock进行锁定,例如该实例主线程要对A、B对象的Lock锁定,副线程也要对A、B两个对象的Lock锁定
  • 具有相同的加锁顺序:如果多个线程需要对多个Lock进行锁定,则应该保证他们以相同的顺序请求加锁
  • 使用定时锁:程序在调用acquire()方法加锁时可指定timeout参数,超出该时间后会自动释放对Lock的锁定
  • 死锁检验:死锁检验是一种依靠算法机制来实现的死锁预防机制,主要用于无法实现按顺序加锁和不能使用定时锁的情况

你可能感兴趣的:(Python快速而美丽)