本篇博客是 python 爬虫 120 例中,基础知识补充篇,内容将围绕 python 协程进行。
在开始协程相关知识前,先补充一下预备概念。
在 python 爬虫的学习过程中,经常要区分两个概念,一个叫做 I/O 密集型任务,另一个叫做 计算密集型任务。
以上两种任务,都有 2 个前提,一是存在可执行的子任务,二是需要计算机支持多核 CPU。
I/O 密集型任务
密集型任务指的硬盘 I/O 或者网络 I/O 占主要任务,程序计算量很小,大部分时间都用在请求网页和读写文件上。这种情况下,CPU 经常等待 I/O 操作完成,所以可以利用这些时间去完成其它事务。
基于上述内容,I/O 密集型任务,采用 多线程 就可以提高程序执行效率,当然采用 多进程 也是可以的,但多进程会出现共享资源和通讯问题,因此,I/O 密集型任务,采用多线程即可。
计算密集型任务
也叫作 CPU 密集型任务,在这种情况下,CPU 注意满负荷状态,例如大数据查找,大字符串处理。
计算密集型任务在 python 中一般采用多进程处理,因为 python 中的多线程有同步锁安全机制,并且采用的是全局锁,所以即便使用多核 CPU,同一时间,也只有一个线程在执行。
除了以上两种任务类型外,还有一些基础概念知识需要补充学习。
阻塞和非阻塞
阻塞状态指程序 未得到 所需资源时,被挂起的状态,在这个状态下,程序必须等待某个操作完成,自身无法继续运行。
引起阻塞的常见原因
网络 I/O 阻塞;
硬盘 I/O 阻塞;
用户输入阻塞。
非阻塞是因阻塞而存在,我们的目标就是实现程序在等待某个操作的过程中,自身不被阻塞,可以继续运行。非阻塞是为了提高程序整体执行效率。
同步和异步
初学阶段,同步可以理解为,不同程序之间为了保证数据的一致性,必须依赖某种通信机制,实现程序单元之间的数据同步性,同步是有序的,例如大家同时去秒杀 10 件商品,不管你在手机端,网页端,平板端,商品只有 10 件。
异步表示在不同程序之间,不需要保证数据的一致性,可以分别执行,异步是无序的,例如咱们之前写的爬虫程序,一堆图片,分别下载即可,谁先下载谁后下载没有区别,异步是高效组织非阻塞任务的方式。
上述描述都非精准描述,大家在本阶段理解概念即可。
并发与并行
并发:描述的是程序的组织结构,程序要被设计成多个可独立执行的子任务,从而可以利用有限的计算机资源使多个任务可以被实时或者近实时的执行,核心是为了让独立的子任务尽快运行,但整体进度不一定有变化。
并行:描述的是程序的执行状态,指多个任务同时被执行,从而可以利用富余的计算机资源(多核 CPU)加速完成多个任务,核心是利用多核。
协程基本知识
正式开始前,一定要先确定一点,协程不是 python 专属概念,它仅仅是一个普通的计算机概念,任何语言都有自己的实现。协程是单线程下的并发。
协程(coroutine):也叫作微线程,纤程。协程的作用:在执行函数 A 时,随时中断去执行函数 B,然后再中断函数 B,返回来执行函数 A,该操作类似多线程,但协程中只有一个线程在执行。
协程的优势:
协程的劣势:
使用 yield 实现协程
yield
关键字翻译成中文,有生产,退让的意思,如果在一个 python 函数中使用 yield
关键字,那这个函数就是一个生成器函数,调用生成器函数会获得一个生成器,根据之前的知识,咱们已经知道函数中出现 yield
关键字 ,会让出程序的控制权,让调用方继续工作,直到下次调用时,调用方则需等待生成器提供给其对应的数据,因此生成器就是一个协程。
测试以下 yield
生成器代码。
import time
def task1():
while True:
print("任务1--准备执行")
yield
print("任务1--执行完毕")
time.sleep(0.5)
def task2():
while True:
print("任务2--准备执行")
yield
print("任务2--执行完毕")
time.sleep(0.5)
if __name__ == '__main__':
t1 = task1()
t2 = task2()
while True:
next(t1)
print("主函数循环")
next(t2)
复制代码
上述代码的运行过程需要重点关注,相关步骤在注释区进行说明
任务1--准备执行 # 主函数中执行 while 循环,调用 next(t1),进入到 t1 函数内部,输出对应提示,然后碰到了 yield,将函数挂起,退出
主函数循环 # t1 函数挂起,主函数运行
任务2--准备执行 # 调用 next(t2),进入到 t2 函数内部,输出对应提示,然后碰到 yield,将函数挂起,退出,注意此时第一次循环结束
任务1--执行完毕 # 第二次循环开始,再次调用 next(t1),此时从之前的 yield 挂起处再次运行,输出对应提示,sleep 0.5 秒
任务1--准备执行 # 输出对应提示之后,又碰到 yield 关键字,挂起
主函数循环 # 执行主函数
任务2--执行完毕 # next(t2),从 yield 关键字位置继续执行
任务2--准备执行 # sleep 0.5 秒输出对应提示,第二次循环结束
任务1--执行完毕 # 第三次循环开始
任务1--准备执行
复制代码
yield
语句将一个函数变为了生成器函数,函数碰到 yield
语句,就会交出程序的控制权,当下一次执行,即第二次被 next
函数调用时,程序会恢复到之前“挂起”时的状态,本案例中 yield
后无任何代码,如果为一个 I/O 操作,那程序的效率就会大幅度的提高。
实现简单协程
生成器只是协程的子集,因为生成器函数交出程序控制权之后,并不能决定由哪个协程接替子集运行,我们在此基础上,增加一个协程之间调度的派遣器,核心实现的功能就是当一个协程交出控制权之后,派遣器可以协调另一个协程来接替子集运行。
def my_coroutine():
for i in range(5):
in_x = yield i + 1
print(f"调用者传入参数为{in_x}")
my_cor = my_coroutine()
a = next(my_cor) # 生成器最初的值
for i in range(7):
try:
out_y = my_cor.send(i)
print(f"生成器返回的值为{out_y}")
except StopIteration as se:
print("生成器所有值都已经获取完毕")
print(f"生成器最初的值为{a}")
复制代码
上述代码的运行逻辑已经在下图进行标识,其中用到了 send
函数,send
不但能触发生成器,还能发送数据到 yield
位置。send
不能作为第一轮循环的开始,会报错,如果要在第一次运行时使用 send
函数,那么传的值就必须是 None
。
greenlet 模块
greenlet
模块是 C 语言实现的协程模块,与 python 的 yield
相比,可以在任意函数之间切换,并且不需要将函数声明为生成器。
该模块需要进行手动安装,pip install greenlet
,目前最新的版本为 1.1.1
(2021 年 10 月版本)。
使用 greenlet 实现协程
from greenlet import greenlet
import time
def task1():
while True:
print("任务1")
g2.switch() # 切换到 g2 中运行
time.sleep(0.5)
def task2():
while True:
print("任务2")
g1.switch() # 切换到 g1 中运行
time.sleep(0.5)
if __name__ == "__main__":
g1 = greenlet(task1) # greenlet 对象
g2 = greenlet(task2)
g1.switch() # 切换到 g1 中运行
复制代码
greenlet
编写的协程代码,需要手动切换各个任务,并且如果程序中并没有 I/O 操作,单纯的切换反而会降低程序运行速度。
gevent 模块
由于 greenlet
需要手动切换各个任务,所以又出现了一款可以自动切换任务的模块 gevent
,该模块的原理是当一个 greenlet
遇到 I/O 操作时,自动切换到其它 greenlet
,等 I/O 操作结束,再切换回继续执行。
该模块使用前注意提前安装:pip install gevent
。
import gevent
def task1(num):
for i in range(num):
print(gevent.getcurrent(), i)
# 模拟 I/O 操作,测试时可以分别测试注释下述代码和不注释下述代码
gevent.sleep(1)
if __name__ == "__main__":
# 创建协程
g1 = gevent.spawn(task1, 5)
g2 = gevent.spawn(task1, 5)
g3 = gevent.spawn(task1, 5)
# 等待协程运行完毕
g1.join()
g2.join()
g3.join()
复制代码
上述代码中注释 gevent.sleep(1)
(模拟 I/O 操作)之后,代码依次执行,非注释时,交替运行。
还可以将上述代码进行完善,例如下述代码:
import gevent
def task1(tag):
print(f'task1 IO 阻塞前,传入参数{tag}')
gevent.sleep(3)
print(f'task1 IO 阻塞后,传入参数{tag}')
def task2(tag):
print(f'task2 IO 阻塞前,传入参数{tag}')
gevent.sleep(1)
print(f'task2 IO 阻塞后,传入参数{tag}')
g1 = gevent.spawn(task1, '橡皮擦')
g2 = gevent.spawn(task2, tag='Python')
g1.join()
g2.join()
# 也可以将上述两个步骤合并为一行 gevent.joinall([g1,g2])
print('主程序')
复制代码
gevent.spawn()
函数的参数说明如下,第一个参数为函数名,例如 task1
,第二个参数开始是 task1
函数的参数,可以为位置实参或者关键字实参,然后 spawn
实现异步提交任务。
g1.join()
等待任务结束,可以将两个等待任务结束代码合并为一行 gevent.joinall([g1,g2])
。
上述代码中 gevent.sleep(2)
为 I/O 阻塞模拟操作,但是却无法识别 yield
中的 time.sleep()
或者其它阻塞,解决办法也非常简单,增加 monkey
补丁。
from gevent import monkey
# 写在最上面,后面的所有阻塞都能识别
monkey.patch_all()
import gevent
import time
def task1(tag):
print(f'task1 IO 阻塞前,传入参数{tag}')
print(threading.current_thread().getName())
time.sleep(3)
print(f'task1 IO 阻塞后,传入参数{tag}')
def task2(tag):
print(f'task2 IO 阻塞前,传入参数{tag}')
print(threading.current_thread().getName())
time.sleep(1)
print(f'task2 IO 阻塞后,传入参数{tag}')
g1 = gevent.spawn(task1, '橡皮擦')
g2 = gevent.spawn(task2, tag='Python')
gevent.joinall([g1, g2])
print('主程序')
复制代码
可以通过 threading.current_thread().getName()
查看虚拟线程的名字。
使用 gevent 实现一个简易爬虫
到这里本文的篇幅已经有些超出长度了,可以写一个协程爬虫进行收尾了。
本次案例要抓取的为:https://www.qqtn.com/tx/nvshengtx_1.html
,女生头像网站。
该案例涉及的 I/O 操作主要为网络请求与图片保存,该网站还存在详情页,逻辑基本一致,不再涉及。
列表页分页规则如下:
https://www.qqtn.com/tx/nvshengtx_1.html
https://www.qqtn.com/tx/nvshengtx_2.html
https://www.qqtn.com/tx/nvshengtx_243.html
复制代码
下面编写爬虫代码,本案例开启 5 个协程去采集数据。
from gevent import monkey
monkey.patch_all()
import threading
from bs4 import BeautifulSoup
import gevent
import requests
import lxml
def get_page(this_urls):
while True:
if this_urls is None:
break
url = this_urls.pop()
print('正在抓取:{},当前的虚拟线程为:{}'.format(url, threading.current_thread().getName()))
res = requests.get(url=url)
res.encoding = "gb2312"
if res.status_code == 200:
soup = BeautifulSoup(res.text, 'lxml')
content = soup.find(attrs={'class': 'g-gxlist-imgbox'})
img_tags = content.find_all('img')
for img_tag in img_tags:
img_src = img_tag['src']
# 注意去除文件路径中的特殊符号,防止出错
try:
name = img_tag['alt'].replace('/', '').replace('+', '').replace('?', '').replace('*', '')
except OSError as e:
continue
save_img(img_src, name)
# 保存图片
def save_img(img_src, name):
res = requests.get(img_src)
with open(f'imgs/{name}.jpg', mode='wb') as f:
f.write(res.content)
if __name__ == '__main__':
urls = [f"https://www.qqtn.com/tx/nvshengtx_{page}.html" for page in range(1, 244)]
# 开启 5 个协程
gevent.joinall([gevent.spawn(get_page, urls) for i in range(5)])
print("爬取完毕")
复制代码
写在后面
协程掌握了,python 爬虫之路就开启了。
评论