Python 协程实现生产者-消费者模型

🐛 从一个假设爬虫说起

先看一个简单的爬虫例子:

import time

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)

if __name__ == "__main__":
	s = time.time()
	main(['url_1', 'url_2', 'url_3', 'url_4'])
	print("Wall time:", time.time()-s, " s")

########## 输出 ##########

crawling url_1
OK url_1
crawling url_2
OK url_2
crawling url_3
OK url_3
crawling url_4
OK url_4
Wall time: 10 s

我们简化了爬虫函数 scrawl_page 为休眠数秒,休眠时间取决于 url 字符串最后的数字。

我们可以看到爬取 5 个页面一共用了 10 秒钟,这显然效率低下,于是我们自然想到并发化爬取操作。用 Python 协程只需简单改动:

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)

if __name__ == "__main__":
	s = time.time()
	asyncio.run(main(['url_1', 'url_2', 'url_3', 'url_4']))
	print("Wall time:", time.time()-s, " s")

########## 输出 ##########

crawling url_1
OK url_1
crawling url_2
OK url_2
crawling url_3
OK url_3
crawling url_4
OK url_4
Wall time: 10 s  # 这。。。怎么还是10秒?

由于 python 3.7+ 优化了 async/await 语法,实现协程非常简单。

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

await 执行的效果,和 Python 正常执行是一样的,也就是说程序会阻塞在这里,进入被调用的协程函数,执行完毕返回后再继续,而这也是 await 的字面意思。代码中 await asyncio.sleep(sleep_time) 会在这里休息若干秒,await crawl_page(url) 则会执行 crawl_page() 函数。

其次,我们可以通过 asyncio.create_task() 来创建任务,我们实现生产者-消费者模型时会用到。

最后,我们需要 asyncio.run 来触发运行。asyncio.run 这个函数是 Python 3.7 之后才有的特性,可以让 Python 的协程接口变得非常简单。

⌚️ 协程始于 Task

但是由于 await 是同步调用,因此,crawl_page(url) 在当前的调用结束之前,是不会触发下一次调用的。于是,这个代码效果就和上面完全一样了,相当于我们用异步接口写了个同步代码。。。

实现真正的异步需要用到协程中的一个重要概念,任务(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]
    await asyncio.gather(*tasks)
    # for task in tasks: await task  # 上一句也可以换成这样

if __name__ == "__main__":
	s = time.time()
	asyncio.run(main(['url_1', 'url_2', 'url_3', 'url_4']))
	print("Wall time:", time.time()-s, " s")


########## 输出 ##########

crawling url_1
crawling url_2
crawling url_3
crawling url_4
OK url_1
OK url_2
OK url_3
OK url_4
Wall time: 4.01 s

有了协程对象后,便可以通过 asyncio.create_task() 来创建任务。任务创建后很快就会被调度执行,这样,我们的代码也不会阻塞在一个任务这里。所以,我们要 await asyncio.gather(*tasks) 来等所有任务都结束才行。

结果显示,运行总时长等于运行时间最长的爬虫。

协程就是在单个线程上执行多个任务(Task),所以 协程的开销 < 线程 < 进程,并且协程有一个巨大的优势,程序员可以通过 await 调用来掌握任务调度的主动权。

⏰ 解密协程运行时

理解下面两段代码很简单:

import time
import asyncio

async def worker_1():
    print('worker_1 start')
    await asyncio.sleep(1)
    print('worker_1 done')

async def worker_2():
    print('worker_2 start')
    await asyncio.sleep(2)
    print('worker_2 done')

async def main():
    print('before await')
    await worker_1()
    print('awaited worker_1')
    await worker_2()
    print('awaited worker_2')

if __name__ == "__main__":
	s = time.time()
	asyncio.run(main())
	print("Wall time:", time.time()-s, " s")

########## 输出 ##########

before await
worker_1 start
worker_1 done
awaited worker_1
worker_2 start
worker_2 done
awaited worker_2
Wall time: 3 s
import time
import asyncio

