前言

    在之前学习python的多线程和多进程的时候,要想实现并发,需要开启多个线程或者多个进程,但是我们也知道开启进程的开销比较大,而多线程只是利用了CPU上下文切换的原理来实现并发,本质上也不是真正意义上的并发.那我们在只有一个线程的情况下如何实现并发呢?这里就用到了python中的协程--------此"协程"非彼"携程",这里的"协程"是用来实现程序的并发的.

1.协程简介

    协程,又称微线程.用一句话来说明协程:协程是一种用户态的轻量级线程.

当然了,这句话我也不太懂什么意思,既然大神们都这么说,我也就这么写了,这样也显得我的博客比较高大上一点…

    协程拥有自己的寄存器上下文和栈.协程调度切换时,将寄存器上下文保存到其他地方.在切回来的时候,恢复先前保存的寄存器上下文和栈,因此,协程能够保留上一次调用时的状态(即所有的局部状态的一个特定组合)…

说了这么多,我发现我自己都不懂上边说的是什么意思,不过没关系,继续往下看!

    协程的本质就是在单线程下,由用户自己控制一个任务遇到I/O阻塞了就切换到另一个任务去执行,以此来提升效率.
    说白了,就是用户自己控制任务如何切换以及何时切换,多进程在切换时,需要涉及到python的GIL锁,既然这样,那我们就在一个线程下切换任务,让你python的Cpython解释器省去切换线程的时间,我自己想什么时候切换就什么时候切换,不用你解释器来干预(当然了,协程切换的原则是遇到I/O操作就切换).
    这里需要再次强调两点:

  • python的线程属于内核级别的,即由操作系统来统一调度,属于原生线程;
  • 单线程内开启协程,一旦遇到I/O操作,就会从应用程序级别(而非操作系统级别)来控制切换,说白了,这个切换是由用户程序来实现的,而不是由操作系统实现的.

    说了这么多,想必大家和我一样,对协程也有了一个直观的认识了.

这些都是我查一些资料,包括自己的理解,要是有不对的地方,欢迎大家在评论区指出,感激不尽!

    下面我们来看一下协程的优点和缺点:

协程的优点:
  • 相对于线程和进程,协程的切换的开销更小,协程的切换是属于程序级别的切换,而操作系统并不知道,所以更加轻量级;
  • 单线程内就可以实现并发的效果,最大限度的利用了CPU.
协程的缺点:
  • 协程的本质是单线程,所以无法利用多核.要是想利用多核,我们可以这样做:开启多个进程,每个进程内最少包含一个线程,在每一个线程里再开启进程,这样就能充分利用多核了;
  • 协程是在单线程内运行的,因此一旦协程出现了阻塞,将会阻塞整个线程.

2.yield实现协程效果

import time


def consumer(name):
    print("--->starting eating baozi...")
    while True:
        new_baozi = yield
        print("[%s] is eating baozi %s" % (name, new_baozi))
        # time.sleep(1)


def producer():
    r = con.__next__()
    r = con2.__next__()
    n = 0
    while n < 5:
        n += 1
        con.send(n)
        con2.send(n)
        time.sleep(1)
        print("\033[32;1m[producer]\033[0m is making baozi %s" % n)


if __name__ == '__main__':
    con = consumer("c1")
    con2 = consumer("c2")
    p = producer()

执行结果:

--->starting eating baozi...
--->starting eating baozi...
[c1] is eating baozi 1
[c2] is eating baozi 1
[producer] is making baozi 1
[c1] is eating baozi 2
[c2] is eating baozi 2
[producer] is making baozi 2
[c1] is eating baozi 3
[c2] is eating baozi 3
[producer] is making baozi 3
[c1] is eating baozi 4
[c2] is eating baozi 4
[producer] is making baozi 4
[c1] is eating baozi 5
[c2] is eating baozi 5
[producer] is making baozi 5

在这里,我主要想说的是我对yield的理解.上面这个例子是我们熟悉的Alex大神写的.

接下来说一下我对yield的理解

    可能很多人会和我一样,在程序中经常会碰见yield,但是不知道它是什么意思,也不知道它具体干了什么(当然了,大神除外),我也是查了很多资料,才对yield有了一个简单的理解,这里要感谢大神的博客:python中yield的用法详解——最简单,最清晰的解释
    先来看下面这一段代码:

def foo():
    n = 0
    print("starting...")
    while True:
        res = yield n
        n += 1
        print('res = ',res)


foo()

print('执行foo:')

print('foo()执行完成')
print('foo() 是:',foo())
g = foo()
print('g 是:',g)
print(next(g))
print(next(g))
print(next(g))

执行结果:

执行foo:
foo()执行完成
foo() 是: <generator object foo at 0x7fc998d59b48>
g 是: <generator object foo at 0x7fc998d59b48>
starting...
0
res =  None
1
res =  None
2

    我们来分析一下代码.
    通过观察,我们会发现执行foo()的时候,程序并没有任何的输出,我们可以得出以下结论,foo()并不是一个函数.这就打破我们传统的认知了,def定义不是函数,它还能是啥??
    继续看代码的执行结果,发现foo()是一个generator,也就是一个生成器.这就有意思了,def定义的竟然成了一个生成器,这是为什么呢?
    再仔细观察发现,foo()中有一个关键字yield,这是干嘛的呢?原来啊,带yield关键字的函数就是一个生成器(虽然我也不懂什么意思),接下来,我们就来看一下函数的执行顺序:

  • 程序开始执行后,因为foo函数中有关键字yield,所以foo函数并不会真的执行,而是先得到一个生成器g;
  • 生成器我们比较了解,调用next方法时,生成器才会执行,这里调用next(g)后,foo函数正式开始执行,先执行n=0和print(“starting…”),然后进入while循环;
  • 程序遇到yield关键字,先把yield看成return,return一个n之后(第一步n等于0),程序停止(因为return的作用就是让函数停止,并返回一个值);
  • 继续执行next(g),和上面的步骤一样,这样就会得到了一个生成器.

注:return只是单纯的让函数停止并返回一个值,而带yield的函数就变成了一个生成器,而不是一个函数了,调用next方法时,函数才会执行.

3.greenlet

    我们用greenlet模块来实现简单的协程:

from greenlet import greenlet


def test1():
    print(12)
    gr2.switch()                # 切换到第二个协程
    print(34)                   # 遇到I/O操作就切换
    gr2.switch()                # 切换到第二个协程


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

if __name__ == '__main__':
    gr1 = greenlet(test1)           # 开启一个协程
    gr2 = greenlet(test2)           # 开启一个协程
    gr1.switch()                    # 先切换到第一个协程,执行第一个协程

执行结果:

12
56
34
78

    对代码进行解释:

gr1 = greenlet(test1)           # 开启一个协程
gr2 = greenlet(test2)           # 开启一个协程

    这两句代码的意思是开启一个协程(这两个协程是在同一个线程中开启的).

def test1():
    print(12)
    gr2.switch()                # 切换到第二个协程
    print(34)                   # 遇到I/O操作就切换
    gr2.switch()                # 切换到第二个协程

    gr2.switch()的意思是切换到第二个线程.
    greenlet模块实际上是手动来进行切换的(上面我们也提到了协程就是用户来定义程序切换的规则以及切换的顺序).
    程序执行顺序:

  • 首先开启两个协程,然后将程序切换到第一个协程,也就是执行test1()函数;
  • 执行test1()中的print(12),输出12,然后切换到第二个协程,也就是执行test2()函数;
  • 执行test2函数中的print(56),输出56,然后切换到第一个协程中,也就是再次执行test1()函数,输出34;
  • 函数再次切换到第二个协程,执行test2()函数中的print(78),输出78;
  • 程序结束.
        这里我们可以看到协程的切换规则,就是遇到I/O操作就切换,但是greenlet模块只能实现手动切换,这样就太麻烦了,接下来我们介绍一个自动切换的python模块----Gevent.

4.Gevent

    Gevent是一个第三方库,是对greenlet的再次封装,可以轻松实现并发同步或异步编程.

pip install gevent

用法:

import gevent
import time

def foo():
    print('Running in foo')
    gevent.sleep(2)             # gevent.sleep(2)就是为了模仿I/O操作
    print('Explicit context switch to foo again')
def bar():
    print('Explicit精确的 context内容 to bar')
    gevent.sleep(1)             # gevent.sleep(2)就是为了模仿I/O操作
    print('Implicit context switch back to bar')
def func3():
    print("running func3 ")
    gevent.sleep(3)             # gevent.sleep(2)就是为了模仿I/O操作
    print("running func3  again ")

start_time = time.time()
gevent.joinall([            
    gevent.spawn(foo),      
    gevent.spawn(bar),
    gevent.spawn(func3),
])

end_time = time.time()
print('cost time {}s'.format(end_time-start_time))

运行结果:

Running in foo
Explicit精确的 context内容 to bar
running func3 
Implicit context switch back to bar
Explicit context switch to foo again
running func3  again 
cost time 3.001248836517334s

程序解释:
(1)创建协程对象

gevent.spawn(foo),      
    gevent.spawn(bar),
    gevent.spawn(func3),

gevent.spawn()中第一个参数是函数名,后面可以有多个参数.
(2)执行三个协程:

gevent.joinall([            # 启动两个协程
    gevent.spawn(foo),      #生成,
    gevent.spawn(bar),
    gevent.spawn(func3),
])

对于gevent.joinall()我们可以将它理解成进程池,在创建进程池的时候,我们会指定进程池的大小,也就是进程池内最多装几个进程,然后这个进程池启动的时候,里面的进程也启动,gevent.joinall()和进程池的效果类似,也是将多个协程放在一起执行,gevent.joinall()等价于下面的代码:

g1 = gevent.spawn(foo)
g2 = gevent.spawn(bar)
g3 = gevent.spawn(func3)
g1.join()
g1.join()
g1.join()

