Python开发——15.协程与I/O模型

一、协程(Coroutine)

1.知识背景

协程又称微线程,是一种用户态的轻量级线程。子程序,或者称为函数,在所有语言中都是层级调用,比如A调用B,B在执行过程中又调用了C,C执行完毕返回,B执行完毕返回,最后是A执行完毕。所以子程序调用是通过栈实现的,一个线程就是执行一个子程序。子程序调用总是一个入口,一次返回,调用顺序是明确的。而协程的调用和子程序不同。协程看上去也是子程序,但执行过程中,在子程序内部可中断,然后转而执行别的子程序,在适当的时候再返回来接着执行。因为协程拥有自己的寄存器上下文和栈,协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈。因此,协程能进入上一次离开时所处逻辑流的位置。

2.优缺点

优点

(1)最大的优势就是协程极高的执行效率。因为子程序切换不是线程切换,而是由程序自身控制,因此,没有线程切换的开销,和多线程比,线程数量越多,协程的性能优势就越明显。

(2)不需要多线程的锁机制,因为只有一个线程,也不存在同时写变量冲突,在协程中控制共享资源不加锁,只需要判断状态就好了,所以执行效率比多线程高很多。

(3)无需线程上下文切换的开销,无需操作锁定及同步的开销,方便切换控制流,简化编程模型,高并发+高扩展性+低成本,一个CPU支持上万的协程都不是问题,所以很适合用于高并发处理。

基于此,利用多核CPU的最简单的方法就是多进程+协程。既能充分利用多核,又能获得极高的性能

缺点:(1)协程的本质是个单线程,它不能同时将 单个CPU 的多个核用上,协程需要和进程配合才能运行在多CPU上,进行阻塞(Blocking)操作(如IO时)会阻塞掉整个程序

3.yield与协程

协程的关键在于什么时候切换

import time,queue
def consumer(name):
    r = ""
    print("%s ready to eat baozi"%name)
    while True:
        new_baozi = yield
        print("%s is eaing baozi %s"%(name,new_baozi))
        time.sleep(1)
def producer():
    r = con.__next__()
    r = con2.__next__()

    n = 0
    while True:
        time.sleep(1)
        print("producer is making baozi %s and %s"%(n,n+1))
        con.send(n)
        con2.send(n+1)
        n += 2

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

4.greenlet

greenlet是一个用C实现的协程模块,相比于Python自带的yield,它可以在任意函数之间随意切换

from greenlet import greenlet
def func1():
    print(12)
    gr2.switch()
    print(34)
def func2():
    print(56)
    gr1.switch()
    print(78)
    gr1.switch()
if __name__ == '__main__':

    gr1 = greenlet(func1)
    gr2 = greenlet(func2)

    gr2.switch()

5.gevent

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

gevent执行到IO操作时,会自动切换

import gevent
import requests,time
start = time.time()
def func(url):
    print("GET:%s"%url)
    resp = requests.get(url)
    data = resp.text
    print("%s bytes received from %s"%(len(data),url))

# gevent.joinall([
#     gevent.spawn(func,"https://nba.hupu.com/"),
#     gevent.spawn(func, "http://tj.58.com/"),
#     gevent.spawn(func, "https://www.baidu.com/"),
#     gevent.spawn(func, "http://sports.qq.com/nba/")
# ])
func("https://nba.hupu.com/")
func("http://tj.58.com/")
func("https://www.baidu.com/")
func("http://sports.qq.com/nba/")
print("costtime",time.time()-start)

爬网页

import gevent
import requests,time
start = time.time()
def func(url):
    print("GET:%s"%url)
    resp = requests.get(url)
    data = resp.text

    f = open("new","w",encoding="utf-8")
    f.write(data)
func("https://nba.hupu.com/")

 二、IO模型

1.事件驱动模型

(1)定义

事件驱动模型是一种编程范式,这个程序的执行流由外部事件来决定,特点是包含一个事件循环,当外部事件发生时使用回调机制来触发相应的处理

(2)区别

