多线程爬虫脏数据源于共享资源未加锁导致的竞态条件;必须用threading.lock保护全局计数器、汇总列表、缓存字典等读写操作,而queue.queue、threading.local()和纯函数返回值是更优替代方案。

多线程爬虫本身不会直接产生脏数据,但共享变量或资源未加保护时,必然出现脏数据。 本质不是“爬虫”出问题,而是你写的解析逻辑、计数器、缓存字典、全局列表这些被多个线程同时读写导致的竞态条件(race condition)。
threading.Lock 不是万能锁,只保护它包裹的代码段
很多人误以为 threading.Lock() 一创建就自动锁住所有变量。其实它完全不感知数据,只控制执行流。你必须显式在读写共享资源前调用 acquire(),操作完立刻 release(),否则毫无作用。
- 没加锁的变量(比如直接修改
results.append(...))照样被并发写坏 - 锁对象本身要是全局或实例级的,不能每次进函数都新建一个
threading.Lock() - 忘记
release()会导致死锁;用try/finally或上下文管理器更安全
推荐写法:
lock = threading.Lock() # 全局或类属性,只初始化一次 <p>def parse_page(html): data = extract_data(html) with lock: # 自动 acquire/release results.append(data) # 这里才是临界区 </p>
哪些场景必须加锁,哪些根本不用
不是所有共享都要锁 —— 锁带来性能开销,且可能引发死锁。关键看是否「多线程同时读写同一内存地址」:
图片提示词生成器?不止如此。 马甲系统 —— 把脑海中的画面,翻译成AI能理解的专业表达。 用得越多,它越懂你:首次需要多问几句确认方向,用久了几乎一说就懂。 用得越多,它越快:缓存机制让后续对话越来越省。 RAG进化:成功案例持续入库,越跑越聪明。 输入「新手指南」查看完整功能介绍
-
必须锁:全局计数器(
total_count += 1)、汇总列表(all_items.append(...))、缓存字典(cache[url] = html) -
不用锁:每个线程自己创建的局部变量、只读的配置字典、
requests.Session实例(本身线程安全) -
容易错判:用
queue.Queue传递任务或结果——它内部已加锁,put()/get()无需额外锁
比 Lock 更轻量、更自然的替代方案
Lock 是最底层的同步原语,但多数爬虫场景有更合适的选择:
-
queue.Queue:任务分发和结果收集首选。线程安全,自带阻塞与容量控制,task_done()和join()能精准等待全部完成 -
threading.local():适合存线程私有状态,比如每个线程独立的数据库连接、用户代理字符串、临时解析上下文 —— 完全避免共享,也就不需要锁 - 局部变量 + 返回值:把解析逻辑写成纯函数,返回结构化数据,由主线程统一收集。这是最干净、最容易测试的方式
例如:parse_page(html) 只返回 dict,不在函数内碰任何全局变量,那它天然线程安全。
调试脏数据时,别只盯着 Lock 是否加了
实际排查中,90% 的“锁了还脏”问题出在别处:
- 锁对象作用域错误:在函数内新建
lock = threading.Lock(),每个线程拿到的都是不同锁 - 临界区遗漏:比如先查
if url not in seen:,再seen.add(url),中间没锁 → 两个线程同时通过判断,重复处理 - 非原子操作:
cache[url] = cache.get(url, 0) + 1看似一行,实为读-改-写三步,必须整体锁住 - 第三方库非线程安全:某些 HTML 解析器或 JSON 库在多线程下需额外注意(虽 requests/beautifulsoup 通常没问题)
真正难的从来不是“怎么加锁”,而是“哪里需要加锁”——这要求你清楚每行代码访问了哪些共享状态,以及这些状态的生命周期是否跨线程。
Python免费学习笔记(深入):立即使用
在学习笔记中,你将探索 Python 的核心概念和高级技巧!










