1. 爬虫是什么?

网络爬虫是一种自动化程序,按照规则从互联网抓取、解析并存储目标数据。

工程定义

  • 输入:起始 URL + 爬取规则
  • 输出:结构化数据(JSON、数据库、CSV)
  • 核心目标:在合法、稳定、高效、可扩展的前提下,获取所需数据。

大神通俗版:看得见的数据,我都能自动化拿下来——但要拿得稳、拿得快、不被封。

实际项目中,80% 的爬虫工作不是「抓数据」,而是应对反爬、保证稳定性、处理规模


2. Web 基础与 HTTP 请求(必备工程知识)

爬虫本质是网络 IO + 数据解析

网页组成

  • HTML:内容结构(骨架)
  • CSS:样式
  • JavaScript:动态渲染(现代网站大量内容靠 JS 生成)

传输协议

  • HTTP/HTTPS(重点掌握状态码、Headers、Cookies、Session)
  • TCP 三次握手、四次挥手(理解连接管理)

URL 组成与解析

协议://域名:端口/路径?参数

工程建议

  • 优先抓包分析(浏览器 DevTools Network 面板),寻找 JSON API 接口,比解析 HTML 往往更快。
  • 尊重 robots.txtsitemap.xml(不是法律强制,但体现合规意图)。

3. 技术选型

根据项目规模和页面类型选择工具,避免「一刀切」:

场景 推荐技术栈 理由与特点 适用页面规模
小型/快速原型(<1000 页) requests / HTTPX + BeautifulSoup 轻量、快速、上手快 静态页面
中大型静态爬取 Scrapy 内置队列、去重、管道、中间件,结构化强 1 万+ 页面
动态/JS 重渲染页面 Playwright(推荐)/ Selenium 全浏览器渲染,自动等待,应对复杂交互 SPA、复杂交互
高并发 IO 密集 aiohttp + asyncio / HTTPX 协程异步,资源占用低 API 密集型
企业级分布式 Scrapy + Redis + Scrapyd / Docker 任务调度、分布式部署 百万级+

主流趋势

  • HTTPX 常与 requests 对照选用(异步能力更友好)
  • Playwright 在不少场景替代 Selenium(速度、稳定性、防检测能力)
  • Scrapy 仍是大规模结构化爬取首选,可搭配 scrapy-playwright 处理 JS 页面
  • 优先找公开 API 或 JSON 接口,能大幅降低复杂度
  • AI 辅助抓取(Crawl4AI、Firecrawl 等,面向 LLM/RAG 的 Markdown/JSON 输出):见 AI 爬虫:工具实战与能力边界(2026)

4. 并发模型(性能提升关键)

爬虫瓶颈通常是网络等待(IO 密集),而非 CPU 计算。

  • 多进程:适合 CPU 密集(如数据清洗),可绕过 GIL
  • 多线程:IO 密集可用,但受 GIL 限制,实际并发往往不如协程
  • 协程(推荐)asyncio + aiohttp/HTTPX,单线程高并发,资源利用率较高

工程实践

  • 小项目:asyncio + aiohttp
  • 大项目:Scrapy 内置 Twisted/异步支持,或选用 Crawlee 等现代框架
  • 控制并发数(Semaphore),避免对目标站点造成过大压力

5. 反爬虫应对策略

现代网站反爬常见手段包括 IP 封禁、指纹检测、行为分析、验证码、JS 加密等。

核心应对手段(分层防御):

  1. 基础伪装

    • 随机/真实 User-Agent
    • 完整 Headers(Referer、Accept 等)
    • 随机延迟 + 人类行为模拟(鼠标轨迹、滚动等)
  2. IP 与会话管理

    • 代理池(住宅 IP 通常优于数据中心 IP)
    • Cookie/Session 持久化(Playwright 自动管理往往更省心)
    • 模拟登录(复杂场景下 Playwright 常优于纯 requests.Session)
  3. 高级规避

    • TLS/JA3 指纹伪装
    • 浏览器指纹隐藏(Playwright stealth 等方案)
    • 验证码处理:打码平台或 AI 识别(复杂场景)
    • 动态渲染时网络拦截(block images/css 等减负)
  4. 工程化措施

    • AutoThrottle / 智能限速
    • 重试机制 + 失败日志
    • 缓存原始响应(避免重复请求)
    • 监控响应时间与成功率,动态调整策略

