title: “Python并发编程:线程、多进程、协程与线程池实战”
date: 2026-04-28
tags:

  • Python
  • 并发编程
  • 多线程
  • 多进程
  • 协程
  • 爬虫
  • FastAPI
    description: “一篇从概念到实战的 Python 并发编程入门博客,覆盖线程、多进程、协程、线程池、进程池、GIL、爬虫加速、生产者消费者模型、线程安全和 Web 服务加速。”

Python并发编程:线程、多进程、协程与线程池实战

很多人学 Python 时都会遇到一个问题:
代码明明没写错,为什么还是慢?

如果你的程序里有大量网络请求、磁盘读写、数据库访问,或者需要同时处理很多任务,那么瓶颈往往不在语法本身,而在于你是不是用对了并发模型。

这篇文章尽量把 Python 并发编程里最核心、最常用、也最容易混淆的部分讲清楚:

  • 什么是线程、多进程、协程
  • 什么是线程池、进程池
  • CPU 密集任务和 IO 密集任务有什么区别
  • 怎样选择多线程、多进程、多协程
  • 为什么 Python 会被 GIL 限制
  • 为什么多线程能把爬虫加速到接近 10 倍
  • 如何用生产者消费者模型写一个更像样的爬虫
  • 什么是线程安全,如何解决线程安全问题
  • 为什么 ThreadPoolExecutor 很好用
  • 如何在 Web 服务中用线程池为阻塞任务提速

如果你刚开始接触 Python 并发,这篇文章适合作为一个完整的入门框架。

一、先搞清楚:并发不等于并行

很多资料一上来就讲 API,但真正容易搞混的是概念。

1. 并发是什么

并发指的是:
在一段时间内同时处理多个任务。

它不一定要求多个任务“同一时刻”真的一起执行,而是强调多个任务交替推进,让整体吞吐更高。

2. 并行是什么

并行指的是:
多个任务在同一时刻真的同时执行。

并行通常依赖多个 CPU 核心,或者多个进程分别占用不同的核心。

3. 一个直观类比

  • 并发:一个服务员轮流给很多桌点单、上菜、结账
  • 并行:多个服务员同时服务多桌

在 Python 里:

  • 多线程更偏“并发”
  • 多进程更容易做到“并行”
  • 协程是单线程里的高效并发
    在这里插入图片描述

二、Python并发编程简介

Python 里最常见的三种并发方案是:

方案核心单位是否共享内存切换成本适合场景
多线程线程共享IO 密集
多进程进程默认不共享CPU 密集
协程协程任务共享同一线程很低高并发 IO

除此之外,工程里还经常会用到:

  • ThreadPoolExecutor:线程池,适合大量短小的 IO 任务
  • ProcessPoolExecutor:进程池,适合 CPU 密集型计算任务
  • asyncio:Python 官方异步编程框架

可以先记住一句最重要的话:

Python 并发编程不是“哪种技术更高级”,而是“哪种技术更适合当前任务”。

三、线程、多进程、协程分别是什么

1. 线程

线程是进程中的执行单元。一个进程里可以有多个线程,这些线程共享同一块内存空间,所以通信方便,但也更容易产生数据竞争。

线程的优点:

  • 创建和切换成本相对较低
  • 共享内存,传数据方便
  • 适合网络请求、文件读写、数据库访问等 IO 密集场景

线程的缺点:

  • 共享数据会带来线程安全问题
  • 在 CPython 中受 GIL 限制,CPU 密集任务无法真正并行

2. 多进程

进程是操作系统分配资源的基本单位。不同进程之间默认隔离,互不影响。

多进程的优点:

  • 能绕过 GIL,利用多核 CPU
  • 进程彼此隔离,稳定性更高

多进程的缺点:

  • 创建成本更高
  • 进程间通信更复杂
  • 内存占用更大
    在这里插入图片描述

3. 协程

协程可以理解为用户态的轻量级任务。它通常运行在一个线程里,由程序员通过 async/await 主动让出执行权。

协程的优点:

  • 切换开销很小
  • 适合海量网络连接
  • 非常适合高并发 IO 场景

协程的缺点:

  • 写法和普通同步代码不同,需要适应异步思维
  • 遇到阻塞代码时,整个事件循环可能被卡住
  • CPU 密集任务同样不适合协程本身处理

