Python 并发编程之死锁

发布于:2022-12-19 ⋅ 阅读:(380) ⋅ 点赞:(0)

前言

在并发编程中,死锁指的是一种特定的情况,即无法取得进展,程序被锁定在其当前状态。在大多数情况下,这种现象是由于不同的锁对象(用于线程同步)之间缺乏协调,或者处理不当造成的。在这一节中,我们将讨论一个思想实验,通常被称为餐饮哲学家问题,以说明死锁的概念及其原因;从这里开始,你将学习如何在 Python 并发程序中模拟这个问题。

哲学家就餐问题

哲学家就餐(Dining philosophers problem)问题是计算机科学中的一个经典问题,用来演示在并发计算中多线程同步时产生的问题。

在 1971 年,著名的计算机科学家艾兹格·迪科斯彻提出了一个同步问题,即假设有五台计算机都试图访问五份共享的磁带驱动器。稍后,这个问题被托尼·霍尔重新表述为哲学家就餐问题。这个问题可以用来解释死锁和资源耗尽

假如有 5 个哲学家,围坐在一起,每个人面前有一碗饭和一只筷子。在这里每个哲学家可以看做是一个独立的线程,而每只筷子可以看做是一个锁。

他们每个人都需要两个叉子来吃饭。如果他们同时拿起他们左边的叉子,那么它将一直等待右边的叉子被释放。每个哲学家可以处在静坐、 思考、吃饭三种状态中的一个。需要注意的是,每个哲学家吃饭是需要两只筷子的,这样问题就来了:如果每个哲学家都拿起自己左边的筷子, 那么他们五个都只能拿着一只筷子坐在那儿,直到饿死。此时他们就进入了死锁状态。

下面是一个简单的使用死锁避免机制解决“哲学家就餐问题”的实现:

import threading
# The philosopher threaddef philosopher(left, right):    while True:        with acquire(left,right):             print(threading.currentThread(), 'eating')
# The chopsticks (represented by locks)NSTICKS = 5chopsticks = [threading.Lock() for n in range(NSTICKS)]
# Create all of the philosophersfor n in range(NSTICKS):    t = threading.Thread(target=philosopher,                         args=(chopsticks[n],chopsticks[(n+1) % NSTICKS]))    t.start()

最后,要特别注意到,为了避免死锁,所有的加锁操作必须使用 acquire() 函数。如果代码中的某部分绕过 acquire 函数直接申请锁,那么整个死锁避免机制就不起作用了。

死锁的常见例子

另一个例子是在银行账户上:假如要在两个银行账户之间执行交易,你必须确保两个账户都被锁定,不受其他交易的影响,以达到正确的资金转移量。在这里,这个类比并不完全成立--哲学家对应的是锁定账户的交易(分叉)--但同样的技术困难也会出现。

其他的例子包括电商秒杀系统,多个用户抢一个商品,不允许一个数据库被多个客户同时修改。

死锁也是由一个并发程序需要同时具备的条件来定义的,这样才会发生死锁。这些条件是由计算机科学家 Edward G. Coffman, Jr .首先提出的,因此被称为 Coffman 条件。这些条件如下:

  • 至少有一个资源必须处于不可共享的状态。这意味着该资源被一个单独的进程(或线程)持有,不能被其他人访问; 在任何时间内,该资源只能被单个的进程(或线程)访问和持有。这个条件也被称为相互排斥

  • 有一个进程(或线程)同时访问一个资源并等待其他进程(或线程)持有的另一个资源。换句话说,这个进程(或线程)需要访问两个资源来执行其指令,其中一个它已经持有,另一个它正在等待其他进程(或线程)。这种情况被称为保持和等待

  • 只有在有特定指令让进程(或线程)释放资源的情况下,才能由持有这些资源的进程(或线程)来释放。这就是说,除非进程(或线程)自愿主动地释放资源,否则该资源仍处于不可共享的状态。这就是无抢占条件

  • 最后一个条件叫做循环等待。顾名思义,这个条件规定了一组进程(或线程)的存在,因此这组进程中的第一个进程(或线程)正在等待第二个进程(或线程)释放资源,而第二个进程(或线程)又需要等待第三个进程(或线程);最后,这组进程中的最后一个进程(或线程)正在等待第一个进程。

