Fork me on GitHub

协程控制

本文是Python通用编程系列教程,已全部更新完成,实现的目标是从零基础开始到精通Python编程语言。本教程不是对Python的内容进行泛泛而谈,而是精细化,深入化的讲解,共5个阶段,25章内容。所以,需要有耐心的学习,才能真正有所收获。虽不涉及任何框架的使用,但是会对操作系统和网络通信进行全局的讲解,甚至会对一些开源模块和服务器进行重写。学完之后,你所收获的不仅仅是精通一门Python编程语言,而且具备快速学习其他编程语言的能力,无障碍阅读所有Python源码的能力和对计算机与网络的全面认识。对于零基础的小白来说,是入门计算机领域并精通一门编程语言的绝佳教材。对于有一定Python基础的童鞋,相信这套教程会让你的水平更上一层楼。

一 协程理论

协程的目的是为了在单线程下实现并发,基于我们以前的学习,单线程下同一时间只能做一个任务,而且单线程下的多个任务是串行执行的,不可能实现并发,这时我们再回到并发的理解上来看:多个任务看起来像是同时运行的,这就是并发。并发实现的本质是:切换+保存状态。如果我们能有一种解决方案,当单线程下有多个任务的时候,我们在一个A任务的运行过程中把他打断,快速地去切换到另外一个B任务,B任务运行过程中再快速地切换的C任务,以此来在多个任务之间来回切换,前提是切换之前把上一个任务的运行状态保存下来,一会还能再切换回来继续运行,这样就实现了并发。
其实我们之前学习yield就学了这个用法,既能实现切换,又能实现保存状态,只是那个时候你还不知道并发的概念,以下是之前课程里面的代码。

def eater(name):
    print('%s 准备开始吃饭啦' % name)
    food_list = []
    while True:
        food = yield food_list
        print('%s 吃了 %s' % (name, food))
        food_list.append(food)
g = eater('albert')
g.send(None)  # 对于表达式形式的yield,在使用时,第一次必须传None,g.send(None)等同于next(g)
g.send('蒸羊羔')
g.send('蒸鹿茸')
g.send('蒸熊掌')
g.send('烧素鸭')
g.close()
# close之后后面的就不吃了
# g.send('烧素鹅')
# g.send('烧鹿尾')

为了让单线程下,并发的执行变得更加明显,我们看下面的代码示例。

import time
"""
我们现在没有开多进程多线程,那么默认就是一个进程一个线程
基于yield保存状态,实现两个任务直接来回切换,即并发的效果
两个任务交替打印,这就说明了他们之间是并发执行的
"""
# 任务:消费
def consumer():
    while True:
        x = yield
        print(x)
# 任务:生产
def producer():
    g = consumer()  # 造一个生成器
    print(g)
    a = next(g)  # 相当于传None参数
    print(a)
    for i in range(10000):
        b = g.send(i)  # 相当于上面的代码给他喂吃的,没有返回值,即返回值为空
        print(b)  # 打印以下,就能够看出来这两个任务是交替执行的
start = time.time()
producer()
stop = time.time()
print(stop - start)

如果我们开两个线程肯定也能够实现上面的生产者消费者模型,注意:这两个线程之间的切换是由操作系统来控制的(多进程的切换也是由操作来控制的),但是向我们上面写的代码,单线程下的切换是由我们自己的应用程序来控制的,对于操作系统而言他看到的就是一个线程。如果要实现一个生产者消费者模型,我们原来的思路就是开多个线程或者开多个进程,这个种类型的并发(切换+保存状态)都是由操作系统来控制的。现在我们在单线程下的并发是把操作系统要做的任务自己来做,对比一下由操作来控制切换和我们自己控制切换,哪一个更快一些呢?
操作系统要控制很多个进程或者线程的切换,这里面并不只有我们自己开的进程或者线程,还会有操作系统自身的,也可能还会有其他应用软件的,所以它的切换需要很长一段时间才会切换到我们自己的进程或者线程,当然这里的“很长一段时间”是相对于计算机而言的。而对于由应用软件自己控制的切换,只需要管理自己线程内部的任务,这种切换是一种轻量级的切换,它的速度要快的多。
接下来我们再来看一下上面代码由我们自己控制的切换,我们实现的这种并发是否有意义?
并发的目的是为了提高效率,并发本质就是切换+保存状态,切换有两种,一种是遇到I/O操作,另外一种是遇到长时间计算操作,只有遇到I/O操作切换,这样的切换才能够提高效率。而我们上面写的代码没有遇到任何的I/O操作,但是却加上了切换的时间,如果我们把任务串行执行,需要执行的任务一个都没有减少,这样不需要切换反而节省了时间。因此,这样是降低了程序的执行效率。
如果我们有两个纯计算的任务,我们来看一下切换与不切换所花费的时间,以此,来验证以下上面的说法。

