python多线程编程(线程安全和避免死锁)

1. 多线程编程与线程安全相关重要概念

开始之前,我们熟悉了几个特别重要的概念:GIL,线程,进程, 线程安全,原子操作。

  • GIL: Global Interpreter Lock,全局解释器锁。
    Cpython解释器上的一把互斥锁,不能利用多线程实现多核任务即并行,因为解释器的C语言实现部分在完全并行执行时不是线程安全的,GIL确保任何时候只能一个python线程执行

    为了解决多线程之间数据完整性和状态同步的问题,设计为在任意时刻只有一个线程在解释器中运行。

  • 线程: 程序执行的最小单位。

  • 进程: 系统资源分配的最小单位。

  • 线程安全: 多线程环境中,共享数据同一时间只能有一个线程来操作。

  • 原子操作: 原子操作就是不会因为进程并发或者线程并发而导致被中断的操作。

还有一个重要的结论:当对全局资源存在写操作时,如果不能保证写入过程的原子性,会出现脏读脏写的情况,即线程不安全。

Python的GIL只能保证原子操作的线程安全,因此在多线程编程时我们需要通过加锁来保证线程安全。

最简单的锁是互斥锁(同步锁),互斥锁是用来解决io密集型场景产生的计算错误,即目的是为了保护共享的数据,同一时间只能有一个线程来修改共享的数据。

下面我们会来介绍如何使用互斥锁。

2. 线程锁 Lock()

多线程和多进程最大的不同在于,多进程中,同一个变量,各自有一份拷贝存在于每个进程中,互不影响,而多线程中,所有变量都由所有线程共享,所以,任何一个变量都可以被任何一个线程修改,因此,线程之间共享数据最大的危险在于多个线程同时改一个变量,把内容给改乱了。

2.1 先看一个没有加锁的操作:

balance = 0
def change_it_without_lock(n):
    global balance
    # 不加锁的话 最后的值不是0
    # 线程共享数据危险在于 多个线程同时改同一个变量
    # 如果每个线程按顺序执行,那么值会是0, 但是线程是系统调度,又不确定性,交替进行
    # 没锁的话,同时修改变量
    # 所以加锁是为了同时只有一个线程正在修改,别的线程一定不能改
    for i in range(100000):
        balance = balance + n
        balance = balance - n

    print balance

threads = [
    threading.Thread(target=change_it_without_lock, args=(8,) ),
    threading.Thread(target=change_it_without_lock, args=(10,) )
]

[t.start() for t in threads]
[t.join() for t in threads]

print balance

我们定义了一个共享变量balance,初始值为0,并且启动两个线程,先存后取,理论上结果应该为0
但是,由于线程的调度是由操作系统决定的,当t1、t2交替执行时,只要循环次数足够多,balance的结果就不一定是0了。

按照一个线程的思想, balance无论处理循环多少次都是等于0的,然而开了2个线程最后结果却不是0,那是因为两个线程交换进行,操作没有原子性,比如,
线程1进行到 balance=balance + 8 == 0 + 8 = 8

此时切换到线程2,线程2拿到的balance=8, 进行balance = 8 + 10 = 18,
此时切换到线程1 ,balance = balance - 8 = 18-8=10 ! = 0

线程1的结果已经不等0,之后的结果也会乱套,就是因为操作没有原子性,
所以我们多线程操作同一个资源(这里就是全局变量balance)的时候,需要加锁

2.2 下面是线程安全的例子,我们可以用threading.Lock()获得锁

def change_it_with_lock(n):
    global balance
    # acquire()是获取锁,获取锁的结果,成功获取到互斥锁为True,如果没有获取到互斥锁则返回False
    if lock.acquire():
        try:
            for i in range(100000):
                balance = balance + n
                balance = balance - n
        # 这里的finally 防止中途出错了,也能释放锁
        finally:
            lock.release() # 一系列操作结束之后需要释放锁
threads = [
    threading.Thread(target=change_it_without_lock, args=(8,) ),
    threading.Thread(target=change_it_without_lock, args=(10,) )
]
lock = threading.Lock()
[t.start() for t in threads]
[t.join() for t in threads]

print balance
# 这里的结果一定是0

2.3 获取锁和释放锁的语句也可以用Python的with来实现,这样更简洁。

