Python Concurrency Crawler Lab
离线运行 · 逐步学习 · 可动手修改

用一个小爬虫学懂异步。

从广度优先搜索开始,对比线程与 asyncio,再通过聚焦的例子练习 timeout、retry、semaphore 和 cancellation。

/ /tutorial /reference /examples/basic /examples/concurrent 外链已过滤

一个包含环路、重复链接与过滤链接的确定性网站。

可运行课程

循序渐进的课程

每一节都有需要理解的想法、可运行代码,以及继续之前应该回答的问题。

00 网站图、BFS 与 crawler 状态
学习

Crawler 把页面看作节点、把链接看作边。先从顺序版本开始,队列顺序是可观察且确定的。

  • urls_to_crawl 保存已经发现、等待抓取的工作。
  • discovered_urls 阻止环路和重复 URL。
  • fetched_urls 记录完成的页面。
动手

先预测抓取顺序,再运行基线版本验证你的判断。

python3 synchronous_crawlers.py
python3 -m unittest -v test_crawlers

打开 sequential run loop

自查:为什么 URL 应在入队时就被标记为 discovered,而不是抓取完成以后?

01 Thread pool、Future 与等待进展
学习

线程池会在线程中运行阻塞式抓取函数。submit() 立即返回 Future,它是一个指向“可能还在运行的任务”的句柄。

Crawler 保存 pending futures,知道哪些工作正在进行;等第一个完成后调用 result() 观察异常,再提交更多 URL。

运行
python3 compare_examples.py

# 阅读:
ThreadPoolWebCrawler.run()
wait(..., FIRST_COMPLETED)

打开 Future/wait loop

自查:为什么 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)

打开 asyncio run loop

自查:为什么并发完成顺序可能不同于顺序 BFS,即使所有可达 URL 最终仍会被找到?

03 Timeout:限制等待占用容量的时间
学习

非阻塞等待仍会占用一个请求槽位。Timeout 防止某个很慢的页面无限期占住容量。

动手
python3 -m lessons.lesson_timeout

return await asyncio.wait_for(
    get_html_content_async(url),
    timeout=timeout_seconds,
)

打开 timeout 源码

实验:将 timeout 调到低于模拟页面延迟。fetch_with_timeout() 应返回什么结果?

04 Retry with backoff:恢复但不过度请求
学习

只对暂时性错误进行 retry。指数退避会拉开后续尝试的距离,让有压力的服务有恢复时间。

动手
python3 -m lessons.lesson_retry

delay_seconds = base_delay_seconds * (
    2 ** (attempt_number - 1)
)

打开 retry 源码

自查:为什么错误 URL 或权限失败通常不应该 retry?

05 Semaphore:限制活跃异步请求
学习

Semaphore 限制受保护抓取区域中的活跃工作。它能避免请求突发,但不会限制已经发现的 URL 总量。

动手
python3 -m lessons.lesson_semaphore

async with semaphore:
    await fetch_page()

打开 semaphore 源码

自查:还需要什么单独的限制,才能避免巨大网站不断扩大抓取 frontier?

06 Cancellation:停止并收集未完成 tasks
学习

Cancellation 发出停止工作的信号。继续 await 被取消的 tasks 可以让清理完成,并观察它们的最终状态。

动手
python3 -m lessons.lesson_cancellation

task.cancel()
await asyncio.gather(
    task, return_exceptions=True
)

打开 cancellation 源码

自查:为什么创建 tasks 却没有最终 await 或 cancel 它们会有风险?

代码阅读

不离开课程,也能阅读关键代码。

先看带解释的关键片段,再直接在本页打开完整可运行源码。GitHub 是可选入口,不是学习过程中的必经跳转。

共享边界:同一个离线网站提供阻塞 fetch 和 async fetch,因此对比是公平的。
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)
在下方打开完整源码 ↓
完整源码阅读器

在页面内阅读完整文件。

在 GitHub 打开当前文件 →
互动实验

改变输入,然后解释发生了什么。

这些小模拟聚焦于 Python 可运行例子背后的思维模型。

Lab 1 · 容量

让等待时间重叠

这里请求已经被预先知道。提高上限会重叠等待时间,而不会让单个页面自身变快。

模拟总耗时:

Lab 2 · 状态

逐步执行一次 crawl

使用两个请求槽位,每次推进一个事件,观察 discovered、等待、运行和完成 URL 的变化。

已发现
等待
运行
完成

Lab 3 · 可靠性

组合 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 执行清理逻辑,也让调用方观察它们的最终状态。