python 生成器和协程详解

第一 进程简介

第二 进程与线程区别

第三 线程

第四 生成器(generator)

第五 协程

第六 协程和生成器

第七 多线程和协程

说到Python 协程就会想到,进程和线程,当然更离不开生成器。

一 进程简介

一个程序的执行实例就是一个进程。每一个进程提供执行程序所需的所有资源。(进程本质上是资源的集合)

一个进程有一个虚拟的地址空间、可执行的代码、操作系统的接口、安全的上下文(记录启动该进程的用户和权限等等)、唯一的进程ID、环境变量、优先级类、最小和最大的工作空间(内存空间),还要有至少一个线程。

每一个进程启动时都会最先产生一个线程,即主线程。然后主线程会再创建其他的子线程。

与进程相关的资源包括:

    A 内存页(同一个进程中的所有线程共享同一个内存空间)

 B 文件描述符(e.g. open sockets)

 C 安全凭证(e.g.启动该进程的用户ID)

二 进程与线程区别

    A 同一个进程中的线程共享同一内存空间,但是进程之间是独立的。
    B 同一个进程中的所有线程的数据是共享的(进程通讯),进程之间的数据是独立的。
    C 对主线程的修改可能会影响其他线程的行为,但是父进程的修改(除了删除以外)不会影响其他子进程。
    D 线程是一个上下文的执行指令,而进程则是与运算相关的一簇资源。
    E 同一个进程的线程之间可以直接通信,但是进程之间的交流需要借助中间代理来实现。
    F 创建新的线程很容易,但是创建新的进程需要对父进程做一次复制。
    G 一个线程可以操作同一进程的其他线程,但是进程只能操作其子进程。
   H 线程启动速度快,进程启动速度慢(但是两者运行速度没有可比性)。

三 线程

A 线程定义 

线程是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。一个线程是一个execution context(执行上下文),即一个cpu执行时所需要的一串指令。

B 工作方式

假设你正在读一本书,没有读完,你想休息一下,但是你想在回来时恢复到当时读的具体进度。有一个方法就是记下页数、行数与字数这三个数值,这些数值就是execution context。如果你的室友在你休息的时候,使用相同的方法读这本书。你和她只需要这三个数字记下来就可以在交替的时间共同阅读这本书了。

线程的工作方式与此类似。CPU会给你一个在同一时间能够做多个运算的幻觉,实际上它在每个运算上只花了极少的时间,本质上CPU同一时刻只干了一件事。它能这样做就是因为它有每个运算的execution context。就像你能够和你朋友共享同一本书一样,多任务也能共享同一块CPU。

C   常用方法

方法 注释
start() 线程准备就绪,等待CPU调度
setName() 为线程设置名称
getName() 获取线程名称
setDaemon(True) 设置为守护线程
join() 逐个执行每个线程,执行完毕后继续往下执行
run() 线程被cpu调度后自动执行线程对象的run方法,如果想自定义线程类,直接重写run方法就行了

D 工作过程

python在使用多线程的时候,调用的是c语言的原生线程。

  1. 拿到公共数据
  2. 申请gil
  3. python解释器调用os原生线程
  4. os操作cpu执行运算
  5. 当该线程执行时间到后,无论运算是否已经执行完,gil都被要求释放
  6. 进而由其他进程重复上面的过程
  7. 等其他进程执行完后,又会切换到之前的线程(从他记录的上下文继续执行)
    整个过程是每个线程执行自己的运算,当执行时间到就进行切换(context switch)。

E PIL

在非python环境中,单核情况下,同时只能有一个任务执行。多核时可以支持多个线程同时执行。但是在python中,无论有多少核,同时只能执行一个线程。究其原因,这就是由于GIL的存在导致的。

GIL的全称是Global Interpreter Lock(全局解释器锁),来源是python设计之初的考虑,为了数据安全所做的决定。某个线程想要执行,必须先拿到GIL,我们可以把GIL看作是“通行证”,并且在一个python进程中,GIL只有一个。拿不到通行证的线程,就不允许进入CPU执行。GIL只在cpython中才有,因为cpython调用的是c语言的原生线程,所以他不能直接操作cpu,只能利用GIL保证同一时间只能有一个线程拿到数据。而在pypy和jpython中是没有GIL的。

GIL在python中的版本差异:

1、在python2.x里,GIL的释放逻辑是当前线程遇见IO操作或者ticks计数达到100时进行释放。(ticks可以看作是python自身的一个计数器,专门做用于GIL,每次释放后归零,这个计数可以通过sys.setcheckinterval 来调整)。而每次释放GIL锁,线程进行锁竞争、切换线程,会消耗资源。并且由于GIL锁存在,python里一个进程永远只能同时执行一个线程(拿到GIL的线程才能执行),这就是为什么在多核CPU上,python的多线程效率并不高。
2、在python3.x中,GIL不使用ticks计数,改为使用计时器(执行时间达到阈值后,当前线程释放GIL),这样对CPU密集型程序更加友好,但依然没有解决GIL导致的同一时间只能执行一个线程的问题,所以效率依然不尽如人意。

F 执行效率

python针对不同类型的代码执行效率也是不同的

1、CPU密集型代码(各种循环处理、计算等等),在这种情况下,由于计算工作多,ticks计数很快就会达到阈值,然后触发GIL的释放与再竞争(多个线程来回切换当然是需要消耗资源的),所以python下的多线程对CPU密集型代码并不友好。
2、IO密集型代码(文件处理、网络爬虫等涉及文件读写的操作),多线程能够有效提升效率(单线程下有IO操作会进行IO等待,造成不必要的时间浪费,而开启多线程能在线程A等待时,自动切换到线程B,可以不浪费CPU的资源,从而能提升程序执行效率)。所以python的多线程对IO密集型代码比较友好。

建议 :

python下想要充分利用多核CPU,就用多进程。因为每个进程有各自独立的GIL,互不干扰,这样就可以真正意义上的并行执行,在python中,多进程的执行效率优于多线程(仅仅针对多核CPU而言)。

G 线程锁

由于线程之间是进行随机调度,并且每个线程可能只执行n条执行之后,当多个线程同时修改同一条数据时可能会出现脏数据,所以,出现了线程锁,即同一时刻允许一个线程执行操作。线程锁用于锁定资源,你可以定义多个锁, 像下面的代码, 当你需要独占某一资源时,任何一个锁都可以锁这个资源,就好比你用不同的锁都可以把相同的一个门锁住是一个道理。

由于线程之间是进行随机调度,如果有多个线程同时操作一个对象,如果没有很好地保护该对象,会造成程序结果的不可预期,我们也称此为“线程不安全”。

第四 生成器 (generator)

什么是生成器

通过列表生成式,我们可以直接创建一个列表。但是,受到内存限制,列表容量肯定是有限的。而且,创建一个包含100万个元素的列表,不仅占用很大的存储空间,如果我们仅仅需要访问前面几个元素,那后面绝大多数元素占用的空间都白白浪费了。

所以,如果列表元素可以按照某种算法推算出来,那我们是否可以在循环的过程中不断推算出后续的元素呢?这样就不必创建完整的list,从而节省大量的空间。在Python中,这种一边循环一边计算的机制,称为生成器:generator。

如何创建一个生成器

第一种方法很简单,只要把一个列表生成式的[]改成(),就创建了一个generator

>>> L = [x * x for x in range(10)]
>>> L
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
>>> g = (x * x for x in range(10))
>>> g
<generator object <genexpr> at 0x1022ef630>

创建Lg的区别仅在于最外层的[]()L是一个list,而g是一个generator。

方法二, 

在一个一般函数中使用yield关键字,可以实现一个最简单的生成器,此时这个函数变成一个生成器函数,调用函数就是创建了一个生成器(generator)对象。yield与return返回相同的值,区别在于return返回后,函数状态终止,而yield会保存当前函数的执行状态,在返回后,函数又回到之前保存的状态继续执行。

生成器函数包含一个或者多个yield当调用生成器函数时,函数将返回一个对象,但是不会立刻向下执行像__iter__()和__next__()方法等是自动实现的,所以我们可以通过next()方法对对象进行迭代一旦函数被yield,函数会暂停,控制权返回调用者局部变量和它们的状态会被保存,直到下一次调用函数终止的时候,StopIteraion会被自动抛出,生成器如何节省资源?

只记住当前位置,生成器只保留一个值,next之后上一个值就没有了

只有一个next方法,

#b#__next__()

工作原理:

1 工作原理就是通过重复调用next()方法,直到捕获一个异常。

2 可用next()调用生成器对象来取值。next 两种方式 t.__next__()  |  next(t)。

可用for 循环获取返回值(每执行一次,取生成器里面一个值)

(基本上不会用next()来获取下一个返回值,而是直接使用for循环来迭代)。

3 yield相当于 return 返回一个值,并且记住这个返回的位置,下次迭代时,代码从yield的下一条语句开始执行。

4 .send() 和next()一样,都能让生成器继续往下走一步(下次遇到yield停),但send()能传一个值,这个值作为yield表达式整体的结果