def change_it_with_lock(n):
    global balance
    with lock:
		for i in range(100000):
		    balance = balance + n
		    balance = balance - n

3. 两种死锁情况及处理

3.1 迭代死锁与递归锁(RLock)

该情况是一个线程“迭代”请求同一个资源,直接就会造成死锁。这种死锁产生的原因是我们标准互斥锁threading.Lock的缺点导致的。标准的锁对象(threading.Lock)并不关心当前是哪个线程占有了该锁;如果该锁已经被占有了,那么任何其它尝试获取该锁的线程都会被阻塞,包括已经占有该锁的线程也会被阻塞。

下面是例子,
#/usr/bin/python3
# -- coding: utf-8 --

import threading
import time

count_list = [0,0]
lock = threading.Lock()

def change_0():
    global count_list
    with lock:
        tmp = count_list[0]
        time.sleep(0.001)
        count_list[0] = tmp + 1
        time.sleep(2)
        print("Done. count_list[0]:%s" % count_list[0])
        
def change_1():
    global count_list
    with lock:
        tmp = count_list[1]
        time.sleep(0.001)
        count_list[1] = tmp + 1
        time.sleep(2)
        print("Done. count_list[1]:%s" % count_list[1])
        
def change():
    with lock:
        change_0()
        time.sleep(0.001)
        change_1()
    
def verify(sub):
    global count_list
    thread_list = []
    for i in range(100):
        t = threading.Thread(target=sub, args=())
        t.start()
        thread_list.append(t)
    for j in thread_list:
        j.join()
    print(count_list)
    
if __name__ == "__main__":
    verify(change)

示例中,我们有一个共享资源count_list,有两个分别取这个共享资源第一部分和第二部分的数字(count_list[0]和count_list[1])。两个访问函数都使用了锁来确保在获取数据时没有其它线程修改对应的共享数据。
现在,如果我们思考如何添加第三个函数来获取两个部分的数据。一个简单的方法是依次调用这两个函数,然后返回结合的结果。

这里的问题是,如有某个线程在两个函数调用之间修改了共享资源,那么我们最终会得到不一致的数据。

最明显的解决方法是在这个函数中也使用lock。然而,这是不可行的。里面的两个访问函数将会阻塞,因为外层语句已经占有了该锁。

结果是没有任何输出,死锁。

为了解决这个问题,我们可以用threading.RLock代替threading.Lock

#/usr/bin/python3
# -*- coding: utf-8 -*-

import threading
import time

count_list = [0,0]
lock = threading.RLock()

def change_0():
    global count_list
    with lock:
        tmp = count_list[0]
        time.sleep(0.001)
        count_list[0] = tmp + 1
        time.sleep(2)
        print("Done. count_list[0]:%s" % count_list[0])
        
def change_1():
    global count_list
    with lock:
        tmp = count_list[1]
        time.sleep(0.001)
        count_list[1] = tmp + 1
        time.sleep(2)
        print("Done. count_list[1]:%s" % count_list[1])
        
def change():
    with lock:
        change_0()
        time.sleep(0.001)
        change_1()
    
def verify(sub):
    global count_list
    thread_list = []
    for i in range(100):
        t = threading.Thread(target=sub, args=())
        t.start()
        thread_list.append(t)
    for j in thread_list:
        j.join()
    print(count_list)
    
if __name__ == "__main__":
    verify(change)
3.2 互相等待死锁与锁的升序使用

死锁的另外一个原因是两个进程想要获得的锁已经被对方进程获得,只能互相等待又无法释放已经获得的锁,而导致死锁。假设银行系统中,用户a试图转账100块给用户b,与此同时用户b试图转账500块给用户a,则可能产生死锁。
2个线程互相等待对方的锁,互相占用着资源不释放。

下面是一个互相调用导致死锁的例子:

#/usr/bin/python3
# -*- coding: utf-8 -*-

import threading
import time

class Account(object):
    def __init__(self, name, balance, lock):
        self.name = name
        self.balance = balance
        self.lock = lock
        
    def withdraw(self, amount):
        self.balance -= amount
        
    def deposit(self, amount):
        self.balance += amount
        
