python之全栈开发——————IO模型

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了python之全栈开发——————IO模型相关的知识,希望对你有一定的参考价值。

一:在讲IO模型之前我们首先来讲一下事件驱动模型,属于一种编程的范式,那么我们以前就是传统式编程,来看看有什么区别吧(此处为借鉴别人的)

传统的编程是如下线性模式的:

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

每一个代码块里是完成各种各样事情的代码,但编程者知道代码块A,B,C,D...的执行顺序,唯一能够改变这个流程的是数据。输入不同的数据,根据条件语句判断,流程或许就改为A--->C--->E...--->结束。每一次程序运行顺序或许都不同,但它的控制流程是由输入数据和你编写的程序决定的。如果你知道这个程序当前的运行状态(包括输入数据和程序本身),那你就知道接下来甚至一直到结束它的运行流程。

 对于事件驱动型程序模型,它的流程大致如下:

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

 与上面传统编程模式不同,事件驱动程序在启动之后,就在那等待,等待什么呢?等待被事件触发。传统编程下也有“等待”的时候,比如在代码块D中,你定义了一个input(),需要用户输入数据。但这与下面的等待不同,传统编程的“等待”,比如input(),你作为程序编写者是知道或者强制用户输入某个东西的,或许是数字,或许是文件名称,如果用户输入错误,你还需要提醒他,并请他重新输入。事件驱动程序的等待则是完全不知道,也不强制用户输入或者干什么。只要某一事件发生,那程序就会做出相应的“反应”。这些事件包括:输入信息、鼠标、敲击键盘上某个键还有系统内部定时器触发。

1.事件驱动模型

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

(1)每收到一个请求,创建一个新的进程,来处理该请求; 
(2)每收到一个请求,创建一个新的线程,来处理该请求; 
(3)每收到一个请求,放入一个事件列表,让主进程通过非阻塞I/O方式来处理请求
第三种就是协程、事件驱动的方式,一般普遍认为第(3)种方式是大多数网络服务器采用的方式 

论事件驱动模型

来看一个例子:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<p onclick = "func()">点我啊</p>

<script>
    function func(){
        alert("nihao23")
    }
</script>
</body>
</html>

运行之后点击一下 “点我啊”就会弹出一个提示框

在UI编程中,常常要对鼠标点击进行相应,首先如何获得鼠标点击呢? 两种方式:

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

CPU资源浪费,可能鼠标点击的频率非常小,但是扫描线程还是会一直循环检测,这会造成很多的CPU资源浪费;如果扫描鼠标点击的接口是阻塞的呢?
如果是堵塞的,又会出现下面这样的问题,如果我们不但要扫描鼠标点击,还要扫描键盘是否按下,由于扫描鼠标时被堵塞了,那么可能永远不会去扫描键盘;
如果一个循环需要扫描的设备非常多,这又会引来响应时间的问题; 
所以,该方式是非常不好的。
2 就是事件驱动模型 
目前大部分的UI编程都是事件驱动模型,如很多UI平台都会提供onClick()事件,这个事件就代表鼠标按下事件。事件驱动模型大体思路如下:

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

事件驱动编程是一种编程范式,这里程序的执行流由外部事件来决定。它的特点是包含一个事件循环,当外部事件发生时使用回调机制来触发相应的处理。另外两种常见的编程范式是(单线程)同步以及多线程编程。 

2.                         IO模型

2.1前戏准备

1 IO模型前戏准备

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

用户空间和内核空间
进程切换
进程的阻塞
文件描述符
缓存 I/O
用户空间与内核空间
现在操作系统都是采用虚拟存储器,那么对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方)。 
操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。 
为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。 
针对linux操作系统而言,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为内核空间,而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个进程使用,称为用户空间。 

进程切换
为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换,这种切换是由操作系统来完成的。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。 
从一个进程的运行转到另一个进程上运行,这个过程中经过下面这些变化:

保存处理机上下文,包括程序计数器和其他寄存器。

更新PCB信息。

把进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列。

选择另一个进程执行,并更新其PCB。

更新内存管理的数据结构。

恢复处理机上下文。 
注:总而言之就是很耗资源的

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