# 纯计算的任务串行执行
import time
def task1():
    res=1
    for i in range(1000000):
        res+=i
def task2():
    res=1
    for i in range(1000000):
        res*=i
start=time.time()
task1()
task2()
stop=time.time()
print(stop-start)
# 纯计算的任务并发执行
def task1():
    res=1
    for i in range(1000000):
        res+=i
        yield
def task2():
    g=task1()
    res=1
    for i in range(1000000):
        res*=i
        next(g)
start=time.time()
task2()
stop=time.time()
print(stop-start)

从打印结果上,你就能看出来,对于这种纯计算的任务,很明显串行执行的效率会更高一些。这时候我们就要思考一下,单线程下实现的并发应该是遇到I/O操作才切换任务,这样的并发才是有意义的,那么接下里我们就要找到一种能够监测I/O操作的方式,才能达到遇到I/O就切换的目的。
假如现在有10个任务每个任务都是2秒计算,3秒IO,串行执行需要50秒的时间,开多进程是5秒+创建进程开销时间+切换时间,开多线程是5秒+切换时间,用单线程自己控制切换是5秒+切换时间(这个切换时间比由操作系统控制时间更少),3秒的I/O操作的时间不可能减少,更不可能没有,但是我们可以控制操作系统先让CPU计算完,再控制操作执行I/O操作。
如果单线程下你做了这样的处理,其实就完成了非常牛逼的一件事:就是把操作系统给骗了。
操作系统一直在盯着你是否在干活,有没有在偷懒,一旦遇到I/O就相当于是偷懒了,操作系统就直接把你的CPU执行权限拿走了,看你流泪,头也不回。本来你有一个计算的任务要执行,这个任务肯定是由CPU来执行的,执行完了

你说:我有一个I/O操作要执行
CPU:不好意思,那我就不能陪你了,我老大(操作系统)必须要我走了

假如现在我们把写代码和改bug都当作是计算的任务,你都需要CPU的帮助才能完成,你写完了代码你可以这样跟他说:

你说:CPU大哥,你别走,我还有一个改bug的任务要执行,你得陪我。
CPU:好吧,你小子,算了,那我再等你一会
你说:改bug的任务完了,大哥,等下,我还有写代码的任务要执行,你还要再等我一会
CPU:你过分了啊,勉强再等你一会吧。。。

就这样,在CPU的老大(操作系统)不知情的情况下,你就把傻乎乎的CPU给骗过去了。我们刚开始说的是把操作系统给骗了,其实操作系统是完全不知情的,由操作系统控制的切换是以线程为单位的,无法监测的线程内部的情况,而真正干活的是它的小弟CPU,那就要什么脏活累活都的干,这个干活的任务是由操作系统分配的,而他又不知道线程里面的情况,所以这就是可以理解为欺骗了操作系统,从而达到了尽可能多的占用CPU的目的。
当我们使用这种方式把I/O尽可能降低了,这个线程就会有大量时间一直处于计算状态,那么该线程所在的进程的就绪态就增多了,这样相对于操作系统上的所有进程,操作系统就会尽可能的把CPU执行权限给我们自己写的应用程序上面,这样的话,就是程序的执行效率高了。
我们上面代码用的yield可以实现单线程下的并发,但是遇到I/O是不能切换的。

import time
def task1():
    res = 1
    for i in range(1000000):
        res += i
        yield
        time.sleep(10)  # 在这一直等10秒
def task2():
    print('程序开始了')
    g = task1()
    res = 1
    for i in range(1,1000000):
        res *= i
        print(res)
        next(g)
start = time.time()
task2()
stop = time.time()
print(stop - start)

用yield可以实现协程,但是遇到I/O无法切换这就是没有意义的,并不是所有的协程都与提升效率有关系,假如同一个线程内有两个纯计算的任务,你让来来回的切换这并不能够提升效率,反而是降低了效率。所以,协程并不是都能够提升效率。注意:这句话很重要,这也是有些人的误区,协程并不是都能够提升效率。
协程总结:是单线程下的并发,又称微线程,纤程,英文名Coroutine。用一句话说明什么是协程:协程是一种用户态的轻量级线程,即协程是由程序自己控制调度的。
对于操作系统而言,协程是不存在的,这是由程序员构思出来的概念,最后起了一个名字。
强调:

1. Python的线程属于内核级别的,即由操作系统控制调度
   (如单线程遇到IO或执行时间过长就会被迫交出CPU执行权限,切换其他线程运行)
