Python高级技术之:`multiprocessing`与`threading`:在`CPU`密集型和`I/O`密集型任务中的选型。

各位好,今天咱们来聊聊Python里的两个重量级选手:multiprocessing(多进程)和threading(多线程)。这俩哥们儿都能让你的程序“同时”干活,但擅长的领域却大不相同。选错了,轻则效率打折,重则适得其反。所以,咱们得摸清它们的脾气,才能让它们各司其职,把活儿干得漂漂亮亮的。

开场白:别被“并发”忽悠了

先来个小概念澄清一下。很多人把“并发”(concurrency)和“并行”(parallelism)混为一谈,以为都是“一起干活”。其实不然。

  • 并发: 就像一个人同时处理多项任务。你一边回微信,一边听歌,一边还要想着晚上吃啥。表面上看起来你在同时干三件事,但实际上你的大脑是在快速切换,轮流处理。
  • 并行: 就像几个人同时干活。你、你媳妇儿、你老妈,三个人同时包饺子,那就是并行。

threading 实现的是并发,而 multiprocessing 才能实现真正的并行(在多核 CPU 的情况下)。记住这一点,是咱们后续讨论的基础。

第一回合:threading,I/O 密集型任务的救星

threading 是Python自带的模块,用来创建线程。线程是轻量级的执行单元,多个线程共享同一个进程的内存空间。这就好比一个公司里,大家都在同一个办公室里工作,共享打印机、会议室等资源。

threading 的优势在于:

  1. 轻量级: 创建和销毁线程的开销比进程小得多。
  2. 共享内存: 线程之间可以方便地共享数据。
  3. 适用于 I/O 密集型任务: 当程序需要频繁等待 I/O 操作(比如网络请求、文件读写)时,线程可以释放 CPU,让其他线程继续执行,从而提高整体效率。

threading 也有它的局限性。由于Python的全局解释器锁(GIL)的存在,同一时刻只能有一个线程真正地执行Python字节码。这就好比,虽然公司里有很多员工,但只有一个打印机可以用,大家得排队,谁先拿到谁用。

所以,对于 CPU 密集型任务,threading 几乎不起作用。因为大家都在争抢 GIL,反而增加了线程切换的开销。

代码示例:用threading处理网络请求

假设我们要从多个网站下载网页内容。这是一个典型的 I/O 密集型任务。

import threading
import requests
import time

def download_page(url):
    """下载网页内容"""
    print(f"开始下载: {url}")
    try:
        response = requests.get(url)
        response.raise_for_status()  # 检查请求是否成功
        print(f"成功下载: {url}, 长度: {len(response.content)}")
    except requests.exceptions.RequestException as e:
        print(f"下载失败: {url}, 错误: {e}")

def main():
    urls = [
        "https://www.example.com",
        "https://www.baidu.com",
        "https://www.python.org",
        "https://www.google.com" # 模拟一些需要等待的请求
    ]

    threads = []
    start_time = time.time()

    for url in urls:
        thread = threading.Thread(target=download_page, args=(url,))
        threads.append(thread)
        thread.start()

    for thread in threads:
        thread.join() # 等待所有线程完成

    end_time = time.time()
    print(f"总耗时: {end_time - start_time:.2f} 秒")

if __name__ == "__main__":
    main()

在这个例子中,我们创建了多个线程,每个线程负责下载一个网页。由于下载网页需要等待网络响应,线程可以释放 CPU,让其他线程继续执行。这样,多个网页可以“同时”下载,大大提高了效率。

第二回合:multiprocessing,CPU 密集型任务的王者

multiprocessing 是 Python 的另一个模块,用来创建进程。进程是操作系统分配资源的最小单位,每个进程都有自己独立的内存空间。这就好比多个公司,每个公司都有自己的办公室、打印机、会议室等资源。

multiprocessing 的优势在于:

  1. 绕过 GIL: 每个进程都有自己的 Python 解释器,不受 GIL 的限制。因此,对于 CPU 密集型任务,multiprocessing 可以充分利用多核 CPU 的性能。
  2. 隔离性好: 进程之间互不干扰,一个进程崩溃不会影响其他进程。
  3. 适用于 CPU 密集型任务: 当程序需要进行大量的计算时,multiprocessing 可以将任务分配到多个进程中并行执行,从而提高效率。

multiprocessing 也有它的缺点:

  1. 开销大: 创建和销毁进程的开销比线程大得多。
  2. 内存占用高: 每个进程都有自己的内存空间,会占用更多的内存。
  3. 进程间通信复杂: 进程之间不能直接共享数据,需要使用特殊的机制(比如管道、队列、共享内存)进行通信。

