2. threading模块入门:创建线程的两种方式、线程的启动与等待、线程的daemon属性
好,咱们正式开始玩线程了。Python里搞多线程,最核心的就是threading模块。说实话,我当年刚接触它的时候,觉得挺简单的——不就是开个线程跑任务嘛。但用着用着才发现,里面有不少门道。今天咱们就把最基础的东西捋清楚。
2.1 创建线程的两种方式
Python里创建线程,说白了就两条路:直接传函数,或者继承Thread类。两种我都用过,各有各的适用场景。
方式一:直接传入目标函数
这是最常用的方式。你定义一个普通函数,然后把它丢给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()
你看,代码就这么几行。我在项目中处理大量IO任务时,经常用这种模式。比如批量下载文件,每个文件开一个线程,传不同的URL进去,跑起来特别顺畅。
args=("A",)。我见过不少新手在这栽跟头。
方式二:继承Thread类
这种方式适合你需要在线程里维护一些状态,或者想封装更复杂的逻辑。说白了,就是把线程本身当做一个对象来用。
import threading
import time
class MyWorker(threading.Thread):
def __init__(self, name):
super().__init__()
self.name = name
self.result = None
def run(self):
print(f"线程 {self.name} 开始工作")
time.sleep(2)
self.result = f"来自 {self.name} 的结果"
print(f"线程 {self.name} 工作结束")
# 创建并启动
t = MyWorker("C")
t.start()
t.join()
print(t.result) # 获取线程执行结果
嗯,这里要注意:必须重写run方法,而不是start方法。start是父类的方法,它会自动调用run。我曾经见过有人直接重写了start,结果线程根本没跑起来——因为start里本该做的初始化工作全被覆盖了。
2.2 线程的启动与等待
创建完线程,下一步就是让它跑起来。这里有两个关键操作:start()和join()。
start():让线程真正跑起来
你想想看,创建Thread对象只是准备好了任务,但线程还没开始执行。只有调用了start(),操作系统才会真正分配资源,让线程跑起来。
t = threading.Thread(target=worker, args=("D",))
# 此时线程还没开始
t.start() # 现在才开始
这里有个坑:一个线程只能start一次。如果你调了两次,Python会直接抛RuntimeError。我在早期项目里踩过这个坑,当时写了个循环,不小心重复启动了同一个线程,排查了半天才发现。
join():等待线程结束
有时候,主线程需要等子线程干完活,才能继续往下走。这时候就用join()。说白了,就是让主线程「等一下」。
import threading
import time
def slow_worker():
time.sleep(3)
print("子线程完成")
t = threading.Thread(target=slow_worker)
t.start()
print("主线程等待中...")
t.join() # 主线程停在这里,等t结束
print("主线程继续执行")
运行结果会是这样:
主线程等待中...
子线程完成
主线程继续执行
你注意到没有?「主线程等待中」先打印出来,然后等了3秒,子线程打印完,主线程才继续。这就是join的效果。
join(),主线程可能直接跑完退出,子线程还在后台跑。这在某些场景下会导致程序行为不可预测。我曾经在写爬虫时,主线程退出了,子线程的请求还没发完,结果数据丢了。
join()还可以传超时参数:
t.join(timeout=2) # 最多等2秒
如果2秒内线程没结束,主线程就不等了,继续往下走。这个在需要控制总执行时间时特别有用。
2.3 线程的daemon属性
这个属性,说实话,很多人一开始会忽略。但它其实挺重要的。
daemon线程,也叫守护线程。它的特点是:当主线程结束时,所有daemon线程会被强制终止。而非daemon线程(默认情况)会继续运行,直到自己结束。
为什么会这样?你想想看,有些线程是「辅助性」的,比如后台日志收集、心跳检测。主线程都退出了,这些线程留着也没意义。这时候就应该设为daemon。
import threading
import time
def daemon_worker():
while True:
print("守护线程工作中...")
time.sleep(1)
t = threading.Thread(target=daemon_worker)
t.daemon = True # 设为守护线程
t.start()
time.sleep(3)
print("主线程结束")
# 主线程一结束,daemon线程立刻被终止
运行结果:
守护线程工作中...
守护线程工作中...
守护线程工作中...
主线程结束
看到没?主线程睡了3秒,daemon线程打印了3次。主线程一结束,daemon线程就停了,不会继续打印第4次。
start()之前设置。一旦线程启动了,就不能再改了。否则Python会抛RuntimeError。
那什么时候用daemon?我总结了几种场景:
- 后台监控任务:比如定期检查某个资源是否可用
- 日志收集:主程序退出时,日志线程没必要继续
- 心跳检测:网络连接的心跳包发送
- 缓存刷新:定期刷新本地缓存
反过来,什么时候不要用daemon?
- 你需要线程执行完某些关键操作,比如写文件、提交数据库事务
- 线程里有资源需要手动释放
- 你希望线程无论如何都要跑完
我曾经在做一个实时数据处理系统时,把数据写入线程设成了daemon。结果主程序异常退出时,写入线程被强制终止,最后一批数据没来得及落盘。嗯,从那以后,凡是涉及数据持久化的线程,我都不敢设daemon了。
2.4 本章知识体系
为了让你更直观地理解本章内容,我画了一张图:
好了,这一章的内容就到这。记住三个核心操作:start()让线程跑起来,join()让主线程等一等,daemon属性控制线程是不是「随主而终」。把这些基础打牢,后面学锁、学队列、学线程池,都会轻松很多。