2. 单线程内开启协程,一旦遇到IO,就会从应用程序级别
   (而非操作系统)控制切换,以此来提升效率(!!!非IO操作的切换降低效率)

对比操作系统控制线程的切换,用户在单线程内控制协程的切换
优点

1. 协程的切换开销更小,属于程序级别的切换,操作系统完全感知不到,因而更加轻量级
2. 单线程内就可以实现并发的效果,最大限度地利用CPU

缺点

1. 协程的本质是单线程下,无法利用多核优势。
      解决方案:可以是一个程序开启多个进程,每个进程内开启多个线程,每个线程内开启协程
2. 协程指的是单个线程,因而一旦协程出现阻塞,将会阻塞整个线程

二 greenlet模块

greenlet模块其实并不能实现遇到I/O操作切换,它能实现的也只是普通的切换,但是我们在这里讲其实是为了后面的Gevent做铺垫的,gevent模块能够实现遇到I/O切换,而他的底层就是封装的greenlet模块,而greenlet模块底层就是封装的yield,所以有兴趣研究源码要先把这个先后顺序弄个清楚。
安装

pip3 install greenlet

使用

from greenlet import greenlet
import time
def eat(name):
    print('%s eat 1' % name)
    # time.sleep(30)  # 遇到IO不会切换
    g2.switch('James')  # 切换
    print('%s eat 2' % name)
    g2.switch()  # 切换
def play(name):
    print('%s play 1' % name)
    g1.switch()  # 切换,在三个switch之间来回切换
    print('%s play 2' % name)
g1 = greenlet(eat)  # 这里的参数只能放函数,不能传参数
g2 = greenlet(play)
g1.switch('Albert')  # 在第一次切换的时候为任务传参

三 gevent模块

gevent就能实现遇到I/O切换,我们来看一下他的用法。
安装

pip3 install gevent

使用

import gevent
def eat(name):
    print('%s eat 1' % name)
    gevent.sleep(5)  # 模拟I/O行为,和time.sleep类似
    print('%s eat 2' % name)
def play(name):
    print('%s play 1' % name)
    gevent.sleep(3)
    print('%s play 2' % name)
g1 = gevent.spawn(eat, 'Albert')  # 提交任务,传函数内存地址和函数需要的参数
g2 = gevent.spawn(play, 'James')

这么做我们看似是没有问题的,然后执行代码,你会发现程序没有任何结果,直接结束了,这是什么情况?

很明显,我们是在单线程下,异步提交任务,第一个任务提交完了,不在原地等待,继续提交下一个任务,
此时可能第一个任务还没有起来,第二个任务提交完了,后面没有其他的代码,主线程就执行完了,
同时我们可以确定没有其他的线程,那么主线程就结束了,这个进程也就结束了。

修改

import gevent
def eat(name):
    print('%s eat 1' % name)
    gevent.sleep(5)  # 遇到IO就会切换
    print('%s eat 2' % name)
def play(name):
    print('%s play 1' % name)
    gevent.sleep(3)  # 遇到IO就会切换
    print('%s play 2' % name)
g1 = gevent.spawn(eat, 'Albert')
g2 = gevent.spawn(play, 'James')
gevent.sleep(10)  # 让主线程多睡一会就可以了

很明显,我们让主线在这里睡10秒是不合理的,你能想到的解决方案就是.join(),除此之外还有一个好一点的joinall()方法。

import gevent
def eat(name):
    print('%s eat 1' % name)
    gevent.sleep(5)
    print('%s eat 2' % name)
def play(name):
    print('%s play 1' % name)
    gevent.sleep(3)
    print('%s play 2' % name)
g1 = gevent.spawn(eat, 'Albert')
g2 = gevent.spawn(play, 'James')
# 可以写这两行,也可以写下面的一行
"""
g1.join()
g2.join()
"""
# gevent.joinall(g1, g2)  # 不可以
# gevent.joinall((g1, g2))  # 可以
gevent.joinall([g1, g2])  # joinall的参数必须作为一个整体传入,是元组或者列表都可以

下面我们带大家看一部分Python的源码内容,以此来说明joinall参数必须作为整体传入,而不能分开。
第一步:
点击图示位置进入目标代码位置

第二步:
点击两个位置都可以,再次进入目标代码位置

第三步:
这一步你可以看到joinall这个函数了,你所传的参数就是greenlets这一个参数,注意是一个参数,到此你就已经明白了,为什么不能分开传,那么追根究底继续往下看。你会发现joinall正常返回的结果其实就是wait函数的返回值,这时点击wait函数,再次进入目标代码位置。

第四步:
你会发现到了wait_on_objects这个函数,同时greenlets参数名称变成了objects。看下面的注释内容第一句话,我的翻译:等待对象准备就绪或者事件循环完成。

