Skip to content

Python 多线程:一个厨房里的多个厨师

引言:如果只有一个厨师,但可以同时切菜和煮汤

上一篇我们讲了多进程——雇多个厨师,每人一个独立厨房,同时做菜。

但有些任务并不需要"多个厨房"。比如:

  • 一个厨师煮汤(等水开的间隙),同时切菜;
  • 一个服务员同时照看好几桌客人(哪桌招手就去哪桌)。

这些任务的特点是:大部分时间在"等"——等水开、等客人招手、等网络响应、等文件读写。等待的时候,完全可以去做别的事。

这就是**多线程(Threading)**的舞台:在同一个进程(厨房)里,启动多个"分身"(线程),交替执行任务,把等待时间利用起来。

这一篇,我们从线程的基本概念讲起,到 threading 模块的使用,再到线程锁、死锁、GIL 全局锁,最后讲清楚 Python 多线程的适用场景和常见误区。


一、线程是什么?进程和线程的关系

1.1 生活化理解

想象一家餐厅:

概念对应现实说明
进程一家独立的餐厅有自己的厨房、设备、员工,互不干扰
线程餐厅里的一个厨师/服务员共享同一个厨房,但可以同时做不同的事
  • 一家餐厅(进程)至少有一个厨师(主线程);
  • 可以雇多个厨师(多个线程),共享厨房资源;
  • 厨师之间要协调:不能同时用同一把刀(共享资源冲突)。

1.2 技术定义

  • 进程(Process):操作系统分配资源的最小单位。每个进程有独立的内存空间。
  • 线程(Thread):操作系统调度执行的最小单位。线程是进程内的执行单元,共享进程的内存空间

一个进程可以包含多个线程,这些线程:

  • 共享进程的内存、文件句柄、网络连接等资源;
  • 各自有独立的执行栈(局部变量互不干扰);
  • 由操作系统统一调度,轮流使用 CPU。

1.3 Python 的线程

Python 的线程是真正的操作系统线程(POSIX Thread),不是模拟出来的。

Python 标准库提供了两个线程模块:

模块级别说明
_thread低级模块接近操作系统原生 API,功能少,不推荐直接使用
threading高级模块_thread 的封装,功能丰富,推荐使用

记住:99% 的情况下,你只需要用 threading


二、启动线程:把函数交给 Thread

2.1 最简单的线程

启动一个线程分三步:

  1. 定义一个函数(线程要执行的任务);
  2. 创建 threading.Thread 实例,把函数传进去;
  3. 调用 start() 启动。
python
import time
import threading

def loop():
    print('线程 %s 开始运行...' % threading.current_thread().name)
    n = 0
    while n < 5:
        n = n + 1
        print('线程 %s >>> %s' % (threading.current_thread().name, n))
        time.sleep(1)
    print('线程 %s 结束。' % threading.current_thread().name)

print('线程 %s 开始运行...' % threading.current_thread().name)
t = threading.Thread(target=loop, name='LoopThread')
t.start()
t.join()
print('线程 %s 结束。' % threading.current_thread().name)

运行结果:

线程 MainThread 开始运行...
线程 LoopThread 开始运行...
线程 LoopThread >>> 1
线程 LoopThread >>> 2
线程 LoopThread >>> 3
线程 LoopThread >>> 4
线程 LoopThread >>> 5
线程 LoopThread 结束。
线程 MainThread 结束。

2.2 代码解析

代码作用
threading.Thread(target=loop, name='LoopThread')创建线程,指定要执行的函数和线程名
t.start()启动线程,线程开始并行执行
t.join()主线程等待子线程结束后再继续
threading.current_thread().name获取当前线程的名字

2.3 主线程和子线程

  • 任何 Python 程序启动时,自动创建一个主线程MainThread);
  • 主线程可以启动子线程;
  • 如果不给子线程起名,Python 自动命名为 Thread-1Thread-2……
  • join() 的作用:主线程"等一等",等子线程干完活再继续。

生活化理解:主线程是餐厅老板,子线程是厨师。老板启动厨师后,如果不 join(),老板就直接下班了(程序退出),厨师干到一半会被强制终止。join() 就是老板说"我等你做完再关门"。


三、多线程共享变量:一把双刃剑

3.1 多进程 vs 多线程的内存差异

特性多进程多线程
内存空间每个进程独立所有线程共享
变量各自有一份拷贝共享同一份
数据安全天然安全需要小心

多线程共享内存是优势(通信方便),也是最大的坑

3.2 一个银行存款的灾难

来看一个经典例子:

python
import time
import threading

balance = 0  # 银行存款

def change_it(n):
    global balance
    balance = balance + n   # 存钱
    balance = balance - n   # 取钱(理论上应该回到 0)

def run_thread(n):
    for i in range(1000000):
        change_it(n)

t1 = threading.Thread(target=run_thread, args=(5,))
t2 = threading.Thread(target=run_thread, args=(8,))
t1.start()
t2.start()
t1.join()
t2.join()
print(balance)  # 理论上应该是 0,但实际可能不是!