最佳实践:从小规模测试开始,逐步加防护。避免高并发、无延迟地持续请求同一目标。


6. 数据解析与存储(工程落地)

  • 解析

    • 静态 HTML:BeautifulSoup(易用)或 selectolax(更快)
    • XPath/CSS 选择器(Scrapy 内置)
    • JS 页面:Playwright page.content()locator
  • 数据清洗:使用 Item Loader / Pydantic 模型,统一处理字段清洗、类型转换

  • 存储

    • 管道(Pipelines):Scrapy 推荐,异步写入数据库/文件/消息队列
    • 常见方案:MongoDB(灵活)、MySQL/PostgreSQL(结构化)、Kafka/RabbitMQ(解耦)

7. 项目结构与部署

推荐项目结构(Scrapy 示例):

1
2
3
4
5
6
7
project/
├── spiders/ # 爬虫逻辑
├── items.py # 数据模型
├── pipelines.py # 数据处理与存储
├── middlewares.py # 代理、指纹、限速等
├── settings.py # 配置(并发、延迟、User-Agent 等)
└── main.py / scrapyd # 入口与调度

部署方式

  • Docker 容器化(便于横向扩展)
  • Scrapyd + Supervisor / Kubernetes(分布式)
  • 云函数 / Serverless(小规模定时任务)
  • 监控:日志(logging + ELK)、告警、成功率仪表盘

版本控制与环境

  • 使用 Poetry / uv 管理依赖
  • 虚拟环境隔离
  • 配置分离(开发/测试/生产)

8. 工程最佳实践与注意事项

  • 合法合规第一:仅抓取公开数据,遵守网站条款与 robots.txt,避免抓取隐私/版权内容。
  • 做个「好公民」:合理限速、缓存、随机行为。
  • 日志与监控:记录每个 URL 的状态、耗时、异常,便于排查。
  • 错误处理:优雅重试、超时控制、部分失败不影响整体。
  • 迭代开发:先用简单栈验证可行性,再迁移到 Scrapy/Playwright。
  • 安全:代理、账号信息加密存储,避免硬编码敏感数据。

常见踩坑

  • 被封 IP → 立即加代理 + 降速
  • JS 渲染失败 → 切换 Playwright
  • 数据不完整 → 检查选择器是否动态变化,增加等待
  • 内存/CPU 爆炸 → 控制并发 + 及时释放资源

附录:经典定义与蜘蛛网比喻

百度说

网络爬虫,是一种按照一定的规则,自动地抓取万维网信息的程序或者脚本。

某书上说

它是指自动的连接到互联网站点,读取网页中的内容或者存放在网络上的各种信息,并按照某种策略对目标的信息进行采集。

大神说

但凡你看得到摸得到的数据,我都能拿下来。

我理解的爬虫概念

1. 词汇「爬虫」(spider)是一种动作

像一只蜘蛛在一张很大网(web)上爬行。假设这张网是一张很大的图,每个顶点代表一类信息。

蜘蛛网与爬虫比喻:绿色蛛网上的水珠

我如何理解爬虫(web 端)

既然说信息都放在一张很大很大网上,那么首先理解这张网(web)。

这张网的基础组成

html(超文本标记语言)

超文本标记语言(英语:HyperText Markup Language,简称:HTML)是一种用于创建网页的标准标记语言。

网页浏览器可以读取 HTML 文件,并将其渲染成可视化网页。

HTML 元素是构建网站的基石。HTML 允许嵌入图像与对象,并且可以用于创建交互式表单,它被用来结构化信息——例如标题、段落和列表等等,也可用来在一定程度上描述文档的外观和语义。

css(层叠样式表)

CSS 描述了在屏幕、纸质、音频等其它媒体上的元素应该如何被渲染的问题。

