知微集:Python中的线程(四)

欢迎来到"一起学点什么吧"的合集「NLP知微集」。在这里,我们不愿宏大叙事,只聚焦于自然语言处理领域中那些细微却关键的“齿轮”与“螺丝钉”。我相信,真正深刻的理解,源于对细节的洞察。本期,我将为您拆解的是:[Python中的线程(四)]

知微集:Python中的线程(一)

知微集:Python中的线程(二)

知微集:Python中的线程(三)

前三期,我们一步步对Python中的线程threading进行探索,今天,我们开始Python线程的第四期,主要从以下来探索Python线程:线程屏障、在Python中最佳实践线程的案例以及Python线程常见的错误,如竞态、死锁活锁。

在这里插入图片描述
在这里插入图片描述

线程屏障(Thread Barrier)

一个屏障允许你协调线程。

可以通过 threading.Barrier 类在 Python 中使用线程屏障。

什么是 barrier

屏障是一种同步原语。

它允许多个线程等待同一个屏障对象实例(例如在代码的同一位置)直到预定义的固定数量的线程到达(例如屏障已满),之后所有线程都会被通知并释放以继续执行。

在内部,一个屏障维护着等待该屏障的线程数量以及配置的最大参与方(线程)数量。一旦预期参与方数量达到预定义的最大值,所有等待的线程都会被通知。

这为在多个线程之间协调操作提供了一个有用的机制。

如何使用 barrier

Python 通过 threading.Barrier 类提供了一个 barrier。

一个屏障实例必须首先通过构造函数创建和配置,指定必须到达的参与方(线程)数量,屏障才会被解除。

barrier = threading.Barrier(10)

# 可以在所有线程都到达屏障时执行一个动作,这个动作可以通过构造函数中的“action”参数指定。
# 这个动作必须是一个可调用对象,例如函数或 lambda 表达式,它不接受任何参数,并且当所有线程到达屏障但线程被释放之前,将由一个线程执行。
barrier = threading.Barrier(10, action=my_function)

# 可以为所有到达屏障并调用 wait()函数的线程设置一个默认的超时时间
# 默认超时时间可以通过构造函数中的“timeout”参数(以秒为单位)进行设置。
barrier = threading.Barrier(10, timeout=5)

# 配置完成后,屏障实例可以在线程之间共享和使用。
# 线程可以通过 wait()函数到达并等待在屏障处
barrier.wait()
# 这是一个阻塞调用,当所有其他线程(预配置的参与方数量)都到达屏障时才会返回。

# 等待函数确实会返回一个整数,表示仍需到达屏障的参与方数量。如果某个线程是最后一个到达的线程,那么返回值将为零。这在你希望最后一个线程或某个线程在屏障释放后执行某个操作时很有用,可以作为在构造函数中使用“action”参数的替代方案。
# wait on the barrier
remaining = barrier.wait()
# after released, check if this was the last party
if remaining == 0:
    print('I was last...')

# 可以在调用中通过“timeout”参数设置等待秒数。如果超时在所有参与方到达屏障之前发生,则在屏障上等待的所有线程将引发 BrokenBarrierError,并且屏障将被标记为已损坏。

# 如果通过“timeout”参数或构造函数中的默认超时使用超时,则所有对 wait()函数的调用可能需要处理 BrokenBarrierError。
# wait on the barrier for all other threads to arrive
try:
    barrier.wait()
except BrokenBarrierError:
    # ...

# 也可以中止屏障
# 中止屏障意味着所有通过 wait()函数在屏障上等待的线程将引发 BrokenBarrierError,并且屏障将被置于损坏状态。
barrier.abort()
# 损坏的屏障不能使用。对 wait() 的调用将引发 BrokenBarrierError。

# 一个屏障可以通过调用 reset() 函数来固定并再次准备好使用。

# 如果你虽然希望使用相同的屏障实例再次尝试协调工作,但取消了协调工作,这可能会有所帮助。
barrier.reset()

最后,可以通过属性检查屏障的状态。

  • parties:报告必须达到的参与方数量,才能解除屏障。
  • n_waiting:报告当前正在屏障上等待的线程数量。
  • broken:属性指示屏障当前是否已断开

使用线程屏障的示例

# 使用屏障的示例
from time import sleep
from random import random
from threading import Thread
from threading import Barrier

# 准备一些工作的目标函数
def task(barrier, number):
    # 生成唯一值
    value = random() * 10
    # 阻塞一会儿
    sleep(value)
    # 报告结果
    print(f'Thread {number} done, got: {value}')
    # 等待所有其他线程完成
    barrier.wait()

