杰瑞科技汇

Python threading 输出为何顺序混乱?

为什么输出会“混乱”?

当你使用 threading 时,多个线程会并发地执行,这意味着它们会交替使用 Python 的主线程(GIL - 全局解释器锁),当多个线程都尝试向标准输出(print 函数)写入数据时,就会发生竞争条件

Python threading 输出为何顺序混乱?-图1
(图片来源网络,侵删)

线程 A 可能刚打印了 "Hello ",但还没来得及打印 "World",操作系统就切换到了线程 B,线程 B 打印了 "from Thread-B",然后再切换回线程 A 完成打印 "World",最终你看到的输出就是乱序的。


基础示例:无锁控制的混乱输出

这是最经典的例子,能直观地看到线程竞争导致的问题。

import threading
import time
def print_numbers(thread_id):
    """一个简单的函数,打印线程ID和数字"""
    for i in range(5):
        # print 函数内部不是原子操作,可以被其他线程打断
        print(f"Thread-{thread_id}: Count {i}")
        time.sleep(0.1) # 模拟一些I/O或计算操作
# 创建两个线程
thread1 = threading.Thread(target=print_numbers, args=(1,))
thread2 = threading.Thread(target=print_numbers, args=(2,))
# 启动线程
thread1.start()
thread2.start()
# 等待两个线程都执行完毕
thread1.join()
thread2.join()
print("Main thread finished.")

可能的输出(每次运行都可能不同):

Thread-1: Count 0
Thread-2: Count 0
Thread-1: Count 1
Thread-2: Count 1
Thread-1: Count 2
Thread-2: Count 2
Thread-1: Count 3
Thread-2: Count 3
Thread-1: Count 4
Thread-2: Count 4
Main thread finished.

更混乱的可能输出:

Python threading 输出为何顺序混乱?-图2
(图片来源网络,侵删)
Thread-1: Count 0
Thread-2: Count 0
Thread-1: Count 1
Thread-2: Count 1Thread-1: Count 2
Thread-2: Count 2
Thread-1: Count 3
Thread-2: Count 3
Thread-1: Count 4
Thread-2: Count 4
Main thread finished.

(注意 Thread-2: Count 1Thread-1: Count 2 没有换行,这是因为 print 的缓冲区被另一个线程“抢走”了。)


如何控制输出:使用锁

为了解决输出混乱的问题,我们需要一种机制来确保在任何一个时刻,只有一个线程能够访问 print 函数,这个机制就是

threading.Lock() 就像一个“公共厕所”,只有一个人(线程)能进去使用,其他人(线程)必须在门口排队等待。

修改后的代码(使用锁):

Python threading 输出为何顺序混乱?-图3
(图片来源网络,侵删)
import threading
import time
# 创建一个全局的锁对象
print_lock = threading.Lock()
def print_numbers_locked(thread_id):
    """使用锁来保护打印操作"""
    for i in range(5):
        # 获取锁,如果获取不到,就阻塞等待
        with print_lock:
            # 在 'with' 代码块内,锁是获取状态
            # 其他线程执行到这里时,会等待 'with' 块执行完毕
            print(f"Thread-{thread_id}: Count {i}")
        # 'with' 块执行完毕,锁被自动释放
        time.sleep(0.1)
# 创建两个线程
thread1 = threading.Thread(target=print_numbers_locked, args=(1,))
thread2 = threading.Thread(target=print_numbers_locked, args=(2,))
# 启动线程
thread1.start()
thread2.start()
# 等待两个线程都执行完毕
thread1.join()
thread2.join()
print("Main thread finished.")

使用锁后的输出(顺序是确定的):

Thread-1: Count 0
Thread-1: Count 1
Thread-1: Count 2
Thread-1: Count 3
Thread-1: Count 4
Thread-2: Count 0
Thread-2: Count 1
Thread-2: Count 2
Thread-2: Count 3
Thread-2: Count 4
Main thread finished.

(注意:由于 time.sleep(0.1) 的存在,线程1会先执行完一轮,然后线程2再开始,如果去掉 sleep,输出顺序可能是 1,2,1,2...,但每一行 print 都会完整地执行,不会被其他线程打断。)