理论上,两个线程各存各取,余额应该始终是 0。但运行结果可能是:

-8

或者:

5

为什么?

3.3 问题根源:一条语句 ≠ 一条指令

你以为 balance = balance + n 是一步完成的?错!CPU 执行时分为多步:

python
# 高级语言:一行代码
balance = balance + n

# CPU 实际执行:多步
x = balance + n   # 步骤1:读取 balance,计算,存入临时变量 x
balance = x       # 步骤2:把 x 写回 balance

两个线程的 x 是各自独立的局部变量,互不干扰。但 balance 是共享的。

看一个出错的执行序列:

初始值 balance = 0

t1: x1 = balance + 5  # x1 = 0 + 5 = 5
                        # 此时 t1 被操作系统暂停,切换到 t2

t2: x2 = balance + 8  # x2 = 0 + 8 = 8  (t2 读到的还是旧值 0!)
t2: balance = x2      # balance = 8
t2: x2 = balance - 8  # x2 = 8 - 8 = 0
t2: balance = x2      # balance = 0
                        # t2 执行完毕,切换回 t1

t1: balance = x1      # balance = 5  (t1 把之前算好的 5 写回去)
t1: x1 = balance - 5  # x1 = 5 - 5 = 0
t1: balance = x1      # balance = 0

# 等等,t2 的结果被 t1 覆盖了!

生活化理解:两个会计同时改同一本账。会计 A 读到余额 100,正准备写 105;会计 B 也读到余额 100,写 108。A 写完 105,B 的 108 把 A 的结果覆盖了——最终余额不是 105 也不是 108,而是其中一个,另一个操作丢了。


四、线程锁(Lock):给共享资源上把锁

4.1 锁的作用

**锁(Lock)**的核心思想:同一时刻,只允许一个线程修改共享数据

就像银行柜台的"暂停服务"牌——一个客户办理业务时,其他客户必须排队。

4.2 使用 Lock 修复银行存款

python
import threading

balance = 0
lock = threading.Lock()  # 创建一把锁

def change_it(n):
    global balance
    balance = balance + n
    balance = balance - n

def run_thread(n):
    for i in range(1000000):
        lock.acquire()   # 获取锁(如果别人拿着,就等)
        try:
            change_it(n) # 安全地修改共享变量
        finally:
            lock.release()  # 释放锁(必须!)

t1 = threading.Thread(target=run_thread, args=(5,))
t2 = threading.Thread(target=run_thread, args=(8,))
t1.start()
t2.start()
t1.join()
t2.join()
print(balance)  # 结果一定是 0

4.3 锁的注意事项

要点说明
必须释放获得锁后一定要 release(),否则其他线程永远等待
try...finallyfinally 确保即使出错也能释放锁
粒度锁的范围越小越好,只锁必要的代码

4.4 更优雅的写法:with 语句

Python 的锁支持上下文管理器,可以自动获取和释放:

python
def run_thread(n):
    for i in range(1000000):
        with lock:  # 自动 acquire(),退出时自动 release()
            change_it(n)

推荐用 with,再也不怕忘记释放锁。


五、死锁:两个线程互相等对方

5.1 什么是死锁?

两个线程,各拿一把锁,又都想拿对方的锁:

线程 A:拿到锁1,想要锁2
线程 B:拿到锁2,想要锁1

结果:A 等 B 放锁2,B 等 A 放锁1 → 永远等下去

生活化理解:两个人过独木桥,一个从东边来,一个从西边来,在桥中间相遇,谁也不肯退,都僵住了。

5.2 死锁代码示例

python
import threading
import time

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

def thread_1():
    with lock_a:
        print('线程1拿到锁A')
        time.sleep(0.1)  # 模拟处理,给线程2机会拿锁B
        with lock_b:     # 想要锁B,但锁B被线程2拿着
            print('线程1拿到锁B')

def thread_2():
    with lock_b:
        print('线程2拿到锁B')
        time.sleep(0.1)
        with lock_a:     # 想要锁A,但锁A被线程1拿着
            print('线程2拿到锁A')

t1 = threading.Thread(target=thread_1)
t2 = threading.Thread(target=thread_2)
t1.start()
t2.start()
t1.join()
t2.join()

运行后程序卡死,只能强制终止。

5.3 如何避免死锁?

方法说明
按固定顺序加锁所有线程都按"先锁A后锁B"的顺序,避免循环等待
减少锁的数量能不用锁就不用,能用一个锁就不用两个
用超时机制acquire(timeout=5),超时自动放弃
用更高级的同步原语threading.RLockthreading.Condition

六、GIL:Python 多线程的"紧箍咒"

6.1 一个令人困惑的实验

你的电脑是 4 核 CPU,写个死循环试试:

python
import threading
import multiprocessing

def loop():
    x = 0
    while True:
        x = x ^ 1

# 启动 4 个线程(4核CPU)
for i in range(multiprocessing.cpu_count()):
    t = threading.Thread(target=loop)
    t.start()

打开任务管理器,你会发现:CPU 占用率只有 100%,也就是只用了一个核!

