Python学习:揭秘Python协程

news/2024/7/19 11:37:21 标签: python, 协程, 爬虫

协程是实现并发编程的一种方式。一说并发,肯定想到了多线程 / 多进程模型,没错,多线程 / 多进程,正是解决并发问题的经典模型之一。

协程 ,又称为微线程,它是实现多任务的另一种方式,只不过是比线程更小的执行单元。因为它自带CPU的上下文,这样只要在合适的时机,我们可以把一个协程切换到另一个协程

通俗的理解: 在一个线程中的某个函数中,我们可以在任何地方保存当前函数的一些临时变量等信息,然后切换到另外一个函数中执行,注意不是通过调用函数的方式做到的 ,并且切换的次数以及什么时候再切换到原来的函数都由开发者自己确定

协程与线程的差异:

在实现多任务时, 线程切换__从系统层面__远不止保存和恢复CPU上下文这么简单。操作系统为了程序运行的高效性,每个线程都有自己缓存Cache等等数据,操作系统还会帮你做这些数据的恢复操作,所以线程的切换非常耗性能。但是__协程的切换只是单纯地操作CPU的上下文__,所以一秒钟切换个上百万次系统都抗的住。

在 Python中,使用生成器(Python学习:深入理解迭代器和生成器),是 Python 2 开头的时代实现协程的老方法了,Python 3.7 提供了新的基于 asyncio 和 async / await 的方法。

先从一个爬虫实例,逐步了解协程的核心。

一、从一个爬虫说起

爬虫,就是互联网的蜘蛛,在搜索引擎诞生之时,与其一同来到世上。爬虫每秒钟都会爬取大量的网页,提取关键信息后存储在数据库中,以便日后分析。

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

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)

main(['url_1', 'url_2', 'url_3', 'url_4']) 

########## 输出 ##########
 
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 最后的那个数字。

这是一个很简单的爬虫,main() 函数执行时,调取 crawl_page() 函数进行网络通信,经过若干秒等待后收到结果,然后执行下一个。

看起来很简单,但仔细一算,它也占用了不少时间,五个页面分别用了 1 秒到 4 秒的时间,加起来一共用了 10 秒。这显然效率低下,该怎么优化呢?

一个很简单的思路出现了——我们这种爬取操作,完全可以并发化。看看使用协程怎么写。

python">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) 

asyncio.run(main(['url_1', 'url_2', 'url_3', 'url_4']))

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

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

首先 import asyncio,这个库包含了大部分我们实现协程所需的工具。

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

举个例子,如果 print(crawl_page(’’)),便会输出<coroutine object crawl_page at 0x000002BEDF141148>,提示这是一个 Python 的协程对象,而并不会真正执行这个函数。

协程的执行。执行协程有多种方法,这里介绍一下常用的三种。

  1. 可以通过 await 来调用。await 执行的效果,和 Python正常执行是一样的,也就是说程序会阻塞在这里,进入被调用的协程函数,执行完毕返回后再继续,而这也是 await 的字面意思。代码中await asyncio.sleep(sleep_time) 会在这里休息若干秒,await crawl_page(url) 则会执行crawl_page() 函数。
  2. 可以通过 asyncio.create_task() 来创建任务。这里只是提一下。
  3. 需要 asyncio.run 来触发运行。asyncio.run 这个函数是 Python 3.7 之后才有的特性,可以让 Python 的协程接口变得非常简单,不用去理会事件循环怎么定义和怎么使用的问题(会在下面讲)。一个非常好的编程规范是,asyncio.run(main()) 作为主程序的入口函数,在程序运行周期内,只调用一次 asyncio.run。

运行一下代码,发现还是10s。10 秒就对了,还记得上面所说的,await 是同步调用,因此, crawl_page(url) 在当前的调用结束之前,是不会触发下一次调用的。于是,这个代码效果就和上面完全一样了,相当于用异步接口写了个同步代码。

如何做?先学习协程中的一个重要概念,任务(Task)。

python">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

asyncio.run(main(['url_1', 'url_2', 'url_3', 'url_4']))

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

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: 3.99 s

有了协程对象后,便可以通过 asyncio.create_task 来创建任务。任务创建后很快就会被调度执行,这样,代码也不会阻塞在任务这里。所以,要等所有任务都结束才行,用for task in tasks: await task 即可。

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

这里用多线程应该怎么写?而如果需要爬取的页面有上万个又该怎么办呢?再对比下协程的写法,谁更清晰自是一目了然。

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

python">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']))
 
########## 输出 ##########

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

代码也很好理解。唯一要注意的是,*tasks 解包列表,将列表变成了函数的参数;与之对应的是, ** dict 将字典变成了函数的参数。

