带你渐入佳境,一文捋清协程


一句话说明什么是协程: 协程是一种用户态的轻量级线程,即协程是由用户程序自己控制调度的。

协程就是告诉Cpython解释器,你不是nb吗,不是搞了个GIL锁吗,那好,我就自己搞成一个线程让你去执行,省去你切换线程的时间,我自己切换比你切换要快很多,避免了很多的开销,对于单线程下,我们不可避免程序中出现io操作,但如果我们能在自己的程序中(即用户程序级别,而非操作系统级别)控制单线程下的多个任务能在一个任务遇到io阻塞时就切换到另外一个任务去计算,这样就保证了该线程能够最大限度地处于就绪态,即随时都可以被cpu执行的状态,相当于我们在用户程序级别将自己的io操作最大限度地隐藏起来,从而可以迷惑操作系统,让其看到:该线程好像是一直在计算,io比较少,从而更多的将cpu的执行权限分配给我们的线程。

协程的本质就是在单线程下,由用户自己控制一个任务遇到io阻塞了就切换另外一个任务去执行,以此来提升效率。

想要理解协程gevent,我们首先要了解greenlet,而要了解greenlet我们就要了解yield,而yield 就在生成器里,生成器又是特殊的迭代器,接下来,我们就开始从迭代器进行讲起。

一、迭代器

迭代是访问集合元素的一种方式。迭代器是一个可以记住遍历的位置的对象。迭代器对象从集合的第一个元素开始访问,直到所有的元素被访问完结束。迭代器只能往前不会后退。

  1. 可迭代对象
    我们已经知道可以对list、tuple、str等类型的数据使用for…in…的循环语法从其中依次拿到数据进行使用,我们把这样的过程称为遍历,也叫迭代。

我们之前⼀直在⽤可迭代对象进⾏迭代操作,那么到底什么是可迭代对象. ⾸先我们先回顾⼀下⽬前我们所熟知的可迭代对象有哪些:str, list, tuple, dict, set.
那为什么我们可以称他们为可迭代对象呢? 因为他们都遵循了可迭代协议. 什么是可迭代协议. ⾸先我们先看以下错误代码:

for i in 123:
	 print(i)

注意看报错信息中有这样⼀句话. ‘int’ object is not iterable . 翻译过来就是整数类型对象
是不可迭代的。iterable表⽰可迭代的,表⽰可迭代协议. 那么如何进⾏验证你的数据类型是否符合可迭代协议?
我们可以通过dir函数来查看类中定义好的所有⽅法.如:

print(dir(str)) # 打印类中声明的⽅法和函数

结果:
在这里插入图片描述

查看一个对象是否是可迭代对象方法

1.用dir()函数在打印结果中. 寻找__iter__ ,如果能找到,那么这个类的对象就是⼀个可迭代对象.
2.通过isinstance()函数来查看⼀个对象是什么类型的如:

l = [1,2,3]
l_iter = l.__iter__()
from collections import Iterable
from collections import Iterator
print(isinstance(l,Iterable)) #True 表明是可迭代的,遵守可迭代协议
print(isinstance(l,Iterator)) #False 表明不是迭代器
print(isinstance(l_iter,Iterator)) #True   表明是迭代器
print(isinstance(l_iter,Iterable)) #True

综上. 我们可以确定. 如果对象中有__iter__函数. 那么我们认为这个对象遵守了可迭代协议,就可以获取到相应的迭代器. 这⾥的__iter__是帮助我们获取到对象的迭代器. 我们使⽤迭代器中的__next__()来获取到⼀个迭代器中的元素. 那么我们之前讲的for的⼯作原理到底是什么, 继续看代码:

s = "一帘幽梦晓生凉"
c = s.__iter__() # 获取迭代器
print(c.__next__()) # 使⽤迭代器进⾏迭代. 获取⼀个元素 一
print(c.__next__()) # 帘
print(c.__next__()) # 幽
print(c.__next__()) # 梦
print(c.__next__()) # 晓
print(c.__next__()) # 生
print(c.__next__()) # 凉
print(c.__next__()) # StopIteration