def transfer(from_account, to_account, amount):
    with from_account.lock:
        from_account.withdraw(amount)
        time.sleep(1)
        print("trying to get %s's lock..." % to_account.name)
        with to_account.lock:
            to_account_deposit(amount)
    print("transfer finish")
    
if __name__ == "__main__":
    a = Account('a',1000, threading.Lock())
    b = Account('b',1000, threading.Lock())
    thread_list = []
    thread_list.append(threading.Thread(target = transfer, args=(a,b,100)))
    thread_list.append(threading.Thread(target = transfer, args=(b,a,500)))
    for i in thread_list:
        i.start()
    for j in thread_list:
        j.join()

最终的结果是死锁:

trying to get account a's lock...
trying to get account b's lock...

即我们的问题是:

你正在写一个多线程程序,其中线程需要一次获取多个锁,此时如何避免死锁问题。
解决方案:
在多线程程序中,死锁问题很大一部分是由于线程同时获取多个锁造成的。
举个例子:A线程获取了第一个锁,然后在获取第二个锁的 时候发生阻塞,那么这个线程A就可能阻塞其他线程的执行,从而导致整个程序假死。

其实解决这个问题,核心思想也特别简单:目前我们遇到的问题是两个线程想获取到的锁,都被对方线程拿到了,那么我们只需要保证在这两个线程中,获取锁的顺序保持一致就可以了。

举个例子,我们有线程thread_a, thread_b, 锁lock_1, lock_2。只要我们规定好了锁的使用顺序,比如先用lock_1,再用lock_2,当线程thread_a获得lock_1时,其他线程如thread_b就无法获得lock_1这个锁,也就无法进行下一步操作(获得lock_2这个锁),也就不会导致互相等待导致的死锁。

简言之,解决死锁问题的一种方案是为程序中的每一个锁分配一个唯一的id,然后只允许按照升序规则来使用多个锁,这个规则使用上下文管理器 是非常容易实现的,示例如下:

#/usr/bin/python3
# -*- coding: utf-8 -*-

import threading
import time
from contextlib import contextmanager

thread_local = threading.local()

@contextmanager
def acquire(*locks):
    #sort locks by object identifier
    locks = sorted(locks, key=lambda x: id(x))
    
    #make sure lock order of previously acquired locks is not violated
    acquired = getattr(thread_local,'acquired',[])
    if acquired and (max(id(lock) for lock in acquired) >= id(locks[0])):
        raise RuntimeError('Lock Order Violation')
    
    # Acquire all the locks
    acquired.extend(locks)
    thread_local.acquired = acquired
    
    try:
        for lock in locks:
            lock.acquire()
        yield
    finally:
        for lock in reversed(locks):
            lock.release()
        del acquired[-len(locks):]

class Account(object):
    def __init__(self, name, balance, lock):
        self.name = name
        self.balance = balance
        self.lock = lock
        
    def withdraw(self, amount):
        self.balance -= amount
        
    def deposit(self, amount):
        self.balance += amount
        
def transfer(from_account, to_account, amount):
    print("%s transfer..." % amount)
    with acquire(from_account.lock, to_account.lock):
        from_account.withdraw(amount)
        time.sleep(1)
        to_account.deposit(amount)
    print("%s transfer... %s:%s ,%s: %s" % (amount,from_account.name,from_account.balance,to_account.name, to_account.balance))
    print("transfer finish")
    
if __name__ == "__main__":
    a = Account('a',1000, threading.Lock())
    b = Account('b',1000, threading.Lock())
    thread_list = []
    thread_list.append(threading.Thread(target = transfer, args=(a,b,100)))
    thread_list.append(threading.Thread(target = transfer, args=(b,a,500)))
    for i in thread_list:
        i.start()
    for j in thread_list:
        j.join()

我们获得的结果是

100 transfer...
500 transfer...
100 transfer... a:900 ,b:1100
transfer finish
500 transfer... b:600, a:1400
transfer finish

成功的避免了互相等待导致的死锁问题。

今天我们主要讨论了Python多线程中如何保证线程安全,互斥锁的使用方法。另外着重讨论了两种导致死锁的情况:迭代死锁与互相等待死锁,以及这两种死锁的解决方案:递归锁(RLock)的使用和锁的升序使用。

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值