另外,asyncio.create_task,asyncio.run 这些函数都是 Python 3.7 以上的版本才提供的,自然,相比于旧接口它们也更容易理解和阅读。

二、解密协程运行时

来看下面两段代码:

python">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')

asyncio.run(main())

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

before await
worker_1 start
worker_1 done
awaited worker_1
worker_2 start
worker_2 done
awaited worker_2
Wall time: 3 s
python">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')

asyncio.run(main())

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

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_1 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’,task_2 完成任务,从事件循环中退出;
  10. 主任务输出 ‘awaited worker_2’,协程全任务结束,事件循环结束。

接下来,进阶一下。如果想给某些协程任务限定运行时间,一旦超时就取消,又该怎么做呢?再进一步,如果某些协程运行时出现错误,又该怎么处理呢?同样的,来看代码。

python">import asyncio

async def worker_1():
    await asyncio.sleep(1)
    return 1

async def worker_2():
    await asyncio.sleep(2)
    return 2 / 0 

async def worker_3():
    await asyncio.sleep(3)
    return 3

async def main():
    task_1 = asyncio.create_task(worker_1())
    task_2 = asyncio.create_task(worker_2())
    task_3 = asyncio.create_task(worker_3()) 

    await asyncio.sleep(2)
    task_3.cancel()

    res = await asyncio.gather(task_1, task_2, task_3, return_exceptions=True)
    print(res) 

asyncio.run(main())

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

[1, ZeroDivisionError('division by zero'), CancelledError()]
Wall time: 2 s

可以看到,worker_1 正常运行,worker_2 运行中出现错误,worker_3 执行时间过长被 cancel 掉了,这些信息会全部体现在最终的返回结果 res 中。

不过要注意return_exceptions=True这行代码。如果不设置这个参数,错误就会完整地 throw 到这个执行层,从而需要 try except 来捕捉,这也就意味着其他还没被执行的任务会被全部取消掉。为了避免这个局面,将 return_exceptions 设置为 True 即可。

线程能实现的,协程都能做到。那就温习一下这些知识点,用协程来实现一个经典的生产者消费者模型吧。

python">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(1, 10)
        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()
    consumer_2.cancel()
    
    await asyncio.gather(consumer_1, consumer_2, producer_1, producer_2, return_exceptions=True)
 
asyncio.run(main())
 
########## 输出 ##########
 
producer_1 put a val: 5
producer_2 put a val: 3
consumer_1 get a val: 5
consumer_2 get a val: 3
producer_1 put a val: 1
producer_2 put a val: 3
consumer_2 get a val: 1
consumer_1 get a val: 3
producer_1 put a val: 6
producer_2 put a val: 10
consumer_1 get a val: 6
consumer_2 get a val: 10
producer_1 put a val: 4
producer_2 put a val: 5
consumer_2 get a val: 4
consumer_1 get a val: 5
producer_1 put a val: 2
producer_2 put a val: 8
consumer_1 get a val: 2
consumer_2 get a val: 8
Wall time: 10 s

三、实战:豆瓣近日推荐电影爬虫

任务描述:https://movie.douban.com/cinema/later/beijing/ 这个页面描述了北京最近上映的电影,你能否通过 Python 得到这些电影的名称、上映时间和海报呢?这个页面的海报是缩小版的,我希望你能从具体的电影描述页面中抓取到海报。

下面给出了同步版本的代码和协程版本的代码,通过运行时间和代码写法的对比,希望能对协程有更深的了解。(注意:为了突出重点、简化代码,这里省略了异常处理。)

python">import requests
from bs4 import BeautifulSoup

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']))

main()

如果运行出错:NoneType object has no attribute ‘find_all’
在这里插入图片描述
可以打印一下,就是出现NoneType类型
在这里插入图片描述

python"><class 'NoneType'>

出现这种问题就是,获取不到数据。原因就是该网站被爬太多次,我们被该网页服务器的反爬虫程序发现了,并禁止我们爬取。因此我们需要模拟浏览器,重新给服务器发送请求,并且添加头等信息headers,headers是解决requests请求反爬的方法之一,相当于我们进去这个网页的服务器本身,假装自己本身在爬取数据。对反爬虫网页,可以设置一些headers信息,模拟成浏览器取访问网站 。

这里直接给一个header用

python">header = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36"}
    init_page = requests.get(url, headers=header).content

把header参数加入到获取信息requests里面。下同。

结果如下:
在这里插入图片描述

