Python协程还不理解?请收下这份超详细的异步编程教程!

​1. 初探

在了解异步协程之前,我们首先得了解一些基础概念,如阻塞和非阻塞、同步和异步、多进程和协程。

1.1 阻塞

阻塞状态指程序未得到所需计算资源时被挂起的状态。程序在等待某个操作完成期间,自身无法继续处理其他的事情,则称该程序在该操作上是阻塞的。

常见的阻塞形式有:

  • 网络 I/O 阻塞

  • 磁盘 I/O 阻塞

  • 用户输入阻塞等。

阻塞是无处不在的,包括 CPU 切换上下文时,所有的进程都无法真正处理事情,它们也会被阻塞。如果是多核 CPU 则正在执行上下文切换操作的核不可被利用。

1.2 非阻塞

程序在等待某操作过程中,自身不被阻塞,可以继续处理其他的事情,则称该程序在该操作上是非阻塞的。

非阻塞并不是在任何程序级别、任何情况下都可以存在的。仅当程序封装的级别可以囊括独立的子程序单元时,它才可能存在非阻塞状态。

非阻塞的存在是因为阻塞存在,正因为某个操作阻塞导致的耗时与效率低下,我们才要把它变成非阻塞的。

1.3 同步

不同程序单元为了完成某个任务,在执行过程中需靠某种通信方式以协调一致,我们称这些程序单元是同步执行的。

例如购物系统中更新商品库存,需要用“行锁”作为通信信号,让不同的更新请求强制排队顺序执行,那更新库存的操作是同步的。

简言之,同步意味着有序。

1.4 异步

为完成某个任务,不同程序单元之间过程中无需通信协调,也能完成任务的方式,不相关的程序单元之间可以是异步的。

例如,爬虫下载网页。调度程序调用下载程序后,即可调度其他任务,而无需与该下载任务保持通信以协调行为。不同网页的下载、保存等操作都是无关的,也无需相互通知协调。这些异步操作的完成时刻并不确定。

简言之,异步意味着无序。

1.5 多进程

多进程就是利用 CPU 的多核优势,在同一时间并行地执行多个任务,可以大大提高执行效率。

image

1.6 协程

协程,英文叫作 Coroutine,又称微线程、纤程,协程是一种用户态的轻量级线程。

协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈。因此协程能保留上一次调用时的状态,即所有局部状态的一个特定组合,每次过程重入时,就相当于进入上一次调用的状态。

协程本质上是个单进程,协程相对于多进程来说,无需线程上下文切换的开销,无需原子操作锁定及同步的开销,编程模型也非常简单。

我们可以使用协程来实现异步操作,比如在网络爬虫场景下,我们发出一个请求之后,需要等待一定的时间才能得到响应,但其实在这个等待过程中,程序可以干许多其他的事情,等到响应得到之后才切换回来继续处理,这样可以充分利用 CPU 和其他资源,这就是协程的优势。

1.7 协程相对于多线程的优点

多线程编程是比较困难的, 因为调度程序任何时候都能中断线程, 必须记住保留锁, 去保护程序中重要部分, 防止多线程在执行的过程中断。

而协程默认会做好全方位保护, 以防止中断。我们必须显示产出才能让程序的余下部分运行。对协程来说, 无需保留锁, 而在多个线程之间同步操作, 协程自身就会同步, 因为在任意时刻, 只有一个协程运行。总结下大概下面几点:

  • 无需系统内核的上下文切换,减小开销;

  • 无需原子操作锁定及同步的开销,不用担心资源共享的问题;

  • 单线程即可实现高并发,单核 CPU 即便支持上万的协程都不是问题,所以很适合用于高并发处理,尤其是在应用在网络爬虫中。

2. 协程用法

接下来,我们来了解下协程的实现,从 Python 3.4 开始,Python 中加入了协程的概念,但这个版本的协程还是以生成器对象为基础的,在 Python 3.5 则增加了 async/await,使得协程的实现更加方便。

