【协程】【一发入魂】小白也可以快速入门协程! 用最浅显的话告诉你什么是协程,为什么需要协程。
导语
「什么是协程?」几乎是现在面试的必考题什么是协程?几乎是现在面试的必考题。网上很多涉及到协程的文章讲解了篇幅巨大,讲解了并发和并行,线程和协程,函数的底层调用原理。有些文章十分深入,从汇编语言入手,手撕一个协程的实现。看完这些文章,你有没有觉得依旧无法理解协程?甚至无法理解,为什么明明我要学习的协程,为什么很多文章开始讲起了汇编,讲起了操作系统是怎么调入调出函数的?
协程到底是什么?它有什么用?怎么实现一个协程?为什么了解协程,我们要学习函数的调用方式,并发和并行等等。本文力争用最少最简单的代码,深入浅出地讲解,带你轻松入门协程。
前言
本文主要尝试着回答以下两个问题,也根据这两个问题将文章分成两个部分。在这两个部分,先分别告诉你这两个问题的答案,然后再用最简单语言讲清楚这两个问题,加深你对这两个问题的理解。
Q1(What):什么是协程?
我们经常谈到协程,但是当这样一个简单的问题出现在你的面前,你能准确的给出一个答案吗?协程,听上去和进程,线程很像,因此他们经常拿来一起比较,有人说协程为「用户态线程」;有人从实现的角度,将协程和函数进行比较,说函数只是协程的一个特例,说「协程是一种泛化的函数」。
每个人有每个人的理解,我也会根据我的角度来回答,到底什么是协程。并且我会结合最简单的代码,来讲解我的回答。
(分享一个真实的经历,我曾经面试某家公司的夏季实习的岗位的时候,面试官问我:你了解协程吗?当时我一脸懵逼,我说怎么聊的好好的问我携程了,这家公司和携程有什么关系吗?然后我也没怎么想就回答:"是做旅游应用的那个携程吗?"然后面试官就沉默了一秒,然后就没有然后了。😓😓😓😓😓)
Q2(Why): 为什么需要协程?
既然我们知道了协程是什么,现在我们要了解,那为什么需要它呢?不管上一个问题中,我们是拿它和线程和进程比较,还是拿它和函数比较,我们都要继续问了:既然有了函数,线程,为什么还需要协程呢?
要回答这个问题,就要从协程的优势和劣势讲起,并且列举出协程的典型的适用场景来加以佐证。
相信如果你思考清楚了这两个问题,你也基本掌握了协程的基础知识,当面试官问到你对协程的问题,你也可以从容地说出你对协程的理解了。
什么是协程
协程的定义
首先我们摘取维基百科和C++ 20的官方文档中对协程的描述,看看它们是怎么回答这个问题的:
「Coroutines are computer program components that generalize subroutines for non-preemptive multitasking, by allowing execution to be suspended and resumed. —— from Wikipieda」
协程是一类程序组件,通过允许执行过程的停止和恢复来达到对非抢占式的多任务函数的泛化的功能。
「A coroutine is a function that can suspend execution to be resumed later. Coroutines are stackless: they suspend execution by returning to the caller. This allows for sequential code that executes asynchronously (e.g. to handle non-blocking I/O without explicit callbacks), and also supports algorithms on lazy-computed infinite sequences and other uses.—— from C++ 20」
协程是一个函数,它可以暂停执行,并且随后继续执行。协程是无堆栈的:它们通过返回到调用者来暂停执行。这允许异步执行的顺序代码(例如,在没有显式回调的情况下处理非阻塞I/O),还支持惰性计算无限序列和其他用途的算法。
从上面两个官方文档中,我们可以看出虽然它们的表述不一样,但是它们的回答还是异曲同工的。两个文档都首先对协程进行了定义:一个说是一个用来泛化函数的程序组件,另一个说就是可以执行和暂停的函数。接着,两个文档都对协程的功能进行了简单的说明,一个说协程是用来执行非抢占的多任务处理的,另一个说它可以支持惰性计算无限序列和其他用途的算法。
两个文档的回答回答了协程是什么,以及主要的使用场景。但是我们现在的问题只是协程是什么?那么归纳一下,简单的概括一下,其实很简单。我们可以得到我们问题的答案。
协程就是一个可以被多次暂停,并且随后可以被继续从暂停的位置继续执行的函数。
不要着急!这些涉及到协程的特性,会放在为什么需要协程的章节里来讲。还记得吗,我们本节要讲的内容是:什么是协程?再讲一遍:
「协程就是一个可以被多次暂停,并且可以从被暂停的位置继续执行的函数」。
接着,从最最最简单的代码,带你来感受一下,什么叫一个可以被多次暂停,并且随后可以被继续从暂停的位置继续执行的函数。
协程初体验
我们使用一个单生产者单消费者的模型来进行协程的初体验。代码使用python语言来编写。注意在python中实现协程,不同的python版本会不同,从早期的greenlet模块,到python2.x开始的yield关键字,再到Python 3.4开始的asyncio关键字,await关键字等等。虽然它们有多个版本,使用的方法也不一样,但是协程的核心是不变的:协程就是一个可以被多次暂停,并且可以从被暂停的位置继续执行的函数。
我们为什么要讲这个例子还记得吗?一定要明确我们的目的,我们现在是在讲解什么是协程:协程就是一个可以被多次暂停,并且随后可以被继续从暂停的位置继续执行的函数。(重复了很多遍,但是这就是本小节的核心)。所以无关语言,无关用法,我们这个例子只是为了带你从代码的角度来感受一下协程的暂停和继续的用法。所以本例中你关心的它的进入和暂停,深入感受协程是什么。
用协程实现生产者消费者
我们这里实现一个最最简单的单生产者-消费者模型:生产者生产 ->通知消费者->消费者消费->通知生产者->生产者生产->通知消费者 …如此循环。
为了实现这个模型,我们就需要有一个函数表示生产者,函数中做两件事情:生产-通知消费者。 另一个函数表示消费者,函数中也有两件事:消费-通知生产者。我们要生产者模型,首先我们进入生产者函数,进行生产,然后我们要通知消费者。接着是不是该消费者去做事情了呢?那么生产者的函数就暂停了。接着在消费者的函数中,消费者开始消费,接着通知生产者。接着消费者函数暂停,生产者函数继续。接着就是循环了,函数暂停,函数继续,暂停,继续…
理解了这句话吗?「协程就是一个可以被多次暂停,并且可以从被暂停的位置继续执行的函数」。我们用协程的暂停和继续的特性,实现了第一个使用协程的小程序:生产者-消费者模型。
代码出奇得简单。
这里的consumer.send(None):意味着本次函数暂停,我要去执行consume函数啦,就完成了生产者的暂停,消费者函数的继续。
这里的 yield 表示我这个函数暂停,返回到调用我的函数:也就完成了消费者的暂停,生产者的继续。
其它的地方不用深究,因为我们的目的就是带你感受协程的暂停和继续的特性。这就是本章的目的,我觉得到这里你应该知道了什么是协程?也从代码的层面感受到了它吧。
「协程就是一个可以被多次暂停,并且可以从被暂停的位置继续执行的函数」
#consumer-producer.py
def consume():
while True:
print('消费者消费了') #生产
yield #通知
def produce(consumer):
while True:
print('生产者生产了') #生产
time.sleep(2) #假设需要两秒钟进行生产
consumer.send(None) #通知
if __name__ == '__main__':
consumer = consume()
produce(consumer)
为什么需要协程
好了,现在你知道了什么是协程。现在要回答第二个问题了:「为什么需要协程?」
从上面官方给出的协程的定义中,其实我们也可见一斑。比如C++中说的,协程这允许异步执行的顺序代码(例如,在没有显式回调的情况下处理非阻塞I/O),还支持惰性计算无限序列和其他用途的算法。维基百科说的:协程允许执行过程的停止和恢复来达到对非抢占式的多任务函数的泛化的功能。它的这些良好的特性其实也就是它存在的理由。总结一下,我们为什么需要协程呢?
- 协程能够让业务开发人员很便捷地去开发业务逻辑代码,以同步编程的风格来达到「异步回调模型的性能」。
- 协程相比较于线程更加轻量级,使用锁的必要性减少了,协程切换的开销,在很多场景下使用协程可以提升非阻塞I/O任务处理的效率。
第一点是从编程方式的改进上来说的。使用协程,我们可以有一种更易于阅读的编程方式,相较于之前我们实现多任务的程序,很多地方使用回调函数,这样使代码结构变得零散,难懂。而协程提供了一种同步编程的风格来替代了之前的异步回调的模型,使程序更加易于理解。
第二点是从性能改进上来说的。使用协程,在很多任务的形况下,可以获得更高的性能。比如微信后台大规模使用的libco的c/c++协程库,使得2013年至今稳定运行在微信后台的数万台机器上。当然这也不是说协程没有缺点,具体的使用场景还是要具体的分析。
下面,我还是会从上面生产者消费者的模型,来说明这一点。
以同步的编程方式实现异步回调模型
同步编程模型
协程的好处是提供了一种同步编程的方式,来实现异步回调模型。或者也可以按照c++定义中的说法:允许异步执行的顺序代码。两者是一个意思。如何理解这一句话呢?我们来看你一个例子。
假设以下场景,我们的程序需要执行以下操作: 发送邮件->等待邮件回复->打印回复邮件的内容。其中等待回复需要耗时5S。在同步编程中我们如何实现呢?
if __name__ == '__main__':
print("发送邮件")
synchWaitForReply() #cost 5 seconds
print("打印邮件内容")
这个很简单,也非常符合我们的思考逻辑。
然而这样的代码效率很低,我们等待邮件回复的5S内,实际上是不需要我们电脑做任何操作的,然而我们的电脑就傻等着,程序被阻塞了,5S内没有做任何其它的事情。假设我们发送邮件和打印邮件内容这两步只用花1S,那么整个程序的耗时是6S,我们浪费了5S在等待邮件上!
这就是同步编程逻辑,它使得代码易懂,但是却浪费了时间花在等待上。
以回调函数实现异步编程
现在,为了节约这个5S的等待时间,我们要将这个等待邮件回复的程序编程异步的,将synchWaitForReply变成了asynWaitForReply。这意味着当我们执行这个函数时,程序不会被阻塞,程序会继续往下走。程序就变成了这样的:
def doProcesses():
print("发送邮件")
asynWaitForReply() #cost 5 seconds
print("打印邮件内容")
if __name__ == '__main__':
doProcesses()
然而,当我们调用了asynWaitForReply后,因为不用等待它的结果,马上就会执行下一步,打印邮件内容。这时候就会产生错误了。我们的邮件还没回复完成呢,怎么可能可以打印邮件内容呀!
这就是在异步变成里遇到的问题,我们要当我们异步的代码执行完后,进行一个通知,收到了通知我们才能进行下一步的内容。很多情况下,实现这一功能我们是使用回调来进行的。
什么是回调?简单的来说就是我将一个要做的事情封装好,当作参数传递到我们想要异步执行的代码中,这样当异步执行的代码完成后,就直接调用回调函数,来达到告诉这个异步代码它执行完后要执行什么。于是代码就变成了这样:
def printEmailContent():
print("打印邮件内容")
def doProcesses():
print("发送邮件")
callBack = printEmailContent()
asynWaitForReply(callBack) # cost 5 seconds
# Press the green button in the gutter to run the script.
if __name__ == '__main__':
doProcesses()
我将打印邮件内容变成了一个回调函数,传给了asynWaitForReply()函数中,也就是告诉了他,邮件回复完成后,你要做什么(打印邮件内容)。
这样我们再分析我们的程序,最开始发送邮件花了1S中,然后进入到asynWaitForReply函数中,因为它是异步的,不阻塞程序,我们不用等待它,我们马上就可以做其他事了。除此之外,我们也将打印邮件内容以参数的形式,传给了等待回复的函数,传递给它了我们的想法,你收到邮件回复后接着做什么。
这样很棒!我们实现了异步编程,节省了等待实现,并且使用回调函数的形式进行了通知。这就是我们在没有协程之前经常使用的模型,回调+异步编程。
但是这么做缺点是什么呢? 我的程序编的支离破碎了,我的逻辑很清晰,我是先发送邮件,再等待,再打印邮件内容,这是我的顺序!但是我们回到主函数中跟一下,我们发送了邮件,然后调用等待邮件回复的程序,下一步打印邮件的内容没有放在回复邮件的后面,而是直接隐藏在这个函数之中! 在复杂的代码中,很多代码结构复杂,回调众多。最终真正的打印是通过回调实现的,如果这个等待邮件回复的代码很复杂,并且又分布在不同的文件中,我们最终的打印回复内容的实现也被划分到了这些文件中,代码支离破碎,难以理解。
以回调函数实现异步编程破坏了程序原有的逻辑结构,难以理解。因此这个时候,我们的协程也就该来救场了!
以同步编程风格来实现异步代码(协程)
def doProcesses():
print("发送邮件")
coroutine = asncWaitForReplyWithCoroutine()
next(coroutine)
yield
print("打印邮件内容")
# Press the green button in the gutter to run the script.
if __name__ == '__main__':
doProcesses()
现在我们用协程以同步的方式编写异步的程序,我们的doProcesses能够在 print(“发送邮件”)后,不用等待邮件回复,就完成了,实现了异步操作。下面我来简单分析一下:
首先本章的代码都是伪代码,这一点需要明确。为什么是伪代码,是因为首先我认为你不用关心具体的语法实现,细枝末节。本章的内容就是带你体会协程的:以同步的代码风格编写异步的代码的这个特点。至于其它的细枝末节,python中协程的语法是怎样的,这不是重点。
所以你要管的是两个点:同步的代码风格,异步的代码结果。 同步的代码风格:这很简单就可以看出来吧,在doProcesses的函数体重,执行了发送邮件,然后就是去等待回复,最后打印结果,和我们最开始的代码一样。
异步的代码结果指的是你并没有等待邮件回复,doProcesses在发送完邮件后,很快就退出了,之后在收到回复邮件后,又重新进入了,打印出了邮件内容!看吧,程序暂停,然后恢复执行,非常符合协程的风格!
具体来说,我们在asncWaitForReplyWithCoroutine()函数执行等待邮件回复的功能,它是一个异步的,所以并不会对程序阻塞,程序马上就执行下一个语句,yield。于是doProcesses()的函数就暂时执行完毕,通过yield退出了。
接着在asncWaitForReplyWithCoroutine执行完毕后,他会唤醒doProcesses函数,然后函数会从它上次暂停的地方(也就是yield)处开始执行,也就是开始打印邮件内容了。
我们这个例子,相信你能够感受到了协程的魅力了吧:你感受到了协程用同步的风格写出来异步的代码的能力了吧!
当然我们这里只是一个伪代码,并且非常简单。如果想要更复杂一点的情况,可以参考这个例子协程同步风格的异步代码示例。我这个例子就是从他讲的例子中进行简化得到的。
对性能的改进
1. 最大优势就是协程极高的执行效率。因为子程序切换不是线程切换,而是由程序自身控制,因此,没有线程切换的开销,和多线程比,线程数量越多,协程的性能优势就越明显。
2. 协程的编程方式,在很多场景避免了锁的使用。这样也减少了锁带来的性能消耗。
从本文中生产者和消费者的例子我们就可以看出来,之前我们生产者消费者是怎么实现的呢:使用两个线程,一个线程表示生产者,一个线程表示消费者。为了让他们有序进行,在他们之中会通过信号量的机制同步,相当于加锁,来保证它们的有序推进。
当我们改用协程后:第一个,我们只有一个线程,不存在线程的切换开销,而协程本身开销又更小。第二个,我们也不需要锁了,所以性能会得到提升。当然具体问题要具体分析,因为协程是一个线程执行,虽然它不用锁,切换开销小。如果计算机是一个核,那么这个模型中协程的效率会比两个线程高,但如果是两个核呢,那就不好说了,虽然你性能高,但抵不住人家多线程两个核,相当于两个人一起干活啊。
那怎么利用多核CPU呢?最简单的方法是多进程+协程,既充分利用多核,又充分发挥协程的高效率,可获得极高的性能。
总之,我们要具体场景具体分析,充分利用场景的特点,结合最佳的方式来保证性能的最优。
结尾
「关于协程的东西,我们还有很多要探索,比如如何自己实现一个协程,协程的具体种类,包括协程的各种适用场景以及结合相应语言的实战。但是这些不是本文的目标,本文就是让一个小白也可以深刻的了解什么是协程,为什么需要协程,并且深刻理解协程的特性。至于更深入的内容,也需要大家进一步一起学习啦!」
欢迎关注我的微信公众号:"奔跑的鲁班七号”,回复:“面试学习交流群” 获得群二维码,入群学习和我们一起交流学习~