Python Multithreading, Concurrency, and the GIL 101

Lewis LovelockLewis Lovelock
5 min read

🎉 欢迎再次来到编程奇妙世界!🚀 今天,我们要探索 Python 多线程、并发和神秘的 GIL 锁! 就像我们上次一起探索分布式系统和消息队列一样,这次我们也要深入浅出,用生动的例子、实用的代码,以及面试黄金知识点,让你彻底搞懂 Python 的并发魔法! 准备好了吗?Let's get concurrent! 🤹‍♂️

并发 (Concurrency) vs. 并行 (Parallelism) 🤔

首先,我们要区分两个经常被混淆的概念:并发 (Concurrency)并行 (Parallelism)。 想象一下,你是一个超级厉害的咖啡师 ☕,同时要处理很多顾客的订单:

  • 并发 (Concurrency):看起来像是在同时处理多个订单。 你在不同的订单之间快速切换,处理一部分订单 A,然后切换到订单 B 处理一部分,再切换回订单 A,如此往复。 虽然你不是真的在同一时刻 同时所有 订单,但从顾客的角度来看,你好像在并行工作,订单都在被处理,效率很高。 这就像 单核咖啡师,快速切换任务,高效处理多个顾客。

  • 并行 (Parallelism): 你真的在 同一时刻 处理多个订单。 你有多个帮手咖啡师 🧑‍🍳🧑‍🍳🧑‍🍳,每个人负责一部分订单。 订单 A 由你做,订单 B 由帮手 1 做,订单 C 由帮手 2 做,大家同时开工! 这样,你可以真正地 同时 完成多个订单,速度更快! 这就像 多核咖啡师团队,每个人同时工作,真正加速订单处理。

总结:

  • 并发 (Concurrency) 是指程序看起来能同时执行多个任务,通过快速切换任务来实现,实际上可能是在单核交替执行。

  • 并行 (Parallelism) 是指程序真正能同时执行多个任务,需要多核处理器,同时执行多个任务。

线程 (Threads) 和 进程 (Processes) 🧵 🏭

要理解 Python 的并发和并行,我们需要先了解 线程 (Threads)进程 (Processes)

  • 进程 (Process) 🏭: 进程就像一个独立的 工厂。 每个进程都有自己独立的 内存空间代码数据 等资源。 进程之间相互独立,互不干扰。 就像每个工厂有自己的土地、厂房、设备和工人。

  • 线程 (Thread) 🧵: 线程就像工厂里的 工人。 线程存在于进程之中,是进程中的一个 执行单元。 同一个进程中的多个线程 共享 进程的内存空间、代码、数据等资源。 就像同一个工厂里的工人们共享厂房、设备和原材料。

进程和线程的区别 (面试常考):

特性进程 (Process)线程 (Thread)
资源拥有独立内存空间共享进程内存空间
创建开销开销大开销小
上下文切换开销大开销小
通信方式IPC (进程间通信)共享内存 (更方便)
健壮性一个进程崩溃不影响其他进程一个线程崩溃可能影响整个进程

Python 多线程 (Multithreading) 🐍🧵

Python 通过 threading 模块来支持多线程编程。 让我们用一个例子来演示如何创建和使用线程:

import threading
import time

def task(task_id):
    """模拟一个耗时任务"""
    print(f"线程 {threading.current_thread().name} - 任务 {task_id}: 开始执行")
    time.sleep(2) # 模拟任务耗时 2 秒
    print(f"线程 {threading.current_thread().name} - 任务 {task_id}: 执行完成")

if __name__ == "__main__":
    threads = []
    for i in range(3): # 创建 3 个线程
        thread = threading.Thread(target=task, args=(i + 1,), name=f"Thread-{i+1}") # 创建线程对象,指定任务函数和参数
        threads.append(thread)
        thread.start() # 启动线程

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

    print("所有线程执行完成.")

代码解释:

  1. import threading: 导入 threading 模块。

  2. threading.Thread(target=task, args=(i + 1,), name=f"Thread-{i+1}"): 创建线程对象。

    • target=task: 指定线程要执行的函数是 task

    • args=(i + 1,): 传递给 task 函数的参数,这里是一个元组 (i + 1,),注意逗号 , 不能省略,即使只有一个参数。

    • name=f"Thread-{i+1}": 给线程命名,方便识别。

  3. thread.start(): 启动线程,线程开始执行 task 函数。

  4. thread.join(): 等待线程执行完成。 主线程会阻塞在这里,直到 thread 线程执行结束才继续往下执行。 join() 方法确保所有线程都执行完毕后再退出主程序。

  5. threading.current_thread().name: 获取当前线程的名称。

