2. threading模块入门:创建线程的两种方式、线程的启动与等待、线程的daemon属性

好,咱们正式开始玩线程了。Python 的 threading 模块,说白了就是帮你把单线程的程序变成多线程的。我刚开始接触它的时候,觉得这东西挺神奇的——明明代码是顺序写的,怎么就能同时跑起来了?

嗯,别急,咱们一步步来。这一节我会带你搞定三个核心问题:怎么创建线程、怎么控制它的生命周期、以及daemon 属性到底是个什么鬼。

2.1 创建线程的两种方式

Python 里创建线程,主流就两种路子。我个人习惯用第二种,但第一种在某些场景下也挺顺手。咱们都看看。

方式一:直接传入函数

这是最直观的方式。你把一个函数丢给 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("所有线程执行完毕")

你看,代码很简洁。target 指定要执行的函数,args 传参数。启动用 start(),等待用 join()。这里有个坑——千万别直接调用 worker(),那是在主线程里同步执行,不是多线程。我曾经见过新手把 start() 写成 run(),结果线程根本没跑起来,代码顺序执行了。嗯,注意一下。

方式二:继承 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} 结束工作")

# 创建并启动
t1 = MyWorker("A")
t2 = MyWorker("B")
t1.start()
t2.start()
t1.join()
t2.join()

print(t1.result)
print(t2.result)

这里的关键是重写 run() 方法。启动线程时依然调用 start(),它会自动调用 run()。我建议你在 __init__ 里初始化一些状态变量,比如 self.result,这样线程结束后还能拿到返回值。

两种方式怎么选?
  • 任务简单、逻辑单一 → 用函数方式,代码少
  • 任务复杂、需要状态管理 → 用继承方式,结构清晰

2.2 线程的启动与等待

创建完线程,下一步就是让它跑起来。这里有两个核心方法:start() 和 join()。

start():让线程真正跑起来

start() 会告诉操作系统:嘿,给我开个新线程,跑这个函数。注意,一个线程对象只能调用一次 start()。如果你调两次,Python 会直接抛 RuntimeError。我踩过这个坑,当时调试了半天才发现是重复启动了。

join():等线程干完活

join() 的意思是:主线程在这里等着,直到子线程执行完毕。如果不加 join(),主线程可能直接跑完退出,子线程还没干完活呢。你想想看,如果主线程退出了,子线程可能被强制终止,数据都没写完。

小技巧:你可以给 join() 传一个超时参数,比如 t.join(5),表示最多等 5 秒。如果 5 秒后线程还没结束,主线程就不等了。这在处理可能卡死的线程时特别有用。

2.3 线程的 daemon 属性

这个属性,说实话,刚接触时容易搞混。我简单解释一下:daemon 线程是“守护”线程,主线程退出时,它会自动被干掉。而非 daemon 线程,主线程会等它执行完才退出。

为什么会这样?因为 Python 解释器退出时,会检查是否还有非 daemon 线程在运行。如果有,它就等着。如果全是 daemon 线程,它直接走人,不管它们死活。

import threading
import time

def daemon_worker():
    while True:
        print("守护线程工作中...")
        time.sleep(1)

def normal_worker():
    print("普通线程开始工作")
    time.sleep(3)
    print("普通线程结束工作")

# 创建守护线程
d = threading.Thread(target=daemon_worker, daemon=True)
# 创建普通线程
n = threading.Thread(target=normal_worker)

d.start()
n.start()

# 主线程只等普通线程
n.join()
print("主线程结束")

运行这段代码,你会发现:普通线程结束后,主线程打印完就退出了,守护线程被强制终止,连“结束工作”都没机会说。嗯,这就是 daemon 的特性。

避坑指南:我曾经在写一个数据采集程序时,把写日志的线程设成了 daemon。结果程序异常退出时,日志没来得及写,排查问题全靠猜。后来我改成非 daemon,并加了优雅退出机制,才搞定。所以,daemon 线程只适合那些“丢了也无所谓”的任务,比如心跳检测、缓存清理。千万别把重要的数据写入线程设成 daemon。

2.4 知识体系一览

为了让你更直观地理解这一节的内容,我画了一张图。它把创建线程的两种方式、启动等待流程、以及 daemon 属性的影响都串起来了。

threading 模块核心知识体系 创建线程 方式一:函数方式 Thread(target=func, args=()) 方式二:继承方式 class MyThread(Thread): run() 启动与等待:start() → 执行 run() → join() 等待结束 daemon=True 主线程退出时自动终止 适合:心跳、缓存清理 daemon=False(默认) 主线程等待其执行完毕 适合:数据写入、重要任务

这张图把咱们这一节的内容都串起来了。你从上往下看:先选创建方式,然后启动等待,最后根据任务性质决定 daemon 属性。嗯,逻辑很清晰。

我的建议:刚开始学的时候,先用函数方式写几个小例子,感受一下多线程的“同时执行”效果。等熟悉了,再尝试继承方式做复杂任务。daemon 属性先别乱用,默认非 daemon 最安全。

好了,这一节就到这里。你掌握了创建线程的两种方式、启动等待的流程、以及 daemon 属性的使用场景。下一节咱们会深入线程同步问题,到时候你会遇到锁、条件变量这些更有趣的东西。嗯,先消化一下今天的内容吧。

可可精品资料库