第2章:threading模块基础——创建线程的两种方式、线程的启动与等待、线程的属性和方法
好,咱们正式开始动手写多线程代码了。
这一章,我带你从最基础的 threading 模块入手。说实话,Python 的多线程入门门槛并不高,但坑也不少。我个人习惯先把基础打牢,再谈那些花里胡哨的并发模型。
你想想看,如果连线程怎么创建、怎么启动、怎么等它结束都搞不清楚,后面谈线程安全、谈锁,那都是空中楼阁。
2.1 创建线程的两种方式
Python 的 threading 模块给了我们两条路走。我在项目中两种都用过,各有各的适用场景。
方式一:直接使用 Thread 类(函数式)
这是最直观的方式。你把一个普通函数丢给 Thread 对象,它就帮你跑起来了。
import threading
import time
def download_file(file_id):
print(f"开始下载文件 {file_id}")
time.sleep(2) # 模拟下载耗时
print(f"文件 {file_id} 下载完成")
# 创建线程
t1 = threading.Thread(target=download_file, args=(1,))
t2 = threading.Thread(target=download_file, args=(2,))
# 启动线程
t1.start()
t2.start()
print("主线程继续做其他事情...")
嗯,这里要注意:args 参数必须是一个元组。哪怕只有一个参数,也要写成 (1,) 这种形式,逗号不能省。我曾经在这上面栽过跟头,少写个逗号,结果传参传了个寂寞。
方式二:继承 Thread 类(面向对象式)
当你的线程逻辑比较复杂,或者需要维护一些内部状态时,我建议用这种方式。说白了,就是把线程封装成一个类。
import threading
import time
class DownloadThread(threading.Thread):
def __init__(self, file_id):
super().__init__()
self.file_id = file_id
self.progress = 0
def run(self):
print(f"开始下载文件 {self.file_id}")
for i in range(5):
time.sleep(0.5)
self.progress = (i + 1) * 20
print(f"文件 {self.file_id} 进度: {self.progress}%")
print(f"文件 {self.file_id} 下载完成")
# 创建并启动
t = DownloadThread(1)
t.start()
关键点在于:你必须重写 run() 方法。线程启动后,会自动调用这个方法。别问我为什么不是 __init__,这是 threading 模块的设计约定。
两种方式怎么选?
- 任务简单、逻辑单一 → 用函数式,代码更简洁
- 需要维护状态、有复杂逻辑 → 用继承式,结构更清晰
2.2 线程的启动与等待
创建完线程,只是第一步。你得知道怎么让它跑起来,怎么等它跑完。
启动线程:start()
调用 start() 方法,线程才会真正开始执行。注意,不是 run()!
千万别直接调 run()!
直接调 run() 不会启动新线程,它只是在当前线程里执行函数。我见过不少新手犯这个错,结果发现程序还是串行执行的,百思不得其解。
等待线程:join()
有时候,主线程需要等子线程干完活,才能继续往下走。这时候就用 join()。
import threading
import time
def worker():
print("子线程开始工作")
time.sleep(3)
print("子线程工作结束")
t = threading.Thread(target=worker)
t.start()
print("主线程等待子线程...")
t.join() # 主线程阻塞在这里,直到 t 结束
print("主线程继续执行")
运行结果会告诉你:主线程确实在 join() 那里停住了,等子线程跑完才继续。
你还可以给 join() 传一个超时参数,比如 t.join(1),表示最多等 1 秒。超时后主线程就不等了,继续往下走。这个特性我在做监控系统时用过,防止某个线程卡死导致整个程序挂掉。
2.3 线程的属性和方法
一个线程对象,身上挂着不少有用的信息。我挑几个常用的说说。
| 属性/方法 | 说明 | 使用场景 |
|---|---|---|
name |
线程名称 | 调试时区分不同线程 |
ident |
线程唯一标识符 | 底层系统识别 |
is_alive() |
线程是否还在运行 | 检查线程状态 |
daemon |
是否为守护线程 | 后台任务管理 |
current_thread() |
获取当前线程对象 | 在任意位置获取线程信息 |
name 和 ident
每个线程都有名字。你不指定的话,Python 会帮你自动生成,比如 Thread-1、Thread-2。我个人习惯在创建线程时给个有意义的名字,方便调试。
t = threading.Thread(target=worker, name="下载线程-1")
print(t.name) # 输出:下载线程-1
print(t.ident) # 输出:一个整数,比如 12345
is_alive() 方法
这个方法返回布尔值,告诉你线程是否还在跑。我在做进度监控时经常用它。
t = threading.Thread(target=long_task)
t.start()
while t.is_alive():
print("线程还在跑...")
time.sleep(0.5)
print("线程已结束")
daemon 属性——守护线程
这个属性有点意思。守护线程的特点是:当主线程结束时,守护线程会被强制终止,不管它有没有跑完。
什么时候用守护线程?
比如后台日志收集、心跳检测这类任务。主程序退出时,这些后台任务也没必要继续了。
def background_task():
while True:
print("后台任务运行中...")
time.sleep(1)
t = threading.Thread(target=background_task, daemon=True)
t.start()
time.sleep(3)
print("主线程结束,守护线程也会被强制终止")
运行这段代码,你会发现主线程结束后,程序就退出了,后台任务不再打印。如果不设置 daemon=True,程序会一直跑下去,因为那个 while True 永远不会停。
注意: 守护线程里不要做重要的资源清理工作。因为它随时可能被强制终止,你无法保证它执行完毕。
2.4 避坑指南
最后,分享几个我踩过的坑。
- 线程不能重复 start():一个线程对象只能
start()一次。想再跑一次,得新建一个线程对象。 - join() 会阻塞主线程:如果你在 GUI 程序的主线程里调
join(),界面会卡死。记得把join()放在工作线程里,或者用超时参数。 - daemon 线程要小心:我曾经写过一个后台数据同步任务,忘了设
daemon=True,结果程序关不掉,每次都得杀进程。
好了,这一章的内容就这些。创建线程、启动线程、等待线程、查看线程属性,这些都是基本功。下一章,我们会聊线程安全的问题——这才是多线程编程真正的硬骨头。