造成线程死锁的常见例子包括:

  1. 一个在自己身上等待的线程(例如,试图两次获得同一个互斥锁)

  2. 互相等待的线程(例如,A 等待 B,B 等待 A)

  3. 未能释放资源的线程(例如,互斥锁、信号量、屏障、条件、事件等)

  4. 线程以不同的顺序获取互斥锁(例如,未能执行锁排序)

模拟死锁 1:线程等待本身

导致死锁的一个常见原因是线程在自己身上等待。

我们并不打算让这种死锁发生,例如,我们不会故意写代码,导致线程自己等待。相反,由于一系列的函数调用和变量的传递,这种情况会意外地发生。

一个线程可能会因为很多原因而在自己身上等待,比如:

  • 等待获得它已经获得的互斥锁

  • 等待自己被通知一个条件

  • 等待一个事件被自己设置

  • 等待一个信号被自己释放

开发一个 task() 函数,直接尝试两次获取同一个 mutex 锁。也就是说,该任务将获取锁,然后再次尝试获取锁。

# task to be executed in a new thread
def task(lock):
    print('Thread acquiring lock...')
    with lock:
        print('Thread acquiring lock again...')
        with lock:
            # will never get here
            pass

复制代码

这将导致死锁,因为线程已经持有该锁,并将永远等待自己释放该锁,以便它能再次获得该锁, task() 试图两次获取同一个锁并触发死锁。

在主线程中,可以创建锁:

# create the mutex locklock = Lock()

复制代码

然后我们将创建并配置一个新的线程,在一个新的线程中执行我们的 task() 函数,然后启动这个线程并等待它终止,而它永远不会终止。

# create and configure the new threadthread = Thread(target=task, args=(lock,))# start the new threadthread.start()# wait for threads to exit...thread.join()

复制代码

完整代码如下:

from threading import Threadfrom threading import Lock
# task to be executed in a new threaddef task(lock):    print('Thread acquiring lock...')    with lock:        print('Thread acquiring lock again...')        with lock:            # will never get here            pass
# create the mutex locklock = Lock()# create and configure the new threadthread = Thread(target=task, args=(lock,))# start the new threadthread.start()# wait for threads to exit...thread.join()

复制代码

运行结果如下:

首先创建锁,然后新的线程被混淆并启动,主线程阻塞,直到新线程终止,但它从未这样做。

新线程运行并首先获得了锁。然后它试图再次获得相同的互斥锁并阻塞。

它将永远阻塞,等待锁被释放。该锁不能被释放,因为该线程已经持有该锁。因此,该线程已经陷入死锁。

该程序必须被强制终止,例如,通过 Control-C 杀死终端。

模拟死锁 2:线程互相等待

一个常见的例子就是两个或多个线程互相等待。例如:线程 A 等待线程 B,线程 B 等待线程 A。

如果有三个线程,可能会出现线程循环等待,例如:

  • 线程 A:等待线程 B

  • 线程 B:等待线程 C

  • 线程 C:等待线程 A

如果你设置了线程来等待其他线程的结果,这种死锁是很常见的,比如在一个流水线或工作流中,子任务的一些依赖关系是不符合顺序的。

from threading import current_threadfrom threading import Thread # task to be executed in a new threaddef task(other):    # message    print(f'[{current_thread().name}] waiting on [{other.name}]...\n')    other.join() 
# get the current threadmain_thread = current_thread()# create the second threadnew_thread = Thread(target=task, args=(main_thread,))# start the new threadnew_thread.start()# run the first threadtask(new_thread)

复制代码

首先得到主线程的实例 main_thread,然后创建一个新的线程 new_thread,并调用传递给主线程的 task() 函数。新线程返回一条信息并等待主线程停止,主线程用新线程的实例调用 task()函数,并等待新线程的终止。每个线程都在等待另一个线程终止,然后自己才能终止,这导致了一个死锁。

运行结果:

[Thread-1] waiting on [MainThread]...[MainThread] waiting on [Thread-1]...

复制代码

模拟死锁 3:以错误的顺序获取锁

