python —— gevent详解 协程进程线程

Posted 胖虎是只mao

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了python —— gevent详解 协程进程线程相关的知识,希望对你有一定的参考价值。

我们通常所说的协程Coroutine其实是corporate routine的缩写,直接翻译为协同的例程,一般我们都简称为协程。

在linux系统中,线程就是轻量级的进程,而我们通常也把协程称为轻量级的线程即微线程

进程和协程
下面对比一下进程和协程的相同点和不同点:

相同点:
我们都可以把他们看做是一种执行流,执行流可以挂起,并且后面可以在你挂起的地方恢复执行,这实际上都可以看做是continuation,关于这个我们可以通过在linux上运行一个hello程序来理解:
在这里插入图片描述
shell进程和hello进程:

  1. 开始,shell进程在运行,等待命令行的输入
  2. 执行hello程序,shell通过系统调用来执行我们的请求,这个时候系统调用会讲控制权传递给操作系统。操作系统保存shell进程的上下文,创建一个hello进程以及其上下文并将控制权给新的hello进程。
  3. hello进程终止后,操作系统恢复shell进程的上下文,并将控制权传回给shell进程
  4. shell进程继续等待下个命令的输入

当我们挂起一个执行流(协程)的时,我们要保存的东西:

  1. 栈, 其实在你切换前,你的局部变量,以及要函数的调用都需要保存,否则都无法恢复
  2. 寄存器状态,这个其实用于当你的执行流恢复后要做什么

寄存器和栈的结合就可以理解为上下文,上下文切换的理解
CPU看上去像是在并发的执行多个进程,这是通过处理器在进程之间切换来实现的,操作系统实现这种交错执行的机制称为上下文切换

操作系统保持跟踪进程运行所需的所有状态信息。这种状态,就是上下文
在任何一个时刻,操作系统都只能执行一个进程代码,当操作系统决定把控制权从当前进程转移到某个新进程时,就会进行上下文切换,即保存当前进程的上下文,恢复新进程的上下文,然后将控制权传递到新进程,新进程就会从它上次停止的地方开始。

不同点:

  1. 执行流的调度者不同,进程是内核调度,而协程是在用户态调度,也就是说进程的上下文是在内核态保存恢复的,而协程是在用户态保存恢复的,很显然用户态的代价更低
  2. 进程会被强占,而协程不会,也就是说协程如果不主动让出CPU,那么其他的协程,就没有执行的机会。
  3. 对内存的占用不同,实际上协程可以只需要4K的栈就足够了,而进程占用的内存要大的多
  4. 从操作系统的角度讲,多协程的程序是单进程,单协程

线程和协程

既然我们上面也说了,协程也被称为微线程,下面对比一下协程和线程:

线程之间需要上下文切换成本相对协程来说是比较高的,尤其在开启线程较多时,但协程的切换成本非常低。
同样的线程的切换更多的是靠操作系统来控制,而协程的执行由我们自己控制
我们通过下面的图更容易理解:

在这里插入图片描述

在这里插入图片描述
从上图可以看出,**协程只是在单一的线程里不同的协程之间切换,其实和线程很像,线程是在一个进程下,不同的线程之间做切换,**这也可能是协程称为微线程的原因吧

继续分析协程:
在这里插入图片描述

Gevent

Gevent是一种基于协程的Python网络库,它用到Greenlet提供的,封装了libevent事件循环的高层同步API。它让开发者在不改变编程习惯的同时,用同步的方式写异步I/O的代码。

在 Python 里,按照官方解释 greenlet 是轻量级的并行编程,gevent 就是利用 greenlet 实现的基于协程(coroutine)的 python 的网络 library,通过使用greenlet提供了一个在libev事件循环顶部的高级别并发API。即 gevent 是对 greenlet 的高级封装。

主要特性有以下几点:

  1. 基于 libev 的快速事件循环,Linux上面的是 epoll 机制
  2. 基于 greenlet 的 轻量级执行单元
  3. API 复用了 Python 标准库里的内容。API 的概念和 Python 标准库一致(如事件,队列)。
  4. TCP/UDP/HTTP 服务器
  5. 支持 SSL 的协作式 sockets
  6. 子进程支持(通过gevent.subprocess)
  7. 线程池
  8. greenlets是确定性的。给定相同的绿色配置和相同的输入集,它们总是产生相同的输出
  9. gevent每次遇到io操作,需要耗时等待时,会自动跳到下一个协程继续执行。
  10. gevent的代码风格和线程非常相似,运行出来后的效果也非常相似。
  11. 通过monkey patching功能来使得第三方模块变成协作式

