Python 41 IO模型

Python 41 IO模型

旧博客Python系列

IO模型

IO模型并不是用来开启并发的,而是用来处理和接收并发的.

这里说的IO是指Linux环境下的network IO,根据UNIX 网络编程 卷1 第三版内的说法,分为:

  • blocking IO 阻塞IO
  • nonblocking IO 非阻塞IO
  • IO multiplexing IO多路复用
  • signal driven IO 信号驱动IO
  • asynchronous IO 异步IO
    由signal driven IO(信号驱动IO)在实际中并不常用,所以主要介绍其余四种IO Model。

还记得之前在学网络原理通信的时候,一直在用write和read表示通信,所有的IO操作,其实就是由write和read构成的,或者说,一次write或者read操作,就是一次网络IO操作.

对于一个network IO (这里我们以read举例),它会涉及到两个系统对象,一个是调用这个IO的process (or thread),另一个就是系统内核(kernel)。当一个read操作发生时,该操作会经历两个阶段:
#1)等待数据准备 (Waiting for the data to be ready)(比如操作系统系统通过TCP协议收到数据,并且将数据重新呈现在缓冲区)
#2)将数据从内核拷贝到进程中(Copying the data from the kernel to the process)

blocking IO 阻塞IO

py4101.png
这里的recvfrom和datagram是UDP模型中的,实际上TCP协议也适用于该图.

在阻塞的时候发生了什么: 阶段1: 左侧,程序准备去recvfrom,这个时候先去调用操作系统(system call),然后内核发现,并没有数据准备好(缓冲区为空),操作系统就在等待数据准备好,这个等待过程就是阻塞的过程; 阶段2: 当数据准备好的时候,内核需要把数据传送到应用程序的内存,由于应用程序不可能访问内核内存,内核必须先将数据复制到用户态内存的某处,等到复制完毕之后通知应用程序. 等到数据copy好之后,内核才会通知应用程序ok,应用程序才会解除阻塞去拿数据.

阻塞模型的特点就是,在阶段1和阶段2都会阻塞,就叫做blocking IO 阻塞IO模型. 多线程中,出现阻塞状态的线程,就会从运行状态进入阻塞状态,立刻会被扔出CPU的执行队列,等到阻塞结束时候,再进入就绪状态等待执行.

send也有类似的过程: 阶段1: 进行系统调用,然后内核响应请求之后,会把需要发送的数据从用户态内存copy到内核态内存,需要等一段时间; 阶段2: 将用户的信息发送,需要经历网络延时(比如TCP还需要接受回执才能确定发送成功),需要一段时间 成功发送之后,操作系统会通知应用程序发送成功,数据就可以从缓冲区和用户态内存中清楚. 为何send的阻塞会比较小,是因为send一般是主动的,而且是在建立连接之后,得到响应的速度会比较快.

阻塞IO模型的优点和缺点:

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

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

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

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

在我们的初级学习中,大部分的socket接口都是阻塞型的,所以在刚学网络编程还没有接触并发的时候,是无法实现并发的.由于端口会阻塞.想要避开端口,只能采用并发的模式.这样解决了会阻塞在一个任务内的情况,不过值得注意的是,那个阻塞的任务还是必须等待那么多时间.

nonblocking IO 非阻塞IO

py4102.png
Linux下,可以通过设置各种socket端口使其变为non-blocking。设置为没阻塞发生了什么?其实是在阶段1内不在等待,而是系统调用之后,内核返回无数据的情况,应用程序立刻放弃当前请求转而做其他事情或者再次请求.当某次请求的时候操作系统返回了缓冲区内有数据的情况,这个时候就进入等候状态,等候内核copy数据.这个过程就是非阻塞IO模型.
非阻塞IO模型的特点是:阶段1不阻塞,阶段2阻塞,而且应用程序必须不断的询问内核是否有数据.
来用多线程写一个非阻塞IO看一下,之前写的程序,都是阻塞IO

from threading import Thread
import socket
import time

sk = socket.socket()
sk.bind(('127.0.0.1', 8080))
sk.setblocking(False)  # 设置socket为不阻塞,默认True是阻塞
sk.listen()


def handler(conn, addr):
    while True:
        try:
            data = conn.recv(1024)  # 此处已经不阻塞,收不到消息就报错
            print('From {}: {}'.format(addr, data.decode('utf-8')))
            conn.send(data.upper())  # 这里也不阻塞
        except BlockingIOError:
            continue
        except ConnectionResetError:
            print('{} connection lost'.format(addr))
            conn.close()
            break