传统的编程模式

  开始--->代码块A--->代码块B--->代码块C--->代码块D--->......--->结束

  它的控制流程是由输入数据和编写的程序决定的

事件驱动模型

  开始--->初始化--->等待

  事件驱动程序的等待则完全不知道,也不强制用户输入或者干什么。只要某一事件发生,那程序就会做出相应的“反应”。这些事件包括:输入信息、鼠标、敲击键盘上某个键还有系统内部定时器触发。

(3)实例

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>

<p onclick="fun()">点击这里</p>

<script type="text/javascript">
    function fun() {alert("大嘴!")

    }
</script>
</body>
</html>

(4)如何获得鼠标点击?

a.创建线程循环检测是否有鼠标点击

缺点:

  • 扫描线程会一直循环检测,造成很多的CPU资源浪费
  • 当既要扫描鼠标点击,还要扫描键盘是否按下时,如果扫描鼠标时阻塞了,那么永远不会去扫描键盘
  • 如果一个循环需要扫描的设备非常多,又会引来响应时间的问题

b.事件驱动模型

  • 有一个事件(消息)队列
  • 鼠标按下时,往这个队列中增加一个点击事件(消息)
  • 有个循环,不断从队列取出事件,根据不同的事件,调用不同的函数,如onClick()、onKeyDown()等
  • 事件(消息)一般都各自保存各自的处理函数指针,这样,每个消息都有独立的处理函数

2.背景知识

(1)用户空间和内核空间

为了保证内核的安全,操作系统将虚拟空间划分为两部分:一部分为内核空间,另一部分为用户空间。CPU的指令集,通过0和1 决定是用户态,还是内核态,0代表内核态(1g),1代表用户态(3g)

内核态:操作系统内核只能运作于cpu的内核态,这种状态意味着可以执行cpu所有的指令,对计算机硬件资源有着完全的控制权限,并且可以控制cpu工作状态由内核态转成用户态。

用户态:应用程序只能运作于cpu的用户态,这种状态意味着只能执行cpu所有的指令的一小部分(或者称为所有指令的一个子集),这一小部分指令对计算机的硬件资源没有访问权限(比如I/O),并且不能控制由用户态转成内核态。

(2)进程切换

为了控制进程的执行,内核必须有能力挂起正在CPU上执行的进程,并恢复以前挂起的某个进程的执行,这种行为就被称为进程切换,进程切换很消耗资源。

(3)进程的阻塞

正在执行的进程,由于期待的某些事件未发生(如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等),则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。

进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得CPU),才可能将其转为阻塞状态,当进程进入阻塞状态,是不占用CPU资源的。

(4)文件描述符

文件描述符(File descriptor)是一个用于表述指向文件引用的抽象化概念。在形式上是一个非负整数。它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核会向进程返回一个文件描述符。文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。

(5)缓存I/O

缓存 I/O 又被称作标准 I/O,大多数文件系统的默认 I/O 操作都是缓存 I/O。在 Linux 的缓存 I/O 机制中,数据会先被拷贝到操作系统内核的缓冲区中,然后从操作系统内核的缓冲区拷贝到应用程序的地址空间。

缺点:数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。

3.network IO

常用的有五种:阻塞(blocking)IO、非阻塞(non-blocking)IO、同步(synchronous)IO、异步(asynchronous)IO和信号驱动(Signal-driven)IO,其中信号驱动IO实际用的不多

对于一个network IO,它会涉及到两个系统对象,一个是调用这个IO的process (or thread),另一个就是系统内核(kernel)。

比如当一个read操作发生时,会经历两个阶段:a.等待数据准备 (Waiting for the data to be ready);b.将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)

(1)blocking IO(阻塞IO模型)

当用户进程调用了recvfrom这个系统调用,kernel就开始了IO的第一个阶段:准备数据。对于network IO来说,很多时候数据在一开始还没有到达(比如,还没有收到一个完整的UDP包),这个时候kernel就要等待足够的数据到来。而在用户进程这边,整个进程会被阻塞。当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。