async def worker_1():
    print('worker_1 start')
    await asyncio.sleep(1)
    print('worker_1 done')

async def worker_2():
    print('worker_2 start')
    await asyncio.sleep(2)
    print('worker_2 done')

async def main():
    task1 = asyncio.create_task(worker_1())
    task2 = asyncio.create_task(worker_2())
    print('before await')
    await task1
    print('awaited worker_1')
    await task2
    print('awaited worker_2')

if __name__ == "__main__":
	s = time.time()
	asyncio.run(main())
	print("Wall time:", time.time()-s, " s")

########## 输出 ##########

before await
worker_1 start
worker_2 start
worker_1 done
awaited worker_1
worker_2 done
awaited worker_2
Wall time: 2.01 s

下面我们来逐步解析第二段代码:

  1. asyncio.run(main()),程序进入 main() 函数,事件循环开启;
  2. task1 和 task2 任务被创建,并进入事件循环等待运行;运行到 print,输出 before await;
  3. await task1 执行,用户选择从当前的主任务中切出,事件调度器开始调度worker_1;
  4. worker_1 开始运行,运行 print 输出 worker_l start,然后运行到 await asyncio.sleep(1),从当前任务切出,事件调度器开始调度 worker_2;
  5. worker_2 开始运行,运行 print 输出 worker_2 start,然后运行 await asyncio.sleep(2) 从当前任务切出;
  6. 以上所有事件的运行时间,都应该在 1ms 到 10ms 之间,甚至可能更短,事件调度器从这个时候开始暂停调度;
  7. 一秒钟后,worker_1 的 sleep 完成,事件调度器将控制权重新传给 task_1,输出 worker_1 done,task_1 完成任务,从事件循环中退出;
  8. await task1 完成,事件调度器将控制器传给主任务,输出 awaited worker_1 ,.然后在 await task2 处继续等待;
  9. 两秒钟后,worker_2 的 sleep 完成,事件调度器将控制权重新传给task_2,输出 worker_2 done ,task2完成任务,从事件循环中退出;
  10. 主任务输出 awaited worker_2 ,协程全任务结束,事件循环结束。
📬 使用协程来实现生产者消费者模型

下面的代码改编自 asyncio.Queue 官方文档的 Examples

Queues 可用于在多个并发任务之间分配工作负载(消息传递):

# 需要 python 3.7+

import asyncio
import random
import time

random.seed(42)

async def consumer(name, queue):
    while True:
        # 从队列中取出一个资源
        sleep_for = await queue.get()

        # 休眠 sleep_for 秒
        await asyncio.sleep(sleep_for)

        # 通知队列当前资源已经被取出
        queue.task_done()

        print(f'{name} 消耗了 {sleep_for:.2f} s')

async def producer(name, queue):
    # 随机生成一些数 put 到 queue 中,并充当睡眠时间来模拟消费者用时
    global total_sleep_time
    total_sleep_time = 0
    
    for _ in range(5):
        sleep_for = random.uniform(0.05, 1.0)
        total_sleep_time += sleep_for
        queue.put_nowait(sleep_for)
        print(f'{name} 添加了 {sleep_for:.2f} s')

async def main():
    # 创建一个协程队列存放资源,每消耗一个资源需要用到一些时间
    queue = asyncio.Queue()

    # 分别创建三个生产者 tasks 和消费者 tasks
    c_tasks, p_tasks = [], []
    for i in range(3):
        c_tasks.append(asyncio.create_task(consumer(f'consumer-{i}', queue)))
        p_tasks.append(asyncio.create_task(producer(f'producer-{i}', queue)))

    await asyncio.gather(*p_tasks, return_exceptions=True)  # 先执行生产者

    # 下面的 await queue.join() 会阻塞在这里直到队列中的资源全部被取出,即等待全部进入到queue的资源的queue.task_done()信号
    started_at = time.monotonic()
    await queue.join()  # 阻塞在这里
    total_slept_for = time.monotonic() - started_at

    """
    这里要求取消三个消费者 tasks。
    因为它们在 consumer 函数中使用 while True 轮询读取队列,所以必须取消,否则程序不会停。
    由于队列在被消耗完资源之前通过 await queue.join() 阻塞在那里,
    所以 task 被正式取消之时便是队列资源被取完之时。
    """
    for task in c_tasks:
        task.cancel()
    print([task.cancelled() for task in c_tasks])  # [False, False, False], 直接运行程序会发现,消费者们没有马上被取消

    await asyncio.gather(*c_tasks, return_exceptions=True)  # 再执行消费者

    # Note: 使用下面的方式运行将会在最后报错:concurrent.futures._base.CancelledError
    # for task in tasks:
    #     await task

    print('====')
    print(f'三个消费者消耗完队列资源共花了: {total_slept_for:.2f} s')
    print(f'单个消费者情况下消耗完队列资源预计花费: {total_sleep_time*3:.2f} s')