为了拿到它的的返回值,看看它为什么报错,我们继续往下面看。很明显代码的执行到了iwait_on_objects这个函数上面,注意:现在你所传的那个参数就是现在的objects,我们再次点击,进入目标代码位置

第五步:
很明显,接下来要执行的是实例化WaitIterator这个类产生出一个对象,再次点击到达目标代码位置。

第六步:
通过参数的层层传递,最终到达了目标报错位置。

给大家演示这个过程并不是为了简单的说明一个joinall的用法,而是给大家在未来学习中奠定一个阅读源码的基础。网上的东西十分泛滥,你们可能现在没有分辨的能力,但是都学过《小马过河》的故事,要通过自己的思考和行动去辨别,在这过程中,你的技术水平也会不断攀升。

继续回到gevent模块的使用上面,上面我们用了gevent.sleep来模拟的I/O行为,如果我们真的有I/O操作比如open打开文件或者网络连接他能否识别呢,我们就用time来模拟吧。

import gevent
import time
def eat(name):
    print('%s eat 1' % name)
    time.sleep(5)  # 换成了time模拟居然不灵了
    print('%s eat 2' % name)
def play(name):
    print('%s play 1' % name)
    time.sleep(3)
    print('%s play 2' % name)
g1 = gevent.spawn(eat, 'Albert')
g2 = gevent.spawn(play, 'James')
gevent.joinall([g1, g2])

换成了time就不能实现遇到I/O切换了,那么我们要他怎么能监测文件I/O,网络连接的I/O呢?我们要这铁棒有何用?
解决方案

from gevent import monkey  # 一定要在整个程序的开头导入monkey
monkey.patch_all()  # 紧接着,给他打补丁,相当于给所有的IO行为都打了一个标记
import gevent
import time
def eat(name):
    print('%s eat 1' % name)
    time.sleep(5)  # 换成了time.sleep的I/O行为依然能够识别
    print('%s eat 2' % name)
def play(name):
    print('%s play 1' % name)
    time.sleep(3)
    print('%s play 2' % name)
g1 = gevent.spawn(eat, 'Albert')
g2 = gevent.spawn(play, 'James')
gevent.joinall([g1, g2])

因为导入猴子模块的功能非常单一,就是想要给下面所有的I/O操作都打一个标记,所以这两行代码可以合成一行,中间加一个分号,只在这种特殊的情况你可以这么做,因为这两行代码可以合成一个整体代表一个操作。

from gevent import monkey;monkey.patch_all()  # 一定要在整个程序的开头导入monkey

我们的程序一直没有开启过多线程,从始至终只有一个线程,那么我们现在查看一下线程的名字。

from gevent import monkey;monkey.patch_all()
from threading import current_thread
import gevent
import time
def eat():
    print('%s eat 1' % current_thread().name)
    time.sleep(5)
    print('%s eat 2' % current_thread().name)
def play():
    print('%s play 1' % current_thread().name)
    time.sleep(3)
    print('%s play 2' % current_thread().name)
g1 = gevent.spawn(eat, )
g2 = gevent.spawn(play, )
print(current_thread().name)
gevent.joinall([g1, g2])

你看到的DummyThread根据名字就可以猜出来了,程序只有一个线程,但是为了区分,他用了一个名字叫:假的线程-1和假的线程-2。

总结:
有了gevent这个模块其实就已经掌握了一个很牛逼的操作,可以极大的提升单线程下的效率,再结合我们以前讲过的多进程和多线程,以后再遇到并发,你可以先开多进程,这样首先会用到多核优势,然后每个进程里面可以在开启多线程,这样并发数量就会极大的提升,然后每个线程里面在使用gevent模块实现遇到I/O操作就切换把单个线程的效率提升到极致,这样其实一个线程能抵得住原来500个以上的线程,现在再来想程序的效率有多牛逼。

并发其实就是服务端的并发,在工作中,这个服务端的套接字软件其实我们不用自己写,有人已经写好了,有一个非常优秀的套接字软件叫做“Nginx ”,Nginx的工作原理就是一上来有几个CPU就开启几个进程,每个进程里面再开启一堆线程,注意:不能说是每个线程里面再开启一堆协程,这种说法是错误的,协程是一个结果,而不是操作系统的单位,应该是说:在每个线程内监测里面的I/O行为,实现遇到I/O就切换,这种实现的结果叫做协程。我们这种实现协程的方式能够提高单线程下的执行效率,因为不是所有的协程都能够提升执行效率。很多人都知道“Nginx”能够实现高并发,但是原理又有多少人知道,而现在你们却可以写一个自己的Nginx。

posted @ 2019-04-09 18:35  马一特  阅读(79)  评论(0编辑  收藏  举报