49·模块十一:并发与并行高级

并发编程:多线程(threading)入门

concurrencythreadingmultithreading

第49课:并发编程:多线程(threading)入门

1. 学习目标

完成本课学习后,你将能够:

  • 理解进程与线程的区别:明确它们在并发编程中的角色。
  • 掌握threading模块的基本用法:使用Python标准库创建和管理线程。
  • 创建并启动一个或多个线程:编写第一个多线程程序。
  • 了解多线程在I/O密集型任务中的优势:以及它在CPU密集型任务中的局限性。

2. 核心概念

什么是线程? 想象一个餐厅(进程)。厨房里有一个主厨(主线程)。当客人很多时,一个主厨忙不过来,于是雇佣了多个帮厨(子线程)同时做不同的菜(执行不同的任务)。这些帮厨共享厨房的资源(如食材、厨具),但各自处理自己的任务。这就是多线程。

Python的threading模块 Python标准库提供了threading模块来创建和管理线程。核心是Thread类。

创建线程的两种方式

  1. 传入一个函数:这是最直接的方法。将一个可调用的函数(及其参数)传递给Thread类的构造函数。
  2. 继承Thread类:重写run方法。这种方式更面向对象。

启动线程 调用线程对象的start()方法。该方法会启动线程,并让线程开始执行你指定的任务(即run方法)。记住:永远直接调用run()方法,那样它只会在当前线程中同步执行,失去了并发的意义。

GIL(全局解释器锁)是什么? 这是Python(尤其是CPython)的一个重要概念。简单来说,它确保一次只有一个线程能执行Python字节码。这听起来很糟糕,但它让Python的内存管理更安全。这意味着对于CPU密集型任务(如复杂计算),多线程无法利用多核CPU来提升速度。然而,对于I/O密集型任务(如网络请求、文件读写、数据库查询),线程在等待I/O时会释放GIL,允许其他线程运行,从而显著提高效率。

3. 代码示例

示例1:创建你的第一个线程

这个例子展示了如何用函数创建一个线程。

import threading
import time

def task(name, duration):
    """一个简单的任务:打印开始,休眠一段时间,打印结束"""
    print(f"线程 {name}: 开始执行。")
    time.sleep(duration)  # 模拟一个耗时操作,比如下载文件
    print(f"线程 {name}: 执行完毕,耗时 {duration} 秒。")

# 主程序开始
print("主程序开始。")

# 创建一个线程对象
# target: 要执行的函数
# args: 传递给函数的参数,必须是一个元组
my_thread = threading.Thread(target=task, args=("下载", 2))

# 启动线程。这会开始并发地执行task函数
my_thread.start()

# 主程序继续执行自己的代码
print("主程序继续运行,不等待子线程。")
time.sleep(1)
print("主程序休眠了1秒。")

# 等待子线程结束(可选,但通常是好习惯)
my_thread.join()

print("主程序结束。所有线程都已完成。")

输出可能如下(顺序不完全固定):

主程序开始。
线程 下载: 开始执行。
主程序继续运行,不等待子线程。
主程序休眠了1秒。
线程 下载: 执行完毕,耗时 2 秒。
主程序结束。所有线程都已完成。

注意:主线程和子线程的输出可能交错,这体现了并发的特性。

示例2:多线程下载模拟

这个例子更贴近实际场景,展示多线程如何提高I/O密集型任务的效率。

import threading
import time

def download_file(file_name, download_time):
    """模拟下载一个文件"""
    print(f"开始下载 {file_name}...")
    time.sleep(download_time)  # 模拟网络下载耗时
    print(f"{file_name} 下载完成!")

start_time = time.time()

# 要下载的文件列表
files = [
    ("report.pdf", 3),
    ("image.jpg", 2),
    ("data.csv", 1),
]

# 方法1:单线程顺序下载
print("--- 单线程下载 ---")
for name, t in files:
    download_file(name, t)
sequential_time = time.time() - start_time
print(f"顺序下载总耗时: {sequential_time:.2f} 秒\n")

# 方法2:多线程并行下载
print("--- 多线程下载 ---")
start_time = time.time()
threads = []

# 创建并启动线程
for name, t in files:
    thread = threading.Thread(target=download_file, args=(name, t))
    thread.start()
    threads.append(thread)

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