导致死锁的一个常见原因是,两个线程同时以不同的顺序获得锁。例如,我们可能有一个受锁保护的关键部分,在这个关键部分中,我们可能有代码或函数调用受第二个锁保护。

可能会遇到这样的情况:一个线程获得了锁 1 ,然后试图获得锁 2,然后有第二个线程调用获得锁 2 的功能,然后试图获得锁 1。如果这种情况同时发生,线程 1 持有锁 1,线程 2 持有锁 2,那么就会有一个死锁。

  • 线程 1: 持有锁 1, 等待锁 2

  • 线程 2 : 持有锁 2, 等待锁 1

# SuperFastPython.com# example of a deadlock caused by acquiring locks in a different orderfrom time import sleepfrom threading import Threadfrom threading import Lock
# task to be executed in a new threaddef task(number, lock1, lock2):    # acquire the first lock    print(f'Thread {number} acquiring lock 1...')    with lock1:        # wait a moment        sleep(1)        # acquire the next lock        print(f'Thread {number} acquiring lock 2...')        with lock2:            # never gets here..            pass
# create the mutex lockslock1 = Lock()lock2 = Lock()# create and configure the new threadsthread1 = Thread(target=task, args=(1, lock1, lock2))thread2 = Thread(target=task, args=(2, lock2, lock1))# start the new threadsthread1.start()thread2.start()# wait for threads to exit...thread1.join()thread2.join()

复制代码

运行这个例子首先创建了两个锁。然后两个线程都被创建,主线程等待线程的终止。

第一个线程接收 lock1 和 lock2 作为参数。它获得了锁 1 并 sleep。

第二个线程接收 lock2 和 lock1 作为参数。它获得了锁 2 并 sleep。

第一个线程醒来并试图获取锁 2,但它必须等待,因为它已经被第二个线程获取。第二个线程醒来并试图获取锁 1,但它必须等待,因为它已经被第一个线程获取。

结果是一个死锁:

Thread 1 acquiring lock 1...Thread 2 acquiring lock 1...Thread 1 acquiring lock 2...Thread 2 acquiring lock 2...

复制代码

解决办法是确保锁在整个程序中总是以相同的顺序获得。这就是所谓的锁排序。

模拟死锁 4:锁未释放

导致死锁的另一个常见原因是线程未能释放一个资源。这通常是由线程在关键部分引发错误或异常造成的,这种方式会阻止线程释放资源,包括:

  • 未能释放一个锁

  • 未能释放一个信号器

  • 未能到达一个 barrier

  • 未能在一个条件上通知线程

  • 未能设置一个事件

# example of a deadlock caused by a thread failing to release a lockfrom time import sleepfrom threading import Threadfrom threading import Lock
# task to be executed in a new threaddef task(lock):    # acquire the lock    print('Thread acquiring lock...')    lock.acquire()    # fail    raise Exception('Something bad happened')    # release the lock (never gets here)    print('Thread releasing lock...')    lock.release()
# create the mutex locklock = Lock()# create and configure the new threadthread = Thread(target=task, args=(lock,))# start the new threadthread.start()# wait a whilesleep(1)# acquire the lockprint('Main acquiring lock...')lock.acquire()# do something...# release lock (never gets here)lock.release()

复制代码

运行该例子时,首先创建锁,然后创建并启动新的线程。然后主线程阻塞。新线程运行。它首先获得了锁,然后引发了一个异常而失败。该线程解开了锁,但却没有解开锁的代码。新的线程终止了。最后,主线程被唤醒,然后试图获取锁。由于锁没有被释放,主线程永远阻塞,导致了死锁。

Thread acquiring lock...Exception in thread Thread-1:Traceback (most recent call last):  ...Exception: Something bad happenedMain acquiring lock...

复制代码

总结

本文首先介绍了并发编程中的经典问题——哲学家就餐问题,然后引出了死锁的概念及条件。

然后给出了可能出现死锁的情况,并通过 Python 代码模拟哲学家就餐问题和模拟死锁的四种情况。

本文含有隐藏内容,请 开通VIP 后查看

网站公告

今日签到

点亮在社区的每一天
去签到