我们在上一章将生成器的时候最后写了,在Python2中生成器还扮演了一个重要的角色——实现Python的协程。那什么是协程呢?

协程

协程是实现并发编程的一种方式。提到并发,肯很多人都会想到多线程/多进程模型,这就是解决并发问题的经典模型之一。在最初的互联网世界中,多线程/多进程就在服务器并发中起到举足轻重的作用。

但是随着互联网的发展,慢慢很多场合都会遇到C10K瓶颈,也就是同时连接到服务器的客户达到1W,于是,很多代码就跑崩溃,因为进程的上下文切换占用了大量的资源,线程也顶不住如此巨大的压力。这时候,NGINX就带着事件循环闪亮登场了。

事件循环启动一个统一的调度器,让调度器来决定一个时刻去运行哪个任务,于是省却了多线程中启动线程、管理线程、同步锁等各种开销。同一时期的NGINX,在高并发下也能保持资源消耗低、性能高,相比Apache也支持更多的并发连接。

再到后来,出现了一个很有名的名词——回调地狱(callback hell).很多写过JavaScript的朋友明白那是什么。大家惊喜的发现,这种工具很完美的继承了事件循环的优越性,同时还能提供async/await语法糖,解决了执行性和可读性共存的难题。于是,协程渐渐被更多人发现并看好,也有越来越多的人尝试用Node.js做起了后端开发。

回到Python中,使用生成器来实现协程已经是Python2时代的老方法了。而在Python3.7中,提供了基于asynico和async/await的方法。我们这节课就抛弃生成器的方法,基于这种用法来实现协程。

从一个爬虫说起

爬虫的作用就不多讲 了,我们直接看代码

import time
def craw_page(url):
print('crawing{}'.format(url))
sleep_time = int(url.split('_')[-1])
time.sleep(sleep_time)
print('OK {}'.format(url)) def main(urls):
for url in urls:
craw_page(url) main(['url_1','url_2','url_3','url_4'])

我们通过上面的代码依次爬取了5个页面,而每个页面爬取的时间分别为1-4秒(我们用time.sleep模拟了数据抓取的过程)。所以整个程序总耗时为

1+2+3+4= 10秒

程序的时间基本上都消耗在等待上了。那我们是不是可以怎么优化一下呢?一个很简单的思路出现了:我们这种操作可以并发化,就让我们看看怎么写

import time
import asyncio
async def crawl_page(url):
print('crawling{}'.format(url))
sleep_time = int(url.split('_')[-1])
await asyncio.sleep(sleep_time)
print('OK {}'.format(url)) async def main(urls):
for url in urls:
await crawl_page(url) time_start = time.perf_counter()
asyncio.run(main(['url_1','url_2','url_3','url_4']))
print('totle cost {}s'.format(time.perf_counter()-time_start))

在这段代码里就实现了使用协程写异步程序的简便方法。

import asyncio

这个库里就包含了我们需要的大部分实现协程的工具。

而async修饰词声明异步函数,于是这里的两个函数都成了异步函数。而调用的异步函数我们就得到了一个协程对象(coroutine object)

然后通过awaitlai来调用。而await的执行结果和Python的正常执行是一样的。也会使程序阻塞在此处,进入被调用的函数,直到执行完毕后再返回继续。这也就是await字面上的意思。

这里就要我们来看看执行协程的几种方法了,一般常用的方法有三种:

1.通过await来调用,就和上面说的一样代码中await asyncio.sleep(sleep_time)处会模拟爬虫获取数据时等待的时间,而await crawl_page()则会执行craw_page()函数。

2.我们可以通过

asyncio.create_task()

方法来创建任务,具体的方法我们在下一章会说,这里先点明一下

3.我们需要让asyncio.run来触发运行,这个函数实在Python3.7以后的版本中才有的特性,可以让Python协程接口编程变得非常简单,我们不用理会事件循环怎么定义和如何使用的问题(我们在下面会讲到)。而且一个非常好的编程规范是

asyncio.run(main())

作为主程序的入口,在程序的运行周期内,知掉用一次该函数。

这样,我们就可以跑一下上面的代码,看看结论是什么!

############运行结论############
crawlingurl_1
OK url_1
crawlingurl_2
OK url_2
crawlingurl_3
OK url_3
crawlingurl_4
OK url_4
totle cost 10.0089496s

为什么还是10s呢?没错,上面讲过了,await是同步调用(上面字体加粗的部分)。因此,craw_page()函数在当前的调用结束前是不会触发下一次调用的。于是代码效果就和上面完全一样了。相当于用异步接口写了个同步代码

那又该怎么办呢?

其实很简单,也是我们在下面要讲的东西——任务(Task)。老规矩,我们通过下面的代码来讲解