Python 中使用协程最常用的库莫过于 asyncio,所以本文会以 asyncio 为基础来介绍协程的使用。

首先我们需要了解下面几个概念。

  • event_loop:事件循环,相当于一个无限循环,我们可以把一些函数注册到这个事件循环上,当满足条件发生的时候,就会调用对应的处理方法。

  • coroutine:中文翻译叫协程,在 Python 中常指代为协程对象类型,我们可以将协程对象注册到事件循环中,它会被事件循环调用。我们可以使用 async 关键字来定义一个方法,这个方法在调用时不会立即被执行,而是返回一个协程对象。

  • task:任务,它是对协程对象的进一步封装,包含了任务的各个状态。

  • future:代表将来执行或没有执行的任务的结果,实际上和 task 没有本质区别。

另外我们还需要了解 async/await 关键字,它是从 Python 3.5 才出现的,专门用于定义协程。其中,async 定义一个协程,await 用来挂起阻塞方法的执行。

image

2.1 定义协程

协程就是一个函数,只是它满足以下几个特征:

  • 依赖 I/O 操作(有 I/O 依赖的操作)

  • 可以在进行 I/O 操作时暂停

  • 无法直接运行

它的作用就是对有大量 I/O 操作的程序进行加速。

Python 协程属于可等待对象,因此可以在其他协程中被等待。

什么叫可等待对象?——await,如果前面被标记 await 就表明他是个协程,我们需要等待它返回一个数据。

# 代码示例 一
import asyncio
async def net():
    return 11
async def main():
    # net() # error
    await net() # right
asyncio.run(main())

import asyncio
async def net():
    return 11
async def main():
    # net() # error
    return await net() # right
print(asyncio.run(main()))

举个例子,我从网络上下载某个数据文件下载到我的本地电脑上,这很显然是一个 I/O 操作。比方这个文件较大(2GB),可能需要耗时 30min 才能下载成功。而在这 30min 里面,它会卡在 await 后面。这个 await 标记了协程,那就意味着它可以被暂停,那既然该任务可以被暂停,我们就把它分离出去。我这个线程继续执行其它任务,它这个 30min 分出去慢慢的传输,我这个程序再运行其他操作。

上面的代码,Python 3.6 会给你报错。报错信息如下:

Traceback (most recent call last):
  File "C:/Code/pycharm_daima/爬虫大师班/14-异步编程/test.py", line 26, in <module>
    asyncio.run(main())
AttributeError: module 'asyncio' has no attribute 'run'

为什么会出现这样的报错呢?

因为从 Python 3.7+ 之后 Python 已经完全支持异步了,Python 3.6 之前只是支持部分异步,许多的方法是非常冗长的。

一个异步函数调用另一个异步函数:

import asyncio
async def net():
    return 11
async def main():
    # net() # error
    await net() # right
asyncio.run(main())

tips:

异步主要做得是 I/O 类型,CPU 密集型就不需要使用异步。

一个异步调用另一个异步函数,不能直接被调用,必须添加 await

我们使用代码验证一下,不加 await 调用试一试:

import asyncio

async def net():
    return 11
async def main():
    net() # error
asyncio.run(main())

输出结果:

C:/Code/pycharm_daima/爬虫大师班/14-异步编程/test.py:31: RuntimeWarning: coroutine 'net' was never awaited
  net() # error
RuntimeWarning: Enable tracemalloc to get the object allocation traceback

我们添加上 await 即可正常运行:

import asyncio

async def net():
    return 11
async def main():
    # net() # error
    await net() # right
asyncio.run(main())

运行结果:

C:\Users\clela\AppData\Local\Programs\Python\Python37\python.exe C:/Code/pycharm_daima/异步编程/test.py

Process finished with exit code 0

运行成功并没有报错,接下来我们要输出得到的结果该怎么编写代码呢?直接赋值即可:

import asyncio

