摘要:本文探讨了线程同步中的两个核心问题及其解决方案。竞态条件发生在多个线程同时访问共享资源时,导致结果不可预测,可通过Lock类的acquire()和release()方法实现同步控制。死锁问题以哲学家就餐问题为例,展示了资源竞争导致的僵局,提出通过混合贪心型和大方型策略(交替获取和释放锁)来避免死锁。文中提供了Python代码示例,演示了使用线程锁解决竞态条件,以及采用策略组合解决哲学家就餐问题的方法。这些同步机制有效确保了多线程程序的安全性和可靠性。
目录
[acquire () 方法](#acquire () 方法)
[release () 方法](#release () 方法)
[死锁 ------ 哲学家就餐问题](#死锁 —— 哲学家就餐问题)
[Python 程序实现的解决方案](#Python 程序实现的解决方案)
线程同步
线程同步可被定义为一种方法,通过该方法我们能确保两个或多个并发线程不会同时访问被称为临界区的程序段。而临界区,指的是程序中访问共享资源的部分。因此可以说,同步的作用是防止两个或多个线程因同时访问资源而相互干扰。下图展示了四个线程试图同时访问某程序临界区的场景。

为了更易理解,假设两个或多个线程同时尝试向一个列表中添加对象,这个操作最终必然失败 ------ 要么会丢失部分甚至全部对象,要么会彻底破坏列表的状态。而同步机制的作用,就是保证同一时间只有一个线程能访问该列表。
线程同步中的问题
在实现并发编程或使用同步原语时,我们可能会遇到一些问题。本节将探讨其中两个主要问题:
- 死锁
- 竞态条件
竞态条件
这是并发编程中最主要的问题之一。对共享资源的并发访问可能会引发竞态条件。竞态条件指的是这样一种情况:两个或多个线程可以访问共享数据,且试图同时修改其值。这会导致变量的最终值变得不可预测,其结果取决于进程的上下文切换时机。
示例
通过以下示例理解竞态条件的概念:步骤 1:导入线程模块
python
import threading
步骤 2 :定义一个全局变量x,并将其初始值设为 0
python
x = 0
步骤 3 :定义increment_global()函数,实现将全局变量x加 1 的功能
python
def increment_global():
global x
x += 1
步骤 4 :定义taskofThread()函数,该函数会调用increment_global()函数指定次数,本示例中为 50000 次
python
def taskofThread():
for _ in range(50000):
increment_global()
步骤 5 :定义主函数main(),在该函数中创建线程t1和t2,通过start()方法启动两个线程,并通过join()方法等待它们执行完毕。
python
def main():
global x
x = 0
t1 = threading.Thread(target=taskofThread)
t2 = threading.Thread(target=taskofThread)
t1.start()
t2.start()
t1.join()
t2.join()
步骤 6 :指定调用main()函数的迭代次数,本示例中为 5 次。
python
if __name__ == "__main__":
for i in range(5):
main()
print("第{0}次迭代后,x = {1}".format(i, x))
在下方的输出结果中,能清晰看到竞态条件的影响:每次迭代后x的预期值本应是 100000,但实际结果却存在很大偏差,这正是因为两个线程并发访问了共享全局变量x。
输出结果
plaintext
第0次迭代后,x = 100000
第1次迭代后,x = 54034
第2次迭代后,x = 80230
第3次迭代后,x = 93602
第4次迭代后,x = 93289
使用锁解决竞态条件
既然已经看到了上述程序中竞态条件带来的影响,我们就需要一种同步工具来处理多线程间的竞态条件。在 Python 中,threading 模块提供了Lock类来解决该问题,该类还提供了多种方法,帮助我们处理多线程间的竞态条件,具体方法如下:
acquire () 方法
该方法用于获取锁(即阻塞锁),锁的阻塞 / 非阻塞状态由传入的布尔值参数决定:
- 参数设为 True (默认值):调用
acquire(True)时,线程的执行会被阻塞,直到该锁被释放。- 参数设为 False :调用
acquire(False)时,线程的执行不会被阻塞,直到该参数被设为 True(即锁被锁定)。
release () 方法
该方法用于释放锁,相关核心作用如下:
- 若锁处于锁定状态,
release()方法会将其解锁;如果有多个线程因等待锁解锁而被阻塞,该方法会允许其中恰好一个线程继续执行。 - 若锁已处于解锁状态,调用该方法会抛出ThreadError异常。
接下来,我们使用Lock类及其方法重写上述程序,以避免竞态条件的出现。需要为taskofThread()方法添加锁作为参数,再通过acquire()和release()方法实现锁的阻塞与解锁,从而规避竞态条件。
示例
以下 Python 程序演示了如何使用锁处理竞态条件:
python
import threading
x = 0
def increment_global():
global x
x += 1
def taskofThread(lock):
for _ in range(50000):
lock.acquire()
increment_global()
lock.release()
def main():
global x
x = 0
lock = threading.Lock()
t1 = threading.Thread(target=taskofThread, args=(lock,))
t2 = threading.Thread(target=taskofThread, args=(lock,))
t1.start()
t2.start()
t1.join()
t2.join()
if __name__ == "__main__":
for i in range(5):
main()
print("第{0}次迭代后,x = {1}".format(i, x))
下方的输出结果显示,竞态条件的影响已被消除:每次迭代后x的值均为 100000,与程序的预期结果一致。
输出结果
plaintext
第0次迭代后,x = 100000
第1次迭代后,x = 100000
第2次迭代后,x = 100000
第3次迭代后,x = 100000
第4次迭代后,x = 100000
死锁 ------ 哲学家就餐问题
死锁是设计并发系统时可能遇到的棘手问题,我们可以通过哲学家就餐问题来直观阐释这一问题:
哲学家就餐问题由艾兹格・迪杰斯特拉首次提出,是并发系统中死锁问题最经典的示例之一。
问题场景为:五位著名的哲学家围坐在一张圆桌旁,从各自的碗中取食,桌上有五根叉子可供他们使用。但这些哲学家规定,必须同时使用两根叉子才能进食。
哲学家的行为有两个核心前提:一是每位哲学家的状态只能是进食 或思考;二是他们必须同时拿到左右两边的叉子才能开始进食。此时问题便出现了:如果五位哲学家同时拿起了左手边的叉子,接下来他们都会等待右手边的叉子被释放,而他们又会坚持到吃完才放下手中的叉子,这就导致右手边的叉子永远无法被获取,餐桌旁的所有哲学家便陷入了死锁状态。
并发系统中的死锁
将这个问题映射到并发系统中:示例中的叉子对应系统的资源 ,每位哲学家则代表一个进程,这些进程都在竞争获取系统资源,当进程间相互等待对方释放资源时,系统就会陷入死锁。
Python 程序实现的解决方案
该问题的解决思路是将哲学家分为两类 ------贪心型哲学家 和大方型哲学家:
- 贪心型哲学家:会先尝试拿起左手边的叉子,若拿到则一直等待右手边的叉子,直到拿到后进食,吃完再放下两根叉子。
- 大方型哲学家:先尝试拿起左手边的叉子,若拿不到则等待一段时间后重新尝试;若拿到左手边的叉子,再尝试拿右手边的叉子,若成功则进食并释放两根叉子;若拿不到右手边的叉子,会主动释放已拿到的左手边的叉子。
示例
以下 Python 程序实现了哲学家就餐问题的解决方案:
python
import threading
import random
import time
class DiningPhilosopher(threading.Thread):
running = True
def __init__(self, xname, Leftfork, Rightfork):
threading.Thread.__init__(self)
self.name = xname
self.Leftfork = Leftfork
self.Rightfork = Rightfork
def run(self):
while(self.running):
time.sleep(random.uniform(3,13))
print('%s感到饥饿。' % self.name)
self.dine()
def dine(self):
fork1, fork2 = self.Leftfork, self.Rightfork
while self.running:
fork1.acquire(True)
locked = fork2.acquire(False)
if locked: break
fork1.release()
print('%s交换了叉子。' % self.name)
fork1, fork2 = fork2, fork1
else:
return
self.dining()
fork2.release()
fork1.release()
def dining(self):
print('%s开始进食。' % self.name)
time.sleep(random.uniform(1,10))
print('%s结束进食,开始思考。' % self.name)
def Dining_Philosophers():
forks = [threading.Lock() for n in range(5)]
philosopherNames = ('第一位','第二位','第三位','第四位', '第五位')
philosophers= [DiningPhilosopher(philosopherNames[i], forks[i%5], forks[(i+1)%5])
for i in range(5)]
random.seed()
DiningPhilosopher.running = True
for p in philosophers: p.start()
time.sleep(30)
DiningPhilosopher.running = False
print("程序结束。")
Dining_Philosophers()
上述程序结合了贪心型和大方型哲学家的行为逻辑,同时使用了threading模块中Lock类的acquire()和release()方法。下方的输出结果展示了该解决方案的执行效果:
输出结果
plaintext
第四位感到饥饿。
第四位开始进食。
第一位感到饥饿。
第一位开始进食。
第二位感到饥饿。
第五位感到饥饿。
第三位感到饥饿。
第一位结束进食,开始思考。
第三位交换了叉子。
第二位开始进食。
第四位结束进食,开始思考。
第三位交换了叉子。
第五位开始进食。
第五位结束进食,开始思考。
第四位感到饥饿。
第四位开始进食。
第二位结束进食,开始思考。
第三位交换了叉子。
第一位感到饥饿。
第一位开始进食。
第四位结束进食,开始思考。
第三位开始进食。
第五位感到饥饿。
第五位交换了叉子。
第一位结束进食,开始思考。
第五位开始进食。
第二位感到饥饿。
第二位交换了叉子。
第四位感到饥饿。
第五位结束进食,开始思考。
第三位结束进食,开始思考。
第二位开始进食。
第四位开始进食。
程序结束。