杰瑞科技汇

Python threading锁如何避免线程竞争?

为什么需要锁?—— 并发问题的根源

想象一个简单的场景:一个银行账户,初始余额为 1000,现在有两个线程同时从这个账户取款,每个线程取款 500。

Python threading锁如何避免线程竞争?-图1
(图片来源网络,侵删)

没有锁的情况下,可能会发生什么?

每个取款操作通常包含三个步骤:

  1. 读取 当前余额。
  2. 计算 新余额(旧余额 - 500)。
  3. 写入 新余额。

由于线程是并发执行的,CPU 会在不同线程间快速切换,下面是一种可能发生的糟糕执行顺序:

时间点 线程 A (取款 500) 线程 B (取款 500) 账户余额
T0 读取 余额 (1000) 1000
T1 读取 余额 (1000) 1000
T2 计算: 1000 - 500 = 500 1000
T3 计算: 1000 - 500 = 500 1000
T4 写入 新余额 (500) 500
T5 写入 新余额 (500) 500

结果分析:

Python threading锁如何避免线程竞争?-图2
(图片来源网络,侵删)
  • 理论上,两个取款操作后,余额应为 0。
  • 但实际上,最终余额是 500。
  • 原因:线程 A 和 B 都基于同一个旧值(1000) 进行了计算,导致第二次写入覆盖了第一次写入的结果,这种问题被称为竞态条件

就是为了解决这类问题而生的,它就像一个“许可证”,确保在某个关键操作(如修改共享数据)时,只有一个线程能够进入,从而保证了操作的原子性。


什么是锁?

锁是一个同步原语,它提供了一种机制来强制对共享资源的互斥访问

  • 互斥:在任何时刻,最多只有一个线程可以持有该锁。
  • 状态
    • 锁定:当一个线程获取了锁,其他试图获取同一个锁的线程将被阻塞,直到锁被释放。
    • 未锁定:锁可以被任何线程获取。

如何在 Python 中使用锁?

Python 的 threading 模块提供了 Lock 类,其核心方法是 acquire()release()

1 基本用法

import threading
import time
# 1. 创建一个锁对象
lock = threading.Lock()
# 共享资源
balance = 1000
def withdraw(amount):
    global balance
    # 2. 在访问共享资源前,尝试获取锁
    # 使用 with 语句是推荐的最佳实践
    with lock:
        print(f"{threading.current_thread().name} 正在尝试取款 {amount}...")
        # 模拟一些处理时间,增加并发问题发生的概率
        time.sleep(0.01) 
        # 在这个代码块(with 语句内部)中,只有一个线程能执行
        if balance >= amount:
            balance -= amount
            print(f"{threading.current_thread().name} 取款成功,当前余额: {balance}")
        else:
            print(f"{threading.current_thread().name} 取款失败,余额不足!")
# 创建并启动两个线程
t1 = threading.Thread(target=withdraw, args=(500,), name="线程A")
t2 = threading.Thread(target=withdraw, args=(500,), name="线程B")
t1.start()
t2.start()
# 等待两个线程执行完毕
t1.join()
t2.join()
print(f"最终账户余额: {balance}")

代码解释:

Python threading锁如何避免线程竞争?-图3
(图片来源网络,侵删)
  1. lock = threading.Lock():创建一个锁实例。

  2. with lock::这是使用锁的推荐方式with 语句会自动处理锁的获取和释放。

    • 当线程进入 with 代码块时,它会自动调用 lock.acquire()
    • 如果锁已经被其他线程持有,当前线程会在此处阻塞,直到锁被释放。
    • 当线程离开 with 代码块时(无论是否发生异常),它会自动调用 lock.release()
  3. 手动管理(不推荐,但为了理解): 你也可以手动调用 acquire()release(),但这样容易出错,比如忘记释放锁,或者在发生异常时无法释放锁。

    # 不推荐的手动方式
    lock.acquire()
    try:
        # 访问共享资源
        pass
    finally:
        lock.release() # 必须在 finally 块中确保锁被释放

2 运行结果分析