JavaScript (JS)

  • 介绍维基百科地址:https://zh.wikipedia.org/wiki/JavaScript

  • 发展初期,JavaScript 的标准并未确定,同期有 Netscape 的 JavaScript,微软的 JScript 和 CEnvi 的 ScriptEase 三足鼎立。1997 年,在 ECMA(欧洲计算机制造商协会)的协调下,由 Netscape、Sun、微软、Borland 组成的工作组确定统一标准:ECMA-262。

  • JavaScript 的标准是 ECMAScript。截至 2012 年,所有的现代浏览器都完整的支持 ECMAScript 5.1,旧版本的浏览器至少支持 ECMAScript 3 标准。2015 年 6 月 17 日,ECMA 国际组织发布了 ECMAScript 的第六版,该版本正式名称为 ECMAScript 2015,但通常被称为 ECMAScript 6 或者 ES6。自此,ECMAScript 每年发布一次新标准。本文档目前覆盖了最新 ECMAScript 的草案,也就是 ECMAScript 2020

传输协议

  • http(超文本传输协议)
    • 工作在应用层,基于 tcp 传输,默认端口 80
  • 进阶版 https(http+(ssl/tls))(默认端口 443)
    • 由于 http 协议在传输的过程中没有任何加密,导致传输非常不安全,因此,在 http 基础上添加了加密机制,保护传输的可靠性。
  • tcp 协议
    • 面向连接的,可靠的数据传输协议。位于传输层

传输的地址

  • url

    • 在 WWW(万维网)上,任何一个信息资源都有统一的并且在网上唯一的地址,这个地址就叫做 URL。URL 也被称为网页地址,是因特网上标准的资源的地址(Address)
    • url 格式的基本组成
      • 第一部分-协议(scheme)
        • 该 URL 的协议是什么
      • 第二部分-域名(domain)
      • 第三部分-端口(port)
        • 用来区分同一台服务器上不同服务的标识
      • 第三部分-路径(path)
        • 由零或多个「/」符号隔开的字符串,一般用来表示主机上的一个目录或文件地址。
      • 第四部分-参数(parameters)
        • 这是用于指定特殊参数的可选项
  • ip 地址

    • 它能够唯一确定 Internet 上每台计算机、每个用户的位置。Internet 上主机与主机之间要实现通信,每一台主机都必须要有一个地址,而且这个地址应该是唯一的,不允许重复。依靠这个唯一的主机地址,就可以在 Internet 浩瀚的海洋里找到任意一台主机。
  • dns 和域名

    • 由于 IP 地址具有不方便记忆并且不能显示地址组织的名称和性质等缺点,人们设计出了域名,并通过网域名称系统(DNS,Domain Name System)来将域名和 IP 地址相互映射,使人更方便地访问互联网,而不用去记住能够被机器直接读取的 IP 地址数串。

web 浏览器和服务器交互的过程

  1. 从浏览器解析主机名
  2. 浏览器查询主机 ip 地址(dns)
  3. 浏览器获取端口号 80 或者 443
  4. 浏览器发起 tcp 连接(三次握手建立连接)tcp 双向传输
    • 第一次:客户端发送请求到服务器,服务器知道客户端发送,自己接收正常。
    • 第二次:服务器发给客户端,客户端知道自己发送、接收正常,服务器接收、发送正常。
    • 第三次:客户端接收服务器,服务器知道客户端发送,接收正常,自己接收,发送也正常。
  5. 浏览器发送请求的报文
  6. 服务器返回响应的报文
  7. 浏览器渲染 http 或 https 响应的内容
  8. 关闭连接(TCP 四次挥手)
    • 第一次:客户端请求断开
    • 第二次:服务器确认客户端的断开请求
    • 第三次:服务器请求断开
    • 第四次:客户端确认服务器的断开

socket

socket 是应用层和 tcp/ip 协议簇通信的中间软件的抽象层,它是一组接口

网络模型

socket 抽象

socket 编程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import socket
import ssl

def set_headers(head):
headers = ["{}:{}".format(k, item) for k, item in head.items()]
headers.insert(0, "GET / HTTP/1.1")
headers.append("\r\n")
res = "\r\n".join(headers)
return res.encode("utf-8")