(3)打印时间.
程序执行顺序如下:
(1)先执行第一个协程,也就是执行foo()函数,执行print(‘Running in foo’),然后遇到gevent.sleep(2),这里使用gevent.sleep(2)而不是time.sleep(2)的目的是为了模拟I/O操作,而time.sleep(2)只是睡眠两秒,并没有涉及到I/O操作;
(2)遇到I/O操作(也就是gevent.sleep(2))后,切换到下一个协程.下一个协程是bar;然后执行bar()函数;
(3)执行print(‘Explicit精确的 context内容 to bar’)后又遇到了I/O操作,继续切换协程,切换到第三个协程,也就是执行func3函数,执行print("running func3 ")
(4)同样,又遇到了I/O操作,再次切换,切换到第一个协程,发现I/O操作还没有完成,再次切换…直到I/O操作完成.
(5)我们可以看到bar()函数中的I/O操作用的时间最短,是1秒,所以在执行完print("running func3 again ")后就会去执行print(‘Implicit context switch back to bar’),然后执行print(‘Explicit context switch to foo again’),最后执行print("running func3 again ").
(6)所以最后的执行顺序就是:

Running in foo
Explicit精确的 context内容 to bar
running func3 
Implicit context switch back to bar
Explicit context switch to foo again
running func3  again

这也就是协程切换的本质:遇到I/O操作就切换.

4.协程之爬虫

    接下来,我们用协程来做一个简单的爬虫测试(主要是为了测试协程的性能).

from urllib import request
import gevent
import time
from gevent import monkey

monkey.patch_all()      # 把这句话写在程序的最前面,是要把所有的I/O操作都坐上标记,使得gevent能够识别出来是I/O操作


def f(url):
    print('GET: %s' % url)
    resp = request.urlopen(url)
    data = resp.read()
    print('%d bytes received from %s.' % (len(data), url))

urls = ['https://www.python.org/',
        'https://www.yahoo.com/',
        'https://github.com/' ]
time_start = time.time()
for url in urls:
    f(url)
print("同步cost",time.time() - time_start)
async_time_start = time.time()
gevent.joinall([
    gevent.spawn(f, 'https://www.python.org/'),
    gevent.spawn(f, 'https://www.yahoo.com/'),
    gevent.spawn(f, 'https://github.com/'),
])
print("异步cost",time.time() - async_time_start)

执行结果:

GET: https://www.python.org/
49055 bytes received from https://www.python.org/.
GET: https://www.yahoo.com/
523164 bytes received from https://www.yahoo.com/.
GET: https://github.com/
134217 bytes received from https://github.com/.
同步cost 6.522381782531738
GET: https://www.python.org/
GET: https://www.yahoo.com/
GET: https://github.com/
48914 bytes received from https://www.python.org/.
525818 bytes received from https://www.yahoo.com/.
134217 bytes received from https://github.com/.
异步cost 3.5718212127685547

    可以发现,协程还是省了不少时间.
    这里主要介绍一个monkey.patch_all().
    我们上面在模拟I/O操作的时候,是用的gevent.sleep(),而如果用time.sleep()或者其他的阻塞操作的话,Gevent是识别不出来的,把monkey.patch_all()加在程序的最前面是为了识别出程序中所有的I/O操作.这句代码一定要放在阻塞包括导入阻塞包之前,否则gevent无法识别阻塞.例如:

from gevent import monkey
monkey.patch_all()
import socket

monkey.patch_all()一定要放在import socket之前,否则gevent无法识别socket阻塞.

5.协程之socket

服务端:

import sys
import gevent
from gevent import monkey
import time
monkey.patch_all()
import socket

def server(port):
    s = socket.socket()

    s.bind(('0.0.0.0',port))

    s.listen(500)

    while True:
        cli,addr = s.accept()
        # 进来一个请求就创建一个新的协程
        gevent.spawn(handle_request,cli)


def handle_request(conn):
    '''
    处理客户端的请求,直接把客户端发过来的数据原文发回去
    :param conn:
    :return:
    '''
    try:
        while True:
            data = conn.recv(1024)
            print('recv:',data)
            conn.send(data)
            if not data:
                conn.shutdown(socket.SHUT_WR)   # 断开

    except Exception as e:
        print('error',e)

    finally:
        conn.close()

if __name__ == '__main__':
    server(8001)

客户端:

import socket

HOST = 'localhost'
POST = 8001

s = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
s.connect((HOST,POST))

while True:
    msg = bytes(input(">>:"),encoding="utf-8")
    s.sendall(msg)
    data = s.recv(1024)
    print('Recvied:',repr(data))    # repr:格式化输出


s.close()

运行结果:
服务端:

recv: b'1'
recv: b'2'
recv: b'3'
recv: b'4'

客户端1:

>>:1
Recvied: b'1'
>>:3
Recvied: b'3'
>>:

客户端2:

>>:2
Recvied: b'2'
>>:4    
Recvied: b'4'
>>

注:这就实现了一个简单的socketserver.

写在最后

    本文是个人的一些学习笔记,如有侵权,请及时联系我进行删除,谢谢大家.