结果:
在这里插入图片描述
for循环的机制:

for i in [1,2,3]:
 print(i)

使⽤while循环+迭代器来模拟for循环:

lst = [1,2,3]
lst_iter = lst.__iter__()
while True:
     try:
         i = lst_iter.__next__()
         print(i)
     except StopIteration:
         break

自制迭代器

好了,相信通过以上讲解,我们已经对迭代器有了清晰的了解,那么我们能不能通过类创建对象做一个迭代器呢?当然可以,我们都知道了 迭代器内部包含__iter__() 同时包含__next__(). ,下面我们开始尝试,示例代码:

from collections.abc import Iterable,Iterator
import time
class DiyName: # 类A
    def __init__(self):
        self.name = []
        self.start_num = 0 

    def add_name(self,name):
        self.name.append(name)

    def __iter__(self):
        return self # 返回迭代器对象,将本身自己做成迭代器就返回自己

    def __next__(self): # for循环其实就是根据这个方法去取值
        if self.start_num < len(self.name): # 索引值不能大于列表的长度
            a = self.name[self.start_num]
            self.start_num += 1
            return  a # 取出类A登记名单
        else: # 停止for循环无脑取值的方式
            raise StopIteration

man1 = DiyName()
man1.add_name("王一")
man1.add_name("陈二")
man1.add_name("张三")
print('man1是否是可迭代的:',isinstance(man1,Iterable))
print('man1是否是迭代器:',isinstance(man1,Iterator))

for i in man1:
    time.sleep(1)
    print(i)
# # 第一步 判断是否是一个可迭代对象,__iter__
# # 第二步 iter(man1) >>> 得到该实例对象的__iter__的返回值
# # 第三步 返回的这个东西 是否是一个 迭代器 >>> __iter__,__next__
# # 取值的时候 next()

执行结果:
在这里插入图片描述
以上示例代码没有什么难理解的,可能大家对第一行的collections.abc会有疑问,其实这个.abc要不要无所谓,也会拿到结果,但是使用 from collections import Iterable 时
会有如下警告:

 DeprecationWarning: 
 Using or importing the ABCs from 'collections' 
 instead of from 'collections.abc' is deprecated,
 and in 3.8 it willstop working 

翻译过来就是:弃用警告:从collections中导入ABCs已被弃用,并在python3.8中将停止工作,可使用collections.abc代替它进行使用。
由于本人使用的还是python3.7的解释器,所以需要加上.abc去掉警告。

斐波那契数列迭代器

下面再举个迭代器的小栗子:

class FibIterator(object):
    """斐波那契数列迭代器"""

    def __init__(self, n):
        """
        :param n: int, 指明生成数列的前n个数
        """
        self.n = n
        # current用来保存当前生成到数列中的第几个数了
        self.current = 0
        # num1用来保存前前一个数,初始值为数列中的第一个数0
        self.num1 = 0
        # num2用来保存前一个数,初始值为数列中的第二个数1
        self.num2 = 1


    # 0 ,1 ,1, 2 ,3, 5,8,13
    def __next__(self):
        """被next()函数调用来获取下一个数"""
        if self.current < self.n:
            num = self.num1
            self.num1, self.num2 = self.num2, self.num1 + self.num2
            self.current += 1
            return num
        else:
            raise StopIteration

    def __iter__(self):
        """迭代器的__iter__返回自身即可"""
        return self

if __name__ == '__main__':
    fib = FibIterator(10)
    for num in fib:
        print(num, end=" ")

执行结果:
在这里插入图片描述

小结:

Iterable: 可迭代对象. 内部包含__iter__()函数
Iterator: 迭代器. 内部包含__iter__() 同时包含__next__().
迭代器的特点:

  1. 节省内存.
  2. 惰性机制
  3. 不能反复, 只能向下执⾏.