# 创建屏障
barrier = Barrier(5 + 1)
# 为每个打算创建的线程准备一个参与者,此处有五个,此外还需要一个额外的参与者为主线程,它也将等待所有线程到达屏障。
# 创建工作线程
for i in range(5):
    # 启动新线程执行一些工作
    worker = Thread(target=task, args=(barrier, i))
    worker.start()
# 等待所有线程完成
print('Main thread waiting on all results...')
barrier.wait()
# 报告所有线程完成后的情况
print('All threads have their result')

首先运行示例会创建一个屏障,然后创建并启动工作线程。

每个工作线程执行其计算,然后等待在屏障上,直到所有其他线程完成。

最后,所有线程(包括主线程)都完成并释放,报告最终消息。

带有超时的屏障示例

设置超时有两种方式:通过构造函数为 threading.Barrier 实例设置,该实例将在所有对 wait()的调用中默认使用,并在每次对 wait()的直接调用中使用。

# 使用带超时的屏障示例
from time import sleep
from random import random
from threading import Thread
from threading import Barrier
from threading import BrokenBarrierError

# 准备一些工作的目标函数
def task(barrier, number):
    # 生成唯一值
    value = random() * 10
    # 阻塞一会儿
    sleep(value)
    # 报告结果
    print(f'Thread {number} done, got: {value}')
    # 等待所有其他线程完成
    try:
        barrier.wait()
    except BrokenBarrierError:
        pass

# 创建屏障
barrier = Barrier(5 + 1)
# 创建工作线程
for i in range(5):
    # 启动新线程执行一些工作
    worker = Thread(target=task, args=(barrier, i))
    worker.start()
# 等待所有线程完成
print('Main thread waiting on all results...')
try:
    barrier.wait(timeout=5)
    print('All threads have their result')
except BrokenBarrierError:
    print('Some threads did not finish on time...')
Main thread waiting on all results...
Thread 4 done, got: 2.206719881544257
Thread 3 done, got: 2.276306833133599
Thread 2 done, got: 4.548444781604877
Some threads did not finish on time...
Thread 0 done, got: 5.178841909065959
Thread 1 done, got: 5.562966121234857

运行示例会创建屏障,并像之前一样启动所有工作线程。

在这种情况下,主线程比较急躁,只会等待 5 秒钟让所有工作线程完成。有些线程可能需要更长时间,并且每次运行代码时都会有差异。

在此特定运行中,超时发生且屏障被破坏。所有等待的工作线程会引发 BrokenBarrierError,该错误被忽略,线程终止。所有尚未到达屏障的工作线程将到达屏障,引发 BrokenBarrierError 并终止。

主线程在调用 wait() 时引发 BrokenBarrierError,并报告某些工作线程未在超时时间内完成。

带有动作的屏障示例

当所有参与方到达屏障时,我们可以触发一个动作。

这可以通过在 threading.Barrier 构造函数中将“action”参数设置为可调用的对象来实现。

该可调用对象可以是一个 lambda 表达式或一个无参数的函数。

# 使用带动作的屏障示例
from time import sleep
from random import random
from threading import Thread
from threading import Barrier

# 一旦所有线程到达屏障后执行的动作
def report():
    # 报告所有线程完成后的情况
    print('All threads have their result')

# 准备一些工作的目标函数
def task(barrier, number):
    # 生成唯一值
    value = random() * 10
    # 阻塞一会儿
    sleep(value)
    # 报告结果
    print(f'Thread {number} done, got: {value}')
    # 等待所有其他线程完成
    barrier.wait()

# 创建屏障
barrier = Barrier(5, action=report)
# 创建工作线程
for i in range(5):
    # 启动新线程执行一些工作
    worker = Thread(target=task, args=(barrier, i))
    worker.start()
# 等待所有线程完成...
Thread 1 done, got: 0.2832633478590618
Thread 2 done, got: 3.4410753746609557
Thread 0 done, got: 3.5288498532842194
Thread 4 done, got: 5.955035241135056
Thread 3 done, got: 7.981952665421957
All threads have their result

运行示例会创建具有配置操作的屏障。

五个工作线程被创建并启动,执行它们的计算并到达屏障。

一旦所有线程到达屏障,屏障确保由其中一个工作线程触发操作,调用我们配置的 report()函数一次。

与在第一个示例中尝试在主线程中执行相同操作相比,这是一个更简洁的解决方案(代码更少),用于在屏障抬起后执行某个操作。