parallel_time = time.time() - start_time
print(f"多线程下载总耗时: {parallel_time:.2f} 秒")

# 对比结果
print(f"\n速度提升: {sequential_time / parallel_time:.2f} 倍")

预期输出:

--- 单线程下载 ---
开始下载 report.pdf...
report.pdf 下载完成!
开始下载 image.jpg...
image.jpg 下载完成!
开始下载 data.csv...
data.csv 下载完成!
顺序下载总耗时: 6.00 秒

--- 多线程下载 ---
开始下载 report.pdf...
开始下载 image.jpg...
开始下载 data.csv...
data.csv 下载完成!
image.jpg 下载完成!
report.pdf 下载完成!
多线程下载总耗时: 3.00 秒

速度提升: 2.00 倍

这个例子清晰地展示了多线程在I/O等待上的优势。总时间约等于最耗时任务的时间,而不是所有任务时间之和。

4. 实践练习

练习1:基础改编

修改示例1中的task函数,让它接收一个参数task_id和一个sleep_time。创建3个线程,分别传递(1, 1), (2, 2), (3, 3)作为参数。启动它们,并观察输出顺序。

练习2:多线程网络请求模拟

假设你需要同时向多个API发送请求以获取数据。每个请求需要耗时0.5秒。

  1. 编写一个函数fetch_data(api_name, url),它打印"正在请求 {api_name}...",然后time.sleep(0.5),最后打印"{api_name} 数据获取成功!"。
  2. 创建一个包含4个(api_name, url)元组的列表(如[("天气API", "http://..."), ("新闻API", "http://..."), ...])。
  3. 使用多线程同时发起所有请求。
  4. 计算并打印多线程方式所需的总时间。对比如果顺序执行需要多久(应该是0.5 * 4 = 2秒)。

练习3:(挑战)线程参数与返回值

创建一个线程,让它计算一个数字的阶乘。主程序需要获取这个计算结果。

  1. 定义一个函数calculate_factorial(n),计算并返回n的阶乘。
  2. 如何在线程中获取返回值? (提示:线程函数不直接返回。可以传入一个可变对象,如列表或字典,让函数将结果存进去,或者在函数内部将结果存储在一个共享的、线程安全的变量中。对于初学者,可以先用一个列表来存储结果)。
  3. 启动线程计算5!,并在主线程等待它完成后,打印出结果。

5. 常见错误

  1. 忘记调用start(),直接调用了run()

    # 错误示范
    my_thread = threading.Thread(target=my_function)
    my_thread.run()  # 这不会创建新线程,而是在主线程中同步执行函数
    

    正确做法:务必调用start()方法来启动新线程。

  2. 误以为多线程能让CPU密集型任务变快: 由于GIL的存在,对于纯计算任务(如计算一百万个素数),使用多线程可能并不会带来速度提升,甚至可能因为线程切换开销而变慢。对于这类任务,应考虑使用multiprocessing模块(多进程)或concurrent.futures中的ProcessPoolExecutor

  3. 忽略线程安全问题: 当多个线程同时读写同一个全局变量或数据结构时,可能会导致数据混乱(竞态条件)。本课未涉及,但这是下一课"锁"的核心内容。警告:在学习如何使用锁之前,避免让多个线程修改同一个变量。

6. 小结

  • 线程是操作系统能够进行运算调度的最小单位,它被包含在进程之中,是进程中的实际运作单位。
  • 使用Python的threading模块可以轻松创建线程,核心是Thread类和start()方法。
  • join()方法用于等待线程结束,这在需要所有线程结果后再继续时非常有用。
  • 多线程最适合I/O密集型任务(网络、文件操作),能显著提高程序效率。
  • 对于CPU密集型任务,由于GIL的存在,多线程的收益有限,应考虑多进程方案。
  • 在共享数据时,必须小心线程安全问题,这将在下一课中学习使用Lock来解决。

下节预告:我们已经学会了如何创建和启动线程,但当多个线程需要访问相同的数据时,问题就来了。下一课我们将学习线程安全与锁(Lock, RLock),这是编写健壮多线程程序的关键。

练习编辑器

python
Loading...

继续学习

完成本课后,建议继续学习下一课「线程安全与锁(Lock, RLock)」 以巩固所学知识。