if __name__ == '__main__':
    asyncio.run(main())


########## 输出 ##########

producer-0 添加了 0.66 s
producer-0 添加了 0.07 s
producer-0 添加了 0.31 s
producer-0 添加了 0.26 s
producer-0 添加了 0.75 s
producer-1 添加了 0.69 s
producer-1 添加了 0.90 s
producer-1 添加了 0.13 s
producer-1 添加了 0.45 s
producer-1 添加了 0.08 s
producer-2 添加了 0.26 s
producer-2 添加了 0.53 s
producer-2 添加了 0.08 s
producer-2 添加了 0.24 s
producer-2 添加了 0.67 s
consumer-2 消耗了 0.07 s
consumer-0 消耗了 0.31 s
consumer-2 消耗了 0.26 s
consumer-1 消耗了 0.66 s
consumer-2 消耗了 0.69 s
consumer-0 消耗了 0.75 s
consumer-2 消耗了 0.13 s
consumer-2 消耗了 0.08 s
consumer-2 消耗了 0.26 s
consumer-0 消耗了 0.45 s
consumer-1 消耗了 0.90 s
consumer-0 消耗了 0.08 s
consumer-1 消耗了 0.24 s
consumer-2 消耗了 0.53 s
consumer-0 消耗了 0.67 s
[False, False, False]
====
三个消费者消耗完队列资源共花了: 2.27 s
单个消费者情况下消耗完队列资源预计花费: 5.31 s
🐞 豆瓣近日推荐电影爬虫
  • 协程异步:
# 异步协程版
import time
import aiohttp
import asyncio
from bs4 import BeautifulSoup

async def request_get(url=None):
    if url:
        url_ = url
    else:
        url_ = "https://movie.douban.com/cinema/later/beijing/"
    headers = {'User-Agent': 'test'}

    async with aiohttp.ClientSession(headers=headers, connector=aiohttp.TCPConnector(ssl=False)) as session:
        async with session.get(url_) as response:
            return await response.text()

async def main():
    global img_urls
    print("beautiful..")
    page = await request_get()
    init_soup = BeautifulSoup(page, 'lxml')

    movie_names, movie_urls, movie_dates, movie_sites, movie_heats, img_urls = [], [], [], [], [], []

    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)
        movie_urls.append(all_a_tag[0]['href'])
        movie_dates.append(all_li_tag[0].text)
        movie_sites.append(all_li_tag[2].text)
        movie_heats.append(all_li_tag[3].text)

    # tasks = asyncio.create_task(request_get(url) for url in movie_urls)
    tasks = [request_get(url) for url in movie_urls]
    pages = await asyncio.gather(*tasks, return_exceptions=True)
    for name, date, site, heat, page in zip(movie_names, movie_dates, movie_sites, movie_heats, pages):
        soup_item = BeautifulSoup(page, 'lxml')
        img_tag = soup_item.find('img')

        img_urls.append(img_tag['src'])

        print(f"{name}, {date}, {site}, {heat}, img_url: {img_tag['src']}")


