目录
协程是实现并发编程的一种形式。
说起并发编程就容易想到多进程/线程编程,最初的互联网中,多进程/线程在服务器并发中起到了举足轻重的作用。
随着互联网的快速发展,当同一时间连接到服务器的客户量达到一万,也就是C10K瓶颈。于是很多代码跑崩了,进程的上下文切换占用了大量的资源,线程也顶不住这么大的压力。于是NGINX站了出来,它带来的循环事件开始拯救世界。
事件循环启动一个统一的调度器,让调度器来决定一个时刻去运行哪个任务,于是省却了多线程中启动线程、管理线程、同步锁等各种开销。同一时期的 NGINX,在高并发下能保持低资源低消耗高性能,相比 Apache 也支持更多的并发连接。
什么是协程?
协程的特点在于是一个线程执行,那和多线程比,协程有何优势?
- 最大的优势就是协程极高的执行效率。因为子程序切换不是线程切换,而是由程序自身控制,因此,没有线程切换的开销,和多线程比,线程数量越多,协程的性能优势就越明显。
- 第二大优势就是不需要多线程的锁机制,因为只有一个线程,也不存在同时写变量冲突,在协程中控制共享资源不加锁,只需要判断状态就好了,所以执行效率比多线程高很多。
因为协程是一个线程执行,那怎么利用多核CPU呢?最简单的方法是多进程+协程,既充分利用多核,又充分发挥协程的高效率,可获得极高的性能。
asyncio模块
(https://docs.python.org/zh-cn/3.7/library/asyncio.html)
Python对协程的支持还非常有限,用在generator中的yield可以一定程度上实现协程。(yield和yield from 的用法参考https://www.cnblogs.com/gqtcgq/p/8126124.html)
使用生成器,是 Python 2 开头的时代实现协程的老方法了,python也是在python 3.4中引入了协程的概念,Python 3.7 提供了新的基于asyncio 和 async / await 的方法。
asyncio 是干什么的?
- 异步网络操作
- 并发
- 协程
python3.0时代,标准库里的异步网络模块:select(非常底层) python3.0时代,第三方异步网络库:Tornado python3.4时代,asyncio:支持TCP,子进程
现在的asyncio,有了很多的模块已经在支持:aiohttp,aiodns,aioredis等等 https://github.com/aio-libs 这里列出了已经支持的内容,并在持续更新
当然到目前为止实现协程的不仅仅只有asyncio,tornado和gevent都实现了类似功能
关于asyncio的一些关键字的说明:
-
event_loop 事件循环:程序开启一个无限循环,把一些函数注册到事件循环上,当满足事件发生的时候,调用相应的协程函数
-
coroutine 协程:协程对象,指一个使用async关键字定义的函数,它的调用不会立即执行函数,而是会返回一个协程对象。协程对象需要注册到事件循环,由事件循环调用。
-
task 任务:一个协程对象就是一个原生可以挂起的函数,任务则是对协程进一步封装,其中包含了任务的各种状态
-
future: 代表将来执行或没有执行的任务的结果。它和task上没有本质上的区别
-
async/await 关键字:python3.5用于定义协程的关键字,async定义一个协程,await用于挂起阻塞的异步调用接口。
举个例子
举一个非常简单的爬虫
import time
now = lambda :time.perf_counter()
def crawl_page(url):
print('crawling {}'.format(url))
sleep_time = int(url.split('_')[-1])
time.sleep(sleep_time)
print('OK {}'.format(url))
def main(urls):
for url in urls:
crawl_page(url)
start = now()
main(['url_1', 'url_2', 'url_3', 'url_4'])
print(now()-start)
#输出
#crawling url_1
#OK url_1
#crawling url_2
#OK url_2
#crawling url_3
#OK url_3
#crawling url_4
#OK url_4
#9.999895046537901
(注意:我们简化爬虫的scrawl_page 函数为休眠数秒,休眠时间取决于 url 最后的那个数字。)
五个页面分别用了 1 秒到 4 秒的时间,加起来一共用了 10 秒。这显然效率低下,该怎么优化呢?
于是,一个很简单的思路出现了——我们这种爬取操作,完全可以并发化。我们就来看看使用协程怎么写。
import asyncio
import time
now = lambda :time.perf_counter()
async def crawl_page(url): #async定义一个协程,async修饰词声明异步函数,这里的crawl_page变成了异步函数
print('crawling {}'.format(url))
sleep_time = int(url.split('_')[-1])
await asyncio.sleep(sleep_time) #await用于挂起阻塞的异步调用接口
print('OK {}'.format(url))
async def main(urls):
for url in urls:
await crawl_page(url)
start = now()
#在python 3.5 版本中
#调用异步函数便可得到一个协程对象(coroutine object),这里是一个协程对象,这个时候main函数并没有执行
coroutine = main(['url_1', 'url_2', 'url_3', 'url_4'])
#通过async关键字定义一个协程,当然协程不能直接运行,需要将协程加入到循环事件loop中
loop = asyncio.get_event_loop() #创建一个事件循环
loop.run_until_complete(coroutine) #将协程注册到事件循环,并启动事件循环,此时会把协程包装成task
#在python 3.7 及以上版本中加入了asyncio.run用法,更简洁。效果和上面几行一样
#asyncio.run(main(['url_1', 'url_2', 'url_3', 'url_4']))
print(now()-start)
#输出
#crawling url_1
#OK url_1
#crawling url_2
#OK url_2
#crawling url_3
#OK url_3
#crawling url_4
#OK url_4
#10.00548147187418
print(crawl_page('')) #这是一个 Python 的协程对象,而并不会真正执行这个函数。
#输出
#<coroutine object crawl_page at 0x00000000033CA258>
首先来看 import asyncio,这个库包含了大部分我们实现协程所需的魔法工具。
async 修饰词声明异步函数,于是,这里的 crawl_page和main都变成了异步函数。而调用异步函数,我们便可得到一个协程对象(coroutine object)。
await 执行的效果,和 Python 正常执行是一样的,也就是说程序会阻塞在这里,进入被调用的协程函数,执行完毕返回后再继续,而这也是 await 的字面意思。代码中 await asyncio.sleep(sleep_time) 会在这里休息若干秒,await crawl_page(url)则会执行 crawl_page() 函数。
事件循环在线程中运行(通常是主线程),并在其线程中执行所有回调和任务。当一个任务在事件循环中运行时,没有其他任务可以在同一个线程中运行。当一个任务执行一个 await
表达式时,正在运行的任务被挂起,事件循环执行下一个任务。
asyncio.run 这个函数是 Python 3.7 之后才有的特性,可以让 Python的协程接口变得非常简单。一个非常好的编程规范是,asyncio.run(main()) 作为主程序的入口函数,在程序运行周期内,只调用一次 asyncio.run。
观察上面代码的运行结果,what,怎么还是 10 秒?
10 秒就对了,还记得上面所说的,await 是同步调用,因此,crawl_page(url) 在当前的调用结束之前,是不会触发下一次调用的。于是,这个代码效果就和上面完全一样了,相当于我们用异步接口写了个同步代码。
现在又该怎么办呢?
其实很简单,也正是我接下来要讲的协程中的一个重要概念,任务(Task)。
import asyncio
import time
now = lambda:time.perf_counter()
async def crawl_page(url): #async定义一个协程,async修饰词声明异步函数,这里的crawl_page变成了异步函数
print('crawling {}'.format(url))
sleep_time = int(url.split('_')[-1])
await asyncio.sleep(sleep_time) #await用于挂起阻塞的异步调用接口
print('OK {}'.format(url))
async def main(urls):
#python 3.7及以上版本有asyncio.create_task
tasks = [asyncio.create_task(crawl_page(url)) for url in urls]
for task in tasks:
await task
#在python 3.7 及以上版本中加入了asyncio.run用法
#asyncio.run(main(['url_1', 'url_2', 'url_3', 'url_4']))
start = now()
#在python 3.5 版本中
urls = ['url_1', 'url_2', 'url_3', 'url_4']
tasks = [asyncio.ensure_future(crawl_page(url)) for url in urls]
print(tasks)
loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks))
print(tasks)
print(now()-start)
#输出
#[<Task pending coro=<crawl_page() running at E:/python_test/test_asyncio.py:6>>, <Task pending coro=<crawl_page() running at E:/python_test/test_asyncio.py:6>>, <Task pending coro=<crawl_page() running at E:/python_test/test_asyncio.py:6>>, <Task pending coro=<crawl_page() running at E:/python_test/test_asyncio.py:6>>]
#crawling url_1
#crawling url_2
#crawling url_3
#crawling url_4
#OK url_1
#OK url_2
#OK url_3
#OK url_4
#[<Task finished coro=<crawl_page() done, defined at E:/python_test/test_asyncio.py:6> result=None>, <Task finished coro=<crawl_page() done, defined at E:/python_test/test_asyncio.py:6> result=None>, <Task finished coro=<crawl_page() done, defined at E:/python_test/test_asyncio.py:6> result=None>, <Task finished coro=<crawl_page() done, defined at E:/python_test/test_asyncio.py:6> result=None>]
#4.00404582797189
(注意:task的输出信息中有task的状态,结果中可以看到两种状态,pending、finished)
你可以看到(在python3.7+版本),我们有了协程对象后,便可以通过 asyncio.create_task 来创建任务。任务创建后很快就会被调度执行,这样,我们的代码也不会阻塞在任务这里。所以,我们要等所有任务都结束才行,用for task in tasks: await task即可。python3.5版本也是如此。
其实,对于执行 tasks,还有另一种做法:
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]
await asyncio.gather(*tasks)
asyncio.run(main(['url_1', 'url_2', 'url_3', 'url_4']))
(注意:*tasks 解包列表,将列表变成了函数的参数;与之对应的是, ** dict 将字典变成了函数的参数。)
asyncio.create_task,asyncio.run 这些函数都是 Python 3.7 以上的版本才提供的,自然,相比于旧接口它们也更容易理解和阅读。
解密协程运行时
先来看看单个task的例子:
import asyncio
async def compute(x, y):
print("Compute %s + %s ..." % (x, y))
await asyncio.sleep(1.0)
return x + y
async def print_sum(x, y):
result = await compute(x, y)
print("%s + %s = %s" % (x, y, result))
#python 3.5 版本
loop = asyncio.get_event_loop()
loop.run_until_complete(print_sum(1, 2))
loop.close()
示例的时序图 :
再来看看多个任务并发执行,并行执行3个任务(A、B、C)的示例:
import asyncio
@asyncio.coroutine
def factorial(name, number):
f = 1
for i in range(2, number+1):
print("Task %s: Compute factorial(%s)..." % (name, i))
yield from asyncio.sleep(1)
f *= i
print("Task %s: factorial(%s) = %s" % (name, number, f))
loop = asyncio.get_event_loop()
tasks = [
asyncio.ensure_future(factorial("A", 2)),
asyncio.ensure_future(factorial("B", 3)),
asyncio.ensure_future(factorial("C", 4))]
loop.run_until_complete(asyncio.wait(tasks))
loop.close()
#输出
#Task A: Compute factorial(2)...
#Task B: Compute factorial(2)...
#Task C: Compute factorial(2)...
#Task A: factorial(2) = 2
#Task B: Compute factorial(3)...
#Task C: Compute factorial(3)...
#Task B: factorial(3) = 6
#Task C: Compute factorial(4)...
#Task C: factorial(4) = 24
任务在创建时自动计划执行。当所有任务完成时,事件循环停止。
实战
任务描述:https://movie.douban.com/cinema/later/beijing/这个页面描述了北京最近上映的电影,你能否通过 Python 得到这些电影的名称、上映时间和海报呢?这个页面的海报是缩小版的,我希望你能从具体的电影描述页面中抓取到海报。
同步版本:
import requests
from bs4 import BeautifulSoup
import time
now = lambda:time.perf_counter()
def main():
url = "https://movie.douban.com/cinema/later/beijing/"
init_page = requests.get(url).content
init_soup = BeautifulSoup(init_page, 'lxml')
all_movies = init_soup.find('div', id="showing-soon")
for each_movie in all_movies.find_all('div', class_="item"):
all_a_tag = each_movie.find_all('a')
all_li_tag = each_movie.find_all('li')
movie_name = all_a_tag[1].text
url_to_fetch = all_a_tag[1]['href']
movie_date = all_li_tag[0].text
response_item = requests.get(url_to_fetch).content
soup_item = BeautifulSoup(response_item, 'lxml')
img_tag = soup_item.find('img')
print('{} {} {}'.format(movie_name, movie_date, img_tag['src']))
start = now()
main()
print(now()-start)
#输出
#狮子王 07月12日 https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2559742751.jpg
#命运之夜——天之杯II :迷失之蝶 07月12日 https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2561910374.jpg
#素人特工 07月12日 https://img1.doubanio.com/view/photo/s_ratio_poster/public/p2560447448.jpg
#机动战士高达NT 07月12日 https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2558661806.jpg
#。。。
#33.97453982599911
异步版本:
#python 版本 >=3.7
import asyncio
import aiohttp
from bs4 import BeautifulSoup
async def fetch_content(url):
async with aiohttp.ClientSession(
headers=header, connector=aiohttp.TCPConnector(ssl=False)
) as session:
async with session.get(url) as response:
return await response.text()
async def main():
url = "https://movie.douban.com/cinema/later/beijing/"
init_page = await fetch_content(url)
init_soup = BeautifulSoup(init_page, 'lxml')
movie_names, urls_to_fetch, movie_dates = [], [], []
all_movies = init_soup.find('div', id="showing-soon")
for each_movie in all_movies.find_all('div', class_="item"):
all_a_tag = each_movie.find_all('a')
all_li_tag = each_movie.find_all('li')
movie_names.append(all_a_tag[1].text)
urls_to_fetch.append(all_a_tag[1]['href'])
movie_dates.append(all_li_tag[0].text)
tasks = [fetch_content(url) for url in urls_to_fetch]
pages = await asyncio.gather(*tasks)
for movie_name, movie_date, page in zip(movie_names, movie_dates, pages):
soup_item = BeautifulSoup(page, 'lxml')
img_tag = soup_item.find('img')
print('{} {} {}'.format(movie_name, movie_date, img_tag['src']))
%time asyncio.run(main())
(注意:这里用的是aiohttp模块,并不是所有的模块都支持asyncio。现在有很多的模块已经在支持:aiohttp,aiodns,aioredis等等 https://github.com/aio-libs 这里列出了已经支持的内容,并在持续更新)