四、CPU 密集和 IO 密集任务的区别

这是决定并发方案的第一原则。

1. CPU 密集任务

CPU 密集任务的特点是:
程序大部分时间都在做计算,CPU 几乎一直忙着干活。

常见例子:

  • 图像处理
  • 视频编码
  • 大量数学计算
  • 模型训练
  • 数据压缩和解压

这类任务的瓶颈是 CPU。

2. IO 密集任务

IO 密集任务的特点是:
程序大部分时间都在等待外部资源返回结果。

常见例子:

  • 爬虫发 HTTP 请求
  • 读写文件
  • 访问数据库
  • 调第三方 API
  • 网络服务聚合多个下游接口

这类任务的瓶颈通常不是 CPU,而是等待。

3. 两者的选择建议

任务类型推荐方案原因
CPU 密集多进程 / 进程池能绕过 GIL,利用多核
IO 密集多线程 / 协程 / 线程池等待期间可以切换去做别的任务

一句话总结:

  • 计算多,用多进程
  • 等待多,用多线程或协程

五、怎样选择多线程、多进程、多协程

很多初学者的问题不是“不会用”,而是“不会选”。

可以按下面这个思路判断。

1. 如果任务是 IO 密集

优先考虑:

  • 任务数量不大,代码要简单:多线程
  • 任务数量很多,且依赖支持异步:协程
  • 想少写线程管理代码:线程池

2. 如果任务是 CPU 密集

优先考虑:

  • 多进程
  • 进程池

不要优先考虑:

  • 多线程
  • 协程

因为它们都不能从根本上解决 CPU 被 GIL 卡住的问题。

3. 一个实用决策表

场景建议
爬虫、批量请求接口多线程或协程
Web 服务里调用阻塞 SDK线程池
大规模高并发网络服务协程
图像处理、批量计算多进程或进程池
代码新手、先求稳定线程池

4. 选择顺序建议

如果你是 Python 初学者,我建议这样掌握:

  1. 先学多线程
  2. 再学锁、队列、线程池
  3. 再学多进程和进程池
  4. 最后系统学 asyncio

原因很简单:线程和线程池更贴近日常业务,协程虽然高效,但认知门槛更高。

六、Python速度慢的罪魁祸首:全局解释器锁 GIL

讲 Python 并发,绕不开 GIL。

1. GIL 是什么

GIL 全称是 Global Interpreter Lock,即全局解释器锁。

在 CPython 解释器里,同一个进程中的多个线程,同一时刻只有一个线程可以执行 Python 字节码

这意味着:

  • 多线程在 IO 密集场景下仍然有用
  • 但在 CPU 密集场景下,多线程很难真正利用多核并行计算

2. 为什么会有 GIL

GIL 的历史原因很多,最核心的一点是:

它简化了 CPython 的内存管理实现。

代价就是多线程计算受限。

3. GIL 带来的直接影响

下面这句话很重要:

Python 多线程不是没用,而是“对 CPU 密集任务没那么有用”。

比如:

  • 爬虫请求网页:有用
  • 并发读写文件:有用
  • 调很多外部 API:有用
  • 计算一亿次循环:通常没用

4. 关于 GIL 最常见的误区

误区一:有 GIL,所以 Python 不能并发。
错。Python 完全可以并发,尤其擅长 IO 并发。

误区二:多线程一定比单线程快。
错。只有在任务大量等待 IO 时,多线程才通常更快。

误区三:协程能解决所有性能问题。
错。协程擅长高并发 IO,不擅长 CPU 密集计算。

七、使用多线程,Python爬虫为什么能被加速 10 倍

这是理解“IO 密集任务适合多线程”的最好例子。

假设你要爬 10 个网页,每个网页请求耗时约 1 秒:

  • 单线程:一个一个爬,总耗时大约 10 秒
  • 10 个线程并发爬:总耗时接近 1 秒到 2 秒

这就是为什么很多 Python 爬虫一上多线程,速度立刻明显提升。
本质上不是 Python 变快了,而是等待网络返回的时间被重叠了。

1. 单线程和多线程爬虫示例

import threading
import time
import requests

