Python协程原理介绍及基本使用

目录

1.什么是协程?

2.协程运行主要原理

3.小结


1.什么是协程?

协程是实现并发编程的一种方式。一说到并发,你肯定想到了多线程 / 多进程模型,没错,多线程 / 多进程,正是解决并发问题的经典模型之一。最初的互联网世界,多线程 / 多进程在服务器并发中,起到举足轻重的作用。

随着互联网的快速发展,你逐渐遇到了 C10K 瓶颈(参考:C10K问题 - 简书,也就是同时连接到服务器的客户达到了一万个。于是很多代码跑崩了,进程上下文切换占用了大量的资源,线程也顶不住如此巨大的压力,这时, NGINX 带着事件循环出来拯救世界了。

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

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

回到Python,使用生成器,是 Python 2 开头的时代实现协程的老方法了,Python 3.7 +提供了新的基于 asyncio 和 async / await 的新方法。(可以直接研究新方法)

先从一个爬虫实例出发,用清晰的讲解思路,由浅入深,直击协程的核心。

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

import time


def crawl_page(url):
    print('crawling {}'.format(url))
    # 休眠时间取决于 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'])

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

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

说明:不同版本的Python使用asyncio的方式有些区别,如代码中注释说明。

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)

# Python 3.7+
asyncio.run(main(['url_1', 'url_2', 'url_3', 'url_4']))
# 其它低版本Python
# asyncio.get_event_loop().run_until_complete(main(['url_1', 'url_2', 'url_3', 'url_4']))

可以看出来,在 Python中使用协程写异步程序还是比较简单的。

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

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

如果打印print(crawl_page('')),会得到类似“<coroutine object crawl_page at 0x7fbd31281990>”,提示你这是一个 Python 的协程对象,而并不会真正执行这个函数。

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

  • 我们可以通过 await 来调用。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 的协程接口变得非常简单,你不用去理会事件循环怎么定义和怎么使用的问题。一个非常好的编程规范是,asyncio.run(main()) 作为主程序的入口函数,在程序运行周期内,只调用一次 asyncio.run。

好了,这些概念看起来也挺费劲的,我们先运行下代码吧,运行结果如下:

crawling url_1
OK url_1
crawling url_2
OK url_2
crawling url_3
OK url_3
crawling url_4
OK url_4
took 10.00852634900366 s

啊,怎么还是10秒,难道我们优化了个寂寞?哈哈哈,10 秒就对了,还记得上面所说的,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):
    # Python 3.7+
    tasks = [asyncio.create_task(crawl_page(url) for url in urls)]
    # 其它低版本Python
    # tasks = [asyncio.get_event_loop().create_task(crawl_page(url)) for url in urls]
    for task in tasks:
        await task


# 开始计时
start = time.perf_counter()
# Python 3.7+
asyncio.run(main(['url_1', 'url_2', 'url_3', 'url_4']))
# 其它低版本Python
# asyncio.get_event_loop().run_until_complete(main(['url_1', 'url_2', 'url_3', 'url_4']))
# 计算耗时
end = time.perf_counter()
print("took {} s".format(end - start))

运行结果:

crawling url_1
crawling url_2
crawling url_3
crawling url_4
OK url_1
OK url_2
OK url_3
OK url_4
took 4.00705707698944 s

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

结果显示,运行总时长等于运行时间最长的爬虫(url_4需要耗时4s)。

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

其实,对于执行 tasks,还有另一种做法(asyncio.gather),如下:

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):
    # Python 3.7+
    tasks = [asyncio.create_task(crawl_page(url) for url in urls)]
    # 其它低版本Python
    # tasks = [asyncio.get_event_loop().create_task(crawl_page(url)) for url in urls]
    # 第一种执行task方法
    # for task in tasks:
    #     await task
    # 第二种执行task方法
    await asyncio.gather(*tasks)


# 开始计时
start = time.perf_counter()
# Python 3.7+
asyncio.run(main(['url_1', 'url_2', 'url_3', 'url_4']))
# 其它低版本Python
# asyncio.get_event_loop().run_until_complete(main(['url_1', 'url_2', 'url_3', 'url_4']))
# 计算耗时
end = time.perf_counter()
print("took {} s".format(end - start))

运行结果基本是一样的,如下:

crawling url_1
crawling url_2
crawling url_3
crawling url_4
OK url_1
OK url_2
OK url_3
OK url_4
took 4.0046644730027765 s

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

什么是解包?不要被这些概念或者专业名词吓到,当然平时积累也是很重要的。

可以参考下:Python中可变长度的参数args和**kwargs

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

2.协程运行主要原理

首先看两段代码:

代码1:

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


# 开始计时
start = time.perf_counter()

# 执行函数
asyncio.run(main())

# 计算耗时
end = time.perf_counter()
print("took {} s".format(end - start))

运行结果:

before await
worker_1 start
worker_1 done
awaited worker_1
worker_2 start
worker_2 done
awaited worker_2
took 3.00639131 s

 代码2:

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


# 开始计时
start = time.perf_counter()

# 执行函数
asyncio.run(main())

# 计算耗时
end = time.perf_counter()
print("took {} s".format(end - start))

运行结果:

before await
worker_1 start
worker_2 start
worker_1 done
awaited worker_1
worker_2 done
awaited worker_2
took 2.0036995220000002 s

第二段代码运行耗时符合我们正常的使用场景(非阻塞),我们一起来详细地分析了整个过程。

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

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

import time
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)


# 开始计时
start = time.perf_counter()

# 执行函数
asyncio.run(main())

# 计算耗时
end = time.perf_counter()
print("took {} s".format(end - start))

运行结果:

[1, ZeroDivisionError('division by zero'), CancelledError()]
took 2.0058159050000004 s

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

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

到这里,发现了没,线程能实现的,协程都能做到。

我们再来解释下asyncio.sleep()的作用

Ps: 这里可能不是特别好理解,简单的说就是程序遇到asyncio.sleep()时只是暂定在此处而已,不影响其它的协程继续运行。

模拟IO操作,这样的休眠不会阻塞事件循环,前面加上await后会把控制权交给主事件循环,在休眠(IO操作)结束后恢复这个协程。

提示:若在协程中需要有延时操作,应该使用 await asyncio.sleep(),而不是使用time.sleep(),因为使用time.sleep()后会释放GIL,阻塞整个主线程,从而阻塞整个事件循环。

 测试代码:(为了方便观察,我们使用了logzero这个打印日志

import asyncio
from logzero import logger


async def worker_1():
    logger.info("run worker 1 start")
    await asyncio.sleep(2)
    logger.info("run worker 1 end")
    return 1


async def worker_2():
    logger.info("run worker 2 start")
    await asyncio.sleep(3)
    logger.info("run worker 2 end")
    return 2


async def worker_3():
    logger.info("run worker 3 start")
    logger.info("run worker 3 end")
    return 3


async def main():
    logger.info("main start.")
    task_1 = asyncio.create_task(worker_1())
    task_2 = asyncio.create_task(worker_2())
    task_3 = asyncio.create_task(worker_3())
    await asyncio.sleep(5)
    logger.info("main end.")

    await asyncio.gather(task_1, task_2, task_3)


asyncio.run(main())

运行结果:

[I 211109 17:44:52 crawl_url:26] main start.
[I 211109 17:44:52 crawl_url:6] run worker 1 start
[I 211109 17:44:52 crawl_url:13] run worker 2 start
[I 211109 17:44:52 crawl_url:20] run worker 3 start
[I 211109 17:44:52 crawl_url:21] run worker 3 end
[I 211109 17:44:54 crawl_url:8] run worker 1 end
[I 211109 17:44:55 crawl_url:15] run worker 2 end
[I 211109 17:44:57 crawl_url:31] main end.

最后,我们用协程来实现一个经典的生产者消费者模型,如下:

import time
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)

# 开始计时
start = time.perf_counter()

# 运行函数
asyncio.run(main())

# 计算耗时
end = time.perf_counter()
print("took {} s".format(end - start))

运行结果:

producer_1 put a val: 9
producer_2 put a val: 9
consumer_1 get a val: 9
consumer_2 get a val: 9
producer_1 put a val: 1
producer_2 put a val: 9
consumer_1 get a val: 1
consumer_2 get a val: 9
producer_1 put a val: 1
producer_2 put a val: 10
consumer_1 get a val: 1
consumer_2 get a val: 10
producer_1 put a val: 1
producer_2 put a val: 6
consumer_1 get a val: 1
consumer_2 get a val: 6
producer_1 put a val: 3
producer_2 put a val: 10
consumer_1 get a val: 3
consumer_2 get a val: 10
took 10.00648478 s

最后一个问题,协程怎么实现回调函数呢?

在 python 3.7 及以上的版本中,我们对 task 对象调用 add_done_callback() 函数,即可绑定特定回调函数。回调函数接受一个 future 对象,可以通过 future.result() 来获取协程函数的返回值。

import time
import asyncio


async def crawl_page(url):
    print('crawling {}'.format(url))
    sleep_time = int(url.split('_')[-1])
    await asyncio.sleep(sleep_time)
    return 'OK {}'.format(url)

async def main(urls):
    tasks = [asyncio.create_task(crawl_page(url)) for url in urls]
    for task in tasks:
        task.add_done_callback(lambda future2: print('result: ', future2.result()))
    await asyncio.gather(*tasks)


# 开始计时
start = time.perf_counter()
asyncio.run(main(['url_1', 'url_2', 'url_3', 'url_4']))
# 计算耗时
end = time.perf_counter()
print("took {} s".format(end - start))

输出结果:

crawling url_1
crawling url_2
crawling url_3
crawling url_4
result:  OK url_1
result:  OK url_2
result:  OK url_3
result:  OK url_4
took 4.005295292 s

3.小结

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

最后的最后,请一定不要轻易炫技。多线程模型也一定有其优点,一个真正牛逼的程序员,应该懂得,在什么时候用什么模型能达到工程上的最优,而不是自觉某个技术非常牛逼,所有项目创造条件也要上。技术是工程,而工程则是时间、资源、人力等纷繁复杂的事情的折衷。

核心内容参考:极客时间《Python核心技术与实战》课程_揭秘协程(景霄,Facebook资深工程师)

  • 1
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
### 回答1: 《Python Gevent开发指南》 是一本介绍如何使用Gevent库进行Python开发的指南。Gevent是一个基于Python协程库,它提供了很多有用的功能和工具,使得开发人员可以更方便地使用协程进行异步编程。 这本指南从介绍Gevent的基本概念开始,包括了协程概念,以及Gevent库的核心特性。它还详细介绍了如何安装和配置Gevent库,并提供了一些示例代码来帮助读者更好地理解。 指南还涵盖了Gevent的一些高级功能,例如事件循环和信号处理。它详细介绍了如何使用这些功能来编写高效的异步程序,并提供了一些实际应用的示例。 此外,指南还讨论了与Gevent相关的一些其他主题,包括网络编程、并发处理和性能优化等。它提供了一些最佳实践和技巧,帮助开发人员更好地利用Gevent库进行应用程序开发。 总的来说,这本指南提供了一个全面而详细的关于Gevent库的介绍和指导。它适用于任何对Python异步编程感兴趣的开发人员,无论是初学者还是有经验的开发人员。通过阅读这本指南,开发人员可以快速掌握Gevent库的使用方法,并能够更高效地编写异步程序。 ### 回答2: 《Python Gevent开发指南》是一本关于使用Gevent库进行Python开发的指南手册。Gevent是一个基于协程Python网络库,它提供了简单易用的异步编程模型,可以实现高性能的网络应用开发。 这本指南首先介绍了Gevent的基本概念原理,包括协程以及事件循环等。它详细解释了如何使用Gevent库来编写高效的异步代码,以及如何处理并发和并行任务。通过使用Gevent库,开发者可以利用协程的特性,提高应用的性能和响应速度。 书中还介绍了Gevent库的各种特性和功能,如协程的创建、调度和同步等。它讲解了如何使用Gevent库来处理网络请求和响应,以及如何利用协程和事件驱动的编程模型来实现并发的网络通信。同时,这本指南还介绍了Gevent库的常用扩展和工具,如Greenlet、Queue和Pool等,以及如何利用它们来简化异步编程。 此外,书中还附带了一些实用的例子和案例,帮助读者更好地理解和应用Gevent库。通过学习这些例子,读者可以了解到如何使用Gevent库来编写高效的网络应用,如Web应用、爬虫和即时通讯程序等。 总之,《Python Gevent开发指南》是一本对于想要深入掌握Gevent库并进行高效异步编程的Python开发者来说,非常有价值的参考书籍。它详细而全面地介绍了Gevent库的相关知识和技巧,帮助读者在开发过程中充分发挥Gevent库的优势,提升代码的性能和可维护性。 ### 回答3: 《Python Gevent开发指南》是一本介绍如何使用Gevent库进行Python开发的指南。Gevent是一个基于Python协程库,它提供了一种方便的方式来处理并发编程和异步IO操作。 该指南首先介绍了Gevent的基本概念原理,包括协程、事件循环和异步IO等内容。接着,详细讲解了如何安装和配置Gevent库,包括在不同操作系统和Python版本下的安装方法和注意事项。 然后,指南详细介绍了Gevent库的核心功能和用法。其中包括如何创建和管理协程,并发地执行任务,以及如何使用协程间的通信和同步机制。同时,还介绍了如何使用Gevent进行网络编程,包括TCP和UDP通信、HTTP客户端和服务器的开发等。 此外,该指南还提供了一些常见的示例和最佳实践,帮助读者更好地理解和应用Gevent库。通过这些示例,读者可以学习如何使用Gevent来提高程序的性能和效率,并解决并发编程中的常见问题。 最后,指南还讨论了Gevent库的一些高级特性和扩展,并详细介绍了如何使用Gevent与其他Python库和框架集成,如Django、Flask等。读者可以根据自己的需求选择适合的扩展和集成方式,以便更好地利用Gevent的强大功能。 总的来说,《Python Gevent开发指南》是一本很好的教程,对于想要学习和应用Gevent库的Python开发者来说是非常有价值的。通过阅读该指南,读者可以系统地学习和掌握Gevent库的核心概念和技术,从而在实际项目中使用Gevent来实现高效的并发编程。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

rs勿忘初心

您的鼓励将是我的最大创动原动力

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

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

打赏作者

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

抵扣说明:

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

余额充值