libevent 是一个事件分发引擎greenlet 提供了轻量级线程的支持gevent 就是基于这两个的一个专门处理网络逻辑的并行库

  1. gevent.spawn 启动的所有协程,都是运行在同一个线程之中,所以协程不能跨线程同步数据
  2. gevent.queue.Queue 是协程安全的。
  3. gevent 启动的并发协程,具体到 task function,不能有长时间阻塞的IO操作。因为gevent的协程的特点是,当前协程阻塞了才会切换到别的协程。如果当前协程长时间阻塞,则不能显示(gevent.sleep(0),或隐式,由gevent来做)切换到别的协程。导致程序出问题。
  4. 如果有长时间阻塞的 IO 操作,还是用传统的线程模型比较好。
  5. 因为 gevent 的特点总结是:事件驱动 + 协程 + 非阻塞IO,事件驱动指的是 libvent 对 epool 的封装,是基于事件的方式处理 IO。协程指的是 greenlet,非阻塞 IO 指的是 gevent 已经 patch 过的各种库,例如 socket 和 select 等等。
  6. 使用 gevent 的协程,最好要用 gevent 自身的非阻塞的库。如 httplib, socket, select 等等。
  7. gevent 适合处理大量无阻塞的任务,如果有实在不能把阻塞的部分变为非阻塞再交给 gevent 处理,就把阻塞的部分改为异步吧。

原理:

程序的重要部分是将任务函数封装到 gevent.spawn初始化的 greenlet 列表存放在数组 threads 中,此数组被传给 gevent.joinall 函数,gevent.joinall 会阻塞当前流程,并执行所有给定的 greenlet,执行流程只会在所有greenlet执行完后才会继续向下走。 gevent 实现了python 标准库里面大部分的阻塞式系统调用,包括 socket、ssl、threading 和 select 等模块,而将这些阻塞式调用变为协作式运行(参见猴子补丁部分)。

猴子补丁 Monkey Patch:

  • (1)猴子补丁的由来 。猴子补丁的这个叫法起源于 Zope 框架,大家在修正 Zope 的 Bug 的时候经常在程序后面追加更新部分,这些被称作是 “杂牌军补丁(guerillapatch)”,后来 guerilla 就渐渐的写成了 gorllia(猩猩),再后来就写了 monkey(猴子),所以猴子补丁的叫法是这么莫名其妙的得来的。 后来在动态语言中,不改变源代码而对功能进行追加和变更,统称为“猴子补丁”。所以猴子补丁并不是 Python 中专有的。猴子补丁这种东西充分利用了动态语言的灵活性,可以对现有的语言Api 进行追加,替换,修改 Bug,甚至性能优化等等。 使用猴子补丁的方式,gevent 能够修改标准库里面大部分的阻塞式系统调用,包括 socket、ssl、threading 和 select 等模块,而变为协作式运行。也就是通过猴子补丁的 monkey.patch_xxx() 来将 python 标准库中 模块 或 函数 改成 gevent 中的响应的具有协程的协作式对象。这样在不改变原有代码的情况下,将应用的阻塞式方法,变成协程式的。
  • (2)猴子补丁使用时的注意事项 。猴子补丁的功能很强大,但是也带来了很多的风险,尤其是像 gevent 这种直接进行 API替换的补丁,整个 Python 进程所使用的模块都会被替换,可能自己的代码能 hold 住,但是其它第三方库,有时候问题并不好排查,即使排查出来也是很棘手,所以,就像松本建议的那样,如果要使用猴子补丁,那么只是做功能追加,尽量避免大规模的 API 覆盖。 虽然猴子补丁仍然是邪恶的(evil),但在这种情况下它是 “有用的邪恶(useful evil)”。

1、关于Linux的 epoll 机制:

epoll是Linux内核为处理大批量文件描述符而作了改进的poll,是Linux下多路复用IO接口select/poll的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率。epoll的优点:

支持一个进程打开大数目的socket描述符。select的一个进程所打开的FD由FD_SETSIZE的设置来限定,而epoll没有这个限制,它所支持的FD上限是最大可打开文件的数目,远大于2048。

IO效率不随FD数目增加而线性下降:由于epoll只会对“活跃”的socket进行操作,于是,只有”活跃”的socket才会主动去调用 callback函数,其他idle状态的socket则不会。

使用mmap加速内核与用户空间的消息传递。epoll是通过内核于用户空间mmap同一块内存实现的。

内核微调。

2、libev机制

提供了指定文件描述符事件发生时调用回调函数的机制。libev是一个事件循环器:向libev注册感兴趣的事件,比如socket可读事件,libev会对所注册的事件的源进行管理,并在事件发生时触发相应的程序。