urls = [f"https://www.cnblogs.com/#p{i}" for i in range(1, 11)]


def craw(url):
    response = requests.get(url, timeout=10)
    print(url, len(response.text))
    return response.text


def single_thread():
    for url in urls:
        craw(url)


def multi_thread():
    threads = []
    for url in urls:
        t = threading.Thread(target=craw, args=(url,))
        threads.append(t)

    for t in threads:
        t.start()

    for t in threads:
        t.join()


if __name__ == "__main__":
    start = time.time()
    single_thread()
    print("single:", time.time() - start)

    start = time.time()
    multi_thread()
    print("multi:", time.time() - start)

2. 为什么这个例子能加速这么明显

因为 requests.get() 的大部分时间并不是在做计算,而是在等待:

  • 等待 DNS 解析
  • 等待建立 TCP 连接
  • 等待服务器响应
  • 等待数据传输回来

等待期间,CPU 并没有真正忙起来。
如果只有一个线程,这段等待时间就被白白浪费了。
如果有多个线程,一个线程在等网络,另一个线程就可以继续发请求。

3. 什么时候加速达不到 10 倍

如果出现下面这些情况,加速会打折:

  • 网站本身响应很快,请求延迟不明显
  • 线程数过大,带来调度和上下文切换开销
  • 目标网站限流
  • 本地带宽不足
  • 解析逻辑本身很耗 CPU

所以“10 倍”不是铁律,但在高等待比的 IO 场景中,多线程的收益通常非常明显。

八、Python实现生产者消费者爬虫

当爬虫稍微复杂一点,你就会发现“所有线程都做同一件事”不够优雅。

更常见的做法是把流水线拆成两段:

  • 生产者:负责抓取网页
  • 消费者:负责解析网页、提取数据、写入文件或数据库

这就是经典的生产者消费者模型。

1. 为什么这个模型好用

它的优点很明显:

  • 解耦抓取和解析
  • 可以分别调整抓取线程数和解析线程数
  • 使用队列后,线程之间通信更安全
  • 容易扩展成更复杂的数据处理流水线

2. 一个更完整的实现

import queue
import threading
import requests
from bs4 import BeautifulSoup

urls = [f"https://www.cnblogs.com/#p{i}" for i in range(1, 11)]
url_queue = queue.Queue()
html_queue = queue.Queue()
write_lock = threading.Lock()


def craw(url):
    response = requests.get(url, timeout=10)
    return response.text


def parse(html):
    soup = BeautifulSoup(html, "html.parser")
    links = soup.find_all("a", class_="post-item-title")
    return [(link["href"], link.get_text(strip=True)) for link in links]


def producer():
    while True:
        url = url_queue.get()
        if url is None:
            url_queue.task_done()
            break

        html = craw(url)
        html_queue.put(html)
        print(threading.current_thread().name, "crawled", url)
        url_queue.task_done()


def consumer(output):
    while True:
        html = html_queue.get()
        if html is None:
            html_queue.task_done()
            break

        items = parse(html)
        with write_lock:
            for item in items:
                output.write(f"{item}\n")
        print(threading.current_thread().name, "parsed one page")
        html_queue.task_done()


def main():
    for url in urls:
        url_queue.put(url)

    producers = [
        threading.Thread(target=producer, name=f"producer-{i}")
        for i in range(3)
    ]
    for t in producers:
        t.start()

    with open("spider_result.txt", "w", encoding="utf-8") as output:
        consumers = [
            threading.Thread(target=consumer, args=(output,), name=f"consumer-{i}")
            for i in range(2)
        ]
        for t in consumers:
            t.start()

        url_queue.join()
        for _ in producers:
            url_queue.put(None)
        for t in producers:
            t.join()

        html_queue.join()
        for _ in consumers:
            html_queue.put(None)
        for t in consumers:
            t.join()


if __name__ == "__main__":
    main()

3. 这里最值得学的不是爬虫,而是两个点

第一,queue.Queue 是线程安全的。
这意味着多个线程可以安全地从队列里取数据、放数据。

第二,职责拆分很重要。
抓取慢,不一定要改解析线程;解析慢,也不一定要改抓取线程。

这比把所有逻辑塞进一个线程函数里更可维护。