async def net():
    return 11
async def main():
    # net() # error
    a = await net() # right
    print(a)
asyncio.run(main())

# 输出结果:
11

Ps:async 标记异步,await 标记等待。

如果我们不想使用 await 来运行异步函数,那这个时候我们就可以按如下方法来运行代码:

import asyncio

async def net():
    return 11

async def main():
    task = asyncio.create_task(net())
    await task # right
    
asyncio.run(main())

首先我们来定义一个协程,体验一下它和普通进程在实现上的不同之处,代码如下:

# 代码示例二
import asyncio
async def execute(x):
    print('Number:', x)
    
coroutine = execute(1)
print('Coroutine:', coroutine)
print('After calling execute')

loop = asyncio.get_event_loop()
loop.run_until_complete(coroutine)
print('After calling loop')

代码示例二中,我们首先引入了 asyncio这个包,这样我们才可以使用 asyncawait,然后我们使用 async定义了一个 execute方法,方法接收一个数字参数,方法执行之后会打印这个数字。

随后我们直接调用了这个方法,然而这个方法并没有执行,而是返回了一个 coroutine协程对象。随后我们使用 get_event_loop方法创建了一个事件循环 loop,并调用了 loop对象的 run_until_complete方法将协程注册到事件循环 loop中,然后启动。最后我们才看到了 execute方法打印了输出结果。

可见,async定义的方法就会变成一个无法直接执行的 coroutine对象,必须将其注册到事件循环中才可以执行。

上面我们还提到了 task,它是对 coroutine 对象的进一步封装,它里面相比 coroutine 对象多了运行状态,比如 running、finished 等,我们可以用这些状态来获取协程对象的执行情况。

在上面的例子中,当我们将 coroutine 对象传递给 run_until_complete 方法的时候,实际上它进行了一个操作就是将 coroutine 封装成了 task 对象,我们也可以显式地进行声明,如下所示:

"""
project = 'Code', file_name = 'yibudaima', author = 'AI悦创'
time = '2020/4/22 19:24', product_name = PyCharm, 公众号:AI悦创
# code is far away from bugs with the god animal protecting
    I love animals. They taste delicious.
"""
import asyncio

async def execute(x):
    print('Number:', x)
    return x

coroutine = execute(1)
print('Coroutine:', coroutine)
print('After calling execute')
loop = asyncio.get_event_loop()
task = loop.create_task(coroutine)
print('Task:', task)
loop.run_until_complete(task)
print('Task:', task)
# print('Task:', task.result())
print('After calling loop')

运行结果:

Coroutine: <coroutine object execute at 0x10e0f7830>
After calling execute
Task: <Task pending coro=<execute() running at demo.py:4>>
Number: 1
Task: <Task finished coro=<execute() done, defined at demo.py:4> result=1>
After calling loop

这里我们定义了 loop 对象之后,接着调用了它的 create_task 方法将 coroutine 对象转化为了 task 对象,随后我们打印输出一下,发现它是 pending 状态。接着我们将 task 对象添加到事件循环中得到执行,随后我们再打印输出一下 task 对象,发现它的状态就变成了 finished,同时还可以看到其 result 变成了 1,也就是我们定义的 execute 方法的返回结果。

另外定义 task 对象还有一种方式,就是直接通过 asyncio 的 ensure_future 方法,返回结果也是 task 对象,这样的话我们就可以不借助于 loop 来定义,即使我们还没有声明 loop 也可以提前定义好 task 对象,写法如下:

"""
project = 'Code', file_name = 'lession.py', author = 'AI悦创'
time = '2020/4/23 10:18', product_name = PyCharm, 公众号:AI悦创
# code is far away from bugs with the god animal protecting
    I love animals. They taste delicious.
"""
import asyncio
async def execute(x):
    print('Number:', x)
    return x
