第49课:并发编程:多线程(threading)入门
1. 学习目标
完成本课学习后,你将能够:
- 理解进程与线程的区别:明确它们在并发编程中的角色。
- 掌握
threading模块的基本用法:使用Python标准库创建和管理线程。 - 创建并启动一个或多个线程:编写第一个多线程程序。
- 了解多线程在I/O密集型任务中的优势:以及它在CPU密集型任务中的局限性。
2. 核心概念
什么是线程? 想象一个餐厅(进程)。厨房里有一个主厨(主线程)。当客人很多时,一个主厨忙不过来,于是雇佣了多个帮厨(子线程)同时做不同的菜(执行不同的任务)。这些帮厨共享厨房的资源(如食材、厨具),但各自处理自己的任务。这就是多线程。
Python的threading模块
Python标准库提供了threading模块来创建和管理线程。核心是Thread类。
创建线程的两种方式
- 传入一个函数:这是最直接的方法。将一个可调用的函数(及其参数)传递给
Thread类的构造函数。 - 继承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秒。
- 编写一个函数
fetch_data(api_name, url),它打印"正在请求 {api_name}...",然后time.sleep(0.5),最后打印"{api_name} 数据获取成功!"。 - 创建一个包含4个
(api_name, url)元组的列表(如[("天气API", "http://..."), ("新闻API", "http://..."), ...])。 - 使用多线程同时发起所有请求。
- 计算并打印多线程方式所需的总时间。对比如果顺序执行需要多久(应该是
0.5 * 4 = 2秒)。
练习3:(挑战)线程参数与返回值
创建一个线程,让它计算一个数字的阶乘。主程序需要获取这个计算结果。
- 定义一个函数
calculate_factorial(n),计算并返回n的阶乘。 - 如何在线程中获取返回值? (提示:线程函数不直接返回。可以传入一个可变对象,如列表或字典,让函数将结果存进去,或者在函数内部将结果存储在一个共享的、线程安全的变量中。对于初学者,可以先用一个列表来存储结果)。
- 启动线程计算
5!,并在主线程等待它完成后,打印出结果。
5. 常见错误
-
忘记调用
start(),直接调用了run():# 错误示范 my_thread = threading.Thread(target=my_function) my_thread.run() # 这不会创建新线程,而是在主线程中同步执行函数正确做法:务必调用
start()方法来启动新线程。 -
误以为多线程能让CPU密集型任务变快: 由于GIL的存在,对于纯计算任务(如计算一百万个素数),使用多线程可能并不会带来速度提升,甚至可能因为线程切换开销而变慢。对于这类任务,应考虑使用
multiprocessing模块(多进程)或concurrent.futures中的ProcessPoolExecutor。 -
忽略线程安全问题: 当多个线程同时读写同一个全局变量或数据结构时,可能会导致数据混乱(竞态条件)。本课未涉及,但这是下一课"锁"的核心内容。警告:在学习如何使用锁之前,避免让多个线程修改同一个变量。
6. 小结
- 线程是操作系统能够进行运算调度的最小单位,它被包含在进程之中,是进程中的实际运作单位。
- 使用Python的
threading模块可以轻松创建线程,核心是Thread类和start()方法。 join()方法用于等待线程结束,这在需要所有线程结果后再继续时非常有用。- 多线程最适合I/O密集型任务(网络、文件操作),能显著提高程序效率。
- 对于CPU密集型任务,由于GIL的存在,多线程的收益有限,应考虑多进程方案。
- 在共享数据时,必须小心线程安全问题,这将在下一课中学习使用
Lock来解决。
下节预告:我们已经学会了如何创建和启动线程,但当多个线程需要访问相同的数据时,问题就来了。下一课我们将学习线程安全与锁(Lock, RLock),这是编写健壮多线程程序的关键。