九、Python线程安全问题以及解决方案

只要多个线程共享同一份数据,线程安全问题就可能出现。

1. 什么是线程安全问题

多个线程并发读写共享变量时,如果执行顺序不确定,就可能得到错误结果。

比如两个线程同时取钱:

import threading
import time

lock = threading.Lock()


class Account:
    def __init__(self, balance):
        self.balance = balance


def draw(account, amount):
    with lock:
        if account.balance >= amount:
            time.sleep(0.01)
            account.balance -= amount
            print(threading.current_thread().name, "取钱成功,余额", account.balance)
        else:
            print(threading.current_thread().name, "取钱失败,余额不足")


if __name__ == "__main__":
    account = Account(1000)
    t1 = threading.Thread(target=draw, args=(account, 800), name="t1")
    t2 = threading.Thread(target=draw, args=(account, 800), name="t2")
    t1.start()
    t2.start()
    t1.join()
    t2.join()

如果不加锁,两个线程可能都判断“余额够”,最终把账户扣成负数或者逻辑错乱。

2. 常见解决方案

方案一:加锁

最常见的是:

  • threading.Lock
  • threading.RLock

适用场景:

  • 多线程修改共享变量
  • 临界区逻辑必须串行执行

缺点:

  • 容易降低并发度
  • 用不好会死锁
方案二:尽量不共享数据

这是最稳妥的思路。

比如:

  • 线程只处理自己的局部变量
  • 用消息传递代替共享对象
  • 用队列传数据,而不是共享列表随便改
方案三:用线程安全容器

例如:

  • queue.Queue

它天然适合生产者消费者模型。

方案四:把 CPU 密集任务改成多进程

如果共享状态本来就很多,还要频繁加锁,程序会越来越难维护。
这时候可以考虑改成多进程,让进程天然隔离。

3. 一个原则

能不用共享状态,就别共享。
必须共享时,再加锁。
需要传递任务时,优先用队列。

十、Python好用的线程池:ThreadPoolExecutor

如果你已经写过原始 threading.Thread,大概率会有这种感觉:

  • 线程对象要自己创建
  • 线程数要自己管
  • 结果收集要自己处理
  • 异常处理也麻烦

这时候线程池就很有价值。

concurrent.futures.ThreadPoolExecutor 是 Python 标准库里非常实用的线程池工具。

1. 为什么线程池更好用

它帮你解决了很多底层管理问题:

  • 复用线程,避免频繁创建销毁
  • 控制最大并发数
  • 统一提交任务
  • 可以方便地拿到返回值
  • 更容易处理异常

2. 基本用法

from concurrent.futures import ThreadPoolExecutor, as_completed
import requests

urls = [f"https://www.cnblogs.com/#p{i}" for i in range(1, 11)]


def craw(url):
    response = requests.get(url, timeout=10)
    return url, len(response.text)


with ThreadPoolExecutor(max_workers=5) as pool:
    futures = [pool.submit(craw, url) for url in urls]

    for future in as_completed(futures):
        url, size = future.result()
        print(url, size)

3. mapsubmit 怎么选

map 适合:

  • 一批参数结构相似的任务
  • 按提交顺序取结果

submit 适合:

  • 任务更灵活
  • 需要逐个拿 future
  • 需要配合 as_completed 谁先完成先处理谁

4. 线程池最适合的场景

  • 爬虫
  • 批量下载
  • 调多个第三方 API
  • 批量文件读写
  • Web 服务中调用阻塞型库

十一、进程池也很重要:什么时候用 ProcessPoolExecutor

既然有线程池,就也要知道进程池。

对于 CPU 密集任务,推荐使用:

from concurrent.futures import ProcessPoolExecutor


def cpu_heavy(n):
    total = 0
    for i in range(n):
        total += i * i
    return total


with ProcessPoolExecutor(max_workers=4) as pool:
    results = list(pool.map(cpu_heavy, [10**7, 10**7, 10**7, 10**7]))
    print(results)

如果把上面这个计算任务换成线程池,通常不会有理想的提升,因为它受 GIL 影响明显。

所以:

  • IO 密集:线程池
  • CPU 密集:进程池

这条规则非常实用。

十二、Python使用线程池在Web服务中实现加速