coroutine = execute(1)
print('Coroutine:', coroutine)
print('After calling execute')
task = asyncio.ensure_future(coroutine)
print('Task:', task)
loop = asyncio.get_event_loop()
loop.run_until_complete(task)
print('Task:', task)
print('After calling loop')

运行结果:

Coroutine: <coroutine object execute at 0x10aa33830>
After calling execute
Task: <Task pending coro=<execute() running at demo.py:4>>
Number: 1
Task: <Task finished coro=<execute() done, defined at demo.py:4> result=1>
After calling loop

发现其运行效果都是一样的。

2.2 创建 task 总结

  1. 以下代码都是异步函数
  1. loop = asyncio.get_event_loop()
task = loop.create_task(coroutine) # 需要提前声明 loop
  1. task = asyncio.create_task(net())
  1. task = asyncio.ensure_future(coroutine) # 不需要提前声明

2.3 绑定回调

另外我们也可以为某个 task 绑定一个回调方法,比如我们来看下面的例子:

"""
project = 'Code', file_name = 'lession.py', author = 'AI悦创'
time = '2020/4/23 10:18', product_name = PyCharm, 公众号:AI悦创
# code is far away from bugs with the god animal protecting
    I love animals. They taste delicious.
"""
import asyncio
import requests

async def request():
    url = 'https://www.baidu.com'
    status = requests.get(url)
    return status

def callback(task):
    print('Status:', task.result())

coroutine = request()
task = asyncio.ensure_future(coroutine)
task.add_done_callback(callback)
print('Task:', task)

loop = asyncio.get_event_loop()
loop.run_until_complete(task)
print('Task:', task)

在这里我们定义了一个 request 方法,请求了百度,获取其状态码,但是这个方法里面我们没有任何 print 语句。随后我们定义了一个 callback 方法,这个方法接收一个参数,是 task 对象,然后调用 print 方法打印了 task 对象的结果。这样我们就定义好了一个 coroutine 对象和一个回调方法,我们现在希望的效果是,当 coroutine 对象执行完毕之后,就去执行声明的 callback 方法。

那么它们二者怎样关联起来呢?

很简单,只需要调用 add_done_callback方法即可,我们将 callback 方法传递给了封装好的 task 对象,这样当 task 执行完毕之后就可以调用 callback 方法了,同时 task 对象还会作为参数传递给 callback 方法,调用 task 对象的 result 方法就可以获取返回结果了。

运行结果:

Task: <Task pending coro=<request() running at demo.py:5> cb=[callback() at demo.py:11]>
Status: <Response [200]>
Task: <Task finished coro=<request() done, defined at demo.py:5> result=<Response [200]>>

实际上不用回调方法,直接在 task 运行完毕之后也可以直接调用 result 方法获取结果,如下所示:

import asyncio
import requests
 
async def request():
   url = 'https://www.baidu.com'
   status = requests.get(url)
   return status
 
coroutine = request()
task = asyncio.ensure_future(coroutine) # 分配任务
print('Task:', task) # 当前任务状态
 
loop = asyncio.get_event_loop()
loop.run_until_complete(task)
print('Task:', task)
print('Task Result:', task.result())

运行结果是一样的:

Task: <Task pending coro=<request() running at demo.py:4>>
Task: <Task finished coro=<request() done, defined at demo.py:4> result=<Response [200]>>
Task Result: <Response [200]>

2.4 多任务协程

上面的例子我们只执行了一次请求,如果我们想执行多次请求应该怎么办呢?我们可以定义一个 task 列表,然后使用 asyncio 的 wait 方法即可执行,看下面的例子:

import asyncio
import requests
 
async def request():
   url = 'https://www.baidu.com'
   status = requests.get(url)
   return status
 
tasks = [asyncio.ensure_future(request()) for _ in range(5)]
print('Tasks:', tasks)
 
loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks))
 
for task in tasks:
   print('Task Result:', task.result())