我们可以把要迭代的内容当成⼦弹,然后获取到迭代器__iter__(), 就把⼦弹都装在弹夹中,然后发射就是__next__()把每⼀个⼦弹(元素)打出来。即 for循环的时候,⼀开始时是__iter__()来获取迭代器,后⾯每次获取元素都是通过__next__()来完成的,当程序遇到StopIteration将结束循环。

二、生成器(侧重点理清yield,其他的作为补充拓展)

什么是生成器?⽣成器实质就是迭代器.
在python中有三种⽅式来获取⽣成器:

  1. 通过⽣成器函数
  2. 通过各种推导式来实现⽣成器
  3. 通过数据的转换也可以获取⽣成器
    ⾸先, 我们先看⼀个很简单的函数:
def func():
     print("枕上诗书闲处好,")
     return "门前风景雨来佳。"
ret = func()
print(ret)

在这里插入图片描述
将函数中的return换成yield就是⽣成器:

def func():
     print("枕上诗书闲处好,")
     yield "门前风景雨来佳。"
ret = func()
print(ret)

在这里插入图片描述
运⾏的结果和上⾯不⼀样。为什么呢?由于函数中存在了yield,那么这个函数就是⼀个⽣成器函数. 这个时候,我们再执⾏这个函数的时候就不再是函数的执⾏了,⽽是获取这个⽣成器.
如何使⽤呢? 想想迭代器. ⽣成器的本质是迭代器. 所以. 我们可以直接执⾏__next__()来执⾏以下⽣成器:

def func():
     print("枕上诗书闲处好,")
     yield "门前风景雨来佳。"
gener = func() # 这个时候函数不会执⾏,⽽是获取到⽣成器
ret = gener.__next__() # 这个时候函数才会执⾏,yield的作⽤和return⼀样也是返回数据
print(ret)

相同效果:
在这里插入图片描述
我们可以看到, yield和return的效果是⼀样的。有什么区别呢? yield是分段来执⾏⼀个函数, return则直接停⽌执⾏函数。

def func():
     print("1")
     yield 2
     print("3")
     yield 4
gener = func()
ret = gener.__next__()
print(ret)  # 1 2
ret2 = gener.__next__()
print(ret2) # 3 4
ret3 = gener.__next__() # 最后⼀个yield执⾏完毕再次__next__()程序报错
print(ret3)

结果:
在这里插入图片描述

好了,费尽心机提生成器,那为什么要用⽣成器呢? 还是迭代器的特点:节省内存。使⽤⽣成器⼀次就⼀个,⽤多少⽣成多少。⽣成器是⼀个⼀个的指向下⼀个,不会回去, next()到哪, 指针就指到哪⼉,下⼀次继续获取指针指向的值。

我们除了可以使用next()函数来唤醒生成器继续执行外,还可以使用send()函数来唤醒执行。使用send()函数的一个好处是可以在唤醒的同时向断点处传入一个附加数据。
接下来我们来看send⽅法, send和__next__()⼀样都可以让⽣成器执⾏到下⼀个yield.

def gan():
     print("今晚干什么呀?")
     a = yield "看书"
     print("a=",a)
     b = yield "写博客"
     print("b=",b)
     c = yield "撸代码"
     print("c=",c)
     yield "GAME OVER"
gen = gan() # 获取⽣成器
ret1 = gen.__next__()
print(ret1)
ret2 = gen.send("看电影")
print(ret2)
ret3 = gen.send("打游戏")
print(ret3)
ret4 = gen.send("睡觉")
print(ret4)

执行结果:
在这里插入图片描述
仔细看结果,一开始是不是想当然的认为a = 看书,b = 写博客,c = 撸代码?
这里我们就需要关注send和__next__()区别:

  1. send和next()都是让⽣成器向下走⼀次
  2. send可以给上⼀个yield的位置传递值, 不能给最后⼀个yield发送值,在第⼀次执⾏⽣成器代码的时候不能使⽤send()
    看过后问题是不是迎刃而解?咱们用send给第一个yield 位置传递了看电影给a,所以会显示a = 看电影 其他同理。