——换句话说,就是send可以强行修改上一个yield表达式值。比如函数中有一个yield赋值,a = yield 5,第一次迭代到这里会返回5,a还没有赋值。第二次迭代时,使用.send(10),那么,就是强行修改yield 5表达式的值为10,本来是5的,那么a=10

著名的斐波那契数列

著名的斐波那契数列,除第一个和第二个数外,任意一个数都可由前两个数相加得到:

1,1,2,3,5,8,13,21,34,...

斐波那契数列用列表生成式写不出来,但是,用函数把它打印出来却很容易:

def fib(max):

     n,a,b=0,0,1

         while n<max:

              print(b)

              a, b = b, a+b n=n+1

              return 'done'

f=fib(10)

运行结果:

1 1 2 3 5 8 13 21 34 55

想要使它变为生成器,只需要将print(b),改为yiled b即可

def fib(max):

     n,a,b=0,0,1

         while n<max:

              yiled b

              a, b = b, a+b n=n+1

              return 'done'

#异常时打印的消息运行结果:通过使用next的方式进行取值

for i in f:

print(i)

使用next方法取数据取不出来了,就会抛出一个异常

Traceback (most recent call last):

File "D:\python\index.py", line 80, in <module>

print(f.__next__())

StopIteration: done

如何处理异常,可通过抓取异常

g=fib(6)

#异常处理代码

while True:

try:

x = next(g)

print('g:', x)

except StopIteration as e:

print('Generator return value:', e.value)

最终运行结果:

g: 1

g: 1

g: 2

g: 3

g: 5

g: 8

Generator return value: done

===start loop===

为什么使用生成器

更容易使用,代码量较小内存使用更加高效。比如列表是在建立的时候就分配所有的内存空间,而生成器仅仅是需要的时候才使用,更像一个记录代表了一个无限的流。如果我们要读取并使用的内容远远超过内存,但是需要对所有的流中的内容进行处理,那么生成器是一个很好的选择,比如可以让生成器返回当前的处理状态,由于它可以保存状态,那么下一次直接处理即可。流水线生成器。

第五 协程

A 协程定义

线程和进程的操作是由程序触发系统接口,最后的执行者是系统,它本质上是操作系统提供的功能。而协程的操作则是程序员指定的,在python中通过yield,人为的实现并发处理。

协程存在的意义:对于多线程应用,CPU通过切片的方式来切换线程间的执行,线程切换时需要耗时。协程,则只使用一个线程,分解一个线程成为多个“微线程”,在一个线程中规定某个代码块的执行顺序。

协程的适用场景:当程序中存在大量不需要CPU的操作时(IO)。
常用第三方模块gevent和greenlet,后续介绍。(本质上,gevent是对greenlet的高级封装,因此一般用它就行,这是一个相当高效的模块。)

 B greenlet和gevent

greenlet

from greenlet import greenlet

def test1():
    print(12)
    gr2.switch()
    print(34)
    gr2.switch()

def test2():
    print(56)
    gr1.switch()
    print(78)

gr1 = greenlet(test1)
gr2 = greenlet(test2)
gr1.switch()
实际上,greenlet就是通过switch方法在不同的任务之间进行切换。

gevent 使用

from gevent import monkey; monkey.patch_all()
import gevent
import requests

def f(url):
    print('GET: %s' % url)
    resp = requests.get(url)
    data = resp.text
    print('%d bytes received from %s.' % (len(data), url))

gevent.joinall([
        gevent.spawn(f, 'https://www.python.org/'),
        gevent.spawn(f, 'https://www.yahoo.com/'),
        gevent.spawn(f, 'https://github.com/'),
])
通过joinall将任务f和它的参数进行统一调度,实现单线程中的协程。代码封装层次很高,实际使用只需要了解它的几个主要方法即可。

gevent 是基于协程的python网络函数库,使用greenlet 在libev事件循环顶部提供一个有高级别并发性的API。

    特点:

    (1)基于libev 的快速事件循环,Linux上的epoll机制。

    (2)基于greenlet的轻量级执行单元。

    (3)API 复用了python标准库的内容。

    (4)支持SSL的协作式sockets。

    (5)可以通过线程池或者c-ares 实现DNS查询。

    (6)通过 monkey patching功能使得第三方模块编程协作式。