if __name__ == '__main__':
    s = time.monotonic()
    asyncio.run(main())
    print(f"Wall time: {time.monotonic() - s:.2f}")

    # with open('movie_img_urls.txt', 'a') as f:
    #     f.writelines(url + '\n' for url in img_urls)

====输出====
热带往事, 0612, 中国大陆, 37671人想看, img_url: https://img9.doubanio.com/view/photo/s_ratio_poster/public/p2654729576.jpg
超越, 0612, 中国大陆, 3781人想看, img_url: https://img2.doubanio.com/view/photo/s_ratio_poster/public/p2654731061.jpg
暗恋, 0612, 中国大陆, 2792人想看, img_url: https://img2.doubanio.com/view/photo/s_ratio_poster/public/p2634095672.jpg
狗果定理, 0612, 中国大陆, 1368人想看, img_url: https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2641086760.jpg
潜艇总动员8:地心游记, 0612, 中国大陆, 173人想看, img_url: https://img2.doubanio.com/view/photo/s_ratio_poster/public/p2636831662.jpg
饮料超人, 0612, 韩国, 128人想看, img_url: https://img9.doubanio.com/view/photo/s_ratio_poster/public/p2647013125.jpg
疯狂丑小鸭2靠谱英雄, 0612, 中国大陆, 101人想看, img_url: https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2641096140.jpg
困在时间里的父亲, 0618, 英国, 166649人想看, img_url: https://img9.doubanio.com/view/photo/s_ratio_poster/public/p2654915204.jpg
就爱断舍离, 0618, 泰国, 16099人想看, img_url: https://img2.doubanio.com/view/photo/s_ratio_poster/public/p2654762253.jpg
只是一次偶然的旅行, 0618, 中国大陆, 9023人想看, img_url: https://img1.doubanio.com/view/photo/s_ratio_poster/public/p2638782537.jpg
守岛人, 0618, 中国大陆, 3451人想看, img_url: https://img2.doubanio.com/view/photo/s_ratio_poster/public/p2656999371.jpg
了不起的老爸, 0618, 中国大陆, 3051人想看, img_url: https://img1.doubanio.com/view/photo/s_ratio_poster/public/p2654767767.jpg
足球爸爸, 0618, 中国大陆, 1734人想看, img_url: https://img9.doubanio.com/view/photo/s_ratio_poster/public/p2630463446.jpg
为国而歌, 0618, 中国大陆, 1433人想看, img_url: https://img1.doubanio.com/view/photo/s_ratio_poster/public/p2654215228.jpg
离秋, 0618, 中国大陆, 881人想看, img_url: https://img1.doubanio.com/view/photo/s_ratio_poster/public/p2562728877.jpg
我没谈完的那场恋爱, 0625, 中国大陆, 566人想看, img_url: https://img9.doubanio.com/view/photo/s_ratio_poster/public/p2651254245.jpg
遇见喵星人, 0626, 中国大陆, 237人想看, img_url: https://img9.doubanio.com/view/photo/s_ratio_poster/public/p2635832844.jpg
三只小猪3正义大联萌, 0626, 中国大陆, 156人想看, img_url: https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2657266430.jpg
革命者, 0701, 中国大陆, 7201人想看, img_url: https://img1.doubanio.com/view/photo/s_ratio_poster/public/p2629194149.jpg
1921, 0701, 中国大陆, 4人想看, img_url: https://img2.doubanio.com/view/photo/s_ratio_poster/public/p2640045162.jpg
Wall time: 3.83
  • 同步:
# 同步版
import time
import requests
from bs4 import BeautifulSoup

def main():
    url = "https://movie.douban.com/cinema/later/beijing/"
    headers = {'User-Agent': 'test'}
    init_page = requests.get(url, headers=headers).content  # 没有header会被反爬
    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
        movie_url = all_a_tag[0]['href']
        movie_date = all_li_tag[0].text
        movie_site = all_li_tag[2].text
        movie_heat = all_li_tag[3].text

        resp_item = requests.get(movie_url, headers=headers).content
        soup_item = BeautifulSoup(resp_item, 'lxml')
        img_tag = soup_item.find('img')

        print(f"{movie_name}, {movie_date}, {movie_site}, {movie_heat}, img_url: {img_tag['src']}")