运行上面的代码,你会看到类似下面的输出(顺序可能略有不同,但结果正确):

线程A 正在尝试取款 500...
线程A 取款成功,当前余额: 500
线程B 正在尝试取款 500...
线程B 取款失败,余额不足!
最终账户余额: 500

对比没有锁的情况:你会发现,即使两个线程几乎是同时启动的,由于锁的存在,它们必须排队执行 with 块内的代码,这保证了“读取-计算-写入”操作的完整性,从而避免了竞态条件。


锁的类型(threading.Lock vs. threading.RLock

Python 提供了两种锁:Lock(普通锁)和 RLock(可重入锁)。

1 threading.Lock (普通锁 / 不可重入锁)

  • 特点:如果一个线程已经获取了该锁,那么这个线程再次尝试获取它时,会被阻塞,这会导致死锁
  • 适用场景:当你确定一个线程不会在持有锁的同时再次请求同一个锁时使用。

2 threading.RLock (可重入锁 / 递归锁)

  • 特点同一个线程可以多次获取同一个锁而不会被阻塞,获取和释放的次数必须完全匹配,只有当最后一次 release() 被调用后,锁才会真正被释放,其他线程才能获取它。
  • 适用场景:当一个线程需要递归调用函数,或者调用一个内部也会获取同一个锁的函数时,非常有用。

示例:RLock 的必要性

import threading
lock = threading.RLock() # 使用可重入锁
def worker():
    print(f"{threading.current_thread().name} 尝试获取锁 (第1次)")
    with lock:
        print(f"{threading.current_thread().name} 成功获取锁 (第1次)")
        # 在已持有锁的情况下,再次尝试获取同一个锁
        print(f"{threading.current_thread().name} 尝试获取锁 (第2次)")
        with lock: # 不会阻塞
            print(f"{threading.current_thread().name} 成功获取锁 (第2次)")
        print(f"{threading.current_thread().name} 释放锁 (第2次)")
    print(f"{threading.current_thread().name} 释放锁 (第1次)")
t = threading.Thread(target=worker, name="Worker")
t.start()
t.join()
print("线程执行完毕")

如果这里使用的是普通的 Lock,第二个 with lock: 语句会导致线程自己阻塞自己,从而产生死锁。


死锁

死锁是指两个或多个线程因互相等待对方持有的锁而无限期地阻塞下去,导致所有相关线程都无法继续执行。

一个经典的死锁场景:

  • 线程 A 持有锁 1,并等待锁 2。
  • 线程 B 持有锁 2,并等待锁 1。

两者都在等待,谁也无法继续,谁也无法释放自己持有的锁。

如何避免死锁?

  1. 避免嵌套锁:尽量避免在一个锁的 with 块内再去获取另一个锁,如果必须,确保所有线程都以相同的顺序获取锁。
  2. 使用超时Lock 对象的 acquire() 方法可以接受一个 timeout 参数,如果指定时间内无法获取锁,它会返回 False 而不是永久阻塞,这样你就可以在获取失败后执行其他逻辑或释放已持有的锁。
  3. 使用高级同步工具:如 threading.Conditionthreading.Semaphore,它们有时能以更安全的方式解决复杂的同步问题。
  4. 代码审查:仔细检查代码中所有锁的获取和释放逻辑。

总结与最佳实践

特性 描述
目的 保护共享资源,防止竞态条件,确保数据一致性。
核心方法 acquire() / release()
推荐用法 使用 with lock: 语句,它能自动、安全地管理锁的生命周期。
锁类型 Lock (不可重入) 和 RLock (可重入),在不确定时,RLock 更安全。
性能影响 锁会降低并发性能,因为它强制线程串行化,应尽量减少锁的持有时间,只保护必要的临界区。
潜在风险 死锁,需要通过良好的设计和编程习惯来避免。

记住这个黄金法则:

只锁定那些必须被同步的代码段,并且让锁的持有时间尽可能短。

锁是强大的工具,但也是一把双刃剑,正确地使用它,你就能构建出高效且稳定的多线程应用。

分享:
扫描分享到社交APP
上一篇
下一篇