多线程是爬虫性能优化的核心手段之一,尤其适合处理高延迟网络请求和大规模数据抓取。运用好多线程可以让我们很多程序组成和运行中事半功倍。
目标:学会用多线程让程序“一心多用”,从“单线程搬砖”到“多线程开挂”!
学习目标:掌握线程原理、共享变量与互斥锁,从此告别“程序卡死”的尴尬!
8.1 线程是什么?为什么程序员需要“分身术”?
核心概念:
- 线程:程序执行的最小单位,可以理解为“轻量级进程”(比如同时边听歌边写代码)
- 多线程优势:
- CPU利用率高:避免等待I/O时“空转”(如下载文件时同时处理其他任务)
- 响应速度快:主线程卡顿时,子线程仍能运行(比如游戏主循环与音效分离)
举个栗子🌰:
python
import threading
def dance():
print("线程1:正在跳广场舞!")
def sing():
print("线程2:正在唱《孤勇者》!")
# 创建两个线程并启动
t1 = threading.Thread(target=dance)
t2 = threading.Thread(target=sing)
t1.start()
t2.start()
多线程在爬虫中的的核心作用
-
并发请求处理
- 同时发起多个HTTP请求,减少等待时间(如同时爬取100个页面,单线程需100秒,10线程仅需10秒)。
- 示例:处理分页数据时,同时下载多个页面。
-
资源利用率优化
- 充分利用CPU多核特性(但Python因GIL限制,多线程更适合I/O密集型任务)。
- 避免因单线程阻塞(如等待响应)导致资源闲置。
-
提升响应速度
- 异步处理耗时操作(如文件写入、数据解析),避免主线程被阻塞。
8.2 共享全局变量:线程间的“爱情公寓”
问题场景:
python
import threading
# 全局变量(公共财产)
counter = 0
def add_counter():
global counter
for _ in range(100000):
counter += 1
# 启动两个线程同时修改counter
t1 = threading.Thread(target=add_counter)
t2 = threading.Thread(target=add_counter)
t1.start()
t2.start()
t1.join()
t2.join()
print("最终结果:", counter) # 可能输出小于200000的值!
问题原因:
- 竞态条件:多个线程同时读写同一变量,导致数据不一致(就像两个人同时往存钱罐里塞硬币,结果数错了)
8.3 互斥锁:线程间的“排队叫号机”
原理与用法:
python
import threading
counter = 0
lock = threading.Lock() # 创建锁对象
def safe_add():
global counter
for _ in range(100000):
lock.acquire() # 抢锁(如果锁被占用则阻塞)
counter += 1
lock.release() # 释放锁
t1 = threading.Thread(target=safe_add)
t2 = threading.Thread(target=safe_add)
t1.start()
t2.start()
t1.join()
t2.join()
print("安全结果:", counter) # 输出200000
锁的三种状态:
状态 | 说明 | 比喻 |
---|---|---|
未锁定 | 锁可以被任何线程获取 | 公共厕所空闲中 |
锁定 | 锁已被某个线程持有 | 有人正在使用厕所 |
超时 | 等待锁超时(需设置timeout参数) | 等了10分钟厕所没人出来 |
8.4 实战项目:多线程爬虫(带锁保护)
需求:
- 多线程抓取网页标题
- 用锁保护共享的存储列表
- 统计抓取进度
代码实现:
python
import threading
import requests
from bs4 import BeautifulSoup
urls = [
"https://example.com/page1",
"https://example.com/page2",
# ... 更多URL
]
titles = []
lock = threading.Lock()
progress = 0
def fetch_url(url):
global progress
try:
response = requests.get(url)
soup = BeautifulSoup(response.text, 'html.parser')
title = soup.title.string
# 用锁保护共享列表
with lock:
titles.append((url, title))
# 更新进度(加锁防止打印错乱)
with lock:
progress += 1
print(f"进度:{progress}/{len(urls)}")
except Exception as e:
print(f"抓取失败:{url} → {str(e)}")
# 创建并启动线程
threads = []
for url in urls:
t = threading.Thread(target=fetch_url, args=(url,))
t.start()
threads.append(t)
# 等待所有线程完成
for t in threads:
t.join()
print("抓取完成!共获取", len(titles), "条数据")
8.5 GIL(全局解释器锁):Python多线程的“紧箍咒”
关键点:
- GIL的作用:同一时刻仅允许一个线程执行Python字节码(导致CPU密集型任务无法真正并行)
- 适用场景:
- I/O密集型:网络请求、文件读写(多线程有效)
- CPU密集型:数学计算(建议用多进程
multiprocessing
模块)
代码验证:
python
import threading
def count_up():
global count
for _ in range(10000000):
count += 1
count = 0
# 多线程执行(结果可能小于20000000)
t1 = threading.Thread(target=count_up)
t2 = threading.Thread(target=count_up)
t1.start()
t2.start()
t1.join()
t2.join()
print("多线程结果:", count) # 可能输出19999999
# 多进程执行(结果准确)
from multiprocessing import Process
count = 0
def count_up_mp():
global count
for _ in range(10000000):
count += 1
p1 = Process(target=count_up_mp)
p2 = Process(target=count_up_mp)
p1.start()
p2.start()
p1.join()
p2.join()
print("多进程结果:", count) # 输出20000000
8.6 避坑指南:线程的“常见翻车现场”
- 死锁:多个线程互相等待对方释放锁(比如线程A拿锁1等锁2,线程B拿锁2等锁1)
- 解决:按固定顺序获取锁,或设置超时时间(
lock.acquire(timeout=5)
)
- 解决:按固定顺序获取锁,或设置超时时间(
- 资源竞争:过度使用锁导致性能下降(比如每个变量都加锁)
- 优化:缩小锁的范围(只在必要时加锁)
- 守护线程:主线程退出时子线程可能被强制终止
- 解决:设置
daemon=False
(默认值),或手动join()
等待
- 解决:设置
8.7 学习资源推荐:成为线程大师的捷径
- 在线工具:
- Python Threading Debugger(可视化调试多线程程序)
- 书籍推荐:
- 《Python并发编程实战》(详解GIL与多进程/多线程区别)
- 避坑秘籍:
- 优先用
concurrent.futures
模块简化线程池管理 - 避免在锁内执行耗时操作(如网络请求)
- 优先用
8.8 本章小结
-
核心技能:
- 理解线程原理与GIL限制
- 用锁保护共享资源
- 实现多线程爬虫与进度统计
-
高阶技巧:
- 用
ThreadPoolExecutor
实现线程池(自动管理线程复用) - 结合
Queue
模块实现生产者-消费者模型
- 用