⽣成器可以使⽤for循环来循环获取内部的元素:

def func():
     print(1)
     yield 2
     print(3)
     yield 4
     print(5)
     yield 6
gen = func()
for i in gen:
     print(i)

执行结果:
在这里插入图片描述

yield 完成多任务

import time


def sing(): # 生成器模板
    while True:
        print('***我在唱歌***')
        time.sleep(1) # alt
        yield #暂停挂起的机制
def dance():
    while True:
        print('---我在跳舞---')
        time.sleep(1)
        yield
# Thread Process 协程
def main():
    t1 = sing()
    t2 = dance()
    while True:
        try:
            next(t1) # 唤醒生成器
            next(t2)
        except Exception:
            break
if __name__ == '__main__':
    main()


执行效果:
在这里插入图片描述
利用yield的暂停挂起机制起到多任务的效果,实际上这也是并发是假的多任务。

列表推导式

生成器的表现形式之一: 列表推导式 ,⾸先我们先看⼀下这样的代码, 给出⼀个列表, 通过循环, 向列表中添加1-10 :

lst = []
for i in range(1, 11):
     lst.append(i)
print(lst)

替换成列表推导式:

lst = [i for i in range(1, 11)]
print(lst)

在这里插入图片描述

列表推导式是通过⼀⾏来构建你要的列表, 列表推导式看起来代码简单,但是出现错误之后很难排查。
列表推导式的常用写法: [ 结果 for 变量 in 可迭代对象]
从python1到python10:

lst = ['python%s' % i for i in range(1,11)]
print(lst)

在这里插入图片描述
筛选模式:
[ 结果 for 变量 in 可迭代对象 if 条件 ]

# 获取1-100内所有的奇数
lst = [i for i in range(1, 100) if i % 2 != 0]
print(lst)

结果:
在这里插入图片描述

生成器表达式

⽣成器表达式和列表推导式的语法基本上是⼀样的. 只是把[]替换成()

gen = (i for i in range(10))
print(gen)

在这里插入图片描述
打印的结果就是⼀个⽣成器对象,我们可以使⽤for循环来循环这个⽣成器:

gen = ("我第%s次写诗" % i for i in range(10))
for i in gen:
 	print(i)

结果:
在这里插入图片描述
⽣成器表达式也可以进⾏筛选:


# 获取1-100内能被3整除的数
gen = (i for i in range(1,100) if i % 3 == 0)
for num in gen:
    print(num)
    
# 100以内能被3整除的数的平⽅
gen = (i**2 for i in range(100) if i % 3 == 0)
for num in gen:
    print(num)
    
# 寻找名字中带有两个e的⼈的名字
names = [['Tom', 'Billy', 'Jefferson','Joe'],['Alice', 'Jill', 'Ana', 'Wendy', 'Jennifer']]
# 不⽤推导式和表达式
result = []
for first in names:
     for name in first:
         if name.count("e") >= 2:
         result.append(name)
print(result)
# 利用推导式
gen = (name for first in names for name in first if name.count("e") >= 2)
for name in gen:
 	print(name)

⽣成器表达式和列表推导式的区别:

1. 列表推导式比较耗内存,⼀次性加载。 ⽣成器表达式几乎不占⽤内存. 使⽤的时候才分配和使⽤内存。
2. 得到的值不⼀样。列表推导式得到的是⼀个列表. ⽣成器表达式获取的是⼀个⽣成器对象

生成器的惰性机制:== ⽣成器只有在访问的时候才取值. 说⽩了就是你找他要他才给你值,不找他要他是不会执⾏的.==

def func():
     print(111)
     yield 222
