Python 基础之 I/O 模型

一、事件驱动模型介绍

1、传统的编程模式

例如:线程模式大致流程

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

每一个代码里是完成各种各样事情的代码,但编程者知道代码A,B,C,D...的执行顺序,唯一能够改变这个流程的是数据。输入不同的数据,

根据条件语句判断,流程或许就改为A--->B--->D...--->结束。

每一次程序运行顺序或许都不同,但它的控制流程是由输入数据和你编写的程序决定的。如果你知道这个程序当前的运行状态(包括输入

数据和程序本身),那你就知道接下来甚至一直到结束它的运行流程。

例如:事件驱动程序模型大致流程

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

与上面传统编程模式不同,事件驱动程序在驱动之后,就在那等待,等待什么呢?等待被事件触发。传统编程下也有“等待”,比如在

扫描二维码关注公众号,回复: 7178011 查看本文章

编代码D中,你定以了一个input(),你作为程序编写者是知道或者强制用户输入东西的,或许是数字,或许是文件名称,如果用户输入

错误,你还需要提醒他,并请他重新输入,事件驱动程序的等待则是完全不知道,也不强制用户输入或者干什么,只要某一事件

发生,那程序就会做出相应的“反应”。这些事件包括:输入信息、鼠标、敲击键盘上某个键还有系统内部定时器触发。

2、事件驱动模型

通常,我们写服务器处理模型的程序时,有一下几种模型:

(1)每收到一个请求;创建一个新的进程,来处理该请求;

(2)每收到一个请求;创建一个新的进程,来处理该请求;

(3)每收到一个请求;放入一个事件,让主进程通过非阻塞I/O方式来处理请求

