2、多线程编程:threading模块详解、线程创建与启动、线程同步机制(Lock、RLock、Semaphore)

多线程编程,说白了就是让程序同时干好几件事。我刚开始接触并发时,总觉得这玩意儿挺玄乎——不就是多开几条执行路径嘛?后来踩了坑才明白,线程之间的协作与同步,才是真正的硬骨头。

Python 的 threading 模块,是我们处理多线程任务的标准工具。它封装了底层操作系统的线程接口,用起来还算顺手。今天咱们就把它掰开揉碎了聊一聊。

2.1 线程的创建与启动

创建线程有两种主流方式。我个人习惯用继承 Thread 类的方式,因为代码结构更清晰,尤其是当线程逻辑比较复杂时。

方式一:直接传入函数

import threading
import time

def worker(name):
    print(f"线程 {name} 开始工作")
    time.sleep(2)
    print(f"线程 {name} 结束工作")

# 创建线程
t1 = threading.Thread(target=worker, args=("A",))
t2 = threading.Thread(target=worker, args=("B",))

# 启动线程
t1.start()
t2.start()

# 等待线程结束
t1.join()
t2.join()

print("所有线程执行完毕")

这里有个细节:start() 只是告诉系统可以调度这个线程了,并不是立即执行。你想想看,CPU 什么时候切换线程,那是操作系统说了算的。

方式二:继承 Thread 类

class MyThread(threading.Thread):
    def __init__(self, name):
        super().__init__()
        self.name = name

    def run(self):
        print(f"线程 {self.name} 开始工作")
        time.sleep(2)
        print(f"线程 {self.name} 结束工作")

t1 = MyThread("A")
t2 = MyThread("B")
t1.start()
t2.start()
t1.join()
t2.join()

我在项目中遇到过一种情况:线程需要传递多个参数,而且还要共享一些状态。这时候用继承方式就特别方便,直接在 __init__ 里把参数都传进去,比用 args 元组传参要直观得多。

小技巧: 线程的 daemon 属性要留意。设置为 True 的线程,会在主线程退出时自动结束。适合后台监控、日志清理这类任务。但要注意,daemon 线程里别做重要的资源清理工作,否则可能执行到一半就被强制终止了。

2.2 线程同步机制

多线程最头疼的问题是什么?数据竞争。多个线程同时读写同一个变量,结果往往不是你想要的。我早期做爬虫时就吃过这个亏——两个线程同时往列表里追加数据,最后丢了不少记录。

Python 提供了几种同步原语,咱们一个一个看。

2.2.1 Lock(互斥锁)

Lock 是最基础的同步工具。它保证同一时刻只有一个线程能访问共享资源。

import threading

counter = 0
lock = threading.Lock()

def increment():
    global counter
    for _ in range(100000):
        lock.acquire()
        counter += 1
        lock.release()

threads = []
for i in range(10):
    t = threading.Thread(target=increment)
    threads.append(t)
    t.start()

for t in threads:
    t.join()

print(f"最终结果: {counter}")  # 应该是 1000000

嗯,这里要注意:acquire() 和 release() 必须成对出现。如果某个线程获取了锁但忘记释放,其他线程就会永远阻塞。我曾经在线上排查过一个死锁问题,查了半天发现是异常处理没写好,锁没释放。

更安全的写法是用 with 语句:

def increment():
    global counter
    for _ in range(100000):
        with lock:
            counter += 1

这样即使代码抛出异常,锁也会自动释放。

2.2.2 RLock(可重入锁)

RLock 和 Lock 的区别在于:同一个线程可以多次获取 RLock,而不会死锁。说白了,RLock 知道「这把锁是谁拿的」。

lock = threading.RLock()

def func1():
    with lock:
        print("func1 获取锁")
        func2()

def func2():
    with lock:
        print("func2 获取锁")

func1()  # 正常运行

如果换成普通的 Lock,func1 调用 func2 时就会死锁——因为同一个线程试图再次获取自己已经持有的锁。

核心区别:
  • Lock:不支持同一线程重复获取,否则死锁
  • RLock:支持同一线程重复获取,内部维护一个计数器

我建议:除非你明确知道自己在做什么,否则优先用 RLock。它更安全,也更容易排查问题。

2.2.3 Semaphore(信号量)

Semaphore 控制同时访问资源的线程数量。它内部维护一个计数器,每次 acquire() 减一,release() 加一。计数器为 0 时,acquire() 会阻塞。

import threading
import time

# 最多允许 3 个线程同时访问
semaphore = threading.Semaphore(3)

def access_db(thread_id):
    with semaphore:
        print(f"线程 {thread_id} 正在访问数据库")
        time.sleep(2)
        print(f"线程 {thread_id} 释放数据库连接")

for i in range(10):
    t = threading.Thread(target=access_db, args=(i,))
    t.start()

这个场景我遇到过:数据库连接池只有 5 个连接,但业务高峰期有几十个线程同时请求。用 Semaphore 限制并发数,比让所有线程都去抢连接要优雅得多。

避坑指南: 我曾经在项目里用 Semaphore 控制 API 调用频率,结果忘了设置初始值。默认的 Semaphore(1) 其实等价于 Lock,白白浪费了半天排查。记住:Semaphore(n) 的 n 是允许的最大并发数,不是剩余次数。

2.3 知识体系总览

下面这张图总结了 threading 模块的核心知识点,方便你快速回顾:

Python threading 模块知识体系 threading 模块 线程创建与启动 线程同步机制 Thread(target=func, args=()) 继承 Thread 重写 run() start() / join() / daemon Lock(互斥锁) RLock(可重入锁) Semaphore(信号量) 核心原则: 共享资源加锁 → 避免死锁 → 控制并发数

2.4 总结与建议

多线程编程的核心就三件事:创建线程、保护共享资源、控制并发数。Lock 解决互斥问题,RLock 解决嵌套锁问题,Semaphore 解决限流问题。

我个人经验是:能用 with 语句就别手动 acquire/release;能用 RLock 就别用 Lock;能用 Semaphore 控制并发就别让线程野蛮竞争。这些习惯能帮你省下不少调试时间。

嗯,关于 threading 模块的基础内容就聊到这儿。这些同步机制看着简单,但实际项目中组合使用起来,还是有不少门道的。