这是业务里非常常见的场景。

假设你在写一个 FastAPI 服务,接口里需要调用一个阻塞式 SDK,或者使用 requests 去请求第三方服务。

如果你在异步接口里直接执行阻塞代码,会发生什么?

  • 当前请求线程被卡住
  • 事件循环被拖慢
  • 整个服务吞吐下降

这时候常见做法是:
把阻塞任务丢到线程池里执行。

1. 一个典型例子

import asyncio
from concurrent.futures import ThreadPoolExecutor

import requests
from fastapi import FastAPI

app = FastAPI()
pool = ThreadPoolExecutor(max_workers=20)


def fetch_user_profile(user_id: int):
    url = f"https://api.example.com/users/{user_id}"
    response = requests.get(url, timeout=10)
    return response.json()


@app.get("/users/{user_id}")
async def get_user(user_id: int):
    loop = asyncio.get_running_loop()
    data = await loop.run_in_executor(pool, fetch_user_profile, user_id)
    return {"data": data}

2. 这段代码解决了什么问题

接口本身仍然是异步的,但阻塞的网络请求被放进线程池执行了。
这样:

  • 主事件循环不会被长时间卡住
  • 同时处理多个请求的能力更强
  • 旧的阻塞库不需要立刻重写成异步版本

3. 什么时候线程池特别适合 Web 服务

  • 接第三方平台,只提供同步 SDK
  • 调用老系统 HTTP 接口,只能用阻塞式客户端
  • 需要兼容一批已有同步代码

4. 什么时候不要滥用线程池

如果你的依赖本身已经支持异步,比如:

  • httpx.AsyncClient
  • 异步数据库驱动

那通常优先直接使用纯异步方案,而不是“异步外壳 + 内部线程池”。

因为线程池本质上是一种兼容和过渡手段,不是所有场景下的第一选择。

十三、多线程、多进程、多协程的最终选择建议

如果你只想记住一个结论,可以记这个版本:

1. 先看任务类型

  • 主要在等网络、磁盘、数据库:IO 密集
  • 主要在做大量计算:CPU 密集

2. 再做技术选择

  • IO 密集且想快速上手:多线程
  • IO 密集且追求高并发:协程
  • CPU 密集:多进程
  • 不想手动管理线程/进程:线程池或进程池

3. 工程上的推荐顺序

  • 小项目、脚本、爬虫:先线程池
  • 高并发网络服务:优先协程
  • 数据处理、批量计算:优先进程池
  • 混合场景:异步主流程 + 线程池承接阻塞任务

十四、常见误区汇总

1. 多线程不是万能加速器

只有在 IO 等待明显时,多线程收益才通常很高。

2. 协程不是“更高级的线程”

协程不是抢占式调度,而是合作式调度。
你必须理解 async/await,并保证内部不要乱写阻塞代码。

3. 线程越多不一定越快

线程太多会带来:

  • 上下文切换开销
  • 内存占用增加
  • 目标服务限流压力

4. 加锁不是最优雅的解法

很多时候,减少共享状态比拼命加锁更重要。

十五、总结

Python 并发编程最核心的不是 API,而是判断任务性质。

你可以把全文压缩成下面几句话:

  • 并发的目标是提高吞吐,不一定等于并行
  • IO 密集任务更适合多线程、协程、线程池
  • CPU 密集任务更适合多进程、进程池
  • GIL 限制了多线程对 CPU 计算的加速能力
  • 爬虫这类高等待任务,用多线程通常会明显提速
  • 生产者消费者模型适合构建可扩展的抓取流水线
  • 线程安全问题的核心是共享状态,能少共享就少共享
  • ThreadPoolExecutor 是 Python 里非常实用的并发工具
  • 在 Web 服务里,线程池适合承接阻塞型任务

如果你准备继续深入,建议下一步按这个顺序练习:

  1. 手写一个多线程爬虫
  2. queue.Queue 改造成生产者消费者模型
  3. ThreadPoolExecutor 重构线程管理
  4. 对比 ThreadPoolExecutorProcessPoolExecutor
  5. 再系统学习 asyncioFastAPI 的异步生态

到这一步,你对 Python 并发编程的理解就已经超过“只会背概念”的阶段了。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