杰瑞科技汇

Python多线程如何高效实现并发任务?

“多线程”是 Python 中实现并发的一种重要方式,就是让一个程序能够同时“做多件事”,就像一个人可以一边听音乐一边工作一样。

Python多线程如何高效实现并发任务?-图1
(图片来源网络,侵删)

目录

  1. 为什么需要多线程?—— 解决痛点
  2. 核心概念:进程 vs. 线程
  3. Python 中的多线程:threading 模块
    • 创建和启动线程
    • join() 方法:等待线程结束
    • 线程安全与锁 (Lock)
    • 线程间通信:Queue 队列
  4. Python 多线程的“阿喀琉斯之踵”:GIL (全局解释器锁)
  5. 何时使用多线程?何时避免?
  6. 实战案例:多线程下载器

为什么需要多线程?—— 解决痛点

想象一个场景:你正在写一个 Python 脚本,需要从一个网站下载 10 个文件,如果你用一个单线程的方式,代码会是这样:

# 伪代码
for url in url_list:
    download(url) # 下载一个文件,需要等待它完成
    print(f"下载完成: {url}")

这个脚本的执行过程是:下载完第一个文件,才开始下载第二个,以此类推,在下载文件时,程序大部分时间都在等待网络响应,CPU 是空闲的,这显然效率很低。

多线程就是为了解决这个问题:我们可以创建 10 个“线程”,每个线程负责下载一个文件,这样,当一个线程在等待网络时,CPU 可以切换到另一个正在准备下载的线程,从而实现“下载,大大提高了程序的效率。

多线程主要适用于处理 I/O 密集型任务

Python多线程如何高效实现并发任务?-图2
(图片来源网络,侵删)
  • I/O 密集型:程序大部分时间都在等待外部操作完成,如网络请求、文件读写、数据库查询等。
  • CPU 密集型:程序大部分时间都在进行大量的计算,如科学计算、图像处理、数据分析等。

核心概念:进程 vs. 线程

在深入 Python 多线程之前,必须理解这两个概念的区别:

特性 进程 线程
定义 操作系统进行资源分配和调度的基本单位。 进程中的一个执行单元,是 CPU 调度的基本单位。
资源 拥有独立的内存空间和系统资源,一个进程崩溃不会影响其他进程。 共享进程内的内存和资源,一个线程崩溃可能导致整个进程崩溃。
开销 创建和销毁进程的开销大,因为需要分配独立的内存空间。 创建和销毁线程的开销小。
通信 进程间通信复杂,需要通过 IPC (Inter-Process Communication) 机制,如管道、队列。 线程间通信简单,可以直接读写共享变量(但需要注意线程安全)。
关系 进程是线程的容器,一个进程可以包含一个或多个线程。 线程是进程的一部分。

一个生动的比喻:

  • 进程 就像一个 工厂,它拥有自己的土地、厂房、设备(内存、文件等)。
  • 线程 就像工厂里的 工人,多个工人在同一个工厂里工作,共享工厂的资源(工具、材料)。
  • 多进程就是开多个工厂。
  • 多线程就是在同一个工厂里雇佣多个工人。

Python 中的多线程:threading 模块

Python 提供了内置的 threading 模块来支持多线程编程。

1 创建和启动线程

有两种主要方式来创建线程:

Python多线程如何高效实现并发任务?-图3
(图片来源网络,侵删)

继承 threading.Thread

import threading
import time
def download_file(url):
    """模拟下载文件"""
    print(f"开始下载: {url}")
    time.sleep(2)  # 模拟耗时操作,如网络请求
    print(f"下载完成: {url}")
# 创建线程对象
# target 参数指定线程要执行的函数
# args 参数是一个元组,用于向函数传递参数
thread1 = threading.Thread(target=download_file, args=("http://file1.com",))
thread2 = threading.Thread(target=download_file, args=("http://file2.com",))
# 启动线程 (此时线程开始执行,但主线程不会等待它)
thread1.start()
thread2.start()
# 主线程继续执行
print("主线程继续执行...")

使用 threading.Thread 构造函数直接传递函数

这种方式更简洁,适合不需要继承 Thread 类的简单场景。

import threading
import time
def play_music():
    """模拟播放音乐"""
    print("开始播放音乐")
    time.sleep(5)
    print("音乐播放结束")
# 创建并启动线程
music_thread = threading.Thread(target=play_music)
music_thread.start()
print("主线程在听音乐的同时可以做别的事...")