while True:
    try:
        conn, addr = sk.accept()  # 如果无连接,直接报IO错误
        print(""{} connected"".format(addr))
        Thread(target=handler, args=(conn, addr,)).start()
    except BlockingIOError:
        time.sleep(0.5)
        continue

在非阻塞的情况下,关键就是处理没有数据带来的IOError.
还有一种思路是,将生成的连接放入一个列表,无响应的连接从列表里删除,然后轮询列表里所有连接是否产生数据,有则显示,没有则继续下一个.但是逻辑会复杂很多,还是通过多线程比较简单.

非阻塞IO模型的优点和问题:
从我们自己的程序中就能看出来,非阻塞模型大量的反复询问或者等待请求,while循环在实际运行中,一秒钟会执行可能几千次.循环调用recv()将大幅度推高CPU占用率;如果为了降低对机器的压力,改成经过一段时间才去询问一次,实际上拉长了任务的完成时间,导致数据吞吐量降低.非阻塞IO模型的缺点使得它能够节约阶段1阻塞的时间而去做其他任务的优点也不那么明显.一般除非特别调整好客户端与服务端的程序,不太会使用非阻塞IO模型

IO multiplexing IO多路复用

多路复用感觉UNIX网络编程书上描述的不是很清晰,自己总结了一个:

py4105.jpg

IO多路复用的核心是,应用程序不自己去检测IO可读,而是通过一个代理程序去检测所有IO操作,应用程序通过这个代理,就不需要像非阻塞IO那样反复轮询,也不用像阻塞IO模型那样在阶段1和阶段2均处于等待状态.只要在阶段1的时候给代理发出指令,然后就可以进行其他工作,待代理返回IO可读信号之后,应用程序开始准备获取该IO对象的信息,这样应用程序最多也就在阶段2的地方阻塞等候数据.

当然,上边所述是理想状态,由于通过了代理也要花费时间,在IO操作比较少的时候,IO多路复用的效果还不如直接进行阻塞IO操作,但是在大量IO的情况下,IO多路复用搭配非阻塞socket,节省的时间就非常可观了.与多线程和多进程相比,I/O多路复用的最大优势是系统开销小,系统不需要建立新的进程或者线程就可以在一个线程里操作多个IO,也不必进一步维护这些线程和进程。这种叫做事件驱动。如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延迟还更大。select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。在多路复用模型中,对于每一个socket,一般都设置成为non-blocking,但是,如上图所示,整个用户的process其实是一直被block的。只不过process是被select这个函数block,而不是被socket IO给block。select的优势在于可以处理多个连接,不适用于单个连接

这个代理程序是由操作系统提供的,windows和linux下都有系统接口,python对应的模块是select模块

select模块的使用方法
select模块会同时监听列表内的每个IO对象,在某个io对象可读/写的时候,如果去执行select方法,会返回所有可用的对象.有一篇参考文章.

import select
import socket


def handle(conn):
    try:
        data = conn.recv(1024)
        print(data.decode('utf-8'))
        conn.send(data.upper())
    except ConnectionResetError:
        read_lst.remove(conn)


sk = socket.socket()
sk.bind(('127.0.0.1', 8080,))
sk.setblocking(False)
sk.listen()

read_lst = [sk]  # 这里维护一个列表,一开始列表里放的是socket对象

while True:
    r_lst, w_lst, z_lst = select.select(read_lst, [], [])  # 
    if r_lst:
        for i in r_lst:
            if i is sk:
                conn, addr = i.accept()
                read_lst.append(conn)
            else:
                handle(i)

每一次执行select.select方法后,会返回三个列表,我们使用第一个列表,返回给予的监听列表中所有可读的对象构成的可读对象列表.然后遍历这个列表,如果遍历到sk对象,说明又有新连接进来,生成一个连接对象,继续加入到监听列表中;如果遍历到不是sk的对象,那就是后来放入的conn对象,则把conn交给handle函数进行一次收发处理.这样实际上就是通过select监听了所有的IO对象,然后每次执行可读的对象.没有用到多线程和多进程,只在一个线程内就实现了并发.

这里因为写的比较简单,如果给if r_lst 这个判断语句加上对应的else,则服务器端就可以不阻塞,在select没有信号的时候,可以通过else分支去做其他事情.当然,IO多路复用只适合处理多个连接,效率上并不占优,如果每个IO等待的时间很长的话,单线程的select其实无法做到同时处理各个请求.