import time
import asyncio
async def crawl_page(url):
print('crawling{}'.format(url))
sleep_time = int(url.split('_')[-1])
await asyncio.sleep(sleep_time)
print('OK {}'.format(url)) async def main(urls):
tasks = [(asyncio.create_task(crawl_page(url))) for url in urls]
for task in tasks:
await task time_start = time.perf_counter()
asyncio.run(main(['url_1','url_2','url_3','url_4']))
print('totle cost {}s'.format(time.perf_counter()-time_start))

可以看到,我们有了协程对象以后,可以通过

asyncio.creat_task()

来创建任务,任务创建后会被调度执行,这样,我们的代码也不会被阻塞在任务这里,所以我们要等到所有任务都结束才行,用循环启动tasks里的task

这样一来,运行结果就不一样了

##########运行结论##########
crawlingurl_1
crawlingurl_2
crawlingurl_3
crawlingurl_4
OK url_1
OK url_2
OK url_3
OK url_4
totle cost 4.0060087s

程序运行总时长等于运行时间最长的爬虫的运行时间。

其实,对于执行tasks,还有另一种做法

import time
import asyncio
async def crawl_page(url):
print('crawling{}'.format(url))
sleep_time = int(url.split('_')[-1])
await asyncio.sleep(sleep_time)
print('OK {}'.format(url)) async def main(urls):
tasks = [(asyncio.create_task(crawl_page(url))) for url in urls]
for task in tasks:
await asyncio.gather(*tasks) time_start = time.perf_counter()
asyncio.run(main(['url_1','url_2','url_3','url_4']))
print('totle cost {}s'.format(time.perf_counter()-time_start))

我们通过*tasks来解包列表,将列表变成了函数的参数,与之对应的是**dict是将字典变成函数的参数(和)

上面已经大致说明的协程的用法,如果需要爬取的页有上万个又该怎么办呢?再对比一下协程的写法,是不是这种写法更加清晰明了。


解密协程运行时

下面我们可以深入底层代码看看协程工作期间是如何运行的,还是先放两段代码

import asyncio
import time
async def work_1():
print('work_1 start')
await asyncio.sleep(1)
print('work_1 done') async def work_2():
print('work_1 start')
await asyncio.sleep(2)
print('work_2 done') async def main():
print('befort await')
await work_1()
print('awaited work_1')
await work_2()
print('awaited work_2') start_time = time.perf_counter()
asyncio.run(main())
print('totle cost:{}s'.format(time.perf_counter()-start_time)) ##########输出##########
befort await
work_1 start
work_1 done
awaited work_1
work_1 start
work_2 done
awaited work_2
totle cost:3.0037941s

代码段2

import asyncio
import time async def work_1():
print('work_1 start')
await asyncio.sleep(1)
print('work_1 done') async def work_2():
print('work_1 start')
await asyncio.sleep(2)
print('work_2 done') async def main():
task1 = asyncio.create_task(work_1())
task2 = asyncio.create_task(work_2())
print('before await')
await task1
print('awaited work_1')
await task2
print('awaited work_2') start_time = time.perf_counter()
asyncio.run(main())
print('totle cost:{}s'.format(time.perf_counter()-start_time)) ##########输出##########
before await
work_1 start
work_1 start
work_1 done
awaited work_1
work_2 done
awaited work_2
totle cost:2.0024394s

我们分析一下整个流程来更加详细的了解协程和线程的具体区别:

1.asyncio.run(main())进入main()函数,事件循环开启;

2.task1和task2两个任务被创建,并且进入事件循环等待运行;程序执行到第一个print,输入'before await'字符串

3.task1被await执行,用户选择从当前的主任务中切出,事件调度器开始调度work_1;

4.work_1开始运行,先执行print输出work_1 start后运行到等待后从当前任务切出,事件调度器开始调度work_2;

5.work_2开始运行,执行print输出work_2 start后到await处从当前任务切出

6.上述所有事件的运行时间都应该在1-10ms之间,甚至会更短,事件调度器从这个时刻开始暂停调度

7.1s后,work_1的sleep完成,时间调度器将控制权重新传给task1,输出work_1 done 后work_1完成任务,,从事件循环中退出;

8.await task1事件完成,事件调度器将控制器传给主任务,输出awaited work_1。然后在await task2处继续等待;

9.两秒钟后,work_2的sleep完成,事件调度器把控制器传给task2,输出work_2 done。task2完成任务从事件循环中退出;

10。主任务输出await work_2,协程任务结束,事件循环结束。


上面讲了协程的基本用法,可是还有些应用场景是需要有一些附加条件的:比方给协程任务限定一个运行时间,如果超出时间就取消,或者某些协程运行时出现了错误,那该怎么处理呢?我们看看下面的代码