线程的启动、执行和结束

一个线程的生命周期从 start() 开始,到其任务函数执行完毕结束,主线程可以通过 join() 来等待子线程完成。

import threading
import time
def worker():
    """子线程的工作函数"""
    print("Worker: Starting")
    time.sleep(2)
    print("Worker: Finished")
# 创建线程对象
t = threading.Thread(target=worker, name="MyWorkerThread")
print("Main: Before starting thread")
t.start() # 启动线程,worker() 函数开始执行
# 主线程继续执行
print("Main: Waiting for thread to finish")
# t.join() 会阻塞主线程,直到子线程 t 执行完毕
# 如果不加 join,主线程会继续执行,可能在子线程结束前就退出了
t.join()
print("Main: All done.")

输出:

Main: Before starting thread
Worker: Starting
Main: Waiting for thread to finish
# (这里会等待大约2秒)
Worker: Finished
Main: All done.

线程间的通信:共享数据与线程安全

线程经常需要共享数据,比如修改一个全局列表或字典,这同样会引发竞争条件。

不安全的共享数据示例

import threading
# 全局列表,被两个线程共享
shared_list = []
def add_items(thread_id):
    for i in range(1000):
        shared_list.append(i) # 这个操作不是原子的
threads = []
for i in range(2):
    t = threading.Thread(target=add_items, args=(i,))
    threads.append(t)
    t.start()
for t in threads:
    t.join()
# 预期结果是 2000,但实际结果可能小于这个数
print(f"Final list length: {len(shared_list)}")

运行多次,你会发现 Final list length 的结果可能不是 2000,这是因为 append 操作在底层不是一步完成的,可能被线程切换打断,导致数据丢失。

安全的共享数据:使用锁

import threading
shared_list = []
list_lock = threading.Lock() # 为共享数据创建锁
def add_items_safe(thread_id):
    for i in range(1000):
        with list_lock:
            # 在修改共享数据时获取锁
            shared_list.append(i)
threads = []
for i in range(2):
    t = threading.Thread(target=add_items_safe, args=(i,))
    threads.append(t)
    t.start()
for t in threads:
    t.join()
# 现在结果总是 2000
print(f"Final list length: {len(shared_list)}")

更高级的同步原语

除了 Lockthreading 模块还提供了其他工具来控制线程同步:

  • RLock (Reentrant Lock): 可重入锁,同一个线程可以多次获取它而不会造成死锁,这在递归函数中很有用。
  • Semaphore: 信号量,它允许多个线程同时访问一个资源,但数量有限,比如一个有3个座位的等候区,最多允许3个人进入。
  • Event: 事件,一个简单的线程间通信机制,一个线程可以等待一个事件被设置,另一个线程在完成某个操作后设置该事件。
  • Condition: 条件变量,允许线程等待某个条件成立,一个线程可以 wait() 在一个条件上,直到另一个线程 notify()notify_all() 唤醒它。

总结与最佳实践

  1. 理解问题根源threading 的输出混乱是由于多线程竞争共享资源(如 stdout 或全局变量)造成的。
  2. 锁是基本工具:当你需要保护一段代码(通常是写入共享资源)不被多个线程同时执行时,使用 with threading.Lock(): 是最简单直接的方法。
  3. 锁的范围要小:只锁定必要的代码块,如果锁的范围太大,会降低程序的并发性能,因为其他线程需要等待更长时间。
  4. 警惕死锁:不要在持有锁 A 的时候去获取锁 B,而另一个线程持有锁 B 时又去获取锁 A,这会导致两者互相等待,谁也无法继续执行。
  5. 考虑 queue.Queue:如果线程间的生产者和消费者模式比较复杂,使用 queue.Queue 是一个更好的选择,它本身就是线程安全的,可以帮你处理锁的细节,让代码更清晰、更高效。
  6. join() 是你的朋友:在主线程中,记得对创建的子线程调用 join(),以确保主线程在所有子任务完成后再退出,除非你有特殊的设计意图。
分享:
扫描分享到社交APP
上一篇
下一篇