2 join() 方法:等待线程结束

当你启动一个线程后,主线程会立即继续往下执行,而不会等待子线程执行完毕,如果主线程在子线程结束前就退出了,那么整个程序(包括所有子线程)都会被强制终止。

thread.join() 的作用就是 阻塞主线程,直到调用 join() 的那个子线程执行完毕

import threading
import time
def task():
    print("子线程开始...")
    time.sleep(2)
    print("子线程结束...")
t = threading.Thread(target=task)
t.start()
# 主线程在这里暂停,等待子线程 t 执行完毕
print("主线程等待子线程...")
t.join()
# 只有当子线程 t 结束后,这行代码才会被执行
print("所有线程都执行完毕,程序退出。")

3 线程安全与锁 (Lock)

当多个线程同时读写同一个共享资源时,就会发生 竞态条件,导致数据混乱。

示例:银行账户取钱(不安全)

import threading
balance = 100  # 共享资源
def withdraw(amount):
    global balance
    # 检查余额
    if balance >= amount:
        # 模拟网络延迟,在这段时间内,CPU 可能会切换到另一个线程
        # 这就是导致问题的根源
        threading.Event().wait(0.1) 
        # 执行取款
        balance -= amount
        print(f"取款 {amount} 成功,剩余余额: {balance}")
    else:
        print(f"余额不足,无法取款 {amount}")
# 创建并启动两个线程,同时取钱
t1 = threading.Thread(target=withdraw, args=(80,))
t2 = threading.Thread(target=withdraw, args=(50,))
t1.start()
t2.start()
t1.join()
t2.join()
print(f"最终账户余额: {balance}") # 预期结果是 20,但实际可能是 20 或 -30

如何解决?使用锁 (Lock)

锁就像一个“卫生间”,一次只能有一个人进去,线程在访问共享资源前,先获取锁,访问完后,再释放锁。

import threading
balance = 100
lock = threading.Lock() # 创建一个锁
def withdraw_with_lock(amount):
    global balance
    # 获取锁
    lock.acquire()
    try:
        if balance >= amount:
            threading.Event().wait(0.1)
            balance -= amount
            print(f"取款 {amount} 成功,剩余余额: {balance}")
        else:
            print(f"余额不足,无法取款 {amount}")
    finally:
        # 释放锁 (非常重要!通常用 try-finally 确保锁一定会被释放)
        lock.release()
t1 = threading.Thread(target=withdraw_with_lock, args=(80,))
t2 = threading.Thread(target=withdraw_with_lock, args=(50,))
t1.start()
t2.start()
t1.join()
t2.join()
print(f"最终账户余额: {balance}") # 输出结果永远是 20

with lock 语句(推荐用法)

with 语句可以自动处理锁的获取和释放,即使发生异常也能保证锁被释放,代码更简洁安全。

def withdraw_with_with(amount):
    global balance
    with lock: # 自动 acquire() 和 release()
        if balance >= amount:
            threading.Event().wait(0.1)
            balance -= amount
            print(f"取款 {amount} 成功,剩余余额: {balance}")
        else:
            print(f"余额不足,无法取款 {amount}")

4 线程间通信:Queue 队列

当多个线程需要协作时,一个线程产生的数据需要传递给另一个线程,直接使用共享变量容易出错,而 queue.Queue 是一个线程安全的队列,专门用于线程间通信。

Queue 提供了 .put() 方法来添加数据,和 .get() 方法来获取数据。

import threading
import queue
import time
# 创建一个线程安全的队列
q = queue.Queue()
def producer():
    """生产者:向队列中添加数据"""
    for i in range(5):
        print(f"生产者: 放入数据 {i}")
        q.put(i)
        time.sleep(0.5)
def consumer():
    """消费者:从队列中取出数据"""
    while True:
        try:
            # 从队列中获取数据,如果队列为空,会阻塞
            item = q.get(timeout=2) # 设置超时,避免无限等待
            print(f"消费者: 取到数据 {item},正在处理...")
            time.sleep(1)
        except queue.Empty:
            print("消费者: 队列为空,退出。")
            break
# 创建并启动线程
p = threading.Thread(target=producer)
c = threading.Thread(target=consumer)
p.start()
# 启动消费者前,可以稍等一下,确保生产者已经开始
time.sleep(0.2) 
c.start()
# 等待生产者完成
p.join()
# 通知消费者队列已结束,可以退出了
# 一种简单的方式是向队列中放入一个特殊的“哨兵”值
# 但在这个例子中,我们使用了超时机制来优雅地退出
# 在实际应用中,生产者可以在任务完成后,调用 q.task_done() 和 q.join()
# 但这里为了简单,我们让消费者超时后退出。
c.join()
print("生产者和消费者线程都结束了。")