import asyncio
import time
async def work_1():
await asyncio.sleep(1)
return 1 async def work_2():
await asyncio.sleep(2)
return 2/0 #除数不能为0,这里制造出一个错误 async def work_3():
await asyncio.sleep(10)
return 3 async def main():
task_1 = asyncio.create_task(work_1())
task_2 = asyncio.create_task(work_2())
task_3 = asyncio.create_task(work_3()) await asyncio.sleep(3)
task_3.cancel() res = await asyncio.gather(task_1,task_2,task_3,return_exceptions = True)
print(res) start_time = time.perf_counter()
asyncio.run(main())
print('totle cost:{}s'.format(time.perf_counter()-start_time)) ##########输出##########
[1, ZeroDivisionError('division by zero'), CancelledError()]
totle cost:3.00382s

我们可以看到,work_1可以正常运行,work_2在运行过程出现错误,work_3执行时间过长被cancel掉,这些信心都会体现在最后的结果res中,

并且还有一点疑问,我们只是在return中设置 了一处错误,可是如果程序过程出现错误又是什么效果呢?我们把work_2修改一下看看效果

async def work_2():
l = [1,2,3]
l[4]
await asyncio.sleep(2)
return 2 ##########输出##########
[1, IndexError('list index out of range'), CancelledError()]
totle cost:3.0029349s

并不是只有ruturn里有错误才会把错误返回,而是只要程序有错误都会把错误返回给主任务。但是我们必须加上return_exception = True这个条件,否则错误就会被完整的throw到执行层从而需要用try except的方式来捕捉,那么也就意味着其他还没有执行的任务会被全部取消掉。

讲到这里,我们就可以发现,线程能偶实现的,协程也都可做到。那么我们就温习一下上面的知识点,用协程来做一个生产者消费者模型吧

import asyncio
import random
async def consumer(queue,id):
while True:
val = await queue.get()
print('{} get a val:{}.'.format(id,val))
await asyncio.sleep(1) async def producer(queue,id):
for i in range(5):
val = random.randint(4,20)
await queue.put(val)
print('{} put a val:{}.'.format(id,val))
await asyncio.sleep(1) async def main():
queue = asyncio.Queue() consumer_1 = asyncio.create_task(consumer(queue,'consumer_1'))
# consumer_2 = asyncio.create_task(consumer(queue,'consumer_2')) producer_1 = asyncio.create_task(producer(queue,'producer_1'))
# producer_2 = asyncio.create_task(producer(queue,'producer_2')) await asyncio.sleep(10)
consumer_1.cancel()
producer_1.cancel() await asyncio.gather(consumer_1,producer_1,return_exceptions=True) asyncio.run(main())

我们定义了一个生产者和一个消费者,在main里启动了一个生产者一个消费者。并且要求10s后cancel掉消费者(其实生产者在for里已经定义了只通过5次循环生产出来5个元素)。

并且在主任务里的通过sleep规定了主任务的运行时长,不管是否还有任务在执行都通过后面的代码cancel掉。

实战

最后我们通过一个完整的爬虫来进行今天的实战练习

我们通过一个页面:https://movie.douban.com/cinema/later/xian/,这个页面描述了西安最近上映的电影,那如何通过python获取到这些电影的名称’、上映时间和海报呢?

这点我们留着后面完善。

总结

到这里,今天的内容就讲完了,今天用了较长的篇幅从一个简单的爬虫到一个真正的爬虫之间穿插讲述了Python协程比较新的方法和概念。这里复习一下:

协程和多线程的区别在于两点:1.协程为单线程,2.协程有用户决定在哪里交出控制权切换到下一个任务

协程的写法更加简洁清晰,把async/await的语法和create_task结合来用,对于中小级别的开发需求已经毫无压力

写协程程序的时候,大脑里应该有个清晰的事件循环概念,知道在什么时候需要暂停、等待IO、什么时候需要一并执行到底。

最后要记得,什么时候用什么模型能能 达到工程上的最优,而不是觉得那个技术非常牛,就创造条件上该技术。总之一句话:

技术是工程,而工程则是时间、资源、人力等众多纷繁复杂的事情的折中。

最后想一想:

协程是如何实现回调函数的呢?

