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进行

posted @   ShineLe  阅读(148)  评论(0编辑  收藏  举报
编辑推荐:
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析
· 没有源码,如何修改代码逻辑?
· 一个奇形怪状的面试题:Bean中的CHM要不要加volatile?
· [.NET]调用本地 Deepseek 模型
· 一个费力不讨好的项目,让我损失了近一半的绩效!
阅读排行:
· PowerShell开发游戏 · 打蜜蜂
· 在鹅厂做java开发是什么体验
· 百万级群聊的设计实践
· WPF到Web的无缝过渡:英雄联盟客户端的OpenSilver迁移实战
· 永远不要相信用户的输入:从 SQL 注入攻防看输入验证的重要性
点击右上角即可分享
微信分享提示