gevent支持协程,其实也可以说是greenlet实现的工作切换。

    greenlet工作流程如下:如果访问网路的I/O操作出现阻塞时,greenlet就显式切换到另外一个没有被阻塞的代码段执行,直到原先的阻塞状态消失后,再自动切换会原来的代码段继续处理。 可以说,greenlet是在更合理地安排串行工作方式。

    同时,由于IO 操作比较耗时,经常是程序处于等待状态,gevent自动切换协程后,能够保证总有greenlet在运行,而不需要等待IO完成,这是协程比一般多线程效率高的原因。

    IO操作是自动完成的,所以gevent 需要修改python的一些自带标准库,将一些常见的阻塞,如:socket、select等地方实现协程跳转,这一过程可以通过monkey patch完成。

如下代码可以显示 gevent的使用流程:(python版本: 3.6  操作系统环境: windows10)

from gevent import monkey
monkey.patch_all()
import gevent
import urllib.request
 
def run_task(url):
    print("Visiting %s " % url)
    try:
        response = urllib.request.urlopen(url)
        url_data = response.read()
        print("%d bytes received from %s " % (len(url_data), url))
    except Exception as e:
        print(e)
 
if __name__ == "__main__":
    urls = ["https://stackoverflow.com/", "http://www.cnblogs.com/", "http://github.com/"]
    greenlets = [gevent.spawn(run_task, url) for url in urls]
    gevent.joinall(greenlets)
Visiting https://stackoverflow.com/ 
Visiting http://www.cnblogs.com/ 
Visiting http://github.com/ 
46412 bytes received from http://www.cnblogs.com/ 
54540 bytes received from http://github.com/ 
251799 bytes received from https://stackoverflow.com/ 

gevent的spawn方法可以看做是用来形成协程,joinall 方法相当于添加协程任务并启动运行。由结果可以看到,3个网络请求并发执行,而且结束顺序却不一致,但是却只有一个线程。

    gevent还提供池。如果拥有动态数量的 greenlet需要进行并发管理,可以使用池 来处理大量的网络请求 及 IO操作。

    如下是 gevent的pool对象,修改如上的多网络请求示例:


from gevent import monkey
monkey.patch_all()
from gevent.pool import Pool
import urllib.request
 
def run_task(url):
    print("Visiting %s " % url)
    try:
        response = urllib.request.urlopen(url)
        url_data = response.read()
        print("%d bytes reveived from %s " %(len(url_data), url))
    except Exception as e:
        print(e)
    
    return ("%s read finished.." % url)
 
if __name__ == "__main__":
    pool = Pool(2)
    urls = ["https://stackoverflow.com/", 
            "http://www.cnblogs.com/", 
            "http://github.com/"]
    results = pool.map(run_task, urls)
    print(results)

Visiting https://stackoverflow.com/ 
Visiting http://www.cnblogs.com/ 
46416 bytes reveived from http://www.cnblogs.com/ 
Visiting http://github.com/ 
253375 bytes reveived from https://stackoverflow.com/ 
54540 bytes reveived from http://github.com/ 
['https://stackoverflow.com/ read finished..', 'http://www.cnblogs.com/ read finished..', 'http://github.com/ read finished..']

由结果来看,Pool 对象对协程的并发数量进行了管理,先访问前两个,当其中一个任务完成了,再继续执行第三个请求。

C  状态

协程有四种状态,分别是

GEN_CREATED:等待执行

GEN_RUNNING:解释器执行

GEN_SUSPENDED:在yield表达式处暂停

GEN_CLOSED:执行结束

协程的状态可以用inspect.getgeneratorstate()函数来确定,来看下面的例子:

from inspect import getgeneratorstate
from time import sleep
import threading
 
def get_state(coro):
    print("其他线程生成器状态:%s", getgeneratorstate(coro))  # <1>
def simple_coroutine():
    for i in range(3):
        sleep(0.5)
        x = yield i + 1  # <1>
my_coro = simple_coroutine()
print("生成器初始状态:%s" % getgeneratorstate(my_coro))  # <2>
first = next(my_coro)
for i in range(5):
    try:
        my_coro.send(i)
        print("主线程生成器初始状态:%s" % getgeneratorstate(my_coro))  # <3>
        t = threading.Thread(target=get_state, args=(my_coro,))
        t.start()
    except StopIteration:
        print("生成器的值拉取完毕")
print("生成器最后状态:%s" % getgeneratorstate(my_coro))  # <4>

执行结果:

生成器初始状态:GEN_CREATED
生成器状态:%s GEN_SUSPENDED
生成器状态:%s GEN_SUSPENDED
生成器的值拉取完毕
生成器的值拉取完毕
生成器的值拉取完毕
生成器最后状态:GEN_CLOSED

