python-网络编程-05-协程

学习博文-python并发编程之协程, 学习博文-Python 40 协程, 学习博文-协程与异步IO, Python协程

协程

        协程,又称微线程,纤程,英文名Coroutine。协程的作用,是在执行函数A时,可以随时中断,去执行函数B,然后中断继续执行函数A(可以自由切换)。但这一过程并不是函数调用(没有调用语句),这一整个过程看似像多线程,然而协程只有一个线程执行。

​ 通常在Python中我们进行并发编程一般都是使用多线程或者多进程来实现的,对于CPU计算密集型任务由于GIL的存在通常使用多进程来实现,而对于IO密集型任务可以通过线程调度来让线程在执行IO任务时让出GIL,从而实现表面上的并发。

​ 其实对于IO密集型任务我们还有一种选择就是协程。协程,又称微线程,英文名Coroutine,是运行在单线程中的“并发”,协程相比多线程的一大优势就是省去了多线程之间的切换开销,获得了更高的运行效率。Python中的异步IO模块asyncio就是基本的协程模块。

​ Python中的协程经历了很长的一段发展历程。最初的生成器yieldsend()语法,然后在Python3.4中加入了asyncio模块,引入@asyncio.coroutine装饰器和yield from语法,在Python3.5上又提供了async/await语法,目前正式发布的Python3.6中asynico也由临时版改为了稳定版。

# 并发概念
	协程,多线程,多进程,都是用来实现并发的.
	由于现代操作系统的时间片原理,可以将这个概念抽象来看,只要能够满足保存上下文,在中断的地方来回执行的程序,都可以用来实现并发.所以并发的本质就是切换+保存状态,只要能够做到这两点,都可以实现并发.

# 优势
    执行效率极高,因为子程序切换(函数)不是线程切换,由程序自身控制,没有切换线程的开销。所以与多线程相比,线程的数量越多,协程性能的优势越明显。
    不需要多线程的锁机制,因为只有一个线程,也不存在同时写变量冲突,在控制共享资源时也不需要加锁,因此执行效率高很多。
  说明:协程可以处理IO密集型程序的效率问题,但是处理CPU密集型不是它的长处,如要充分发挥CPU利用率可以结合多进程+协程。
    
# 单线程实现并发:
	在应用程序中控制多个任务+保存状态
    优点:应用程序级别速度要远远高于操作系统的切换
    缺点:多个任务一旦有一个阻塞没有切,整个线程都会被阻塞在原地,该线程内的其它任务就都不能执行了
      一旦引用协程,就需要检测单线程下的所有IO行为,实现遇到IO就切换

# 协程的目的?
   想在单线程实现并发 (并发只是多个任务看起来像是同时进行)
     并发=切换+保存状态

Gevent-py2

# gevent是第三方库,通过greenlet实现协程,其基本思想:
	当一个greenlet遇到IO操作时,比如访问网络,就自动切换到其他的greenlet,等到IO操作完成,再在适当的时候切换回来继续执行。由于IO操作非常耗时,经常使程序处于等待状态,有了gevent为我们自动切换协程,就保证总有greenlet在运行,而不是等待IO。

# 安装:  pip install gevent

# 参数说明
monkey: 使一些阻塞的模块变得不阻塞,机制:遇到IO操作则自动切换,手动切换可以用gevent.sleep(0)(将爬虫代码换成这个,效果一样可以达到切换上下文)
gevent.spawn: 	启动协程,参数为函数名称,参数名称
gevent.joinall: 停止协程

demo-1

import time
import gevent
from threading import current_thread

def task1():
    print("task1")
    gevent.sleep(2)
    print(current_thread().name)
    return "hello world task1"

def task2():
    print("task2")
    gevent.sleep(3)
    return "hello world task2"

start = time.time()
g1 = gevent.spawn(task1)
g2 = gevent.spawn(task2)

g1.join()		# 等待子线程完成,在继续进行主线程
g2.join()
stop = time.time()

print("master {}".format(stop - start))
print(g1.value)
print(g2.value)

"""
task1
task2
MainThread
master 3.0362324714660645
hello world task1
hello world task2
"""

# 如果将 gevent 改为 time 模块, 那么同步的效果就会变为异步, 串行执行

# 假设想将全局的IO切换或者时间睡眠改为 gevent, 遇到IO就同步切换到其它任务就需要添加一个补丁

demo-2-monkey

import time
from threading import current_thread
from gevent import monkey, spawn; monkey.patch_all()


def task1():
    print("task1")
    time.sleep(2)
    print(current_thread().name)
    return "hello world task1"