def client(url, charset=None, headers=None):
conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 默认添加请求头
if headers == None:
headers = 'User-Agent: Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1)'
# http协议处理
if 'http://' in url:
url = url.replace('http://', '')
port = 80
# https协议处理
if 'https://' in url:
conn = ssl.wrap_socket(conn)
url = url.replace('https://', '')
port = 443
url = url if '/' in url else url + '/'
urlSplit = url.split('/', 1)
# 连接服务器
conn.connect((urlSplit[0], port))
# 发送报文处理
# bMsg = 'GET /{1} HTTP/1.1\r\nHost: {0}\r\n{2}\r\nConnection: close\r\n\r\n'.format(urlSplit[0], urlSplit[1], # headers)
client_headers = {
"Connection": "close",
# "Connection": "keep-alive",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36",
}
# 发送报文
# print(bMsg.encode())
print(set_headers(client_headers))
bMsg = set_headers(client_headers)
conn.send(bMsg)
html = ''
# 循环接收html字节数据
while True:
data = conn.recv(1024)
if data:
try:
html += data.decode(charset)
except Exception as e:
pass
else:
conn.close()
break
return html.split('\r\n\r\n')[1]


# 运行
if __name__ == '__main__':
import time
start = time.time()
html = client('https://www.baidu.com', charset='utf-8')
print(html)
print("程序结束: {}".format(time.time() - start))
# print('with out hook')
# a = requests.get('https://www.baidu.com/')
# print(a.raw._fp.msg, a.content)
# print('done!')

IO

IO 操作本质

  • io 是 input/output 输入输出的缩写。它描述了计算机的输入输出数据流动的过程。
  • 一次 io 的过程
    • 进程发起读取数据的 io 调用
    • 操作系统把外部的数据加载到内核区
    • 操作系统把数据拷贝到进程的缓冲区
  • 基于上面的过程,我们可以看出,后面俩步的过程之间,就有很多问题,由此产生了很多模型
    • 阻塞 I/O(blocking IO)
    • 非阻塞 I/O(nonblocking IO)
    • I/O 多路复用( IO multiplexing)
    • 信号驱动 I/O( signal driven IO)
    • 异步 I/O(asynchronous IO)

阻塞 IO 模型

在用户空间调用 recvfrom,系统调用直到数据包达到且被复制到应用进程的缓冲区中或中间发生异常返回,在这个期间进程会一直等待。进程从调用 recvfrom 开始到它返回的整段时间内都是被阻塞的,因此,被称为阻塞 IO 模型。

阻塞 IO 模型图如下所示:

blocking-io

非阻塞 IO 模型

recvfrom 从应用到内核的时,如果该缓冲区没有数据,就会直接返回 EWOULDBLOCK 错误,一般都对非阻塞 IO 模型进行轮询检查这个状态,看看内核是不是有数据到来。

也就是说非阻塞的 recvform 系统调用调用之后,进程并没有被阻塞,内核马上返回给进程。

如果数据还没准备好,此时会返回一个 error。进程在返回之后,可以干点别的事情,然后再发起 recvform 系统调用。重复上面的过程,循环往复的进行 recvform 系统调用,这个过程通常被称之为轮询。
轮询检查内核数据,直到数据准备好,再拷贝数据到进程,进行数据处理。需要注意,拷贝数据整个过程,进程仍然是属于阻塞的状态。
模型图如下所示:

non-blocking-io

IO 复用模型

Linux 提供 select、poll、epoll,进程通过将一个或者多个 fd 传递给 select、poll、epoll 系统调用,阻塞在 select 操作(这个是内核级别的调用)上,这样的话,可以同时监听多个 fd 是否处于就绪状态。其中,

select/poll 是顺序扫描 fd 是否就绪,而且支持的 fd 数量有限;
epoll 是基于事件驱动方式代替顺序扫描性能更高。

模型如下所示:

io-multiplexing

信号驱动 IO 模型

首先需要开启 socket 信号驱动 IO 功能,并通过系统调用 sigaction 执行一个信号处理函数(非阻塞,立即返回)。当数据就绪时,会为该进程生成一个 SIGIO 信号,通过信号回调通知应用程序调用 recvfrom 来读取数据,并通知主循环喊出处理数据。

过程如下图所示:

sigio

异步 IO 模型

相对于同步 IO,异步 IO 不是顺序执行。用户进程进行 aio_read 系统调用之后,无论内核数据是否准备好,都会直接返回给用户进程,然后用户态进程可以去做别的事情。等到 socket 数据准备好了,内核直接复制数据给进程,然后从内核向进程发送通知。IO 两个阶段,进程都是非阻塞的。应用程序并不需要主动发起拷贝动作
异步过程如下图所示:

异步 IO 模型示意

对比

IO 模型对比