所以,blocking IO的特点就是在IO执行的两个阶段都被block了。

实例:

server端

import socket

sk = socket.socket()
sk.bind(("10.10.27.37",8080))
sk.listen(5)

while True:
    conn,addr = sk.accept()
    while True:
        conn.send("hello,client".encode("utf-8"))
        data = conn.recv(1024)
        print(data.decode("utf-8"))

client端

import socket
sk = socket.socket()
sk.connect(("10.10.27.37",8080))
while True:
    data = sk.recv(1024)
    print(data.decode("utf-8"))
    sk.send("hello server".encode("utf-8"))

(2)non-blocking IO(非阻塞IO模型)

当用户进程发出read操作时,如果kernel中的数据还没有准备好,并不会block用户进程,而是立刻返回一个error。从用户进程角度讲,它发起一个read操作后,会马上就得到了一个结果。用户进程判断结果是一个error时,会再次发送read操作。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call,就将数据拷贝到了用户内存,然后返回。所以,用户进程其实是需要不断的主动询问kernel数据好了没有。

进程在返回之后,可以干点别的事情,然后再发起recvform系统调用。重复的过程,通常被称之为轮询。轮询检查内核数据,直到数据准备好,再拷贝数据到进程,进行数据处理。但拷贝数据整个过程,进程仍然是属于阻塞的状态。

优点:“后台” 可以有多个任务在同时执行。

缺点:任务完成的响应延迟增大了,因为每过一段时间才去轮询一次read操作,而任务可能在两次轮询之间的任意时间完成。这会导致整体数据吞吐量的降低。

实例

server端

import time
import socket

sk = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
sk.bind(("10.10.27.37",8080))
sk.listen(5)
sk.setblocking(False)
while True:
    try:
        print("waiting client connection....")
        conn,addr = sk.accept()
        print("address",addr)
        client_message = conn.recv(1024)
        print(client_message.decode("utf-8"))
        conn.close()
    except Exception as e:
        print(e)
        time.sleep(4)

client端

import socket,time
sk = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
while True:
    sk.connect(("10.10.27.37", 8080))
    print("已连接")
    sk.sendall("hello server".encode("utf-8"))
    time.sleep(2)
    break

(3)IO multiplexing(IO多路复用)

IO多路复用的基本原理就是select/epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。

工作流程:当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。同时可以监听多个连接,用的是单线程,利用空闲时间实现并发。

用select的优势在于它可以同时处理多个connection。如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好。

IO多路复用的三种方式:

  • select--->效率最低,有最大描述符限制,在linux为1024。(Windows、Mac、Linux)

  • poll---->和select一样,没有最大描述符限制。(Mac,Linux)

  • epoll--->效率最高,没有最大描述符限制,支持水平触发与边缘触发。(Linux)

IO多路复用的两种触发方式:水平触发和边缘触发

水平触发:只有高电平或低电平的时候触发

边缘触发:只在电平变化的时候触发

实例

server端

import socket
import select

sk = socket.socket()
sk.bind(("10.10.27.37",8080))
sk.listen(5)
sk.setblocking(False)
inputs = [sk,]

while True:
    r,w,e = select.select(inputs,[],[],5)
    for obj in r:
        if obj == sk:
            conn,addr = obj.accept()
            print("conn",conn)
            inputs.append(conn)
        else:
            data_byte = obj.recv(1024)
            print(data_byte.decode("utf-8"))
            inp = input("回答%s客户>>>"%inputs.index(obj))
            obj.sendall(inp.encode("utf-8"))
    print(">>>",r)

client端

import socket
sk = socket.socket()
sk.connect(("10.10.27.37",8080))

while True:
    inp = input(">>>").strip()
    sk.send(inp.encode("utf-8"))
    data = sk.recv(1024)
    print(data.decode("utf-8"))

(4)Asynchronous I/O(异步IO)

用户进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。

(5)IO模型比较分析

猜你喜欢

转载自www.cnblogs.com/hechengwei/p/9167657.html