终止屏障的示例

在某些情况下,我们可能需要终止屏障上的线程协调。

这可能是因为其中一个线程无法执行其所需的任务。

我们可以通过调用 abort() 函数来中止屏障,这将导致所有正在等待屏障的线程引发 BrokenBarrierError,并且所有新的调用 wait() 的调用者也会引发相同的错误。

这意味着所有对 wait()的调用都应该使用 try-except 结构进行保护。

# 中止屏障的示例
from time import sleep
from random import random
from threading import Thread
from threading import Barrier
from threading import BrokenBarrierError

# 准备一些工作的目标函数
def task(barrier, number):
    # 生成唯一值
    value = random() * 10
    # 阻塞一会儿
    sleep(value)
    # 报告结果
    print(f'Thread {number} done, got: {value}')
    # 检查结果是否"糟糕"
    # 在线程处理任务中添加一个检查,如果协调工作遇到大于 8 的值,则中断屏障,否则继续。这意味着有时所有线程都会进行协调,有时则不会,具体取决于生成的特定随机数。
    if value > 8:
        print(f'Thread {number} aborting...')
        barrier.abort()
    else:
        # 等待所有其他线程完成
        try:
            barrier.wait()
        except BrokenBarrierError:
            pass

# 创建屏障
barrier = Barrier(5 + 1)
# 创建工作线程
for i in range(5):
    # 启动新线程执行一些工作
    worker = Thread(target=task, args=(barrier, i))
    worker.start()
# 等待所有线程完成
print('Main thread waiting on all results...')
try:
    barrier.wait()
    print('All threads have their result')
except BrokenBarrierError:
    print('At least one thread aborted due to bad results.')
Main thread waiting on all results...
Thread 2 done, got: 3.5233885580826185
Thread 4 done, got: 4.130683455840139
Thread 0 done, got: 4.1764845994604
Thread 3 done, got: 6.4039941892372365
Thread 1 done, got: 8.497202194100177
Thread 1 aborting...
At least one thread aborted due to bad results.

运行示例会创建屏障,然后创建并启动工作线程。

每个线程执行其处理,并根据生成的特定随机数有条件地尝试中止或等待在屏障上。

在这种情况下,五个数字中有四个是好的,但第五个导致屏障被中止,并在主线程中报告了这一事实。

Python 线程最佳实践

在使用 Python 线程时,一些最佳实践如下:

  • 使用上下文管理器
  • 等待时使用超时
  • 使用互斥锁保护关键部分
  • 按顺序获取锁

上下文管理器

上下文管理器的优点在于,无论以何种方式退出代码块(例如正常退出、返回、发生错误或异常),锁都会立即释放。

这适用于多种并发原语,例如:

  • 通过 threading.Lock 类获取互斥锁。
  • 通过 threading.RLock 类获取可重入互斥锁。
  • 通过 threading.Semaphore 类获取信号量。
  • 通过 threading.Condition 类获取条件。

等待时使用超时

在阻塞调用上等待时始终使用超时。

在并发原语上进行的许多调用可能会阻塞。

  • 通过 acquire() 获取 threading.Lock 等待。
  • 通过 join() 等待线程终止。
  • 通过 wait() 在 threading.Condition 上等待通知。

所有在并发原语上的阻塞调用都接受一个“timeout”参数,如果调用成功则返回 True,否则返回 False。

尽可能不要在没有超时的情况下调用阻塞调用。

...
# acquire the lock
if not lock.acquire(timeout=2*60):
    # handle failure case...

这将允许等待线程在固定的时间限制后放弃等待,然后尝试纠正情况,例如报告错误、强制终止等。

使用互斥锁保护关键部分

始终使用互斥锁(mutex)来保护代码中的临界区。

临界区是代码中容易受多线程并发执行影响的敏感部分,可能导致竞争条件。

临界区可能指单个代码块,但也指多个函数从不同位置对同一数据变量或资源进行访问。

互斥锁可用于确保同一时间只有一个线程执行代码的关键部分,而所有其他试图执行相同代码的线程必须等待当前正在执行的线程完成关键部分并释放锁。

每个线程必须在临界区的开始尝试获取锁。如果锁尚未被获取,则某个线程将获取它,而其他线程必须等待获取锁的线程释放它。

互斥锁由 threading.Lock 类提供,可以通过上下文管理器接口自动获取和释放。

按顺序获取锁

在整个应用程序中,尽可能以相同的顺序获取锁。

这被称为“锁顺序”。