g = func() # ⽣成器g
g1 = (i for i in g) # ⽣成器g1. 但是g1的数据来源于g
g2 = (i for i in g1) # ⽣成器g2. 来源g1
print(list(g)) # 获取g中的数据. 这时func()才会被执⾏. 打印111.获取到222. g完毕.
print(list(g1)) # 获取g1中的数据. g1的数据来源是g. 但是g已经取完了. g1 也就没有数据了
print(list(g2)) # 和g1同理

执行结果:
在这里插入图片描述
好好捋捋,理解那句“要值的时候才拿值

字典推导式

# 把字典中的key和value互换
dic = {'a': 1, 'b': '2'}
new_dic = {dic[key]: key for key in dic}
print(new_dic)

# 在以下list中. 从lst1中获取的数据和lst2中相对应的位置的数据组成⼀个新字典
lst1 = ['青花瓷', '天下', '诗仙']
lst2 = ['周杰伦', '张杰', '李白']
dic = {lst2[i]: lst1[i] for i in range(len(lst1))}
print(dic)

执行结果:
在这里插入图片描述

集合推导式

集合推导式可以帮我们直接⽣成⼀个集合,集合的特点: ⽆序, 不重复. 所以集合推导式⾃带去重功能:

lst = [1, -1, 8, -8, 12]
# 绝对值去重
s = {abs(i) for i in lst}
print(s)

程序执行结果:
在这里插入图片描述
通过前面讲的,咱们知道函数里面有yield,那么函数就是一个生成器。

斐波那契数列生成器

咱们前面讲过用迭代器求斐波那契数列,这次咱们试着用生成器做一下:

def fei_bo(c):
    a, b = 0 ,1
    start_num = 0
    while start_num < c: # c的值就是我们取值斐波那契的范围
        yield a # 不会停止整个代码块的运行  暂停挂起的机制 
        a, b = b , a + b
        start_num += 1

fei_bo1 = fei_bo(10)
# 用for 循环将生成器的前10个斐波那契数列打印出来
for i in fei_bo1:
    print(i,end=',')

执行结果:
在这里插入图片描述

总结:

推导式有, 列表推导式, 字典推导式, 集合推导式, 没有元组推导式
⽣成器表达式: (结果 for 变量 in 可迭代对象 if 条件筛选)
⽣成器表达式可以直接获取到⽣成器对象. ⽣成器对象可以直接进⾏for循环,⽣成器具有惰性机制。。。

有人就问了:不是为了讲yield吗?为啥还那么严肃地附带讲那么多?
都是知识点啊,技多不压身,你懂的多你就更牛逼不是吗?。。。

三、greenlet和gevent协程完成多任务

协程通俗理解

在一个线程中的某个函数,可以在任何地方保存当前函数的一些临时变量等信息,然后切换到另外一个函数中执行,注意不是通过调用函数的方式做到的,并且切换的次数以及什么时候再切换到原来的函数都由开发者自己确定。

协程和线程差异

在实现多任务时, 线程切换从系统层面远不止保存和恢复 CPU上下文这么简单。 操作系统为了程序运行的高效性每个线程都有自己缓存Cache等等数据,操作系统还会帮你做这些数据的恢复操作。 所以线程的切换非常耗性能。但是协程的切换只是单纯的操作CPU的上下文,所以一秒钟切换个上百万次系统都抗的住。

greenlet完成多任务

为了更好使用协程来完成多任务,python中的greenlet模块对其封装,从而使得切换任务变的更加简单,程序示例:

# 导入的是greentlet模块里面的greenlet类
from greenlet import greenlet
import time


def sing():
    while True:
        print('***我在唱歌***')
        time.sleep(0.5)
        g2.switch()
        
        
def dance():
    while True:
        print('---我在跳舞---')
        time.sleep(0.5)
        g1.switch()
g1 = greenlet(sing)
g2 = greenlet(dance)


def main():
    g1.switch() # 切换的方法
    
    
if __name__ == '__main__':
    main()

效果:在这里插入图片描述

gevent 完成多任务