示例:

import gevent
from gevent import socket
 
urls = ['www.baidu.com', 'www.example.com', 'www.python.org']
 
jobs = [gevent.spawn(socket.gethostbyname, url) for url in urls]
 
gevent.joinall(jobs, timeout=2)
 
result = [job.value for job in jobs]
print(result)

结果:[‘61.135.169.125’, ‘93.184.216.34’, ‘151.101.228.223’]

注解:gevent.spawn() 方法 spawn 一些 jobs,然后通过 gevent.joinall 将 jobs 加入到 微线程 执行队列中等待其完成,设置超时为 2 秒。执行后的结果通过检查 gevent.Greenlet.value 值来收集。gevent.socket.gethostbyname() 函数与标准的socket.gethotbyname() 有相同的接口,但它不会阻塞整个解释器,因此会使得其他的 greenlets 跟随着无阻的请求而执行。

协程,gevent,greenlet,eventlet 不了解的可以上网查找资料了解下。至于 协程,进程 和 线程 大家平时了解的都比较多,而协程算是一种轻量级进程,但又不能叫进程,因为操作系统并不知道它的存在。什么意思呢,就是说,协程像是一种在程序级别来模拟系统级别的进程由于是单进程,并且少了上下文切换,于是相对来说系统消耗很少,而且网上的各种测试也表明,协程确实拥有惊人的速度。并且在实现过程中,协程可以用以前同步思路的写法,而运行起来确是异步的,也确实很有意思。话说有一种说法就是说进化历程是:多进程->多线程->异步->协程,暂且不论说的对不对,单从诸多赞誉来看,协程还是有必要理解一下的。

比较惭愧,greenlet 没怎么看就直接看 gevent,官方文档还是可以看看的,尤其是源码里的 examples 都相当不错,有助于理解gevent 的使用。

gevent 封装了很多很方便的接口,其中一个就是 monkey:

from gevent import monkey
monkey.patch_all()

这样两行,就可以使用 python 以前的 socket 之类的,因为 gevent 已经给你自动转化了,真是超级方便阿。
而且安装 gevent 也是很方便,首先安装依赖 libevent 和 greenlet,再利用 pypi 安装即可

           安装 libevent:sudo apt-get install libevent-dev
            安装 python-dev:sudo apt-get install python-dev
            安装 gevent:sudo pip install gevent
            安装 greenlet:sudo pip install greenlet

示例代码:

import gevent
from gevent import monkey
 
 
# 切换是在 IO 操作时自动完成,所以gevent需要修改Python自带的一些标准库
# 这一过程在启动时通过monkey patch完成
monkey.patch_all()
 
 
def func_a():
    while 1:
        print('-------A-------')
        # 用来模拟一个耗时操作,注意不是time模块中的sleep
        # 每当碰到耗时操作,会自动跳转至其他协程
        gevent.sleep(1)
 
 
def func_b():
    while 1:
        print('-------B-------')
        gevent.sleep(0.5)
 
 
# gevent.joinall([gevent.spawn(fn)
g1 = gevent.spawn(func_a)  # 创建一个协程
g2 = gevent.spawn(func_b)
g1.join()  # 等待协程执行结束
g2.join()

select() 函数通常是对各种文件描述符进行轮询的阻塞调用。

from gevent import select
...
select.select([], [], [], 2)

gevent 池

示例代码,测试 gevent 的 任务池

from gevent import pool
 
g_pool = pool.Pool()
 
 
def a():
    for i in range(100):
        g_pool.spawn(b)
 
 
def b():
    print('b')
 
 
g_pool.spawn(a)
g_pool.join()

示例代码。程序及注释如下:

# -*- coding: utf-8 -*-
# @Author  : 
# @File    : test_gevent.py
# @Software: PyCharm
# @description : XXX
  
import gevent
import time
from gevent import event  # 调用 gevent 的 event 子模块
 
 
# 三个进程需要定义三个事件 event1,event2,event3,来进行12,23,31循环机制,即进程一,进程二,进程三顺序执行
 
def fun1(num, event1, event2):  # 固定格式
    i = 0
    while i < 10:  # 设置循环10次
        i += 1
        time.sleep(1)  # 睡眠1秒
        print('进程一:111111111')
        event2.set()    # 将event2值设为True
        event1.clear()  # 将event1值设为False
        event1.wait()   # event1等待,其值为True时才执行
 
 
def fun2(num, event2, event3):
    i = 0
    while i < 10:
        i += 1
        time.sleep(1)
        print('进程二:222222222')
        event3.set()  # 将event3值设为True
        event2.clear()  # 将event2值设为False
        event2.wait()  # event2等待,其值为True时才执行
 
 
