def get_html_content(url: str) -> str:
time.sleep(PAGE_DOWNLOAD_DELAYS.get(url, 0.02))
return _read_example_page(url)
async def get_html_content_async(url: str) -> str:
await asyncio.sleep(PAGE_DOWNLOAD_DELAYS.get(url, 0.02))
return _read_example_page(url)
用一个小爬虫学懂异步。
从广度优先搜索开始,对比线程与 asyncio,再通过聚焦的例子练习 timeout、retry、semaphore 和 cancellation。
一个包含环路、重复链接与过滤链接的确定性网站。
一次只学习一个概念。
先运行核心比较,再逐课打开进阶例子。每一课都包含可观察实验和进入下一步之前的自查。
循序渐进的课程
每一节都有需要理解的想法、可运行代码,以及继续之前应该回答的问题。
00 网站图、BFS 与 crawler 状态
Crawler 把页面看作节点、把链接看作边。先从顺序版本开始,队列顺序是可观察且确定的。
urls_to_crawl保存已经发现、等待抓取的工作。discovered_urls阻止环路和重复 URL。fetched_urls记录完成的页面。
先预测抓取顺序,再运行基线版本验证你的判断。
python3 synchronous_crawlers.py
python3 -m unittest -v test_crawlers
自查:为什么 URL 应在入队时就被标记为 discovered,而不是抓取完成以后?
01 Thread pool、Future 与等待进展
线程池会在线程中运行阻塞式抓取函数。submit() 立即返回 Future,它是一个指向“可能还在运行的任务”的句柄。
Crawler 保存 pending futures,知道哪些工作正在进行;等第一个完成后调用 result() 观察异常,再提交更多 URL。
python3 compare_examples.py
# 阅读:
ThreadPoolWebCrawler.run()
wait(..., FIRST_COMPLETED)
自查:为什么 crawler 只按照空闲 worker slot 提交任务,而不是把所有发现的 URL 一次性提交?
02 Asyncio Task、await 与 event loop
Asyncio 会把 Task 调度到一个 event loop thread 上。Task 在 await 处暂停,让 event loop 有机会推进其他正在等待的任务。
这个 async crawler 只在一个 coordinator 方法里更新共享状态;完成的 task 返回链接,由 coordinator 统一入队,所以不需要线程锁。
python3 async_crawler.py
python3 compare_examples.py
# 阅读:
AsyncWebCrawler.run()
asyncio.wait(..., FIRST_COMPLETED)
自查:为什么并发完成顺序可能不同于顺序 BFS,即使所有可达 URL 最终仍会被找到?
03 Timeout:限制等待占用容量的时间
非阻塞等待仍会占用一个请求槽位。Timeout 防止某个很慢的页面无限期占住容量。
python3 -m lessons.lesson_timeout
return await asyncio.wait_for(
get_html_content_async(url),
timeout=timeout_seconds,
)
实验:将 timeout 调到低于模拟页面延迟。fetch_with_timeout() 应返回什么结果?
04 Retry with backoff:恢复但不过度请求
只对暂时性错误进行 retry。指数退避会拉开后续尝试的距离,让有压力的服务有恢复时间。
python3 -m lessons.lesson_retry
delay_seconds = base_delay_seconds * (
2 ** (attempt_number - 1)
)
自查:为什么错误 URL 或权限失败通常不应该 retry?
05 Semaphore:限制活跃异步请求
Semaphore 限制受保护抓取区域中的活跃工作。它能避免请求突发,但不会限制已经发现的 URL 总量。
自查:还需要什么单独的限制,才能避免巨大网站不断扩大抓取 frontier?
06 Cancellation:停止并收集未完成 tasks
Cancellation 发出停止工作的信号。继续 await 被取消的 tasks 可以让清理完成,并观察它们的最终状态。
python3 -m lessons.lesson_cancellation
task.cancel()
await asyncio.gather(
task, return_exceptions=True
)
自查:为什么创建 tasks 却没有最终 await 或 cancel 它们会有风险?
不离开课程,也能阅读关键代码。
先看带解释的关键片段,再直接在本页打开完整可运行源码。GitHub 是可选入口,不是学习过程中的必经跳转。
submit() 返回 Future;wait() 让协调逻辑等待至少一次抓取完成。future = thread_pool.submit(self.crawl_page, url)
self.pending_futures.add(future)
done_futures, pending_futures = wait(
self.pending_futures,
return_when=FIRST_COMPLETED,
)
for future in done_futures:
future.result()
task = asyncio.create_task(self.fetch_links(url))
pending_tasks.add(task)
done_tasks, pending_tasks = await asyncio.wait(
pending_tasks,
return_when=asyncio.FIRST_COMPLETED,
)
for task in done_tasks:
url, discovered_links = task.result()
改变输入,然后解释发生了什么。
这些小模拟聚焦于 Python 可运行例子背后的思维模型。
让等待时间重叠
这里请求已经被预先知道。提高上限会重叠等待时间,而不会让单个页面自身变快。
模拟总耗时:
逐步执行一次 crawl
使用两个请求槽位,每次推进一个事件,观察 discovered、等待、运行和完成 URL 的变化。
组合 Timeout 与 Retry
课后设计实验:将两个独立的可运行课程组合起来。设定页面延迟与暂时失败次数,在真正实现前先预测请求的最终结果。
模型:最多尝试 3 次,退避延迟依次为 1、2 ticks。阅读 lesson_timeout.py 和 lesson_retry.py 后,试着画出组合方案。
你能预测行为吗?
先回答,再回去查看代码。能够解释原因,比记住语法更重要。
为什么不能在 async def 中直接调用 requests.get()?
用自己的话解释
Asyncio 需要 task 在 await 处让出控制权。阻塞调用会占住 event loop 线程,导致其他 tasks 无法继续推进。
max_concurrency=2 限制的是什么?
用自己的话解释
它限制的是活跃 fetch 数量,不是已发现页面总数。Crawler 仍可能发现很多 URL,并把它们保存在等待队列中。
pool.submit(crawl_page, url) 会立即返回什么?
用自己的话解释
Future 让 coordinator 记住已提交工作,等待它完成,并通过 result() 暴露 worker 中的意外异常。
取消 pending tasks 后,为什么还需要 await 它们?
用自己的话解释
Cancellation 会被送进 coroutine 内部。继续 await 可以让 task 执行清理逻辑,也让调用方观察它们的最终状态。