Python 多线程的“阿喀琉斯之踵”:GIL (全局解释器锁)

这是理解 Python 多线程最关键的一点!

GIL 是什么? GIL 是 Python 解释器(特别是 CPython)中的一个互斥锁,它确保在任何时刻,只有一个线程能执行 Python 字节码

GIL 带来的影响:

  • 对于 CPU 密集型 任务,多线程并不能真正实现并行,因为无论你有多少个线程,同一时间只有一个线程在运行,它只是通过快速切换线程来“看起来像”在同时运行,这被称为“并发”而非“并行”,在这种情况下,多线程的性能可能还不如单线程,因为线程切换本身也有开销。
  • 对于 I/O 密集型 任务,多线程仍然非常有效,因为当一个线程在等待 I/O(如网络请求)时,GIL 会被释放,其他线程就可以获得 GIL 并开始执行,这极大地提高了程序的效率。

如何绕过 GIL? 如果你需要进行 CPU 密集型的并行计算,应该使用:

  • 多进程 (multiprocessing 模块):每个进程有自己的 Python 解释器和内存空间,不受 GIL 限制,但进程创建和通信的开销比线程大。
  • C 扩展:将计算密集型的部分用 C 语言编写,并通过 Python 调用,C 代码在执行时可以释放 GIL。
  • 其他实现:如 Jython (运行在 JVM 上) 或 IronPython (运行在 .NET 上) 没有 GIL。

何时使用多线程?何时避免?

场景 推荐方案 原因
I/O 密集型 多线程 (threading) 线程在等待 I/O 时会释放 GIL,其他线程可以继续工作,效率高。
CPU 密集型 多进程 (multiprocessing) 每个 进程独立运行,不受 GIL 限制,能真正利用多核 CPU。
需要大量并发连接 多线程异步编程 (asyncio) 如 Web 服务器,用多线程处理每个连接非常合适。
简单任务,提高响应速度 多线程 如 GUI 程序,用一个线程处理耗时操作,主线程保持界面响应。

实战案例:多线程下载器

下面是一个简单的多线程网页内容下载器,对比单线程和多线程的执行时间。

import threading
import requests
import time
# 模拟要下载的URL列表
urls = [f"http://example.com/page{i}" for i in range(1, 11)]
def download_content(url):
    """下载单个URL的内容"""
    try:
        response = requests.get(url, timeout=5)
        # print(f"成功下载: {url}, 内容长度: {len(response.text)}")
        # 实际应用中,这里可以保存文件
    except requests.RequestException as e:
        print(f"下载失败 {url}: {e}")
# --- 单线程版本 ---
def single_thread_download():
    start_time = time.time()
    for url in urls:
        download_content(url)
    end_time = time.time()
    print(f"单线程总耗时: {end_time - start_time:.2f} 秒")
# --- 多线程版本 ---
def multi_thread_download():
    start_time = time.time()
    threads = []
    for url in urls:
        # 为每个URL创建一个线程
        thread = threading.Thread(target=download_content, 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__":
    print("--- 开始单线程下载 ---")
    single_thread_download()
    print("\n--- 开始多线程下载 ---")
    multi_thread_download()

运行结果(类似):

--- 开始单线程下载 ---
单线程总耗时: 5.12 秒
--- 开始多线程下载 ---
多线程总耗时: 0.85 秒

可以看到,对于这种 I/O 密集型任务,多线程的效率远高于单线程。


  • 多线程 是 Python 实现并发编程的重要工具,核心模块是 threading
  • 它最适合处理 I/O 密集型 任务,能显著提高程序效率。
  • 进程 是资源分配的单位,线程 是 CPU 调度的单位,线程共享进程资源,开销小,但需要处理线程安全问题。
  • 锁 (Lock)队列 (Queue) 是保证多线程程序正确性和可协作性的关键工具。
  • GIL (全局解释器锁) 是 Python 多线程的“枷锁”,它限制了 Python 在 CPU 密集型任务上的并行能力,对于这类任务,应考虑使用 multiprocessing 模块。

希望这个详细的解释能帮助你彻底理解 Python 的多线程!

分享:
扫描分享到社交APP
上一篇
下一篇