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 限制并发数,比让所有线程都去抢连接要优雅得多。
2.3 知识体系总览
下面这张图总结了 threading 模块的核心知识点,方便你快速回顾:
2.4 总结与建议
多线程编程的核心就三件事:创建线程、保护共享资源、控制并发数。Lock 解决互斥问题,RLock 解决嵌套锁问题,Semaphore 解决限流问题。
我个人经验是:能用 with 语句就别手动 acquire/release;能用 RLock 就别用 Lock;能用 Semaphore 控制并发就别让线程野蛮竞争。这些习惯能帮你省下不少调试时间。
嗯,关于 threading 模块的基础内容就聊到这儿。这些同步机制看着简单,但实际项目中组合使用起来,还是有不少门道的。