文件描述符fd
文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。 
文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。
import socket
print(socket.socket())
<socket.socket fd=172, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=0>
缓存 I/O
缓存 I/O 又被称作标准 I/O,大多数文件系统的默认 I/O 操作都是缓存 I/O。在 Linux 的缓存 I/O 机制中,操作系统会将 I/O 的数据缓存在文件系统的页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。用户空间没法直接访问内核空间的,内核态到用户态的数据拷贝 
缓存 I/O 的缺点: 

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

 

       同步(synchronous) IO和异步(asynchronous) IO,阻塞(blocking) IO和非阻塞(non-blocking)IO分别是什么,到底有什么区别?这个问题其实不同的人给出的答案都可能不同,比如wiki,就认为asynchronous IO和non-blocking IO是一个东西。这其实是因为不同的人的知识背景不同,并且在讨论这个问题的时候上下文(context)也不相同。所以,为了更好的回答这个问题,我先限定一下本文的上下文。
本文讨论的背景是Linux环境下的network IO。 

Stevens在文章中一共比较了五种IO Model:

    blocking IO
    nonblocking IO
    IO multiplexing
    signal driven IO
    asynchronous IO
由于signal driven IO在实际中并不常用,所以我这只提及剩下的四种IO Model。
再说一下IO发生时涉及的对象和步骤。
      对于一个network IO (这里我们以read举例),它会涉及到两个系统对象,一个是调用这个IO的process (or thread),另一个就是系统内核(kernel)。当一个read操作发生时,它会经历两个阶段:
 1 等待数据准备 (Waiting for the data to be ready)
 2 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)
记住这两点很重要,因为这些IO Model的区别就是在两个阶段上各有不同的情况。

3.  blocking   IO  阻塞IO

 

在linux中,默认情况下所有的socket都是blocking,一个典型的读操作流程大概是这样:

技术分享

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

4                                 non-blocking  非阻塞IO

linux下,可以通过设置socket使其变为non-blocking。当对一个non-blocking socket执行读操作时,流程是这个样子:

技术分享

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

 注意:

      在网络IO时候,非阻塞IO也会进行recvform系统调用,检查数据是否准备好,与阻塞IO不一样,”非阻塞将大的整片时间的阻塞分成N多的小的阻塞, 所以进程不断地有机会 ‘被’ CPU光顾”。即每次recvform系统调用之间,cpu的权限还在进程手中,这段时间是可以做其他事情的,

      也就是说非阻塞的recvform系统调用调用之后,进程并没有被阻塞,内核马上返回给进程,如果数据还没准备好,此时会返回一个error。进程在返回之后,可以干点别的事情,然后再发起recvform系统调用。重复上面的过程,循环往复的进行recvform系统调用。这个过程通常被称之为轮询。轮询检查内核数据,直到数据准备好,再拷贝数据到进程,进行数据处理。需要注意,拷贝数据整个过程,进程仍然是属于阻塞的状态。

5           IO multiplexing(IO多路复用)

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

技术分享

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

6.               Asynchronous I/O(异步IO)

linux下的asynchronous IO其实用得很少。先看一下它的流程:

技术分享

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

      到目前为止,已经将四个IO Model都介绍完了。现在回过头来回答最初的那几个问题:blocking和non-blocking的区别在哪,synchronous IO和asynchronous IO的区别在哪。
先回答最简单的这个:blocking vs non-blocking。前面的介绍中其实已经很明确的说明了这两者的区别。调用blocking IO会一直block住对应的进程直到操作完成,而non-blocking IO在kernel还准备数据的情况下会立刻返回。

五种IO模型比较:

      技术分享 

7.   select   介绍

select 
select最早于1983年出现在4.2BSD中,它通过一个select()系统调用来监视多个文件描述符的数组,当select()返回后,该数组中就绪的文件描述符便会被内核修改标志位,使得进程可以获得这些文件描述符从而进行后续的读写操作。 
  
select目前几乎在所有的平台上支持 
  
select的一个缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024,不过可以通过修改宏定义甚至重新编译内核的方式提升这一限制。 
  
另外,select()所维护的存储大量文件描述符的数据结构,随着文件描述符数量的增大,其复制的开销也线性增长。同时,由于网络响应时间的延迟使得大量TCP连接处于非活跃状态,但调用select()会对所有socket进行一次线性扫描,所以这也浪费了一定的开销。