在某些应用中,您可以通过一个可迭代并按顺序获取的 threading.Lock 对象列表,或一个按一致顺序获取锁的函数调用来抽象锁的获取过程。

当这种情况无法实现时,您可能需要审查您的代码,以确认代码的所有路径都以相同的顺序获取锁。

Python 线程常见错误

常见的线程错误有四种:

  • 竞态条件
    • 带变量的竞态条件
    • 时序竞争
  • 线程死锁
  • 线程活锁

竞态条件

竞态条件是并发编程中的一个错误。

它是一种失败情况,其中程序的行为取决于两个或多个线程的执行顺序。这意味着,程序的行为将不可预测,每次运行时都可能发生变化。

注意:在使用线程时,Python 中存在竞争条件问题,即使在全局解释器锁(GIL)存在的情况下也是如此。认为由于 GIL 的存在 Python 中没有竞争条件的说法是极其错误的。

带变量的竞态条件

一种常见的竞态条件是两个或多个线程尝试修改同一个数据变量。

例如,一个线程可能正在向一个变量添加值,而另一个线程则从同一个变量中减去值。

让我们称它们为加法线程和减法线程。

在某些时刻,操作系统可能会在更新变量的过程中,从执行加法的线程切换到执行减法的线程。也许正好是在它即将用加法写入更新值的时候,比如从当前值 100 切换到新值 110,variable = variable + 10

对变量进行加或减操作至少由三个步骤组成:

  • 读取变量的当前值。
  • 计算变量的新值。
  • 为变量写入一个新值。

在任务中的任何一点都可能发生线程的上下文切换。

减法线程运行,读取当前值为 100,并将值从 100 减少到 90。
variable = variable - 10
此减法操作按常规执行,变量值现在为 90。

操作系统上下文切换回添加线程,并从它之前停止的地方继续写入值 110。
这意味着在这种情况下,一次减法操作丢失了,共享的余额变量具有不一致的值。这是一个竞态条件。

在两个或多个线程共享变量的情况下,存在多种方法来解决竞态条件。

所采用的方法可能取决于应用程序的具体情况。

一种常见的方法是保护代码的关键部分。这可以通过互斥锁来实现,互斥锁有时也简称为 mutex。

在锁上使用上下文管理器,它会自动为我们获取和释放锁。

...
# acquire the lock
with lock:
	# add to the variable
	variable = variable + 10
# release the lock automatically

如果一个线程已经获取了锁,另一个线程就不能获取它,因此不能执行临界区,进而不能更新共享变量。

相反,任何试图在锁被占用时获取锁的线程都必须等待直到锁被释放。这种等待锁被释放的行为是在调用 acquire() 时自动完成的,无需进行任何特殊操作。

时序竞争

另一种常见的竞态条件是当两个线程试图协调它们的行为时。

例如,考虑两个线程使用 threading.Condition 来协调它们行为的情况。

一个线程可以通过 wait 函数在 threading.Condition 上等待,以接收应用程序内部某些状态变化的通知。

...
# wait for a state change
with condition:
	condition.wait()

在使用 threading.Condition 时,你必须先获取条件,才能调用 wait() 或 notify(),完成后再释放它。这可以通过上下文管理器轻松实现。

另一个线程可以在应用程序内执行某些更改,并通过条件变量使用 notify()函数来唤醒等待的线程。

...
# alert the waiting thread
with condition:
	condition.notify()

这是一个两个线程之间协调行为的例子,其中一个线程向另一个线程发出信号。

为了使行为按预期工作,第二个线程的通知必须在第一个线程开始等待之后发送。如果第一个线程在第二个线程调用 notify()之后调用 wait(),那么它将不会被通知,并且会永远等待。

这可能发生在操作系统进行上下文切换时,允许调用 notify()的第二个线程在调用 wait()的第一个线程运行之前运行。

在两个或多个线程之间基于时间来解决竞态条件的方法有很多。

所采用的方法可能取决于应用程序的具体情况。

然而,一种常见的方法是让等待的线程在通知线程开始工作并调用 notify 之前,发出它们已准备好的信号。

这可以通过一个 threading.Event 来实现,它就像一个线程安全的布尔标志变量。

线程死锁

死锁是一种并发失效模式,其中线程或线程等待一个永远不会发生的条件。

其结果是,死锁的线程无法继续执行,程序卡住或冻结,必须强制终止。

在并发程序中,你可能会遇到死锁的许多方式。

死锁并非有意设计,而是并发编程中的一种意外副作用或错误。