这里我们使用一个 for 循环创建了五个 task,组成了一个列表,然后把这个列表首先传递给了 asyncio 的 wait() 方法,然后再将其注册到时间循环中,就可以发起五个任务了。最后我们再将任务的运行结果输出出来,运行结果如下:

Tasks: [<Task pending coro=<request() running at demo.py:5>>, 
<Task pending coro=<request() running at demo.py:5>>, 
<Task pending coro=<request() running at demo.py:5>>, 
<Task pending coro=<request() running at demo.py:5>>, 
<Task pending coro=<request() running at demo.py:5>>]

Task Result: <Response [200]>
Task Result: <Response [200]>
Task Result: <Response [200]>
Task Result: <Response [200]>
Task Result: <Response [200]>

可以看到五个任务被顺次执行了,并得到了运行结果。

2.5 协程之间的链式调用

我们可以通过使用 await 关键字,在一个协程中调用一个协程。一个协程可以启动另一个协程,从而可以使任务根据工作内容,封装到不同的协程中。我们可以在协程中使用 await 关键字,链式地调度协程,来形成一个协程任务流。像下面的例子一样:

# -*- coding: utf-8 -*-
# @Author: clela
# @Date:   2020-04-26 13:27:11
# @Last Modified by:   clela
# @Last Modified time: 2020-04-26 13:27:44
import asyncio

async def main():
    print("主协程")
    print("等待result1协程运行")
    res1 = await result1()
    print("等待result2协程运行")
    res2 = await result2(res1)
    return (res1, res2)

async def result1():
    print("这是result1协程")
    return "result1"

async def result2(arg):
    print("这是result2协程")
    return f"result2接收了一个参数,{arg}"

if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    try:
        result = loop.run_until_complete(main())
        print(f"获取返回值:{result}")
    finally:
        print("关闭事件循环")
        loop.close()

输出:

主协程
等待result1协程运行
这是result1协程
等待result2协程运行
这是result2协程
获取返回值:('result1', 'result2接收了一个参数,result1')
关闭事件循环

在上面,我们知道调用协程需要通过创建一个事件循环然后再去运行。这里我们需要了解的是如果在协程里想调用一个协程我们需要使用 await 关键字,就拿上面的例子来说在 main 函数里调用协程 result1 和 result2。那么问题来了:await 干了什么呢?

2.6 await 的作用

我们前面使用到了许多次 await 那它的作用到底是什么呢?

await 的作用就是等待当前的协程运行结束之后再继续进行下面代码。因为我们执行 result1 的时间很短,所以在表面上看 result1 和 result2 是一起执行的。这就是 await 的作用。等待一个协程的执行完毕,如果有返回结果,那么就会接收到协程的返回结果,通过使用 return 可以返回协程的一个结果,这个和同步函数的 return 使用方法一样。

2.7 并发的执行任务

一系列的协程可以通过 await 链式调用,但是有的时候我们需要在一个协程里等待多个协程,比如我们在一个协程里等待 1000 个异步网络请求,对于访问次序没有要求的时候,就可以使用关键字 wait 来解决了。wait 可以暂停一个协程,直到后台操作完成。

Task 的使用

import asyncio

async def num(n):
    print(f"当前的数字是:{n}")
    await asyncio.sleep(n)
    print(f"等待时间:{n}")

async def main():
    tasks = [num(i) for i in range(10)] #协程列表
    #await asyncio.gather(*tasks) #有序并发
    await asyncio.wait(tasks) #并发运行协程列表的协程

if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    try:
        loop.run_until_complete(main())
    finally:
        loop.close()

输出:

当前的数字是:0
当前的数字是:4
当前的数字是:8
当前的数字是:1
当前的数字是:5
当前的数字是:7
当前的数字是:2
当前的数字是:6
当前的数字是:9
当前的数字是:3
等待时间:0
等待时间:1
等待时间:2
等待时间:3
等待时间:4
等待时间:5
等待时间:6
等待时间:7
等待时间:8
等待时间:9