IO多路复用模型的优点和缺点

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

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

asynchronous IO 异步IO

py4103.png

异步IO是由UNIX规范POSIX定义的.异步IO模型的核心是应用程序先调用内核发出IO请求,然后立刻返回,内核从阶段1接受数据到阶段2将数据copy至用户态内存之后,给应用程序发一个信号,应用程序回来进行IO操作.

异步IO模型其实是效率最高的.Python依靠asyncio模块实现了异步(该模块是python相当重要的一个模块,由Python3.4引入,Python3.6的重大更新之一就是该模块的更新)除了官方文档之外,有两篇文章: python异步asyncio模块的使用python中重要的模块--asyncio 可供参考.

各个IO模型的总结与比较

py4104.png

通过比较可以看出,阻塞IO模型在两个阶段都阻塞,非阻塞IO模型在阶段1不阻塞,但是监听由应用程序发起,必须不断调用操作系统.IO应用多路复用模型里,应用程序自己不阻塞,但是代理处会阻塞,应用程序依然需要定期去查询代理,其实只是为了处理大量连接所用.这三个模型无论阶段1阻塞与否,阶段2都会阻塞.而异步IO模型,在阶段1和阶段2都不阻塞

POSIX定义了两种分类: 同步I/O操作(synchronous I/O operation)导致请求进程阻塞,直到I/O操作完成。 异步I/O操作(asynchronous I/O operation)直到I/O操作完成都不导致请求进程阻塞。

按照这个分类标准,在完成一整个IO操作的时候,阶段1和阶段2至少一处有阻塞,都是同步IO模型,两个阶段都没有阻塞,才是异步模型.所以阻塞IO,非阻塞IO和IO多路复用模型都属于同步IO操作.异步IO模型属于异步IO操作

poll epoll select 的区别

select和poll都是由操作系统不断循环监听列表,相比select,poll同时可以监听的对象数量要更多.但是随着列表的增加,二者的效率都会下降.

epoll的机制与select和poll不同,相当于给每一个对象都绑定了一个回调函数,每当一个信号来的时候,触发执行回调函数直接给用户端做反馈,无须浪费轮询列表的时间.

windows下只能使用select功能,在Linux编程的时候,最好使用epoll方法.由于操作系统支持的方式不同,可以使用selectors模块,这个模块可以自动选择最适合当前系统的办法.

# selectors 模块用法
from socket import *
import selectors

sel = selectors.DefaultSelector()  # 实例化一个自动选择的最优的多路IO对象


def accept(sk, mask):
    conn, addr = sk.accept()
    sel.register(conn, selectors.EVENT_READ, read)  # 每次有连接对象产生的时候,向IO对象里注册连接对象的句柄和回调函数


def read(conn, mask):
    try:
        data = conn.recv(1024)
        if not data:
            print('closing', conn)
            sel.unregister(conn)
            conn.close()
            return
        conn.send(data.upper() + b'_SB')
    except Exception:
        print('closing', conn)
        sel.unregister(conn)
        conn.close()


sk = socket(AF_INET, SOCK_STREAM)
sk.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
sk.bind(('127.0.0.1', 8080))
sk.listen(5)
sk.setblocking(False)  # 设置socket的接口为非阻塞
sel.register(sk, selectors.EVENT_READ,
             accept)  # 向select的监听列表(其实不是监听,暂时还叫这个名字)里append了一个句柄sk对象,并且绑定了一个回调函数accept.

while True:
    events = sel.select()  # 检测所有的放入列表的对象,是否有完成wait data阶段(阶段1),得到events,events就是包含了可读对象的可迭代对象
    for sel_obj, mask in events:  # events里边有两个东西,一个是可读对象,一个是mask(不用管)
        callback = sel_obj.data  # data属性就是注册给对象的回调函数.如果是sk对象,就是accept,如果是conn,就是read
        callback(sel_obj.fileobj, mask)  # 相当于用注册的函数传参执行

分析代码可以看出来,这个操作的实际逻辑和使用select模块是相同的.唯一的区别是每次从结果列表中获取可读对象时,我们可以自己选择处理方法.selectors函数采用了统一的思想,在对象进入监听列表的时候就注册好处理函数,每次对可读列表中的对象,直接用提前注册好的方法调用和处理.

LICENSED UNDER CC BY-NC-SA 4.0
Comment