线程死锁的常见原因包括:

  • 线程等待自身(例如,尝试两次获取同一个互斥锁)。
  • 线程相互等待(例如,A 等待 B,B 等待 A)。
  • 线程未能释放资源(例如,互斥锁、信号量、屏障、条件变量、事件等)。
  • 在不同顺序获取互斥锁的线程(例如,未能执行锁顺序)。

死锁或许容易描述,但从代码中阅读来判断一个应用程序的死锁却很困难。

培养对不同死锁原因的直觉非常重要。这将有助于您在您自己的代码中识别死锁,并追溯您可能遇到的那些死锁的原因。

死锁的一个常见原因是线程等待自身。

一个线程可能因为多种原因等待自身,例如:

  • 正在尝试获取一个它已经持有的互斥锁。
  • 正在等待自身在条件变量上的通知。
  • 正在等待自身设置的事件。
  • 正在等待自身释放的信号量。
# 线程等待自身导致死锁的示例
from threading import Thread
from threading import Lock

# 在新线程中执行的task2
def task2(lock):
    print('Thread acquiring lock again...')
    with lock:
        # 永远不会到达这里
        pass

# 在新线程中执行的task1
def task1(lock):
    print('Thread acquiring lock...')
    with lock:
        task2(lock)

# 创建互斥锁
lock = Lock()
# 创建并配置新线程
thread = Thread(target=task1, args=(lock,))
# 启动新线程
thread.start()
# 等待线程退出...
thread.join()

运行示例会创建锁,然后创建并启动新线程。

线程在 task1()中获取锁,模拟一些工作后调用 task2()。task2()函数尝试获取同一个锁,线程因此陷入死锁,等待锁自动释放以便再次获取。

这种由互斥锁引起的特定死锁可以通过使用可重入互斥锁来避免。这允许线程多次获取相同的锁。

可重入锁在任何可能存在代码获取一个锁,而这个锁可能会调用其他可能获取相同锁的代码的情况下都应推荐使用。

线程活锁

活锁是一种并发故障情况,其中线程没有被阻塞,但由于另一个线程的行为而无法继续前进。

一个活锁通常涉及两个或多个共享并发原语(如互斥锁(mutex)锁的线程)

线程可能同时尝试获取一个锁或一系列锁,检测到它们正在争夺相同的资源,然后进行回退。这个过程会重复进行,两个线程都无法继续执行,并处于“锁定”状态。

活锁和死锁的主要区别在于线程在无法继续执行时的状态。

在活锁中,线程会执行但不会被阻塞,而在死锁中,线程会被阻塞,例如等待一个并发原语(如锁),并且无法执行代码。

  • Livelock:线程无法继续执行但可以持续运行代码。
  • Deadlock:线程无法继续执行并被阻塞,例如正在等待。
# 活锁示例
from time import sleep
from threading import Thread
from threading import Lock

# 工作线程的任务
def task(number, lock1, lock2):
    # 循环直到任务完成
    while True:
        # 获取第一个锁
        with lock1:
            # 等待一会儿
            sleep(0.1)
            # 检查第二个锁是否可用
            if lock2.locked():
                print(f'Task {number} cannot get the second lock, giving up...')
            else:
                # 获取锁2
                with lock2:
                    print(f'Task {number} made it, all done.')
                    break

# 创建锁
lock1 = Lock()
lock2 = Lock()
# 创建线程
thread1 = Thread(target=task, args=(0, lock1, lock2))
thread2 = Thread(target=task, args=(1, lock2, lock1))
# 启动线程
thread1.start()
thread2.start()
# 等待线程完成
thread1.join()
thread2.join()

首先运行示例会创建锁。

两个工作线程随后被配置并启动,而主线程在等待任务完成时阻塞。

第一个线程获取 lock1 并阻塞片刻。第二个线程获取 lock2 并阻塞片刻。

第一个线程检查 lock2 是否可用。不可用,因此它放弃并重复循环。与此同时,第二个线程检查 lock1 是否可用。不可用,因此它也放弃并重复循环。

这两个线程获取一个锁并尝试获取下一个锁然后放弃的过程会无限重复。

结语

“见微知著,积跬步以至千里”,至此,关于 **[Python线程]**的探索之旅就暂告一段落了。感谢您的耐心阅读。希望这片小小的“知微”碎片,能让你对Python线程有更清晰的认识。

点赞关注不迷路,点击合集标签「#NLP知微集」,不错过每一次细微的洞察。

下期再见,继续我们的拆解之旅!

Reference

  • https://superfastpython.com/threading-in-python/
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

故事挺秃然

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值