代码示例:用multiprocessing计算圆周率

假设我们要计算圆周率 π 的值。这是一个典型的 CPU 密集型任务。

import multiprocessing
import time

def calculate_pi(num_steps):
    """使用蒙特卡洛方法计算圆周率"""
    inside_circle = 0
    for _ in range(num_steps):
        import random
        x = random.random()
        y = random.random()
        if x*x + y*y <= 1:
            inside_circle += 1
    pi = 4 * inside_circle / num_steps
    return pi

def main():
    num_processes = multiprocessing.cpu_count()  # 获取 CPU 核心数
    num_steps_per_process = 10000000 // num_processes  # 每个进程的计算量

    pool = multiprocessing.Pool(processes=num_processes)  # 创建进程池

    start_time = time.time()

    results = pool.map(calculate_pi, [num_steps_per_process] * num_processes)  # 并行计算

    pool.close()
    pool.join()

    end_time = time.time()

    pi = sum(results) / num_processes # 平均多个进程的计算结果,提升准确性

    print(f"圆周率: {pi}")
    print(f"进程数: {num_processes}")
    print(f"总耗时: {end_time - start_time:.2f} 秒")

if __name__ == "__main__":
    main()

在这个例子中,我们创建了一个进程池,将计算任务分配到多个进程中并行执行。由于计算圆周率需要进行大量的计算,multiprocessing 可以充分利用多核 CPU 的性能,大大提高了效率。

第三回合:总结与选择指南

现在,咱们来总结一下 threadingmultiprocessing 的优缺点,并给出一些选择指南。

特性 threading multiprocessing
并发/并行 并发 并行 (在多核 CPU 上)
GIL 影响 受 GIL 限制 不受 GIL 限制
创建开销
内存占用
进程间通信 简单 (共享内存) 复杂 (管道、队列、共享内存)
隔离性 差 (一个线程崩溃可能影响整个进程) 好 (一个进程崩溃不会影响其他进程)
适用场景 I/O 密集型任务 (网络请求、文件读写等) CPU 密集型任务 (大量计算、图像处理等)
资源利用率 在 I/O 密集型任务中 CPU 利用率低 在 CPU 密集型任务中 CPU 利用率高

选择指南:

  1. I/O 密集型任务: 优先选择 threading。线程可以释放 CPU,让其他线程继续执行,提高整体效率。
  2. CPU 密集型任务: 优先选择 multiprocessing。进程可以绕过 GIL,充分利用多核 CPU 的性能。
  3. 混合型任务: 可以结合使用 threadingmultiprocessing。比如,主进程使用 multiprocessing 处理 CPU 密集型任务,子进程使用 threading 处理 I/O 密集型任务。

补充说明:

  • asyncio Python 3.4 引入了 asyncio 模块,提供了一种更高效的并发编程模型。asyncio 使用协程(coroutine)来实现并发,避免了线程切换的开销。对于 I/O 密集型任务,asyncio 通常比 threading 更高效。
  • 进程池和线程池: multiprocessingthreading 都提供了进程池和线程池的机制,可以预先创建好一些进程或线程,避免频繁创建和销毁的开销。
  • 进程间通信: multiprocessing 提供了多种进程间通信的机制,包括管道、队列、共享内存等。选择哪种机制取决于具体的应用场景。

彩蛋:一个错误的例子

很多新手会犯一个错误,就是把 threading 用在 CPU 密集型任务上,以为多线程就能提高效率。让我们来看一个反例:

import threading
import time

def cpu_bound_task(n):
    """模拟一个 CPU 密集型任务"""
    while n > 0:
        n -= 1

def main():
    num_threads = 4  # 假设有 4 个 CPU 核心
    n = 100000000  # 计算量

    threads = []
    start_time = time.time()

    for _ in range(num_threads):
        thread = threading.Thread(target=cpu_bound_task, args=(n // num_threads,))
        threads.append(thread)
        thread.start()

    for thread in threads:
        thread.join()

    end_time = time.time()
    print(f"总耗时 (threading): {end_time - start_time:.2f} 秒")

    start_time = time.time()
    cpu_bound_task(n)
    end_time = time.time()
    print(f"总耗时 (单线程): {end_time - start_time:.2f} 秒")

if __name__ == "__main__":
    main()

运行结果会让你大跌眼镜:多线程的效率甚至不如单线程!这就是 GIL 的威力。

总结的总结:没有银弹

threadingmultiprocessing 都是强大的工具,但没有万能的解决方案。选择哪种方式取决于具体的任务类型和应用场景。重要的是理解它们的原理和优缺点,才能做出正确的选择。

希望今天的讲解对大家有所帮助。下次再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注