3、第三种就是协程、事件驱动的方式,一般普通认为第(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>

 执行结果:

在UI编程中,常常要对鼠标点击进行相应,首先如何获取鼠标点击呢?

两种方式:

1、创建一个线程循环检测是否鼠标点击那么这个方式有以下几个缺点:

  1、CPU资源浪费,可能鼠标点击的频率非常小,但是扫描线程还是会一直循环检测,这会造成很多CPU资源浪费;如果扫描鼠标点击的接口是阻塞的呢?

  2、如果是阻塞的,又会出现这样的问题,如果我们不但要扫描鼠标点击,还要扫描键盘是否按下,由于扫描鼠标被阻塞了,那么可能永远不会去扫描键盘;

  3、如果一个循环需要扫描的设备非常多,这又会引来响应的问题;所以,该方式是非常不好的。

2、事件驱动模型

目前大部分的UI编程都是事件驱动模型,如很多UI平台都会提供onClick()事件,这个事件就代表鼠标按下事件。事件驱动模型大体思路如下:

  1、有一个事件(消息)队列

  2、鼠标按下时,往这个队列中增加一个点击事件(消息);

  3、有个循环,不断从队列取出事件,根据不同的时间,调用不同的函数,如onClick()、onKeyDown()等;

·  4、事件(消息)一般都各自保存各自的处理函数指针、这样,每个消息都有独立的处理函数;

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

另外两种常见的编程范式是(单线程)同步以及多线程编程。

需知:每个CPU都有其一套可执行的专门指令集,如SPARC和Pentium,其实每个硬件之上都要有一个控制程序,CPU的指令集就是CPU的控制程序。

二、IO模型准备

在进程解释之前,首先要说明几个概念:

  1、用户空间和内核空间

  2、进程切换

  3、进程的阻塞

  4、文件描述符

  5、缓存 I/O

1、用户空间和内核空间

例如:采用虚拟存储器,对于32Bit操作系统,它的寻址空间(虚拟存储控制为4G,即2的32次方)。

操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也可以访问底层硬件的所有权限。

  为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操作系统将虚拟空间划分为两部分:一部分为内核空间,另一部分为用户空间。

那么操作系统是如何分配空间的?这里就会涉及到内核态和用户态的两种工作状态。

1G: 0---> 内核态

3G: 1 ---> 用户态

CPU的指令集,是通过0和1决定你是用户态,还是内核态

计算机的两种工作状态:内核态和用户态

CPU的两种工作状态:

  现在的操作系统都是分时操作系统,分时的根源,来自于硬件层面操作系统内核占用的内存与应用程序占用的内存彼此之间隔离。CPU通过psw(程序状态寄存器)中的一个2进制位来控制CPU本身的工作状态,即内核态与用户态。

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

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

2、进程切换

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

总结:进程切换是很耗资源的。

3、进程的阻塞

  正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据向为到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变成阻塞状态。可见,进程的阻塞是进程自身的一种自动行为,也因此只有处于运行状态的进程(获得CPU),才能将其转为阻塞状态。当进程进入阻塞状态,是不占用CPU资源的。

4、文件描述符fd

  文件描述符(File descripor)是计算机科学中的一个术语,是一个用于表达指向文件的引用的抽象化概念。

文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表,当程序打开一个现有文件或者创建一个新的文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNLX,Linux这样的操作系统。

5、缓存 I/O

  缓存 I/O 又被称作标准 I/O,大多数文件系统的默认 I/O 操作都是缓存 I/O。在 Linux 的缓存 I/O 机制中,操作系统会将 I/O 的数据缓存在文件系统的页缓存(page cache)中,也就是说,数据会先拷贝到操作系统内核的缓存区中,然后才会从操作系统内核的缓存区拷贝到应用程序的地址空间。用户空间没法直接访问内核空间的,内核态到用户态的数据拷贝。

缓存 I/O 的缺点:

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

本文讨论的背景是Linux环境下的network IO。

IO发生时涉及的对象和步骤:

  对于一个network IO(这里我们以read举例),它会涉及到两个系统对象,

  1、一个是调用这个IO的process(or thread)

  2、另一个就是系统内核(kernel)

  当一个read操作发生时,它会经历两个阶段:

  1、等待数据准备(Waiting for the data to be ready)

  2、将数据从内核拷贝到进程中(Copying the data from the kernel to the process)

  记住这两点很重要,因为这些IO Model的区别就是在两个阶段上各有不同的情况。

常见的几种 IO  模型:

  blocking IO (阻塞IO)

  nonblocking IO (非阻塞IO)

  IO multiplexing (IO多路复用)

  signal driven IO (信号驱动式IO)

  asynchronous IO (异步)

一、不常见的IO模型

  1、信号驱动IO模型(Signal-driven IO)

  使用信号,让内核在描述符就绪时发送SICIO信号通知应用程序,称这种模型为信号驱动式I/O(signal-driven I/O)。

  原理图:

  首先开启套接字的信号驱动式I/O功能,并通过sigaction系统调用安装一个信号处理函数。该系统调用将立即返回,我们的进程继续工作,也就是说进程没有被阻塞,当数据报准备好读取时,内核就为该进程产生一个SIGIO信号。随后就可以在信号处理函数中调用recvform读取数据报,并通知主循环已经准备好待处理,也可以立即通知主循环,让它读取数据报。

  无论如何处理SIGIO信号,这种模型的优势在于等待数据报到达期间进程不被阻塞。主循环可以继续执行,只要等到来自信号处理函数的通知:即可以是数据已经准备好被处理,也可以是数据报已经准备好被读取。

二、常用的四种IO模型:

1、blocking IO (阻塞IO模型)

原理图:

示例:一收一发程序会进入死循环

server.py

import socket

sk = socket.socket(socket.AF_INET,socket.SOCK_STREAM)

sk.bind(("127.0.0.1",8080))
sk.listen(5)

while True:
    conn,addr = sk.accept()

    while True:
        conn.send("sb".encode("utf-8"))
        data = conn.recv(1024)
        print(data.decode("utf-8"))

 client.py

import socket

sk = socket.socket()
sk.connect(("127.0.0.1",8080))

while True:
    inp = input(">>>:")
    sk.send(inp.encode("utf-8")) # 发送信息
    data = sk.recv(1024) # 接受信息
    print(data.decode("utf-8")) # 打印出来

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

2、non-blocking IO (非阻塞IO)

原理图:

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

注意:

  在网络IO时候,非阻塞IO也会进行recvform系统调用,检查数据是否准备好,也阻塞IO不一样,“非阻塞将大的整片时间的阻塞分成N多的小的阻塞,所以进程不断地有机会“被”“CPU光顾”。即每次recvform系统调用之间,CPU的权限还在进程手中,这段时间是可以做其它事情的,也就是说非阻塞的recvform系统调用之后,进程并没有被阻塞,内核马上返回给进程,如果数据还没准备好,此时会返回一个error。进程在返回之后,可以干点别的事情,然后再发起recvform系统调用,重复上面的过程,循环往复的进行recvform系统调用。这个过程通常被称为轮询。轮询检查内核数据,直到数据准备好了,再拷贝到进程,进行数据处理,需要注意,拷贝数据的整个过程,进程仍然是属于阻塞的状态。

示例:

服务端:

import time
import socket
sk = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
sk.bind(('127.0.0.1',8080))
sk.listen(5)
sk.setblocking(False)  #设置成非阻塞状态
while True:
    try:
        print ('waiting client connection .......')
        conn,addr = sk.accept()   # 进程主动轮询
        print("+++",addr)
        client_messge = conn.recv(1024)
        print(str(client_messge,'utf8'))
        connection.close()
    except Exception as e:  #捕捉错误
        print (e)
        time.sleep(4)  #每4秒打印一个捕捉到的错误

 客户端:

import socket

sk = socket.socket()
sk.connect(("127.0.0.1",8080))

while True:
    inp = input(">>>:")
    sk.send(inp.encode("utf-8")) # 发送信息
    data = sk.recv(1024) # 接受信息
    print(data.decode("utf-8")) # 打印出来

 缺点:

1、发送了太多系统调用数据

2、数据处理不及时

3、IO multiplexing (IO多路复用)

  IO multiplexing这个词可能有点陌生,但是我说select,epoll,大概就都能明白了。有些地方也称这种IO方式为event driven IO。我们都知道,select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO,它的基本原理就是select/epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。

IO多路复用的三种方式:

1、select --->效率最低,但有最大描述符限制,在Linux为1024.

2、poll---> 和select一样,但没有最大描述符限制。

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

IO多路复用的优势:同时可以监听多个连接,用的是单线程,利用空闲时间实现并发。

注意:

Linux系统:select、poll、epoll

Windows系统:select

Mac系统:select、poll

原理图:

  当用户进程调用select,那么整个进程会被block,而同时,kernel会“监视”所有负责的socket,当任何一个socket中的数据准备好了,select就会返回,这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。

  这个图和blocking IO的图其实并没有太大的不同,事实上,还更差一些。因为这里需要用两个system call(select 和 recvform),而blocking IO 只用了一个system call(recvform)。但是,用select的优势在于它可以同时处理多个connevtion。(多说一句。所以,如果处理的链接数不是很高的话,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延迟还更大。select/epoll的优势并不是对于单个链接能处理得更快,而是在于处理更多的链接。)

  在IO multiplexing Model中,实际中,对于每一个socket,一般都设置non-blocking但是,如上图所示,整个用户的process其实是一直被block的。只不过process是被select这个函数block,而不是被socket IO 给block。

  注意1:select函数返回结果中如果有文件可读了,那么进程就可以通过调用accept()或recv()来让kernel将位于内核中准备到的数据copy到用户区。

  注意2:select的优势在于可以处理多个连接,不适用于单个链接。

示例:

server.py

import socket
import select

sk = socekt.socekt()
sk.bind(("127.0.0.1",8080))
sk.listen(5)

while True:
    #sk.accept() 文件描述符
    r,w,e = select.select([sk,],[],[],5) # 输入列表,输出列表,错误列表,5:是监听5秒
    for i in r: # [sk,]
        conn,addr = i.accept()
        print(conn)
        print("hello")
    print("------")

 clent.py

import socket

sk = socket.socket()
sk.connect(("127.0.0.1",8080))

while True:
    inp = input(">>>:")
    sk.send(inp.encode("utf-8")) # 发送信息
    data = sk.recv(1024) # 接受信息
    print(data.decode("utf-8")) # 打印出来

IO多路复用中的两种触发方式:

  水平触发:如果文件描述符已经就绪可以非阻塞的执行IO操作了,此时会触发通知,允许在任意时刻重复检测IO的状态,没有必要每次描述符就绪后尽可能多个执行IO.select,poll就属于水平触发。

  边缘触发:如果文件描述符自带上次状态改变后有新的IO活动到来,此时会触发通知,在收到一个IO事件通知后要尽可能多的执行IO操作,因为如果在一次通知中没有执行完IO那么就需要等到下一次新的IO活动到来才能获取到就绪的描述符信号驱动式IO就属于边缘触发。

  epoll:即可以采用水平触发,也可以采用边缘触发

1、水平触发

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

  1---高电平---触发

  0---低电平---不触发

示例:

server.py

# 水平触发
import socket
import select

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

while True:
    r,w,e = select.select([sk,],[],[],5) # input输入列表,output输出列表,erron错误列表,5:是监听5秒
    for i in r:
        print("hello")

    print("------")

 client.py

import socket

sk = socket.socket()
sk.connect(("127.0.0.1",8080))

while True:
    inp = input(">>>:")
    sk.send(inp.encode("utf-8")) # 发送信息
    data = sk.recv(1024) # 接受信息
    print(data.decode("utf-8")) # 打印出来

2、边缘触发

1---高电平---触发

0---低电平---触发

IO多路复用的优势:同时可以监听多个连接

示例:select可以监听多个对象

server.py

# 优势
import socket
import select

sk = socket.socket()
sk.bind(("127.0.0.1", 8080))
sk.listen(5)
inp = [sk, ]

while True:
    r, w, e = select.select(inp, [], [], 5)  # [sk,conn], 5是每隔几秒监听一次

    for i in r:
        conn,addr = i.accept()  # 发送系统调用
        print(conn)
        print("hello")
        inp.append(conn)
        # conn.recv(1024)
    print("------")

 client.py

import socket

sk = socket.socket()
sk.connect(("127.0.0.1",8080))

while True:
    inp = input(">>>:")
    sk.send(inp.encode("utf-8")) # 发送信息
    data = sk.recv(1024) # 接受信息
    print(data.decode("utf-8")) # 打印出来

 多了一个判断,用select方式实现的并发

示例:实现并发聊天功能(select+IO多路复用,实现并发)

server.py

import socket
import select

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

inp = [sk]

while True: # 监听sk和conn
        r,w,e = select.select(inp,[],[],5) # conn发送变化,sk不变化就走else
        for obj in r:
        # 判断sk or conn 谁发生了变化
            if obj == sk:
                conn,addr = obj.accept()
                print(conn)
                inp.append(conn)
            else:
                data = obj.recv(1024)
                print(str(data.decode("utf-8")))
                a = input("回答 %s 号>>>:"%inp.index(obj))
                obj.send(a.encode("utf-8"))
        print("---",r)

 client.py

import socket

sk = socket.socket()
sk.connect(("127.0.0.1",8080))

while True:
    inp = input(">>>:")
    sk.send(inp.encode("utf-8")) # 发送信息
    data = sk.recv(1024) # 接受信息
    print(data.decode("utf-8")) # 打印出来

 4、Asynchonous I/O (异步IO)

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

异步最大特点:全程无阻塞

synchronous IO(同步IO)和asynchronous IO(异步IO)的区别:

  •  A synchronous I/O operation causes the requesting process to be blocked until that I/O operationcompletes;
  •  An asynchronous I/O operation does not cause the requesting process to be blocked;

  两者的区别就在于synchronous IO 做"IO operation"的时候会将process阻塞。(有一丁点阻塞,都是同步IO)按照这个定义,之前所述的blockin IO,non-blocking IO,IO multiplexing都属于synchronous IO(同步IO)。

同步IO:包括 blocking IO、non-blocking、select、poll、epoll(故:epoll只是伪异步而已)(有阻塞)

异步IO:包括 asynchronous (无阻塞)

五种IO模型比较:

  经过上面的介绍,会发现non-blocking IO和asynchronous IO的区别还是很明显的。在non-blocking IO中,虽然进程大部分时间都不会被block,但是它仍然要求进程去主动的check,并且当数据准备完成以后,也需要进程主动的再次调用recvform来将数据拷贝到用户内存。而asynchronous IO则完全不同。它就像是用户进程将整个IO操作交给了他人(kernel)完成。然后他人做完后发信号通知。在此期间,用户进程不需要去检查IO操作的状态,也不需要主动的去拷贝数据。

5、selectors模块应用

python封装好的模块:selectors

selecors模块:会选择一个最优的操作系统实现方式

示例:

select_module.py

import selectors
import socket

sel = selectors.DefaultSelector()

def accept(sock,mask):
    conn,addr = sock.accept() # should be ready
    print("accepted",conn,"from",addr)
    conn.setblocking(False) # 设置成非阻塞
    sel.register(conn,selectors.EVENT_READ,read) # conn绑定的是read

def read(conn,mask):
    try:
        data = conn.recv(1024) # should be ready
        if not data:
            raise Exception
        conn.send(data) # Hope it won't block
    except Exception as e:
        print("closing",conn)
        sel.unregister(conn) # 解除注册
        conn.close()

sock = socket.socket()
sock.bind(("127.0.0.1",8080))
sock.listen(100)
sock.setblocking(False)

sel.register(sock,selectors.EVENT_READ,accept)

while True:
    events = sel.select() # 监听[sock,conn1,conn2]
    print("events",events)
    # 拿到2个元素,一个key,一个mask
    for key,mask in events:
        # print("key",key)
        # print("mask",mask)
        callback = key.data # 绑定的是read函数
        # print("callback", callback)
        callback(key.fileobj,mask) # key.fileobj = sock,conn1,conn2

 client.py

import socket

sk = socket.socket()
sk.connect(("127.0.0.1",8080))

while True:
    inp = input(">>>:")
    sk.send(inp.encode("utf-8")) # 发送信息
    data = sk.recv(1024) # 接受信息
    print(data.decode("utf-8")) # 打印出来

 6、I/O多路复用的应用场景

(1)当客户处理多个描述字时(一般是交互式输入和套接口),必须使用I/O复用。

(2)当一个客户同时处理多个套接口时,而这种情况是可能的,但很少出现。

(3)如果一个TCP服务器既要处理又要监听套接口,又要处理已连接套接口,一般也要用到I/O复用。

(4)如果一个服务器既要处理TCP,又要处理UDP,一般要使用I/O复用

(5)如果一个服务器要处理多个服务或多个协议,一般要使用I/O复用

“与多进程和多线程技术相比,I/O多路复用技术的最大优势是系统开销小,系统不必创建进程/线程,也不必维护这些进程/线程,从而大大减少了系统的开销”

最后,再举几个不是很恰当的例子来说明这四个IO Model:

有A,B,C,D四人在钓鱼:

A用的是最老式的鱼竿,所以呢,得一直守着,等到鱼上钩个在拉杆;【阻塞】

B的鱼竿有个功能,能够显示是否有鱼上钩(这个显示功能一直去判断鱼是否上钩),所以呢,B就和旁边的MM聊天,隔会再看看有没有鱼上钩,有的话就迅速拉杆;【非阻塞】

C用的鱼竿和B差不多,但他想到一个好办法,就是同时放好几根鱼竿,然后守在旁边,一旦有显示说鱼上钩了,它就将对应的鱼竿拉起来;【同步】

D是个有钱人,干脆雇了一个人帮他钓鱼,一旦那个人把鱼钩上来了,就给D发个信息(信息回调机制,主动告诉)。【异步】

猜你喜欢

转载自www.cnblogs.com/linglinglingling/p/11357236.html