def fun3(num, event3, event1):
    i = 0
    while i < 10:
        i += 1
        time.sleep(1)
        print('进程三:333333333')
        event1.set()
        event3.clear()
        event3.wait()
 
 
if __name__ == "__main__":  # 执行调用格式
    act1 = gevent.event.Event()  # 调用event中的Event类,用act1表示
    act2 = gevent.event.Event()
    act3 = gevent.event.Event()
 
    # 三个进程,act1,act2,act3
    gevent_list = []  # 建立一个数列,用来存和管理进程
 
    # 调用gevent中的Greenlet子模块,用Greenlet创建进程一
    g = gevent.Greenlet(fun1, 1, act1, act2)
    g.start()
    gevent_list.append(g)  # 将进程一加入到Gevents数列
    print('进程一启动:')
 
    g = gevent.Greenlet(fun2, 2, act2, act3)
    g.start()
    gevent_list.append(g)
    print('进程二启动:')
 
    g = gevent.Greenlet(fun3, 3, act3, act1)
    g.start()
    gevent_list.append(g)
    print('进程三启动:')
    print('所有进程都已启动!')
 
    # 调用Greenlet中的joinall函数,将Gevents的进程收集排列
    gevent.joinall(gevent_list)
    

##################################
看看 Gevent :http://www.gevent.org/
您可以创建几个 Greenlet 对象为几个任务。
每个 greenlet 是 绿色的线程 :https://en.wikipedia.org/wiki/Green_threads

import gevent
from gevent import monkey
from gevent import Greenlet
 
 
monkey.patch_all()
 
 
class Task(Greenlet):
    def __init__(self, name):
        Greenlet.__init__(self)
        self.name = name
 
    def _run(self):
        print("Task %s: some task..." % self.name)
 
 
t1 = Task("task1")
t2 = Task("task2")
t1.start()
t2.start()
# here we are waiting all tasks
gevent.joinall([t1, t2])

示例代码:

from gevent import monkey; monkey.patch_all()
import gevent
import requests
 
 
def get_url(url):
    res = requests.get(url)
    print(url, res.status_code, len(res.text))
 
 
url_l = [
    'http://www.baidu.com',
    'http://www.python.org',
    'http://www.cnblogs.com'
]
g_l = []
for i in url_l:
    g_l.append(gevent.spawn(get_url, i))
gevent.joinall(g_l)

示例代码( 利用 gevent 并发抓取 ):

from gevent import monkey
 
monkey.patch_all()
 
import requests
import gevent
import io
import sys
 
# 解决console显示乱码的编码问题
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
 
 
class Douban(object):
    """A class containing interface test method of Douban object"""
 
    def __init__(self):
        self.host = 'movie.douban.com'
        self.headers = {
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:61.0) Gecko/20100101 Firefox/61.0',
            'Referer': 'https://movie.douban.com/',
        }
 
    def get_response(self, url, data):
        resp = requests.post(url=url, data=data, headers=self.headers).content.decode('utf-8')
        return resp
 
    def test_search_tags_movie(self):
        method = 'search_tags'
        url = 'https://%s/j/%s' % (self.host, method)
        post_data = {
            'type': 'movie',
            'source': 'index'
        }
        resp = self.get_response(url=url, data=post_data)
        print(resp)
        return resp
 
 
if __name__ == '__main__':
    douban = Douban()
    threads = []
    for i in range(6):
        thread = gevent.spawn(douban.test_search_tags_movie)
        threads.append(thread)
 
    gevent.joinall(threads)

并发爬图片:

from gevent import monkey
monkey.patch_all()
import requests
import gevent
from lxml import etree
 
 
def downloader(img_name, img_url):
    req = requests.get(img_url)
    img_content = req.content
    with open(img_name, "wb") as f:
        f.write(img_content)
 
 
def main():
    r = requests.get('http://www.nsgirl.com/portal.php')
    if r.status_code == 200:
        img_src_xpath = '//div[@id="frameXWswSe"]//div[@class="portal_block_summary"]//li//img/@src'
        s_html = etree.HTML(text=r.text)
        all_img_src = s_html.xpath(img_src_xpath)
 
        count = 0
        for img_src in all_img_src:
            count += 1
            # print(img_src)
            # http://www.nsgirl.com/forum.php?mod=image&aid=342&size=218x285&key=cd6828baf05c305c
            url = 'http://www.nsgirl.com/' + img_src
            gevent.joinall(
                [gevent.spawn(downloader, f"{count}.jpg", url), ]
            )
 
 