if __name__ == '__main__':
    s = time.monotonic()
    main()
    print(f"Wall time: {time.monotonic() - s:.2f}")

====输出====
热带往事, 0612, 中国大陆, 37675人想看, img_url: https://img9.doubanio.com/view/photo/s_ratio_poster/public/p2654729576.jpg
超越, 0612, 中国大陆, 3781人想看, img_url: https://img2.doubanio.com/view/photo/s_ratio_poster/public/p2654731061.jpg
暗恋, 0612, 中国大陆, 2792人想看, img_url: https://img2.doubanio.com/view/photo/s_ratio_poster/public/p2634095672.jpg
狗果定理, 0612, 中国大陆, 1368人想看, img_url: https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2641086760.jpg
潜艇总动员8:地心游记, 0612, 中国大陆, 173人想看, img_url: https://img2.doubanio.com/view/photo/s_ratio_poster/public/p2636831662.jpg
饮料超人, 0612, 韩国, 128人想看, img_url: https://img9.doubanio.com/view/photo/s_ratio_poster/public/p2647013125.jpg
疯狂丑小鸭2靠谱英雄, 0612, 中国大陆, 101人想看, img_url: https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2641096140.jpg
困在时间里的父亲, 0618, 英国, 166653人想看, img_url: https://img9.doubanio.com/view/photo/s_ratio_poster/public/p2654915204.jpg
就爱断舍离, 0618, 泰国, 16099人想看, img_url: https://img2.doubanio.com/view/photo/s_ratio_poster/public/p2654762253.jpg
只是一次偶然的旅行, 0618, 中国大陆, 9023人想看, img_url: https://img1.doubanio.com/view/photo/s_ratio_poster/public/p2638782537.jpg
守岛人, 0618, 中国大陆, 3451人想看, img_url: https://img2.doubanio.com/view/photo/s_ratio_poster/public/p2656999371.jpg
了不起的老爸, 0618, 中国大陆, 3051人想看, img_url: https://img1.doubanio.com/view/photo/s_ratio_poster/public/p2654767767.jpg
足球爸爸, 0618, 中国大陆, 1734人想看, img_url: https://img9.doubanio.com/view/photo/s_ratio_poster/public/p2630463446.jpg
为国而歌, 0618, 中国大陆, 1433人想看, img_url: https://img1.doubanio.com/view/photo/s_ratio_poster/public/p2654215228.jpg
离秋, 0618, 中国大陆, 881人想看, img_url: https://img1.doubanio.com/view/photo/s_ratio_poster/public/p2562728877.jpg
我没谈完的那场恋爱, 0625, 中国大陆, 566人想看, img_url: https://img9.doubanio.com/view/photo/s_ratio_poster/public/p2651254245.jpg
遇见喵星人, 0626, 中国大陆, 237人想看, img_url: https://img9.doubanio.com/view/photo/s_ratio_poster/public/p2635832844.jpg
三只小猪3正义大联萌, 0626, 中国大陆, 156人想看, img_url: https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2657266430.jpg
革命者, 0701, 中国大陆, 7201人想看, img_url: https://img1.doubanio.com/view/photo/s_ratio_poster/public/p2629194149.jpg
1921, 0701, 中国大陆, 4人想看, img_url: https://img2.doubanio.com/view/photo/s_ratio_poster/public/p2640045162.jpg
Wall time: 20.66
  • 协程和多线程的区别,主要在于两点,一是协程为单线程;二是协程由用户决定,在哪些地方交出控制权,切换到下一个任务。
  • 协程的写法更加简洁清晰,把 async/await 语法和 asyncio.create_task 结合来用,对于中小别的并发需求已经毫无压力。
  • 写协程程序的时候,你的脑海中要有清晰的事件循环概念,知道程序在什么时候需要暂停、等待 I/O,什么时候需要一并执行到底。
  • 2
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Skr.B

WUHOOO~

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值