IO 复用之 select、poll、epoll 简介

epoll 是 linux 所特有,而 select 是 POSIX 所规定,一般操作系统均有实现。

select

select 本质是通过设置或检查存放 fd 标志位的数据结构来进行下一步处理。缺点是:

  1. select 使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最大值为 1024,只能监听 0~1023 的文件描述符。
  2. 对 socket 进行扫描时是线性扫描,即采用轮询方法,效率低。当套接字比较多的时候,每次 select()都要遍历 FD_SETSIZE 个 socket 来完成调度,不管 socket 是否活跃都遍历一遍。会浪费很多 CPU 时间。如果能给套接字注册某个回调函数,当他们活跃时,自动完成相关操作,就避免了轮询,这正是 epoll 与 kqueue 做的
  3. 需要维护一个用来存放大量 fd 的数据结构,会使得用户空间和内核空间在传递该结构时复制开销大
poll

poll 不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。

poll 本质和 select 相同,将用户传入的数据拷贝到内核空间,然后查询每个 fd 对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历所有 fd 后没有发现就绪设备,则挂起当前进程,直到设备就绪或主动超时,被唤醒后又要再次遍历 fd。它没有最大连接数的限制,原因是它是基于链表来存储的,但缺点是:

  1. 大量的 fd 的数组被整体复制到用户态和内核空间之间,不管有无意义。
  2. poll 还有一个特点「水平触发」,如果报告了 fd 后,没有被处理,那么下次 poll 时再次报告该 ffd。
epoll

epoll 支持水平触发和边缘触发,最大特点在于边缘触发,只告诉哪些 fd 刚刚变为就绪态,并且只通知一次。还有一特点是,epoll 使用「事件」的就绪通知方式,通过 epoll_ctl 注册 fd,一量该 fd 就绪,内核就会采用类似 callback 的回调机制来激活该 fd,epoll_wait 便可以收到通知。epoll 的优点:

第一点,epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn)。而 select/poll 内核里没有类似 epoll 红黑树这种保存所有待检测的 socket 的数据结构,所以 select/poll 每次操作时都传入整个 socket 集合给内核,而 epoll 因为在内核维护了红黑树,可以保存所有待检测的 socket ,所以只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。

第二点, epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。

插个题外话,网上文章不少说,epoll_wait 返回时,对于就绪的事件,epoll 使用的是共享内存的方式,即用户态和内核态都指向了就绪链表,所以就避免了内存拷贝消耗。
这是错的!看过 epoll 内核源码的都知道,压根就没有使用共享内存这个玩意。你可以从下面这份代码看到, epoll_wait 实现的内核代码中调用了 __put_user 函数,这个函数就是将数据从内核拷贝到用户空间。 by 小林 coding

select、poll、epoll 区别总结:

epoll 支持两种事件触发模式,分别是边缘触发(edge-triggered,ET)和水平触发(level-triggered,LT)。

  • 使用边缘触发模式时,当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次,即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完;
  • 使用水平触发模式时,当被监控的 Socket 上有可读事件发生时,服务器端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完才结束,目的是告诉我们有数据需要读取;
支持一个进程打开连接数 IO效率 消息传递方式
select 32位机器1024个,64位2048个 IO效率低 内核需要将消息传递到用户空间,都需要内核拷贝动作
poll 无限制,原因基于链表存储 IO效率低 内核需要将消息传递到用户空间,都需要内核拷贝动作
epoll 有上限,但很大,2G内存20W左右 只有活跃的socket才调用callback,IO效率高 通过内核与用户空间共享一块内存来实现

source:xiaolin coding and google search

爬虫和上面的描述有什么关系

爬虫的本质是网络 io,从网络请求数据下载到本地客户端。socket 是对于爬虫就是底层网络请求的一个接口。

多进程,多线程

多进程

  • 进程是操作系统分配资源的最小单元, 线程是操作系统调度的最小单元。
  • 一个应用程序至少包括 1 个进程,而 1 个进程包括 1 个或多个线程,线程的尺度更小。
  • 每个进程在执行过程中拥有独立的内存单元,而一个线程的多个线程在执行过程中共享内存。

多进程 python 版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from multiprocessing import Pool, cpu_count
import os
import time