运行代码: 你会看到类似这样的输出 (线程执行顺序可能略有不同,因为线程调度是非确定的):

线程 Thread-1 - 任务 1: 开始执行
线程 Thread-2 - 任务 2: 开始执行
线程 Thread-3 - 任务 3: 开始执行
线程 Thread-1 - 任务 1: 执行完成
线程 Thread-2 - 任务 2: 执行完成
线程 Thread-3 - 任务 3: 执行完成
所有线程执行完成.

看起来好像 3 个任务是同时执行的,对吧? 但这里有个 大坑! 这就是 Python 的 全局解释器锁 (GIL)! 😱

全局解释器锁 (GIL - Global Interpreter Lock) 🔒

GIL 是什么? GIL 是 CPython 解释器 (Python 的标准实现) 中的一个 互斥锁 (Mutex)。 它 保护 着 Python 解释器内部的资源,保证在同一时刻,只有一个线程可以执行 Python 字节码

为什么要有 GIL? GIL 的存在是为了简化 CPython 解释器的内存管理,特别是在多线程环境下,避免复杂的线程安全问题。 CPython 的内存管理机制 (引用计数) 不是线程安全的,GIL 的引入是为了解决这个问题。 (历史遗留问题,想要移除 GIL 非常困难,牵扯到 CPython 的底层架构。)

GIL 的影响 (面试必考!!!):

  • CPU 密集型 (CPU-bound) 任务: 对于 CPU 密集型任务 (例如,计算密集型、循环计算),由于 GIL 的存在,Python 的多线程 无法实现真正的并行。 即使你创建了多个线程,在同一时刻,只有一个线程能够真正利用 CPU 资源执行 Python 代码。 其他线程会被 GIL 阻塞,等待 GIL 被释放。 因此,对于 CPU 密集型任务,多线程并不能提高执行效率,甚至可能因为线程切换的开销而降低效率。

  • I/O 密集型 (I/O-bound) 任务: 对于 I/O 密集型任务 (例如,网络请求、文件读写、等待用户输入),GIL 的影响相对较小。 当一个线程在等待 I/O 操作完成时,它会 释放 GIL,允许其他线程执行。 因此,对于 I/O 密集型任务,多线程可以提高并发性,因为多个线程可以并发地等待 I/O 操作,提高程序的响应速度。

并发 vs. 并行 in Python (GIL 的限制) 💔

由于 GIL 的存在,在 CPython 中,Python 多线程主要实现的是并发,而不是真正的并行 (对于 CPU 密集型任务)。 虽然你可以创建多个线程,但它们并不能同时利用多核 CPU 进行计算。

想象一下: 你的咖啡店只有一个 单人咖啡机 (GIL)。 即使你雇了很多咖啡师 (线程),同一时刻也只能有一个咖啡师使用咖啡机制作咖啡。 其他咖啡师只能排队等待。 对于制作咖啡这种 CPU 密集型操作 (假设制作咖啡需要大量 CPU 计算),多线程并不能加速制作过程。

但是,如果咖啡制作过程中有很多等待 I/O 的环节 (例如,等待水烧开,等待牛奶加热,等待咖啡豆研磨完成),那么当一个咖啡师在等待 I/O 时,可以把咖啡机让给另一个咖啡师使用。 这样,多线程仍然可以提高整体效率,因为多个咖啡师可以并发地进行 I/O 等待。

Python 并行的替代方案 (绕过 GIL) 💪

如果你的任务是 CPU 密集型,并且想要利用多核 CPU 实现真正的并行,你需要绕过 GIL。 Python 提供了 multiprocessing 模块来实现多进程编程。

多进程 (Multiprocessing) 🏭🏭🏭: multiprocessing 模块创建的是 进程,而不是线程。 每个进程都有独立的 Python 解释器和内存空间,进程之间互不干扰,也 没有 GIL 的限制。 因此,多进程可以实现真正的并行,充分利用多核 CPU 资源。