python">通往春天的列车 0917日 https://img9.doubanio.com/view/photo/s_ratio_poster/public/p2620365836.jpg
麦路人 0917日 https://img2.doubanio.com/view/photo/s_ratio_poster/public/p2619912723.jpg
死无对证 0918日 https://img1.doubanio.com/view/photo/s_ratio_poster/public/p2620256769.jpg
邻里美好的一天 0918日 https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2620261010.jpg
萌宠流浪记 0918日 https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2620364842.jpg
蓝色防线 0918日 https://img1.doubanio.com/view/photo/s_ratio_poster/public/p2617621409.jpg
红巴山 0918日 https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2617808060.jpg
世纪之缘 0922日 https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2617650682.jpg
菊次郎的夏天 0925日 https://img9.doubanio.com/view/photo/s_ratio_poster/public/p2620392435.jpg
夺冠 0925日 https://img2.doubanio.com/view/photo/s_ratio_poster/public/p2620083313.jpg
急先锋 0930日 https://img9.doubanio.com/view/photo/s_ratio_poster/public/p2619027144.jpg
姜子牙 1001日 https://img1.doubanio.com/view/photo/s_ratio_poster/public/p2617446867.jpg
我和我的家乡 1001日 https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2616128001.jpg
...
...

协程版本的代码:

python">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'])) 

asyncio.run(main())

问题同上。

可以发现加完协程的代码,运行时间相比同步版本快的多。

总结

  1. 协程和多线程的区别,主要在于两点,一是协程为单线程;二是协程由用户决定,在哪些地方交出控制权,切换到下一个任务。
  2. 协程的写法更加简洁清晰,把 async / await 语法和 create_task 结合来用,对于中小级别的并发需求已经毫无压力。
  3. 协程程序的时候,你的脑海中要有清晰的事件循环概念,知道程序在什么时候需要暂停、等待 I/O,什么时候需要一并执行到底。

参考:
《Python核心技术与实战》
python多任务—协程


http://www.niftyadmin.cn/n/1728026.html

相关文章

卸载其他进程中的dll模块

啥也不说了&#xff0c;贴代码&#xff1a; [cpp] view plaincopyprint? #include "stdafx.h" #define MAX_MODULE_NAME_LEN 16 // 传入到远程线程中的参数结构定义 typedef struct tagThreadParam { DWORD dwFreeLibrary; // Fr…

解决python爬虫时遇到AttributeError: ‘NoneType‘ object has no attribute ‘find_all‘

最近在练习学到的爬虫实例遇到AttributeError: ‘NoneType’ object has no attribute find_all’的错误。 爬虫要求如下&#xff1a; 任务描述&#xff1a;https://movie.douban.com/cinema/later/beijing/ 这个页面描述了北京最近上映的电影&#xff0c;你能否通过 Python …

VC获取进程启动和结束时间

使用说明&#xff1a; 该工具来自 codeproject&#xff0c;使用 PSAPI 获取内核模式和用户模式进程并将用户模式进程的启动时间和结束时间记入日志。本文工具程序名为 ProcessTime。运行程序后&#xff0c;它启动一个线程&#xff0c;该线程在后台执行每隔一定时间监控是否有新…

SQLServer如何在特定时间范围内按时间段查询

做报表统计的时候会碰到从月初到月末的数据 这样很好统计 如果是按时间段来统计&#xff0c;有该如何处理 比如1号到30号&#xff0c;统计7&#xff1a;30到9&#xff1a;50并且11&#xff1a;00到11&#xff1a;40并且15&#xff1a;01到18&#xff1a;59 时间范围跨度大sql语…

Windows下xarray+cfgrib读取grib文件

在Windows下读取grib文件&#xff0c;在我上一篇博客Windows上python读取grib2文件&#xff08;不用Linux&#xff09;学习了使用wgrib2处理grib2文件&#xff0c;可以直接读&#xff0c;也可以转化为nc文件&#xff08;转化后python就容易处理了&#xff09;。而对于grib文件&…

为PE文件添加CRC32自效验

学习CRC32的一个习作&#xff0c;老实说&#xff0c;对CRC的原理还是不太明白&#xff0c;我是菜鸟&#xff0c;请大家不吝赐教。 原理&#xff1a; 关于CRC32知识&#xff0c;这里有很好的解释http://www.pediy.com/tutorial/chap6/Chap6-2-4.htm 步骤&#xff1a; pe文件通过…

Python学习:Python并发编程之Futures

我们每天在各个网站、各个 App 上获取的新闻信息&#xff0c;很大一部分便是通过并发编程版的爬虫获得。正确合理地使用并发编程&#xff0c;无疑会给我们的程序带来极大的性能提升。今天学习理解、运用 Python 中的并发编程——Futures。 一、区分并发和并行 在操作系统中&a…

C#向excel中写入数据的三种方式

第一种&#xff1a;将DataGrid中的数据以流的形式写到excel中&#xff0c;格式以html的形式存在 Response.Clear();Response.Buffer true;Response.Charset "GB2312";Response.AppendHeader("Content-Disposition", "attachment;filenameDialoutTem…