def long_time_task(i):
print('子进程: {} - 任务{}'.format(os.getpid(), i))
time.sleep(2)
print("结果: {}".format(8 ** 20))


if __name__=='__main__':
print("CPU内核数:{}".format(cpu_count()))
print('当前母进程: {}'.format(os.getpid()))
start = time.time()
p = Pool(4)
for i in range(5):
p.apply_async(long_time_task, args=(i,))
print('等待所有子进程完成。')
p.close()
p.join()
end = time.time()
print("总共用时{}秒".format((end - start)))

多线程

  • 线程是 cpu 调度和分配的基本单位

多线程 python 版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import threading
import time


def long_time_task(i):
print('当前子线程: {} 任务{}'.format(threading.current_thread().name, i))
time.sleep(2)
print("结果: {}".format(8 ** 20))


if __name__=='__main__':
start = time.time()
print('这是主线程:{}'.format(threading.current_thread().name))
thread_list = []
for i in range(1, 3):
t = threading.Thread(target=long_time_task, args=(i, ))
thread_list.append(t)

for t in thread_list:
t.start()

for t in thread_list:
t.join()

end = time.time()
print("总共用时{}秒".format((end - start)))

进程切换与线程切换的区别?

https://blog.csdn.net/github_37382319/article/details/97273713

全局解释器锁(GIL)和多进程多线程

  • GIL
    • 全局解释器锁(英语:Global Interpreter Lock,缩写 GIL),是计算机程序设计语言解释器用于同步线程的一种机制,它使得任何时刻仅有一个线程在执行。[1]即便在多核心处理器上,使用 GIL 的解释器也只允许同一时间执行一个线程。常见的使用 GIL 的解释器有 CPython 与 Ruby MRI
    • 多进程没有 GIL 限制可以利用多核处理器,但是多进程适合计算密集型任务。

多进程和多线程对比

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
from multiprocessing import Pool, cpu_count
import os
import time
import logging


logging.basicConfig(level=logging.INFO, format="%(process)d %(processName)s %(thread)d %(message)s")
log = logging
# def long_time_task(i):
# print('子进程: {} - 任务{}'.format(os.getpid(), i))
# time.sleep(2)
# print("结果: {}".format(8 ** 20))


def get_download(i):
import requests
s = requests.get(url="https://www.baidu.com/")
return s.status_code


def calc_num(i):
sum = 0
for _ in range(10000000):
sum += 1
# log.info(sum)
return sum


if __name__=='__main__':
# print("CPU内核数:{}".format(cpu_count()))
# print('当前母进程: {}'.format(os.getpid()))
start = time.time()
p = Pool(8)
for i in range(8):
p.apply_async(get_download, args=(i,), callback=lambda x: print("{} is callback".format(x)))
print('等待所有子进程完成。')
p.close()
p.join()
end = time.time()
print("总共用时{}秒".format((end - start)))

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import threading
import time
import logging


logging.basicConfig(
level=logging.INFO,
format="%(process)d %(processName)s %(thread)d %(message)s")
log = logging

# def long_time_task(i):
# print('当前子线程: {} 任务{}'.format(threading.current_thread().name, i))
# time.sleep(2)
# print("结果: {}".format(8 ** 20))
#


def calc_num(i):
sum = 0
for _ in range(10000000):
sum += 1
log.info(sum)
return sum


def get_download(i):
import requests
s = requests.get(url="https://www.baidu.com/")
print(s.url)
return s.status_code


if __name__ == '__main__':
start = time.time()
print('这是主线程:{}'.format(threading.current_thread().name))
thread_list = []
for i in range(4):
t = threading.Thread(target=calc_num, args=(i,))
thread_list.append(t)

for t in thread_list:
t.start()

for t in thread_list:
t.join()

end = time.time()
print("总共用时{}秒".format((end - start)))

协程

在 python 中,对于 io 来说,多进程没啥效率,多线程也只是单线程并发,既然这样好不如单线程直接来。要么换语言。

Python 由于众所周知的 GIL 的原因,导致其线程无法发挥多核的并行计算能力(当然,后来有了 multiprocessing,可以实现多进程并行)。既然在 GIL 之下,同一时刻只能有一个线程在运行,那么对于 CPU 密集的程序来说,线程之间的切换开销就成了拖累,而以 I/O 为瓶颈的程序正是协程所擅长的:

多任务并发(非并行),每个任务在合适的时候挂起(发起 I/O)和恢复(I/O 结束)

Python 中的协程经历了很长的一段发展历程。其大概经历了如下三个阶段:

  • 最初的生成器变形 yield/send
  • 引入@asyncio.coroutine 和 yield from
  • 在最近的 Python3.5 版本中引入 async/await 关键字
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
from inspect import  getgeneratorstate


def coro(a):
print("-> start : a = ", a)
b = yield a+1
print("-> recive : b ", b)
c = yield a+b
print("-> recive : c ", c)
d = yield
print("-> recive : d ", d)
try:
my_coro = coro(1)
print(1, getgeneratorstate(my_coro))
print("1-next ->", my_coro.__next__())
print(2, getgeneratorstate(my_coro))
my_coro.send(2)
print("2-next ->", my_coro.__next__())
# my_coro.send(3)
# my_coro.send(4)



except Exception as e:
print(e)
print(3, getgeneratorstate(my_coro))




1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import asyncio
import time


def now(): return time.time()


# callback事件回调处理
def event_handler(future):
if future:
print("回调:" + future.result())
else:
print("回调失败")


# 1.定义一个协程函数
async def hello(name):
return name
start_time = now()

# 2.调用协程函数获取协程对象(coroutine协程对象)
coroutine_obj = hello("world")
print(type(coroutine_obj))

# 3.获取默认的事件循环对象
loop = asyncio.get_event_loop() # 获取当前线程的事件循环,loop是单例

# 4.根据协程对象创建task,下面三种方式都可以创建task
task = loop.create_task(coroutine_obj)
#task = asyncio.ensure_future(coroutine_obj)
#task = asyncio.Task(coroutine_obj)
print(type(task))

# 5.设置回调函数
task.add_done_callback(event_handler)

end_time = now()

# 6.启动事件循环
loop.run_until_complete(task)
print("耗时间:" + str(start_time - end_time))

协程完整的工作流程是这样的

  • 定义/创建协程对象
  • 将协程转为 task 任务
  • 定义事件循环对象容器
  • 将 task 任务扔进事件循环对象中触发

几个重要的概念

  • event_loop 事件循环:程序开启一个无限的循环,程序员会把一些函数(协程)注册到事件循环上。当满足事件发生的时候,调用相应的协程函数。
  • coroutine 协程:协程对象,指一个使用 async 关键字定义的函数,它的调用不会立即执行函数,而是会返回一个协程对象。协程对象需要注册到事件循环,由事件循环调用。
  • future 对象: 代表将来执行或没有执行的任务的结果。它和 task 上没有本质的区别
  • task 任务:一个协程对象就是一个原生可以挂起的函数,任务则是对协程进一步封装,其中包含任务的各种状态。Task 对象是 Future 的子类,它将 coroutine 和 Future 联系在一起,将 coroutine 封装成一个 Future 对象。
  • async/await 关键字:python3.5 用于定义协程的关键字,async 定义一个协程,await 用于挂起阻塞的异步调用接口。其作用在一定程度上类似于 yield。

总结

  • 在异步编程模型与多线程模型之间还有一个不同:在多线程程序中,对于停止某个线程启动另外一个线程,其决定权并不在程序员手里而在操作系统那里,因此,程序员在编写程序过程中必须要假设在任何时候一个线程都有可能被停止而启动另外一个线程。相反,在异步模型中,一个任务要想运行必须显式放弃当前运行的任务的控制权。这也是相比多线程模型来说,最简洁的地方。 值得注意的是:将异步编程模型与同步模型混合在同一个系统中是可以的。但在介绍中的绝大多数时候,我们只研究在单个线程中的异步编程模型。

AI 爬虫专题(延伸阅读)

2026 年工程里 Crawl4AIFirecrawl 等工具把「页面 → LLM 友好 Markdown/JSON」这条链路铺得很顺,但不是银弹:强反爬、成本与合规仍要单独评估。实战命令、结构化提取示例与能力边界已单独成文,便于检索与更新:AI 爬虫:工具实战与能力边界(2026)

若你希望把 AI 提取放在 传统抓取(Scrapy/Playwright)+ 队列 的混合架构里理解,可先读完本文前文的并发与反爬章节,再打开上文专题对照工具选型。