二:  简单的举一些例子来看一下效果

1.阻塞IO,就是简单的客户端和服务端进行交流,但是只能有一个客户端,再开一个客户端就不行了,

来看代码的实现,

服务端:

import socket

sk = socket.socket()
print(sk)
sk.bind(("127.0.0.1",8080))
sk.listen(3)
while 1:
    conn,addr = sk.accept()
    while 1:
        data = conn.recv(1024)
        print(data.decode("utf8"))
        conn.sendall(data)

客户端:

import socket
sk = socket.socket()

sk.connect(("127.0.0.1",8080))
while 1:
    inp = input(">>>")
    sk.sendall(inp.encode("utf8"))
    data = sk.recv(1024)
    print(data.decode("utf8"))

2.non-blocking  非阻塞IO       这里所举的例子都是以客户端和服务端为模板来写的,来看一个代码实现的例子,其实这个例子和上面的差不多就是在其中加了一些简单的代码,只有几行,可以看看差别就 改变了阻塞的方式,

server端

import socket
sk = socket.socket()
#print(sk)
sk.bind(("127.0.0.1",8080))
sk.listen(3)
sk.setblocking(False)
import time
while 1:
    try:
        conn,addr = sk.accept()
        print(addr)
        # while 1:
        data = conn.recv(1024)
        print(data.decode("utf8"))
        #conn.sendall(data)
        conn.close()
    except Exception as e:
        print("error:",e)
        time.sleep(2)

client端

import socket
sk = socket.socket()

sk.connect(("127.0.0.1",8080))
while 1:
    #inp = input(">>>")
    sk.sendall("hello".encode("utf8"))
    data = sk.recv(1024)
    print(data.decode("utf8"))

3.       IO多路复用   最大的优势就是可以监听多个对象,就是通过select来监听,

select-server端

# import socket
# import select
#
# sk1=socket.socket()
# sk1.bind((‘127.0.0.1‘,8080))
# sk1.listen(3)
#
# sk2=socket.socket()
# sk2.bind((‘127.0.0.1‘,8081))
# sk2.listen(3)
#
# while 1:
#     r,w,e=select.select([sk1,sk2],[],[])
#     print(‘rrr‘)
#     for obj in r:#[sk1,]
#         conn,addr=obj.accept()
#         print(addr)
#         conn.send(‘i am server‘.encode(‘utf8‘))
#

select-client端

# #date:  2016/10/17
#
# import socket
# sk=socket.socket()
# sk.connect((‘127.0.0.1‘, 8080))
#
# while 1:
#     data = sk.recv(1024)
#     print(data.decode(‘utf8‘))
#     inp=input(‘>>>‘)
#     sk.sendall(inp.encode(‘utf8‘))
#

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

while True:
    sk.connect((127.0.0.1,8800))
    print("hello")
    sk.sendall(bytes("hello","utf8"))
    time.sleep(2)
    break

 

 4.IO多路复用来实现一个简单的聊天

talk-server端:

import socket
import select

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

while 1:
    inputs,outputs,errors = select.select(inp,[],[],)
    for obj in inputs:          #conn
        if obj == sk:
            conn,addr = sk.accept()
            print(conn)
            inp.append(conn)
        else:
            data = obj.recv(1024)
            print(data.decode("utf8"))
            Inputs = input("回答%s>>>>"%inp.index(obj))
            obj.sendall(Inputs.encode("utf8"))

talk-client端:

import time
import socket
sk = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
sk.connect(("127.0.0.1",8800))
while True:
    # sk.connect(("127.0.0.1",8800))
    #print("hello")
    inp = input(">>>:")
    sk.sendall(bytes(inp,"utf8"))
    data = sk.recv(1024)
    print(data.decode("utf8"))
    # time.sleep(2)
    # break

5:   加一个异常判断,具体还没有实现,后续有待改进,







以上是关于python之全栈开发——————IO模型的主要内容,如果未能解决你的问题,请参考以下文章

python全栈开发从入门到放弃之socket并发编程之IO模型

Python全栈开发-Day10-进程/协程/异步IO/IO多路复用

如何学习python

python全栈开发day36-IO多路复用

Python全栈开发-Day9-异步IO数据库队列缓存

Python要哪些要点要学习