在激活协程之前,协程的状态是GEN_CREATED,而执行next()之后,以及在调用生成器send()之间,我分主线程也就是调用方和多线程去观察协程的状态,结果状态都是GEN_SUSPENDED,也就是协程处于暂停的状态,我原本想用多线程去捕捉协程的运行态,结果即便是多线程捕捉协程也是GEN_SUSPENDED,而GEN_RUNNING也说明,只有带解释器在运行协程的时候,协程的状态才是GEN_RUNNING,最后是GEN_CLOSED,我们拉取完协程的值后,协程的状态就变为执行结束

示例:使用协程计算平均值

我们可以开发一个协程,不断的往协程发送值,并且让协程累计之前的值并计算平均值,如下:

from functools import wraps
def coroutine(func):
    @wraps(func)
    def primer(*args, **kwargs):
        gen = func(*args, **kwargs)
        next(gen)
        return gen
    return primer


@coroutine  # <1>
def averager():
    total = .0
    count = 0
    average = None
    while True:
        term = yield average
        total += term
        count += 1
        average = total / count
try:
    coro_avg = averager()
    print(coro_avg.send(10))
    print(coro_avg.send(20))
    print(coro_avg.send(30))
    coro_avg.close()  # <2>
    print(coro_avg.send(40))
except StopIteration:
    print("协程已结束")

运行结果:

10.0
15.0
20.0
协程已结束

yield from item 表达式对item对象所做的第一件事,就是调用iter(item),从中获取迭代器,因此,item可以是任何一个可迭代的对象,在某些时候,yield from可以代替for循环,使得我们的代码更加的精炼,yield from是委派生成器,主要功能还是和yield一样,打开双向通道,把最外层的调用方和实际在传输值的生成器连接起来,这样,二者就可以发送和产出值

第六 生成器和协程

generator并不是协程,严格意义地讲,generator叫做半协程。半协程与协程有很多共同点,比如都可以把自身的控制权交出,也可以yield多次,也可以重新进入多次,但半协程无法决定把控制权yield给谁。

以生产者消费者模型为例,体会区别,先看看协程实现:


var q := new queue
coroutine produce
    loop
        while q is not full
            create some new items
            add the items to q
        yield to consume
 
coroutine consume
    loop
        while q is not empty
            remove some items from q
            use the items
        yield to produce

可以看到,produce协程可以将控制权让给consume协程,反之亦然。也就是,协程可以决定把自己的控制权让给谁。

而半协程就不行了,它只能将控制权交还给子例程(subroutine)。但半协程依然可以实现上述的生产者消费者模型,只不过此时需要一个协程分派器的辅助(coroutine dispatcher)
var q := new queue
generator produce
    loop
        while q is not full
            create some new items
            add the items to q
        yield consume
generator consume
    loop
        while q is not empty
            remove some items from q
            use the items
        yield produce
subroutine dispatcher
    var d := new dictionary(generator → iterator)
    d[produce] := start produce
    d[consume] := start consume
    var current := produce
    loop
        current := next d[current]


理解了之后,用python实现一下:

queue = []
limit = 9
def producer():
    while True:
        cap = limit - len(queue)
        while cap > 0:
            queue.append(random.randint(0, 100))
            cap -= 1
        print('producer yield back')
        yield 'consumer'
def consumer():
    while True:
        cap = len(queue)
        while cap > 0:
            item = queue.pop(0)
            print(item)
            cap -= 1
        print('consumer yield back')
        yield 'producer'
dic = {
    'producer': producer(),
    'consumer': consumer()
}
current = dic['producer']
times = 8
while times > 0:
    current = dic[next(current)]
    times -= 1

第七 多线程和协程

协程(coroutine)又称微线程,纤程,是种用户级别的轻量级线程。协程拥有自己的寄存器上下文和栈。协程调度切换时候,将寄存器上下文和栈保存到其他地方,等待切换回来的时候恢复,并从之前保存的寄存器上下文 和 栈继续工作。并发编程中,协程与 线程类似,每个协程表示一个执行单元,有自己的本地数据,与其他协程共享全局数据及资源池。

协程需要操作员单独写调度逻辑,对CPU来说,协程也就是单线程,因此CPU 不需要考虑怎么调度、切换上下文,省去了CPU开销,所以,协程又在一定程度上好于多线程。

参考文献 

https://www.cnblogs.com/whatisfantasy/p/6440585.html

https://www.cnblogs.com/liangmingshen/p/9706181.html

https://www.cnblogs.com/beiluowuzheng/p/9064152.html

python 高级教程(第三版)

发布了43 篇原创文章 · 获赞 28 · 访问量 4万+

猜你喜欢

转载自blog.csdn.net/u013380694/article/details/90051730