2020.11.2 异步IO 协程
异步IO
同步IO在一个线程中,CPU执行代码的速度极快,然而,一旦遇到IO操作,如读写文件、发送网络数据时,就需要等待IO操作完成,才能继续进行下一步操作。
在IO操作的过程中,当前线程被挂起,而其他需要CPU执行的代码就无法被当前线程执行了。因为一个IO操作就阻塞了当前线程,导致其他代码无法执行,所以我们必须使用多线程或者多进程来并发执行代码,为多个用户服务。每个用户都会分配一个线程,如果遇到IO导致线程被挂起,其他用户的线程则不不受影响。
多线程/多进程虽然解决了并发的问题,但是系统不能无上限地增加线程。由于系统切换线程的开销也很大,所以一旦线程数量过大,CPU就会花费很多时间在线程切换上,真正运行代码的时间就减少了。
由于我们要解决的问题是CPU高速执行能力和IO设备的低速严重不匹配,多线程/进程只是解决这一问题的一种方法。
另一种解决IO问题的方法是异步IO。当代码需要执行一个耗时的IO操作时,它只发出IO指令,并不等待IO结果,然后就去执行其他代码了。一段时间后,当IO返回结果时,再通知CPU进行处理。
同步IO,也就是我们按照普通顺序写出的代码是无法实现异步IO模型的。
异步IO需要一个消息循环,在消息循环中,主线程不断地重复“读取消息-处理消息”这一过程:
loop=get_event_loop() while True: event=loop.get_event() process_event(event)
消息模型很早就应用在桌面应用程序中了。一个GUI程序的主线程负责不停地读取消息并且处理消息。所有的键盘、鼠标等消息都被发送到GUI程序的消息队列中,然后GUI程序主线程负责处理这些消息。
由于GUI线程处理键盘、鼠标等消息的速度很快,所以用户感觉不到延迟。某些时候,GUI线程在一个消息的处理过程中遇到问题导致一次消息处理时间过长,此时,用户会感觉整个GUI程序停止响应了,敲键盘、点鼠标都没有反应。这说明在消息模型中,处理一个消息必须非常迅速,否则主线程将无法及时处理消息队列中的其他消息,导致程序看上去停止响应。
那么,消息模型是如何解决同步IO必须等待IO操作这一问题的呢?当遇到IO操作时,代码只负责发出IO请求,不等待IO结果,然后直接结束本轮消息处理,进入下一轮消息处理过程。当IO操作完成后,将受到一条“IO完成”的消息,处理该消息就可以直接获取IO操作结果。
在“发出IO请求”到受到“IO完成”的这段时间里,同步IO模型下,主线程只能挂起,但是异步IO模型下,主线程并没有休息,而是在消息循环中继续处理其他消息。这样,在异步IO模型下,一个线程就可以同时处理多个IO请求,并且没有切换线程的操作。对于大多数IO密集型的应用程序,使用异步IO会大大提升系统的多任务处理能力。
协程
协程,又称微线程,纤程,Coroutine。
子程序,或者说函数,在所有语言中都是层级调用,通过栈来实现,一个线程就是执行一个子程序。
子程序调用总是一个入口,一次返回,调用顺序是明确的。而协程的调用和子程序不同。
协程看上去也是子程序,但是在执行过程中,在子程序的内部可以中断,然后转而执行别的子程序,在适当的时候再返回来接着执行。这不是函数调用,而类似CPU的中断。例如子程序A、B:
def A(): print ('1') print ('2') print ('3') def B(): print ('x') print ('y') print ('z')
假设由协程执行,在执行A的过程中,可以随时中断,去执行B,B也可能在执行的过程中中断再去执行A,结果可能是:
1 2 x y 3 z
但是在A中并不存在B的函数调用。看起来A、B的执行有点像多线程,但是协程的特点在于是一个线程执行,那么和多线程相比,协程有何优势呢?
最大的优势在于协程极高的执行效率。因为子程序切换不是线程切换,而是由程序自身控制,所以没有线程切换的开销。和多线程相比,线程数量越多,协程的性能优势就越明显。
第二大优势是不需要多线程的锁机制。因为只有一个线程,也不存在同时写变量冲突,在协程中控制共享资源不加锁,只需要判断状态就可以,所以执行效率比多线程高很多。
因为协程是一个线程执行,那么如何利用多核CPU呢?最简单的方法是多线程+协程,既充分利用多核,又充分发挥协程的高效率,可以获得极高的性能。
Python对于协程的支持是通过Generator生成器实现的。
在Generator中,我们不但可以通过for循环来迭代,还可以不断调用next()函数获取由yield语句返回的下一个值。但是Python的yield不仅可以返回一个值,还可以接收调用者发出的参数。
来看例子:
传统的生产者-消费者模型是一个线程写消息,一个线程取消息,通过锁机制控制队列和等待,但是可能造成死锁。
如果改用协程,生产者生产消息后,直接通过yield跳转到消费者开始执行,消费者执行完毕后,切换回生产者继续生产,效率极高:
def consumer(): r='' while True: n=yield r if not n: return print('[CONSUMER] Consuming %s ...'%n) r='200 OK' def produce(c): c.send(None) n=0 while n<5: n+=1 print('[PRODUCER] Producing %s ...'%n) r=c.send(n) print('[PROCUDER] Consumer return:%s'%r) c.close() c=consumer() produce(c)
执行结果:
[PRODUCER] Producing 1... [CONSUMER] Consuming 1... [PRODUCER] Consumer return:200 OK [PRODUCER] Producing 2... [CONSUMER] Consuming 2... [PRODUCER] Consumer return:200 OK [PRODUCER] Producing 3... [CONSUMER] Consuming 3... [PRODUCER] Consumer return:200 OK [PRODUCER] Producing 4... [CONSUMER] Consuming 4... [PRODUCER] Consumer return:200 OK [PRODUCER] Producing 5... [CONSUMER] Consuming 5... [PRODUCER] Consumer return:200 OK
补充几个知识点,有助于我们理解这段代码运行
1、生成器Generator的运行
对于Generator,
①每次调用next()或send()方法时执行,
②遇到yield语句返回,
③再次执行时,从上次返回的yield语句后一句继续执行。
2、send
send(None)
c.send(None) #等价于 next(c)
而对于send(n),则用于传输变量,相当于把Generator中yield那一句话变成了变量n,再进行send(None)(或者说next(c)),即:
c.send(n) #等价于,先把n传入Generator中替换掉yield表达式,再 next(c)
这就是在正文最后几段中提到的yield接收调用者发出参数的例子
3、空字符串在参与条件判断时,被认为是False
4、赋值运算符=,如果右边是一个表达式,那么赋值运算=会分为两步进行:①先运行右边表达式;②用右边表达式运行结果赋值
因此如果右边是yield 语句的话,一个普通的send()或者next()并不会完成赋值,赋值语句在下一次send()或者next()后首先进行。(这里的逻辑对于理解整个代码非常重要!)
接下来是对于代码的解读,序号表示运行步骤:
①
c=consumer()
生成了一个Generator,用变量c承接
②
produce(c)
把Generator c作为参数传入函数produce中运行
接下来就是produce中的运行:
③
c.send(None) #等价于 next(c)
运行Generator c,直到yield返回,此时
r='' n = yield r
相当于只执行了
r='' yield r
变量n并没有赋值为r,原因见上文补充知识点4。
④
n=0 while n<5: n = n + 1 #n=1 print('[PRODUCER] Producing %s...' % n)
⑤
r=c.send(n)
碰到send(),在Generator中继续上次断掉的地方n = yield r运行,这次要执行赋值语句了。
由于是send(n),所以把n传入Generator,替换掉yield表达式,相当于
n = yield r #c.send(n) 传入了n,因此赋值语句实际为 n = n #注意第二个n为c.send传入的n,这两个n只是名字一样,实际上并不相关
⑥
if not n: return print('[CONSUMER] Consuming %s...' % n) r = '200 OK' #接着第二次while循环,同样是运行到yield停止 while True: n = yield r
由于在主程序语句⑤中,用r=c.send(n),所以第二次yield的值,会赋值给主程序⑤中的r
即
r='200 OK' #此即Generator中通过yield传回来的值
接着
print('[PRODUCER] Consumer return: %s' % r) #r='200 OK'
这样主程序中一次while循环完成。
输出为:
[PRODUCER] Producing 1... [CONSUMER] Consuming 1... [PRODUCER] Consumer return: 200 OK
剩下的while循环逻辑与这一次的相同,不再详述。
⑦
c.close()
主程序中的循环完毕,通过c.close()关闭Generator c。
读到这里,你可能会发现Generator中的return语句并没有用到,实际上是这样的。这里的return,其实就是用来检验你对执行顺序的了解情况的。
此即用yield通过接收调用者传入参数的方式实现协程的一个例子。
总结:
1、异步IO是通过消息循环实现的,在消息循环中,主线程不断重复“读取消息-处理消息”这一过程。
2、消息模型解决同步IO必须等待IO操作问题的方式:当遇到IO操作时,代码只负责发出IO请求,不等待IO结果。然后直接结束本轮消息处理,进入下一轮消息处理过程。当IO操作完成后,将收到一条“IO完成”的消息,处理该消息就可直接获取IO的结果
3、协程看上去也是子函数,但是区别在于协程执行过程中可以在子函数内部中断,转而执行别的子函数,在适当的时候再回来执行。
4、协程是由一个线程执行的。子程序切换时候不需要切换线程,没有了线程切换的开销。
5、Python对于协程的实现是通过Generator生成器完成的,yield不仅可以返回一个值,还可以接收调用者传入的参数。
两个子程序间的通信通过send()和yield n进行
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析
· 没有源码,如何修改代码逻辑?
· 一个奇形怪状的面试题:Bean中的CHM要不要加volatile?
· [.NET]调用本地 Deepseek 模型
· 一个费力不讨好的项目,让我损失了近一半的绩效!
· PowerShell开发游戏 · 打蜜蜂
· 在鹅厂做java开发是什么体验
· 百万级群聊的设计实践
· WPF到Web的无缝过渡:英雄联盟客户端的OpenSilver迁移实战
· 永远不要相信用户的输入:从 SQL 注入攻防看输入验证的重要性