如果运行的话会发现首先会打印 10 次数字,但是并不是顺序执行的,这也说明 asyncio.wait 并发执行的时候是乱序的。如果想保证顺序只要使用 gather 把 task 写成解包的形式就行了,也就是上面的注释部分的代码。

2.8 如何在协程中使用普通的函数呢?

我们知道在普通函数中调用普通函数之间,函数名加括号即可,像下面这样:

# -*- coding: utf-8 -*-
# @Author: clela
# @Date:   2020-04-26 13:38:27
# @Last Modified by:   clela
# @Last Modified time: 2020-04-26 13:43:01
def foo():
    print("这是一个普通函数")
    return "test"

def main():
    print("调用foo函数") 
    res=foo()
    print(f"接收到来自foo函数的值:{res}")

if __name__ == '__main__':
    main()  

那么在协程中如何使用一个普通函数呢?在协程中可以通过一些方法去调用普通的函数。可以使用的关键字有 call_soon 等。

2.9 call_soon

可以通过字面意思理解调用立即返回。下面来看一下具体的使用例子:

# -*- coding: utf-8 -*-
# @Author: clela
# @Date:   2020-04-26 13:43:35
# @Last Modified by:   clela
# @Last Modified time: 2020-04-26 13:44:08
import asyncio
import functools

def callback(args, *, kwargs="defalut"):
    print(f"普通函数做为回调函数,获取参数:{args},{kwargs}")

async def main(loop):
    print("注册callback")
    loop.call_soon(callback, 1)
    wrapped = functools.partial(callback, kwargs="not defalut")
    loop.call_soon(wrapped, 2)
    await asyncio.sleep(0.2)

if __name__ == '__main__':
    loop = asyncio.get_event_loop()
try:
    loop.run_until_complete(main(loop))
finally:
    loop.close()

输出结果:

注册callback
普通函数做为回调函数,获取参数:1,defalut
普通函数做为回调函数,获取参数:2,not defalut

通过输出结果我们可以发现我们在协程中成功调用了一个普通函数,顺序地打印了 1 和 2。

看过这些例子之后,也许你就有疑问了,协程没有缺点的么?

3. 协程的缺点

同样的总结下大概以下 2 点。

3.1 无法使用 CPU 的多核

协程的本质是个单线程,它不能同时用上单个 CPU 的多个核,协程需要和进程配合才能运行在多 CPU 上。当然我们日常所编写的绝大部分应用都没有这个必要,就比如网络爬虫来说,限制爬虫的速度还有其他的因素,比如网站并发量、网速等问题都会是爬虫速度限制的因素。除非做一些密集型应用,这个时候才可能会用到多进程和协程。

3.2 处处都要使用非阻塞代码

写协程就意味着你要一值写一些非阻塞的代码,使用各种异步版本的库,比如后面的异步爬虫教程中用的 aiohttp 就是一个异步版本的request库等。不过这些缺点并不能影响到使用协程的优势。

4. 协程与异步

上面想必你已经完全掌握了,接下来,我们用睡眠来模仿一下耗时的 IO 操作。

import asyncio

# 定义异步函数

async def hello(i):
    print('hello', i)
    await asyncio.sleep(3) # 假设我们下载文件需要3s
    print('world', i)

if __name__ == '__main__':
    tasks = []
    for i in range(4):
        tasks.append(hello(i)) # 把要下载请求的页面放入我们的 tasks,然后交给 asyncio 处理
    loop = asyncio.get_event_loop() # 获取时间循环
    loop.run_until_complete(asyncio.wait(tasks)) # run_until_complete:把所有程序都运行完毕,然后再停止运行。
    loop.close()

输出结果:

hello 3
hello 1
hello 0
hello 2
world 3
world 0
world 1
world 2

tips:

注意区别 time.sleep() 这个是不能使用到异步里面的 sleep,如果你直接用 time 模块里面的 说了 sleep 那代码是真正睡眠了,不会执行其他任务了。所以需要使用 asyncio.sleep() 的睡眠才可以。requests 包也是同理,所以接下来我会给大家讲解一个新的包(aiohttp),我们将用 aiohttp 来代替 requests。

接下来我们来分析一下输出结果:

hello 3 # 当程序执行在这个任务时需要 3s 的时间,所以进入等待,然后继续执行下一个任务
hello 1 # 当上一个任务在等待的时候,这个任务在也遇到了要等待 3s ,接着执行下一个任务,以此类推。
hello 0
hello 2
world 3 # 当任务等待完成(恢复)那 world 就输出出来了)
world 0
world 1
world 2

这时候细心的小伙伴有可能会说,我们添加任务进去的时候是 0、1、2、3,可是在执行的时候却是 3、1、0、2这就是我上面说的异步是不可控,随机的。

小结:

我在使用异步的时候,上面一共说到了三种:

执行单个任务:

  1. await 执行异步

  2. asyncio.create_task(function)

执行多个任务:

  1. 获取事件循环:loop = asyncio.get_event_loop()、loop.run_until_complete(asyncio.wait(list))

5. 异步爬虫实战

pip install aiohttp

抓取目标网站:百思不得姐

# -*- coding: utf-8 -*-
# @Author: clela
# @Date:   2020-04-26 13:45:03
# @Last Modified by:   clela
# @Last Modified time: 2020-04-26 13:45:26
import asyncio
import aiohttp
from bs4 import BeautifulSoup
headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.116 Safari/537.36',
}

async def crawl(i):
    url = f'http://www.budejie.com/{i}'
    async with aiohttp.ClientSession(headers = headers)as session:
        async with session.get(url)as response:
            print(response.status)
            text = await response.text()
            print('start', i)
    soup = BeautifulSoup(text, 'lxml')
    lis = soup.select(".j-r-list ul li div .u-txt a")
    for li in lis:
        print(li.get_text())
if __name__ == '__main__':
    tasks = [crawl(i) for i in range(1, 10)]
    loop = asyncio.get_event_loop()
    loop.run_until_complete(asyncio.wait(tasks))
    loop.close()

输出结果:

C:\Users\clela\AppData\Local\Programs\Python\Python37\python.exe C:/Code/pycharm_daima/爬虫大师班/14-异步编程/异步爬虫实战.py
200
start 6
怀疑人生 
金月 
游先生 
加强 
南南 
心之痕 
怀疑人生 
能认真点吗 
雨婷思梦 
原装正版无添加 
随便了 
滒特 誃瑙菏 
糖水菠萝 
诠忄 
知鱼之乐 
墨染锦年 
懒洋洋 
死神小一生 
圆圆呐 
仙境里的童话 
汪坚他爹是我 
嘘呀 
路上城静 
顾蒙蒙 
Pescado 

Process finished with exit code 0

补充:

if __name__ == '__main__':
    tasks = [crawl(i) for i in range(1, 10)]
    loop = asyncio.get_event_loop()
    # 方法一:
    loop.run_until_complete(asyncio.wait(tasks))
    loop.close()
    # 方法二:
    loop.run_until_complete(asyncio.gather(*tasks))
    loop.close()
    # 方法三:
    for task in tasks:
        loop.run_until_complete(asyncio.gather(task))
    loop.close()

那到这里,同学们已经掌握了:多线程、多进程、线程池、进程池、异步。那有同学可能会问:可不可以把这几个方法结合起来呢?

那我告诉你们的是,异步只能用异步的方法执行,不过大家是否用过 concurrent.future 模块呢?这个模块是底层是 异步,所以这也是我接下来所要说的。

6. 异步使用线程池与进程池

Concurrent.futures 这个模块可以和异步连接,具有线程池和进程池。管理并发编程,处理非确定性的执行流程,同步功能。

使用 requests 的异步

目标文章:http://www.budejie.com

代码如下:

import asyncio, requests,aiohttp
import time
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
# ThreadPoolExecutor :线程池
# ProcessPoolExecutor:进程池
from bs4 import BeautifulSoup
from requests.exceptions import RequestException

headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.116 Safari/537.36',
}

def crawl(i):
    url = f'http://www.budejie.com/{i}'
    try:
        html = requests.get(url, headers=headers)
        if html.status_code == 200:
            soup = BeautifulSoup(html.text, 'lxml')
            lis = soup.select(".j-r-list ul li div .u-txt a")
            for li in lis:
                print(li.get_text())
        return "ok"
    except RequestException:
        return None

async def main():
    loop = asyncio.get_event_loop() # 获取循环事件
    tasks = []
    with ThreadPoolExecutor(max_workers=10)as t:
        # 10 个线程,10 个任务
        for i in range(1, 10):
            tasks.append(loop.run_in_executor(t, crawl, i))
    #       task.append(loop.run_in_executor(放入你的线程,爬虫函数,爬虫函数参数)

    # 以下代码可以不写
    # await asyncio.wait(tasks)
    # for result in await asyncio.wait(tasks):
    #   print(result)# 当你执行的爬虫函数有返回信息时使用
    #   pass

if __name__ == '__main__':
    start_time = time.time()
    loop = asyncio.get_event_loop()
    loop.run_until_complete(main())
    loop.close()
    print(time.time() - start_time)

编写程序测试时间,建议不要同时运行,注释掉其他运行方法再运行:

import asyncio, requests,aiohttp
import time
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
from bs4 import BeautifulSoup
from requests.exceptions import RequestException

headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.116 Safari/537.36',
}

def crawl(i):
    url = f'http://www.budejie.com/{i}'
    try:
        html = requests.get(url, headers=headers)
        if html.status_code == 200:
            soup = BeautifulSoup(html.text, 'lxml')
            lis = soup.select(".j-r-list ul li div .u-txt a")
            for li in lis:
                pass
            #   print(li.get_text())
        return "ok"
    except RequestException:
        return None

if __name__ == '__main__':
    start_time_1 = time.time()
    for i in range(1, 10):
        crawl(i)
    print("单线程时间:>>>", time.time() - start_time_1)

    start_time_2 = time.time()
    with ThreadPoolExecutor(max_workers=10)as t:
        for i in range(1, 10):
            t.submit(crawl, i)
    print("线程池时间:>>>", time.time() - start_time_2)

    start_time_3 = time.time()
    with ProcessPoolExecutor(max_workers=10)as t:
        for i in range(1, 10):
            t.submit(crawl, i)
    print("进程池时间:>>>", time.time() - start_time_3)

输出结果:

单线程时间:>>> 2.1695995330810547
线程池时间:>>> 0.5049772262573242
进程池时间:>>> 0.920097827911377

我们来分析一下输出结果,我们会分析进程池花费的时间会比线程池更多,这是为什么呢?

  1. 多线程非常适合 I/O 密集型,不适合 CPU 密集型;

  2. 进程池创建销毁的资源开销大,创建一个进程所耗费的资源要比创建一个线程耗费的时间大很多,销毁它也需要很长的时间。(准备工作非常多)

7. 小结

对于协程的入门来说,这些知识已经够用了。当然协程涉及到的知识不止这些,这里只是为了大家提前对协程有一定的了解,后面将继续讲解协程的其他知识,一切的协程知识基础都是为后面的异步爬虫教程做准备,只有熟悉了使用协程才能在后面教程中快速上手操作。接下来将进一步提到本文没有提及的事件循环、Task、Future、Awaitable 等一系列知识点,以及协程的高层 API 知识。敬请期待!

image

更多干货内容,欢迎关注公众号:知了python

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,547评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,399评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,428评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,599评论 1 274
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,612评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,577评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,941评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,603评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,852评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,605评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,693评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,375评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,955评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,936评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,172评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,970评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,414评论 2 342