例子 (多进程 vs. 多线程 - CPU 密集型任务):

import time
import threading
import multiprocessing

def cpu_bound_task(name):
    """CPU 密集型任务:计算平方和"""
    result = 0
    for i in range(10**7): # 大量计算
        result += i * i
    print(f"{name} 完成计算,结果: {result}")

if __name__ == "__main__":
    start_time = time.time()

    # 单进程执行
    cpu_bound_task("单进程")
    single_process_time = time.time() - start_time
    print(f"单进程耗时: {single_process_time:.4f} 秒")

    # 多线程执行
    start_time = time.time()
    threads = []
    for i in range(4): # 4 个线程
        thread = threading.Thread(target=cpu_bound_task, args=(f"线程-{i+1}",))
        threads.append(thread)
        thread.start()
    for thread in threads:
        thread.join()
    multithreading_time = time.time() - start_time
    print(f"多线程耗时: {multithreading_time:.4f} 秒")

    # 多进程执行
    start_time = time.time()
    processes = []
    for i in range(4): # 4 个进程
        process = multiprocessing.Process(target=cpu_bound_task, args=(f"进程-{i+1}",))
        processes.append(process)
        processes.start()
    for process in processes:
        process.join()
    multiprocessing_time = time.time() - start_time
    print(f"多进程耗时: {multiprocessing_time:.4f} 秒")

运行结果 (大致): (实际运行时间会因机器性能而异)

单进程 完成计算,结果: 333333283333350000
单进程耗时: 1.2543 秒
线程-1 完成计算,结果: 333333283333350000
线程-2 完成计算,结果: 333333283333350000
线程-3 完成计算,结果: 333333283333350000
线程-4 完成计算,结果: 333333283333350000
多线程耗时: 1.3012 秒  (甚至比单进程还慢,因为线程切换开销)
进程-1 完成计算,结果: 333333283333350000
进程-2 完成计算,结果: 333333283333350000
进程-3 完成计算,结果: 333333283333350000
进程-4 完成计算,结果: 333333283333350000
多进程耗时: 0.3528 秒  (明显快于单进程和多线程,接近 4 倍加速)

结论: 对于 CPU 密集型任务,多线程并不能提高效率 (由于 GIL),甚至可能降低效率。 多进程可以实现真正的并行,显著提高效率

异步编程 (Asyncio) ⏱️

除了多线程和多进程,Python 还提供了 异步编程 (Asynchronous Programming),通过 asyncio 模块实现。 异步编程是另一种实现并发的方式,特别适合 I/O 密集型任务

Asyncio 核心概念:

  • 事件循环 (Event Loop): Asyncio 的核心。 事件循环就像一个 调度员,不断地 轮询 (polling) 各种任务的状态 (是否准备好执行),并调度准备好的任务执行。

  • 协程 (Coroutine): 使用 async def 定义的函数。 协程是 可暂停可恢复 的函数。 当协程遇到 I/O 等待时,它可以 暂停 执行,将控制权交还给事件循环,让事件循环去执行其他就绪的协程。 当 I/O 操作完成后,协程可以被 恢复 执行。

  • await 关键字: 用于在协程中 等待 一个 awaitable 对象 (例如,另一个协程、Future、Task 等) 完成。 当遇到 await 时,协程会 暂停,并将控制权交还给事件循环。

Asyncio 例子 (并发网络请求):

import asyncio
import aiohttp
import time

async def fetch_url(url):
    """异步获取 URL 内容"""
    print(f"开始请求: {url}")
    async with aiohttp.ClientSession() as session: # 创建异步 HTTP 会话
        async with session.get(url) as response: # 异步 GET 请求
            content = await response.text() # 异步等待响应
            print(f"完成请求: {url}, 状态码: {response.status}, 内容长度: {len(content)}")
            return content

async def main():
    urls = [
        "https://www.example.com",
        "https://www.google.com",
        "https://www.python.org"
    ]
    tasks = [fetch_url(url) for url in urls] # 创建任务列表
    results = await asyncio.gather(*tasks) # 并发执行所有任务,并等待结果
    print("所有请求完成.")
    return results

if __name__ == "__main__":
    start_time = time.time()
    results = asyncio.run(main()) # 运行事件循环
    end_time = time.time()
    print(f"总耗时: {end_time - start_time:.4f} 秒")

