1. 为什么需要线程?
在Python中,线程(Thread)是最基础的并发编程方式之一。想象你正在经营一家餐厅:如果只有一个服务员(单线程),他需要依次完成点餐、上菜、结账等所有工作,效率显然低下。而引入多个服务员(多线程)后,不同顾客的需求可以同时被处理,这就是线程的核心价值。
我曾在处理一个网络爬虫项目时,单线程下载1000个页面需要近2小时,而使用10个线程后,时间缩短到12分钟左右。这种性能提升在I/O密集型任务中尤为明显,因为线程可以在等待网络响应时释放CPU资源给其他线程使用。
注意:Python中的线程由于GIL(全局解释器锁)的存在,并不适合CPU密集型任务。对于计算密集型场景,建议考虑多进程(multiprocessing)或C扩展。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 线程基础操作
2.1 创建与启动线程
Python标准库提供了threading模块,创建线程有两种常用方式:
python复制import threading
import time
# 方式1:直接实例化Thread类
def task(name):
print(f"{name}开始执行")
time.sleep(1)
print(f"{name}执行完成")
t1 = threading.Thread(target=task, args=("线程1",))
t1.start()
# 方式2:继承Thread类
class MyThread(threading.Thread):
def run(self):
print("自定义线程执行")
time.sleep(2)
print("自定义线程结束")
t2 = MyThread()
t2.start()
实际项目中,我推荐第一种方式,因为它更符合Python的鸭子类型哲学,且避免了不必要的类继承。但第二种方式在需要封装复杂线程逻辑时会更清晰。
2.2 线程的生命周期
一个线程会经历以下状态变化:
- 新建(New):创建Thread对象但未调用start()
- 就绪(Runnable):调用start()后等待CPU调度
- 运行(Running):正在执行任务
- 阻塞(Blocked):等待I/O操作或锁释放
- 终止(Terminated):run()方法执行完毕或抛出未处理异常
通过threading.current_thread()可以获取当前线程对象,其is_alive()方法能判断线程是否在运行状态。
3. 线程同步与控制
3.1 join方法:等待线程结束
python复制def slow_task():
time.sleep(3)
print("耗时任务完成")
t = threading.Thread(target=slow_task)
t.start()
print("主线程继续执行")
t.join() # 等待t线程结束
print("所有线程执行完毕")
在日志收集系统中,我曾遇到主线程提前退出导致子线程日志丢失的问题。通过合理使用join(),确保了所有日志线程完成工作后才退出程序。
3.2 daemon线程:后台守护者
python复制def background_task():
while True:
print("后台任务运行中...")
time.sleep(1)
d = threading.Thread(target=background_task, daemon=True)
d.start()
time.sleep(3)
print("主程序退出,daemon线程会自动终止")
daemon线程适合执行不重要的后台任务,如心跳检测、缓存刷新等。但要注意:
- 不能用于需要保证完整执行的任务(如文件写入)
- 退出时不会执行finally块
- 子线程默认继承父线程的daemon属性
4. 线程安全与锁机制
4.1 竞争条件实例
python复制counter = 0
def increment():
global counter
for _ in range(100000):
counter += 1
threads = []
for i in range(5):
t = threading.Thread(target=increment)
threads.append(t)
t.start()
for t in threads:
t.join()
print(f"最终计数器值: {counter}") # 预期500000,实际可能小于
这个经典例子展示了多线程同时修改共享资源的问题。在我的性能测试中,5个线程并发执行时,实际结果通常在480000-499999之间波动。
4.2 使用Lock保证原子性
python复制counter = 0
lock = threading.Lock()
def safe_increment():
global counter
for _ in range(100000):
with lock: # 自动获取和释放锁
counter += 1
锁的使用要注意:
- 获取锁后必须释放,否则会导致死锁
- 锁粒度要适中:太粗影响并发,太细增加开销
- 避免嵌套锁(容易引发死锁)
- 考虑使用RLock(可重入锁)解决递归调用问题
5. 线程池实战
5.1 ThreadPoolExecutor基础用法
python复制from concurrent.futures import ThreadPoolExecutor
import requests
def fetch_url(url):
resp = requests.get(url)
return len(resp.content)
urls = ["https://example.com"] * 10
with ThreadPoolExecutor(max_workers=4) as executor:
results = list(executor.map(fetch_url, urls))
print(f"总共获取字节数: {sum(results)}")
线程池的优势包括:
- 避免频繁创建/销毁线程的开销
- 方便控制并发数量
- 提供Future对象支持回调机制
5.2 线程池大小设置经验
根据任务类型选择线程数:
- I/O密集型:通常2-5倍CPU核心数
- CPU密集型:由于GIL限制,建议不超过CPU核心数
在我的爬虫项目中,通过以下代码动态调整线程池大小:
python复制import os
import math
def calculate_pool_size():
cpu_count = os.cpu_count() or 1
if is_io_bound_task: # 需要自行判断任务类型
return min(32, cpu_count * 3 + 4)
return cpu_count
6. 常见问题排查
6.1 线程卡死分析
当程序出现假死时,可以通过以下步骤排查:
- 使用
threading.enumerate()获取所有活跃线程 - 检查是否有线程阻塞在I/O操作
- 使用
pdb或py-spy进行堆栈跟踪 - 检查锁的获取/释放是否成对出现
6.2 性能优化技巧
- 使用
threading.local()实现线程局部存储,避免锁竞争 - 对于读多写少场景,考虑使用
RLock或读写锁 - 使用队列(Queue)实现生产者-消费者模式
- 避免在临界区执行耗时操作
python复制from queue import Queue
def worker(q):
while True:
item = q.get()
if item is None: # 终止信号
break
process_item(item)
q.task_done()
q = Queue()
threads = []
for i in range(4):
t = threading.Thread(target=worker, args=(q,))
t.start()
threads.append(t)
for item in source_items:
q.put(item)
q.join() # 等待所有任务完成
for i in range(4): # 停止worker
q.put(None)
for t in threads:
t.join()
7. 线程与协程的选择
虽然线程是传统的并发方案,但在Python中,协程(asyncio)在某些场景更具优势:
| 特性 | 线程 | 协程 |
|---|---|---|
| 并发模型 | 抢占式多任务 | 协作式多任务 |
| 内存占用 | 每个线程约8MB栈 | 轻量级(约1KB) |
| 切换开销 | 高(需要内核介入) | 极低(用户态切换) |
| 适用场景 | 阻塞型I/O操作 | 高并发网络I/O |
| 调试难度 | 较难(竞态条件) | 相对简单 |
在我的Web爬虫实践中,对于简单的HTTP请求,线程池足够高效;但当需要维护数千个长连接时,asyncio的表现更出色。