def task2():
    print("task2")
    time.sleep(3)
    return "hello world task2"

start = time.time()
g1 = spawn(task1)
g2 = spawn(task2)

g1.join()
g2.join()
stop = time.time()

print("master {}".format(stop - start))
print(g1.value)
print(g2.value)

"""
task1
task2
Dummy-1
master 3.030284881591797
hello world task1
hello world task2
"""

协程模块遇到能够识别的IO操作的时候,才会进行任务切换,实现并发的效果.

线程是CPU调度的最小单位,所以协程实际上操作系统是不管的,是由用户自行调度的.协程本质上是在一个线程内部来回切换.有效利用协程可以提高单线程的效率.

高IO切换模拟

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#

import time
from gevent import monkey, spawn, joinall

monkey.patch_all()


def task(*args):
    time.sleep(0.4)
    print(args)


def sync():
    for i in range(100):
        task(i)


def asy():
    g_list = [spawn(task, i) for i in range(100)]
    joinall(g_list)

start = time.time()
sync()                  # 执行时长: 40.13500428199768
# asy()                 # 执行时长  0.43876171112060547
stop = time.time()
print("执行时长: {}".format(stop-start))

# 从测试上看,协程适合单线程内IO操作频繁的时侯,所以协程很适合用在网络IO处理上

模拟socker的多线程连接

# server端
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
import socket
from gevent import spawn
from threading import Thread


def talk(connect):
    print(connect)
    while True:
        try:
            data = connect.recv(1024)
            # print(data)
            if len(data) == 0: break
            connect.send(data.upper())
        except ConnectionResetError:
            break
    conn.close()


def server(ip, port):
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server.bind((ip, port))
    server.listen(10)

    print("start...")
    while True:
        conn, addr = server.accept()
        x = spawn(talk, conn)	# 遇到IO时进行切换
        x.join()			# 需要加上 join


if __name__ == '__main__':
    # t = Thread(target=server, args=("127.0.0.1", 2299))
    # t.start()
    g = spawn(server, "127.0.0.1", 8080)
    g.join()

# client端
import socket
from threading import Thread, current_thread

client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect(("127.0.0.1", 8080))

def talk():
    print("start")
    while True:
        msg = "{} hello world".format(current_thread().name)
        client.send(msg.encode("utf-8"))
        data = client.recv(1024).decode("utf-8")
        print(data)

if __name__ == '__main__':
    for i in range(100):
        t = Thread(target=talk)		# 模拟100个线程连接
        t.start()

IO模型

https://www.cnblogs.com/linhaifeng/articles/7454717.html

	IO发生时涉及的对象和步骤。对于一个network IO (这里我们以read举例),它会涉及到两个系统对象,一个是调用这个IO的process (or thread),另一个就是系统内核(kernel)。当一个read操作发生时,该操作会经历两个阶段:

接收:
	wait data: 等待客户端产生数据 --> 客户端OS --> 网络 ——-> 服务端OS缓存中 --> 应用程序
    copy data: 由本地操作系统缓存中的数据拷贝到应用程序的内存中
发布:
	copy data: 由本地操作系统缓存中的数据拷贝到应用程序的内存中
  1. 输入操作:read、readv、recv、recvfrom、recvmsg共5个函数,如果是阻塞状态,则会经过 wait data和copy data两个阶段,如果设置为非阻塞则在wait 不到data时抛出异常
  2. **输出操作:**write、writev、send、sendto、sendmsg共5个函数,在发送缓冲区满了会阻塞在原地,如果设置为非阻塞,则会抛出异常
  3. **接收外来链接:**accept,与输入操作类似
  4. **发起外出链接:**connect,与输出操作类似

阻塞IO(blocking IO)

在linux中,默认情况下所有的socket都是blocking,一个典型的读操作流程大概是这样
python-网络编程-05-协程_第1张图片

  • 当用户进程调用了recvfrom这个系统调用接口,内核就开始了IO第一个阶段:数据等待,而如果客户端并没有发送网络IO过来,这时内核就得等待数据的到来;
  • 而在用户进程这边,整个进程都会被阻塞,只有当内核一直等待数据准备好了,它就会将数据从内核中拷贝到用户内存,然后内核返回结果,用户进程才解除block状态,重新运行起来;
  • blocking IO的特点就是在IO执行的两个阶段(等待数据和拷贝数据两个阶段)都被block了。
  • 实际上,除非特别指定,几乎所有的IO接口 ( 包括socket接口 ) 都是阻塞型的。这给网络编程带来了一个很大的问题,如在调用recv(1024)的同时,线程将被阻塞,在此期间,线程将无法执行任何运算或响应任何的网络请求。