Python核心技术与实战——十六|Python协程的更多相关文章

  1. Python核心技术与实战——十八|Python并发编程之Asyncio

    我们在上一章学习了Python并发编程的一种实现方法——多线程.今天,我们趁热打铁,看看Python并发编程的另一种实现方式——Asyncio.和前面协程的那章不太一样,这节课我们更加注重原理的理解. ...

  2. Python核心技术与实战——十四|Python中装饰器的使用

    我在以前的帖子里讲了装饰器的用法,这里我们来具体讲一讲Python中的装饰器,这里,我们从前面讲的函数,闭包为切入点,引出装饰器的概念.表达和基本使用方法.其次,我们结合一些实际工程中的例子,以便能再 ...

  3. Python核心技术与实战——十二|Python的比较与拷贝

    我们在前面已经接触到了很多Python对象比较的例子,例如这样的 a = b = a == b 或者是将一个对象进行拷贝 l1 = [,,,,] l2 = l1 l3 = list(l1) 那么现在试 ...

  4. Python核心技术与实战——十九|一起看看Python全局解释器锁GIL

    我们在前面的几节课里讲了Python的并发编程的特性,也了解了多线程编程.事实上,Python的多线程有一个非常重要的话题——GIL(Global Interpreter Lock).我们今天就来讲一 ...

  5. 流畅的python第十六章协程学习记录

    从句法上看,协程与生成器类似,都是定义体中包含 yield 关键字的函数.可是,在协程中,yield 通常出现在表达式的右边(例如,datum = yield),可以产出值,也可以不产出——如果 yi ...

  6. Python核心技术与实战——十五|深入了解迭代器和生成器

    我们在前面应该写过类似的代码 for i in [1,2,3,4,5]: print(i) for in 语句看起来很直观,很便于理解,比起C++或Java早起的 ; i<n;i++) prin ...

  7. Python核心技术与实战——十|面向对象的案例分析

    今天通过面向对象来对照一个案例分析一下,主要模拟敏捷开发过程中的迭代开发流程,巩固面向对象的程序设计思想. 我们从一个最简单的搜索做起,一步步的对其进行优化,首先我们要知道一个搜索引擎的构造:搜索器. ...

  8. Python核心技术与实战 笔记

    基础篇 Jupyter Notebook 优点 整合所有的资源 交互性编程体验 零成本重现结果 实践站点 Jupyter 官方 Google Research 提供的 Colab 环境 安装 运行 列 ...

  9. 二十六. Python基础(26)--类的内置特殊属性和方法

    二十六. Python基础(26)--类的内置特殊属性和方法 ● 知识框架 ● 类的内置方法/魔法方法案例1: 单例设计模式 # 类的魔法方法 # 案例1: 单例设计模式 class Teacher: ...

随机推荐

  1. Linux日志筛选命令

    (1)Linux目录操作命令 cd ..退出当前目录,返回上一级目录:cd / 退出当前目录,返回根目录: mkdir命令用于创建一个新的目录:rmdir命令功能删除指定的空目录. (2)Linux筛 ...

  2. Mysql数据库事务的四大特性:

    什么是事务? 事务Transaction,是指作为一个基本工作单元执行的一系列SQL语句的操作,要么完全地执行,要么完全地都不执行.为什么要使用事务:保证对数据操作的完整性和准确性.1,原子性:一个事 ...

  3. 关于使用unittest单元测试框架的一些问题集

    1.使用unittest.TestSuites生成的测试套件,使用HtmlTestRunner运行时报Type Error. 1)是由于使用unittest.TestSuites生成的测试套件里的Te ...

  4. C#学习笔记四(LINQ,错误和异常,异步编程,反射元数据和动态编程)

    LINQ 1.使用类似的数据库语言来操作集合? 错误和异常 异步编程 1.异步和线程的区别: 多线程和异步操作两者都可以达到避免调用线程阻塞的目的.但是,多线程和异步操作还是有一些区别的.而这些区别造 ...

  5. redis缓存与数据一致性

    目录 缓存 缓存穿透 缓存雪崩(缓存失效) 缓存击穿(热点key) 缓存并发竞争(并发set) 数据一致性 缓存(双写)一致性 Redis集群(Redis-cluster)一致性原理 哨兵(Senti ...

  6. 关于mysql8.0及以上版本连接navicat时候报错(密码加密方式需要修改)

    首先这个原因是因为MySQL版本的密码加密方式变了,要把它修改成以前的方式(因为,navicat不支持这种方式) 1:先进入mysql: mysql -uroot -p123456; 2:查询密码加密 ...

  7. 《Python编程从0到1》笔记5——图解递归你肯定看完就能懂!

    本小节的示例比较简单,因为在每次递归过程中原问题仅缩减为单个更小的问题.这样的问题往往能够用简单循环解决.这类递归算法的函数调用图是链状结构.这种递归类型被称为“单重递归”(single recurs ...

  8. Python pymysql对数据库的基础操作

    示例数据库名demo,表名info select * from info; 查看该表数据 +----+-------+--------+-----+---------------------+---- ...

  9. xc语言l博客作业03

    问题 答案 这个作业属于那个课程 c语言程序设计ll 这个作业要求在哪里 https://edu.cnblogs.com/campus/zswxy/CST2019-4/homework/8719 我在 ...

  10. etcd单节点数据备份与恢复

    插入测试数据 # etcdctl put smith # etcdctl put allen # etcdctl put ward # etcdctl put jones # etcdctl put ...