daemon 线程说没就没?一文讲透 threading 的适用场景与线程安全

6 阅读7分钟

「Python 进阶之路」系列 Day17

写在前面

Day16 讲透了 GIL,结论是"CPU 密集型任务用多线程没有加速效果,I/O 密集型任务才是 threading 的用武之地"。今天这篇就在这个结论之上,把 threading 模块实际怎么用讲清楚——从创建线程的两种写法,到 daemon 线程消失的坑,再到死锁怎么产生、怎么用锁避免,最后落到更现代的 ThreadPoolExecutor 写法。


一、是什么:创建线程的两种方式与生命周期

threading 模块创建线程有两种方式:传一个函数给 target 参数,或者继承 Thread 重写 run 方法。

import threading

# 方式一:函数式
def worker(name):
    print(f"函数式线程 {name} 在运行")

t1 = threading.Thread(target=worker, args=("A",))
t1.start()
t1.join()   # 阻塞主线程,等待t1执行完毕

# 方式二:继承式
class MyThread(threading.Thread):
    def __init__(self, name):
        super().__init__()
        self.name_ = name
    def run(self):
        print(f"继承式线程 {self.name_} 在运行")

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

start() 真正启动线程去执行;join() 会阻塞调用它的线程(通常是主线程),直到目标线程执行完毕——不调用 join(),主线程不会等待子线程,会继续往下跑。


二、为什么:daemon 线程与死锁

1. daemon 守护线程:主线程退出时会怎样

普通线程(非 daemon)会让主进程"等它跑完",哪怕主线程的代码已经执行完了;daemon(守护)线程则相反:一旦主线程结束,daemon 线程会被强制终止,不管它跑到哪一步了。

用两个独立脚本对比验证:

# daemon_demo.py
import threading, time

def daemon_worker():
    time.sleep(2)
    print("守护线程执行完了(不应该被看到)")

d = threading.Thread(target=daemon_worker, daemon=True)
d.start()
print("主线程立刻结束,不等待daemon线程")
# nondaemon_demo.py
import threading, time

def worker():
    time.sleep(2)
    print("普通线程执行完了")

t = threading.Thread(target=worker)   # 默认daemon=False
t.start()
print("主线程代码跑完了,但程序不会立刻退出")

实测运行结果:daemon_demo.py 总耗时约 0.17s,只打印了"主线程立刻结束"那一行,"守护线程执行完了"根本没有机会打印;nondaemon_demo.py 总耗时约 2.15s,两行都打印了。这说明 Python 进程会等待所有非 daemon 线程执行完才真正退出,但不会等 daemon 线程。适合设成 daemon 的场景:后台监控、日志上报这类"跟着主程序活、主程序死了它也该跟着死"的辅助任务;不适合的场景:任何必须执行完(比如写文件、提交事务)的任务,daemon 线程可能在关键操作做到一半时就被粗暴终止。

2. 死锁是怎么产生的

Day16 讲过,多个线程共享同一份内存空间,操作共享数据需要用锁保护。但用锁本身也会引入新问题:死锁——两个线程各自持有一把锁,同时想要获取对方手里的另一把锁,谁都不肯先放手,于是永远互相等待。

lock_a = threading.Lock()
lock_b = threading.Lock()

def task1():
    with lock_a:
        print("task1 拿到 lock_a")
        time.sleep(0.1)
        got = lock_b.acquire(timeout=1)   # 用timeout避免真的死锁卡死
        print("task1 尝试拿 lock_b:", "成功" if got else "超时失败(死锁发生了)")
        if got:
            lock_b.release()

def task2():
    with lock_b:
        print("task2 拿到 lock_b")
        time.sleep(0.1)
        got = lock_a.acquire(timeout=1)
        print("task2 尝试拿 lock_a:", "成功" if got else "超时失败(死锁发生了)")
        if got:
            lock_a.release()

th1 = threading.Thread(target=task1)
th2 = threading.Thread(target=task2)
th1.start(); th2.start()
th1.join(); th2.join()
flowchart LR
    A[线程1持有LockA] --> B[线程1等待LockB]
    B --> C[线程2持有LockB]
    C --> D[线程2等待LockA]
    D --> A

实测结果:task1 尝试拿 lock_b 超时失败——因为 task2 一直握着它;task2 反而成功拿到了 lock_a——因为 task1acquire 超时失败后,紧接着退出了 with lock_a: 代码块,把 lock_a 释放了,task2 才趁机拿到手。这正是用 timeout 化解死锁的原理:只要有一方肯"放弃等待并释放自己手里的锁",这个循环等待的僵局就被打破了。如果两边都不设置超时(用普通的 acquire() 死等),这个例子会真的卡死,程序永远无法继续。