阻塞IO解决方案

一个简单的解决方案:

# 在服务器端使用多线程(或多进程)。多线程(或多进程)的目的是让每个连接都拥有独立的线程(或进程),这样任何一个连接的阻塞都不会影响其他的连接。

该方案的问题是:

# 开启多进程或都线程的方式,在遇到要同时响应成百上千路的连接请求,则无论多线程还是多进程都会严重占据系统资源,降低系统对外界响应效率,而且线程与进程本身也更容易进入假死状态。

改进方案:

# 很多程序员可能会考虑使用“线程池”或“连接池”。“线程池”旨在减少创建和销毁线程的频率,其维持一定合理数量的线程,并让空闲的线程重新承担新的执行任务。“连接池”维持连接的缓存池,尽量重用已有的连接、减少创建和关闭连接的频率。这两种技术都可以很好的降低系统开销,都被广泛应用很多大型系统,如 websphere、tomcat 和各种数据库等。

改进后方案其实也存在着问题:

# “线程池”和“连接池”技术也只是在一定程度上缓解了频繁调用IO接口带来的资源占用。而且,所谓“池”始终有其上限,当请求大大超过上限时,“池”构成的系统对外界的响应并不比没有池的时候效果好多少。所以使用“池”必须考虑其面临的响应规模,并根据响应规模调整“池”的大小。

非阻塞IO

python-网络编程-05-协程_第2张图片

  • 当用户进程发起read操作, 如果内核的数据没有准备好,它不会block而是会立即返回error
  • 而从用户进程上看, 当它发起一个read操作时并不需要等待而是立即会返回一个结果,用户进程判断结果是一个error时,它就知道数据还没有准备好,于是用户就可以在本次到下次再发起read询问的时间间隔内做其他事情,或者直接再次发送read操作;
  • 当内核中数据准备好时,它会再次收到用户进程的system call,系统进程就会立即将数据拷贝到用户内存(这一阶段仍然是阻塞状态, copy data)
  • 也就是说当非阻塞的recvfrom系统调用之后,进程并不会被阻塞,内核会直接返回error给用户进程,循环往复一直不停的进行系统调用,这一过程通常被称为轮询,轮询会检查内核数据,直接数据准备好再拷贝到进程,进行数据处理,需要注意,拷贝数据整个过程,进程仍然是属于阻塞的状态;
  • 所以,在非阻塞式IO中,用户进程其实是需要不断的主动询问kernel数据准备好了没有

缺点

# 1、对cpu无效占用率过高
# 2、不能即时反馈客户端信息
# 3、循环调用recv()将大幅度推高CPU占用率;这也是我们在代码中留一句time.sleep(2)的原因,否则在低配主机下极容易出现卡机情况
# 4、任务完成的响应延迟增大了,因为每过一段时间才去轮询一次read操作,而任务可能在两次轮询之间的任意时间完成。这会导致整体数据吞吐量的降低。

demo

# 服务端

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
from socket import *

server = socket(AF_INET, SOCK_STREAM)
server.bind(("127.0.0.1", 19992))
server.listen(10)
server.setblocking(False)

conn_count = []  # 1、客户端每次建立的连接在重新循环之后都会被重新建立
while True:
    try:
        conn, addr = server.accept()
        conn_count.append(conn)
        print("当前共有{}个连接".format(len(conn_count)))
    except BlockingIOError:
        del_conn = []
        # 2、服务端非阻塞里会时刻盯着系统接收数据
        try:
            # 3、循环连接,当有数据时将它发送
            for conn in conn_count:
                try:
                    conn.send(conn.recv(1024))
                # 4、当客户端断开连接时,处理,不让服务端强迫关闭
                except ConnectionResetError:
                    del_conn.append(conn)
        # 2、每次try都会有BlockingIOError
        except BlockingIOError:
            pass

        for conn in del_conn:
            conn_count.remove(conn)


# 客户端
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
from socket import *
from threading import Thread

client = socket(AF_INET, SOCK_STREAM)
client.connect(("127.0.0.1", 19992))

while True:
    msg = input(">>>: ").encode("utf-8")
    if len(msg) == 0: continue
    # msg = "hello world".encode("utf-8")
    client.send(msg)
    data = client.recv(1024).decode("utf-8")
    print(data)

IO多路复用

select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。它的基本原理就是select/epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。它的流程如图:

python-网络编程-05-协程_第3张图片

  • 当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。
  • 这个图和blocking IO的图其实并没有太大的不同,事实上还更差一些。因为这里需要使用两个系统调用(select和recvfrom),而blocking IO只调用了一个系统调用(recvfrom)。但是,用select的优势在于它可以同时处理多个connection

强调

  1. 如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延迟还更大。select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接;
  2. 在多路复用模型中,对于每一个socket,一般都设置成为non-blocking,但是,如上图所示,整个用户的process其实是一直被block的。只不过process是被select这个函数block,而不是被socket IO给block;
# select的优势在于可以处理多个连接,不适用于单个连接

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
import socketserver
from socket import *
import select

server = socket(AF_INET, SOCK_STREAM)
server.bind(("127.0.0.1", 28312))
server.listen(10)

read_list = [server]
write_list = []
x_list = []
data_dic = {}

while True:
    # select给操作系统发送一个请求, 操作系统去遍历所有连接
    # 如果有select就会拿到, 然后运行下一行代码, 如果没有就会原地阻塞
    rl, wl, xl = select.select(read_list, write_list, x_list)
    print("wl: {}, write_list: {}".format(wl, write_list))
    del_conn = []
    for sock in rl:
        if sock == server:
            conn, addr = sock.accept()
            read_list.append(conn)
        else:
            try:
                data = sock.recv(1024)      # 有消息的列表,读消息
                write_list.append(sock)     # 1、收到数据后 把消息放到要读的空列表里面
                # 因为如果文件过大就会让用户感觉到明显的等待,因为内存空间只有那么大,超过内存空间的大小就会一点点的读
                data_dic[sock] = data       # 2、用k:vlua的形式把消息存起来
            except ConnectionResetError:
                del_conn.append(sock)

    for sk in wl:      				 # 2、将收到的数据循环出来
        sk.send("hello world".encode("utf-8"))  # 循环消息列表,通过k拿到消息返回给客户端
        data_dic.pop(sk)              # 删除字典里的k
        write_list.remove(sk)         # 删除列表里的消息.因为这个消息已经读过了

    for conn_del in del_conn:
        read_list.remove(conn_del)
    print("read_list: {}".format(read_list))

select监听fd变化的过程分析

# 用户进程创建socket对象,拷贝监听的fd到内核空间,每一个fd会对应一张系统文件表,内核空间的fd响应到数据后,就会发送信号给用户进程数据已到;

# 用户进程再发送系统调用,比如(accept)将内核空间的数据copy到用户空间,同时作为接受数据端内核空间的数据清除,这样重新监听时fd再有新的数据又可以响应到了(发送端因为基于TCP协议所以需要收到应答后才会清除)。

该模型的优点

        相比其他模型,使用select() 的事件驱动模型只用单线程(进程)执行,占用资源少,不消耗太多 CPU,同时能够为多客户端提供服务。如果试图建立一个简单的事件驱动的服务器程序,这个模型有一定的参考价值。

该模型的缺点:

  • select()接口并不是实现“事件驱动”的最好选择。因为当需要探测的句柄值较大时,select()接口本身需要消耗大量时间去轮询各个句柄;
  • 所以很多操作系统提供了更为高效的接口,如linux提供了epoll,BSD提供了kqueue,Solaris提供了/dev/poll;
  • 如果需要实现更高效的服务器程序,类似epoll这样的接口更被推荐。遗憾的是不同的操作系统特供的epoll接口有很大差异,所以使用类似于epoll的接口实现具有较好跨平台能力的服务器会比较困难。
  • 最后,该模型将事件探测和事件响应夹杂在一起,一旦事件响应的执行体庞大,则对整个模型是灾难性的。

  相比其他模型,使用select() 的事件驱动模型只用单线程(进程)执行,占用资源少,不消耗太多 CPU,同时能够为多客户端提供服务。如果试图建立一个简单的事件驱动的服务器程序,这个模型有一定的参考价值。

该模型的缺点:

  • select()接口并不是实现“事件驱动”的最好选择。因为当需要探测的句柄值较大时,select()接口本身需要消耗大量时间去轮询各个句柄;
  • 所以很多操作系统提供了更为高效的接口,如linux提供了epoll,BSD提供了kqueue,Solaris提供了/dev/poll;
  • 如果需要实现更高效的服务器程序,类似epoll这样的接口更被推荐。遗憾的是不同的操作系统特供的epoll接口有很大差异,所以使用类似于epoll的接口实现具有较好跨平台能力的服务器会比较困难。
  • 最后,该模型将事件探测和事件响应夹杂在一起,一旦事件响应的执行体庞大,则对整个模型是灾难性的。

你可能感兴趣的:(python)