greenlet只是提供了一种比generator更加便捷的切换方式,当切到一个任务执行时如果遇到io,那就原地阻塞,仍然是没有解决遇到IO自动切换来提升效率的问题。greenlet已经实现了协程,但是这个还得人工切换,是不是觉得太麻烦了,不要着急,python还有一个比greenlet更强大的并且能够自动切换任务的模块gevent

gevent原理是当一个greenlet遇到IO(指的是input output 输入输出,比如网络、文件操作等)操作时,比如访问网络,就自动切换到其他的greenlet,等到IO操作完成,再在适当的时候切换回来继续执行。

由于IO操作非常耗时,经常使程序处于等待状态,有了gevent为我们自动切换协程,就保证总有greenlet在运行,而不是等待IO

import gevent
import time

def sing():
    for i in range(1,4):
        print('***我在唱第%s首歌***' % i)
        # time.sleep(0.5) 不能用,用了没效果,gevent有自己得sleep方法
        gevent.sleep(0.5)

def dance():
    for i in range(1,4):
        print('---我在跳第%s支舞---' % i)
        # time.sleep(0.5)
        gevent.sleep(0.5)
g1 = gevent.spawn(sing)
g2 = gevent.spawn(dance)

def main():
    g1.join()
    g2.join()

if __name__ == '__main__':
    main()

效果:
在这里插入图片描述
如果将gevent.sleep(0.5)换成 time.sleep(0.5):
在这里插入图片描述
可以看到用time.sleep()在gevent中完全达不到多任务的效果,有人说我已经用习惯了time,那有没有办法可以使用time依然可以完成多任务呢?当然有,这种方法我们称之为打补丁,程序示例:

# 打补丁
import gevent
import time
from gevent import monkey

monkey.patch_all()
def sing():
    for i in range(1, 4):
        print('***我在唱第%s首歌***' % i)
        time.sleep(0.5) # >>>实际上底层它最终也是指向 gevent.sleep()


def dance():
    for i in range(1, 4):
        print('---我在跳第%s只舞---' % i)
        time.sleep(0.5)


g1 = gevent.spawn(sing)
g2 = gevent.spawn(dance)


def main():
    g1.join()
    g2.join()


if __name__ == '__main__':
    main()

效果:
在这里插入图片描述
可以这样记:要用gevent,需要将from gevent import monkey;monkey.patch_all()放到文件的开头。
其实代码我们还是可以稍作一下优化,gevent有一个joinall方法,可以把对象放进一个列表传入:

import gevent
import time
from gevent import monkey

monkey.patch_all()
def sing():
    for i in range(1, 4):
        print('***我在唱第%s首歌***' % i)
        time.sleep(0.5) # >>>实际上它最终也是指向 gevent.sleep()


def dance():
    for i in range(1, 4):
        print('---我在跳第%s只舞---' % i)
        time.sleep(0.5)


def main():
    gevent.joinall([
        gevent.spawn(sing),
        gevent.spawn(dance)
    ])

if __name__ == '__main__':
    main()

效果:
在这里插入图片描述

进程、线程和协程对比

在这里插入图片描述
上图截自:python中进程、线程、协程比较
通俗理解:
一条流水线及其生产资料为一个进程
流水线上的工人为单个线程
给闲时的工人分配新的任务, 这个概念就是协程(gevent)

多进程:多条流水线(multiprocessing)
多线程:一条流水线上的多个工人 (threading)

一般在工作中我们都是进程+线程+协程的方式来实现并发,以达到最好的并发效果,如果是4核的cpu,一般起5个进程,每个进程中20个线程(5倍cpu数量),每个线程可以起500个协程,大规模爬取页面的时候,等待网络延迟的时间的时候,我们就可以用协程去实现并发。 并发数量 = 5 * 20 * 500 = 50000个并发,这是一般一个4cpu的机器最大的并发数,nginx在负载均衡的时候最大承载量就是5w个。

在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

冰履踏青云

你的鼓励将是我创作的最大动力

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

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

打赏作者

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

抵扣说明:

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

余额充值