避免死锁最根本的办法:让所有代码路径都按照同一个固定顺序去获取多把锁(比如永远先拿 lock_a 再拿 lock_b),从根源上消除"互相等待对方"的可能性。


三、怎么用:更多同步原语与现代写法

1. Semaphore:限制同时访问的线程数量

Lock 只允许一个线程同时进入临界区,Semaphore(信号量)可以指定一个数量上限,允许多个线程同时进入:

sem = threading.Semaphore(2)   # 最多同时2个线程能进入

def access_resource(name):
    with sem:
        print(f"{name} 进入资源")
        time.sleep(0.3)
        print(f"{name} 离开资源")

threads = [threading.Thread(target=access_resource, args=(f"线程{i}",)) for i in range(5)]
for t in threads: t.start()
for t in threads: t.join()

实测输出显示:5 个线程里始终只有 2 个能同时处于"进入资源"和"离开资源"之间的状态,其余的会排队等待——这是限流、控制并发连接数(比如限制同时访问某个外部 API 的线程数)的典型场景。

2. Event:线程间的信号通知

Event 是最简单的线程间通信方式:一个线程等待某个信号,另一个线程在合适的时机发出这个信号。

event = threading.Event()

def waiter():
    print("waiter 开始等待信号")
    event.wait()   # 阻塞,直到event被set
    print("waiter 收到信号,继续执行")

def setter():
    time.sleep(0.5)
    print("setter 发出信号")
    event.set()

w = threading.Thread(target=waiter)
s = threading.Thread(target=setter)
w.start(); s.start()
w.join(); s.join()

waiter 会一直卡在 event.wait(),直到 setter 调用 event.set() 才会继续往下走——适合"必须等某个前置条件达成才能继续"的场景。

3. ThreadPoolExecutor:更现代的线程池写法

手动创建、管理一堆 Thread 对象比较繁琐,concurrent.futures.ThreadPoolExecutor 提供了更方便的线程池接口:

from concurrent.futures import ThreadPoolExecutor
import time

def fake_io(n):
    time.sleep(0.3)
    return n * n

start = time.perf_counter()
with ThreadPoolExecutor(max_workers=5) as executor:
    results = list(executor.map(fake_io, range(5)))
print(results, time.perf_counter() - start)
# [0, 1, 4, 9, 16]  耗时约0.30s

5 个各耗时 0.3s 的"I/O 任务"并发执行,总耗时约等于单个任务的耗时(0.3s),而不是 5 个任务顺序执行的 1.5s——这正是 Day16 结论的直接应用:I/O 密集型任务用线程池能有效缩短总耗时,ThreadPoolExecutorwith 语句自动管理线程池的创建和关闭(呼应 Day06 的上下文管理器),比手动维护一堆 Thread 对象更简洁。


四、面试追问

Q1:threading 创建线程的方式有哪些?

两种:传一个函数给 Thread(target=func);或者继承 Thread 类并重写 run 方法。两种方式都要调用 start() 启动线程,join() 阻塞等待线程执行完毕。

Q2:守护线程(daemon)和普通线程有什么区别?

普通线程会让主进程等它跑完才真正退出,哪怕主线程的代码已经执行完;守护线程在主线程结束时会被强制终止,不管有没有执行完。适合放后台监控、日志上报这类可以随时被打断的辅助任务,不适合放必须完整执行的关键操作(写文件、提交事务等)。

Q3:死锁产生的条件是什么,怎么避免?

多个线程各自持有一把锁,同时想获取对方手里的另一把锁,谁都不放手就会死锁。避免的根本方法是让所有代码路径按同一个固定顺序获取多把锁,从根源上消除循环等待;实践中也常给 acquire() 设置超时,超时后主动放弃并释放已持有的锁,避免真正卡死,但这只是缓解手段,不是根本解决方案。

Q4:Semaphore 和 Lock 的区别是什么?

Lock 同一时刻只允许一个线程进入临界区,本质是信号量数量为 1 的特例;Semaphore 可以指定一个数量上限,允许多个线程同时进入,常用于限流场景,比如限制同时访问某个外部资源/API 的并发线程数。

Q5:什么场景该用 threading,什么场景不该用?

I/O 密集型任务(网络请求、文件读写、数据库查询)适合用多线程,因为 I/O 等待期间会释放 GIL(Day16 讲过),多线程能有效利用这些等待空档、缩短总耗时;CPU 密集型任务(大量数值计算)用多线程得不到并行加速效果,应该考虑用多进程绕开 GIL 的限制。


下一篇预告

Day18 讲多进程 multiprocessing——既然 GIL 让多线程没法真正并行跑 CPU 密集型任务,多进程是怎么绕开这个限制的,以及多进程之间数据不共享带来的新问题该怎么解决。