代码解释:

  1. import asyncio, import aiohttp: 导入 asyncioaiohttp 库 (需要安装 aiohttp: pip install aiohttp)。 aiohttp 是一个异步 HTTP 客户端库。

  2. async def fetch_url(url):: 定义一个异步协程函数 fetch_url

  3. async with aiohttp.ClientSession() as session:: 创建一个异步 HTTP 会话。 使用 async with 确保会话在使用完毕后正确关闭。

  4. async with session.get(url) as response:: 发送异步 GET 请求。

  5. content = await response.text(): 异步等待响应内容。 当遇到 await 时,fetch_url 协程会暂停,将控制权交还给事件循环,事件循环可以去执行其他协程。 当网络请求完成后,fetch_url 协程会被恢复执行。

  6. async def main():: 主协程函数。

  7. tasks = [fetch_url(url) for url in urls]: 创建任务列表,每个任务是一个 fetch_url 协程。

  8. results = await asyncio.gather(*tasks): 使用 asyncio.gather() 并发执行所有任务。 asyncio.gather() 接收一个协程列表,并发地运行这些协程,并返回一个包含所有协程结果的列表。 await asyncio.gather(*tasks) 会等待所有任务完成。

  9. asyncio.run(main()): 运行事件循环,并执行 main 协程。

运行代码: 你会看到 3 个 URL 的请求并发执行,总耗时会远小于串行请求的时间。

选择合适的并发方式 (面试常考):

任务类型推荐方案理由
CPU 密集型多进程 (multiprocessing)绕过 GIL,实现真正的并行,充分利用多核 CPU。
I/O 密集型多线程 (threading) 或 异步编程 (asyncio)多线程可以提高并发性,异步编程更轻量级,效率更高 (通常)。
混合型 (CPU+I/O)多进程 + 多线程/异步编程使用多进程处理 CPU 密集型部分,在每个进程内使用多线程或异步编程处理 I/O 密集型部分。

面试常见问题回顾 (Python 并发、多线程、GIL):

  • 什么是并发?什么是并行? 它们有什么区别?

  • 什么是进程?什么是线程? 它们有什么区别?

  • Python 中如何创建和管理线程? (使用 threading 模块)

  • 什么是 GIL (全局解释器锁)? 它的作用是什么? 为什么 CPython 中要有 GIL?

  • GIL 对 Python 多线程有什么影响? 对于 CPU 密集型任务和 I/O 密集型任务,多线程的表现有什么不同?

  • Python 多线程能实现真正的并行吗? 为什么?

  • 如何在 Python 中实现真正的并行? (使用 multiprocessing 模块)

  • 什么是异步编程? Python 中如何进行异步编程? (使用 asyncio 模块)

  • 什么是事件循环? 什么是协程? await 关键字的作用是什么?

  • 多线程、多进程、异步编程,它们分别适用于什么场景? 如何选择合适的并发方式?

  • 如何解决 Python 多线程中的线程安全问题? (例如,使用锁、条件变量、队列等)

  • 你了解哪些 Python 并发编程的高级库? (例如,concurrent.futures, asyncio.Queue, asyncio.Lock, 等)

总结 🏁

恭喜你!🎉 你又完成了一个 Python 核心主题的 101 教程! 这次我们一起深入探索了 Python 的多线程、并发、以及神秘的 GIL 锁。 我们区分了并发和并行,理解了线程和进程,学习了如何使用 threadingmultiprocessingasyncio 进行并发编程。 最重要的是,我们揭开了 GIL 的面纱,理解了它对 Python 多线程的影响,以及如何选择合适的并发方式来应对不同的任务类型。

Python 的并发编程是一个强大而灵活的工具箱。 掌握这些工具,你可以构建更高效、更响应迅速的 Python 应用。 继续练习,不断探索,你会成为 Python 并发大师! 🚀

💪 祝你在 Python 并发编程的道路上越走越远! 💯

0
Subscribe to my newsletter

Read articles from Lewis Lovelock directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Lewis Lovelock
Lewis Lovelock

I am a developer working in BGI located in Shenzhen. I am familiar with genomics 🧬 and coding. I love 🏀 👩🏻‍💻 and Hiphop🎵