if __name__ == '__main__':
    main()

使用Gevent的性能确实要比用传统的线程高,甚至高很多。但这里不得不说它的一个坑:

  1. Monkey-patching,我们都叫猴子补丁,因为如果使用了这个补丁,Gevent直接修改标准库里面大部分的阻塞式系统调用,包括socket、ssl、threading和 select等模块,而变为协作式运行。但是我们无法保证你在复杂的生产环境中有哪些地方使用这些标准库会由于打了补丁而出现奇怪的问题
  2. 第三方库支持。得确保项目中用到其他用到的网络库也必须使用纯Python或者明确说明支持Gevent
    既然Gevent用的是Greenlet,我们通过下图来理解greenlet:
    在这里插入图片描述
    每个协程都有一个parent,最顶层的协程就是man thread或者是当前的线程,每个协程遇到IO的时候就把控制权交给最顶层的协程(主线程),它会看那个协程的IO event已经完成,就将控制权给它。
    下面是greenlet一个例子:
from greenlet import greenlet

def test1(x,y):
    z = gr2.switch(x+y)
    print(z)


def test2(u):
    print(u)
    gr1.switch(42)


gr1 = greenlet(test1)
gr2 = greenlet(test2)


gr1.switch("hello",'world')

greenlet(run=None, parent=None): 创建一个greenlet实例.
gr.parent:每一个协程都有一个父协程,当前协程结束后会回到父协程中执行,该 属性默认是创建该协程的协程.
gr.run: 该属性是协程实际运行的代码. run方法结束了,那么该协程也就结束了.
gr.switch(*args, **kwargs): 切换到gr协程.
gr.throw(): 切换到gr协程,接着抛出一个异常.

下面是gevent的一个例子:

import gevent

def func1():
    print("start func1")
    gevent.sleep(1)
    print("end func1")


def func2():
    print("start func2")
    gevent.sleep(1)
    print("end func2")

gevent.joinall(
    [
        gevent.spawn(func1),
        gevent.spawn(func2)
    ]
)

关于gevent中队列的使用
gevent中也有自己的队列,但是有一个场景我用的过程中发现一个问题,就是如果我在协程中通过这个q来传递数据,如果对了是空的时候,从队列获取数据的那个协程就会被切换到另外一个协程中,这个协程用于往队列里put放入数据,问题就出在,gevent不认为这个放入数据为IO操作,并不会切换到上一个协程中,会把这个协程的任务完成后在切换到另外一个协程。我原本想要实现的效果是往对了放入数据后就会切换到get的那个协程。(或许我这里理解有问题)下面是测试代码:

import gevent
from gevent.queue import Queue


def func():
    for i in range(10):

        print("int the func")
        q.put("test")

def func2():
    for i in range(10):
        print("int the func2")
        res = q.get()
        print("--->",res)

q = Queue()
gevent.joinall(
    [
        gevent.spawn(func2),
        gevent.spawn(func),
    ]
)

这段代码的运行效果为:
在这里插入图片描述
如果我在fun函数的q.put(“test”)后面添加gevent.sleep(0),就会是如下效果:
在这里插入图片描述

原本我预测的在不修改代码的情况下就应该是第二个图的结果,但是实际却是第一个图的结果(这个问题可能是我自己没研究明白,后面继续研究)

关于Gevent的问题
就像我上面说的gevent和第三方库配合使用会有一些问题,可以总结为:
python协程的库可以直接monkey path
C写成的库可以采用豆瓣开源的greenify来打patch(这个功能自己准备后面做测试)

不过总的来说gevent目前为止还是有很多缺陷,并且不是官网标准库,而在python3中有一个官网正在做并且在3.6中已经稳定的库asyncio,这也是一个非常具有野心的库,非常建议学习,我也准备后面深入了解

使用gevent,可以获得极高的并发性能,但gevent只能在Unix/Linux下运行,在Windows下不保证正常安装和运行。

由于gevent是基于IO切换的协程,所以最神奇的是,我们编写的Web App代码,不需要引入gevent的包,也不需要改任何代码,仅仅在部署的时候,用一个支持gevent的WSGI服务器,立刻就获得了数倍的性能提升

以上是关于python —— gevent详解 协程进程线程的主要内容,如果未能解决你的问题,请参考以下文章

python全栈脱产第37天------进程池与线程池协程gevent模块单线程下实现并发的套接字通信

python 线程(队列,线程池),协程(理论greenlet,gevent模块,)

Python并发编程(线程队列,协程,Greenlet,Gevent)

进程和线程协程的区别

进程线程和协程的区别(转)

Gevent模块,协程应用