同样的代码,用 C、C++ 或 Java 写,4 核能跑到 400%。为什么 Python 不行?

6.2 GIL 是什么?

**GIL(Global Interpreter Lock,全局解释器锁)**是 CPython 解释器的设计:

  • 任何 Python 线程执行前,必须先获得 GIL
  • 每执行 100 条字节码,自动释放 GIL,让其他线程有机会执行;
  • 同一时刻,只有一个线程在执行 Python 代码

生活化理解:GIL 就像厨房里的"总开关"。4 个厨师都想做菜,但同一时刻只能有一个厨师碰灶台,其他人只能等着。即使厨房有 4 个灶台(4 核 CPU),也只能用一个。

6.3 为什么要有 GIL?

这是 CPython 的历史遗留问题:

  • CPython 的内存管理不是线程安全的;
  • 如果没有 GIL,多线程同时操作引用计数,会导致内存泄漏或崩溃;
  • 去掉 GIL 需要重写解释器,工作量巨大,且会降低单线程性能。

6.4 GIL 的影响

场景多线程效果原因
CPU 密集型(计算、循环)❌ 基本无效GIL 导致只能用一个核
I/O 密集型(网络、文件、数据库)✅ 有效等待 I/O 时 GIL 会释放,其他线程可以执行

关键结论:Python 多线程适合 I/O 密集型任务,不适合 CPU 密集型任务。


七、多线程 vs 多进程:如何选择?

对比项多线程多进程
内存开销小(共享内存)大(独立内存)
创建速度
数据共享方便(直接共享变量)复杂(需要 IPC)
数据安全需要锁天然安全
多核利用❌ 受 GIL 限制✅ 可以用满多核
稳定性一个线程崩溃可能拖垮整个进程一个进程崩溃不影响其他

选择建议

任务类型推荐方案例子
I/O 密集型多线程 / 异步IO网络爬虫、Web 服务器、文件批量下载
CPU 密集型多进程图像处理、科学计算、视频转码
混合型多进程 + 每个进程内多线程大型 Web 应用

八、常见误区解析

误区 1:多线程一定比单线程快

真相:对于 CPU 密集型任务,Python 多线程可能比单线程更慢

原因:GIL 导致线程切换有开销,且无法并行计算。

python
# CPU 密集型任务,多线程反而更慢
import threading
import time

def count(n):
    while n > 0:
        n -= 1

# 单线程
start = time.time()
count(100000000)
count(100000000)
print('单线程:', time.time() - start)

# 多线程
start = time.time()
t1 = threading.Thread(target=count, args=(100000000,))
t2 = threading.Thread(target=count, args=(100000000,))
t1.start(); t2.start()
t1.join(); t2.join()
print('多线程:', time.time() - start)
# 多线程通常更慢!

误区 2:锁越多越好,越安全

真相:锁越多,死锁风险越大,性能越差。

正确做法:锁的粒度尽量小,只锁必要的代码

误区 3:threading 模块的线程是"假线程"

真相:Python 的线程是真正的操作系统线程(POSIX Thread),只是被 GIL 限制了并行能力。

误区 4:多线程可以随便改全局变量

真相:改全局变量必须加锁,否则数据会错乱。


九、实战案例

案例 1:多线程下载文件

python
import threading
import requests

def download(url, filename):
    print('开始下载:', filename)
    r = requests.get(url)
    with open(filename, 'wb') as f:
        f.write(r.content)
    print('下载完成:', filename)

urls = [
    ('https://example.com/file1.zip', 'file1.zip'),
    ('https://example.com/file2.zip', 'file2.zip'),
    ('https://example.com/file3.zip', 'file3.zip'),
]

threads = []
for url, filename in urls:
    t = threading.Thread(target=download, args=(url, filename))
    t.start()
    threads.append(t)

for t in threads:
    t.join()

print('全部下载完成')

为什么适合多线程? 下载是 I/O 密集型,大部分时间在等网络响应,GIL 会释放,多个线程可以并行下载。

案例 2:带锁的计数器

python
import threading

class Counter:
    def __init__(self):
        self._value = 0
        self._lock = threading.Lock()

    def increment(self):
        with self._lock:
            self._value += 1

    def value(self):
        with self._lock:
            return self._value

counter = Counter()

def worker():
    for _ in range(100000):
        counter.increment()

threads = [threading.Thread(target=worker) for _ in range(5)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(counter.value())  # 500000

十、小结

核心知识点一句话总结
线程进程内的执行单元,共享内存,切换开销小
threading 模块Python 多线程的标准库,用 Thread + start() + join()
共享变量多线程共享内存,修改时必须加锁
Lock确保同一时刻只有一个线程修改共享数据
死锁多个线程互相等待对方的锁,程序卡死
GILCPython 的全局锁,导致多线程无法利用多核
适用场景I/O 密集型用多线程,CPU 密集型用多进程

记住三句话

  1. 多线程共享变量,改数据必须加锁
  2. Python 多线程有 GIL,CPU 密集型别用多线程
  3. I/O 密集型任务,多线程能显著提升效率