Fork me on GitHub

I/O模型

本文是Python通用编程系列教程,已全部更新完成,实现的目标是从零基础开始到精通Python编程语言。本教程不是对Python的内容进行泛泛而谈,而是精细化,深入化的讲解,共5个阶段,25章内容。所以,需要有耐心的学习,才能真正有所收获。虽不涉及任何框架的使用,但是会对操作系统和网络通信进行全局的讲解,甚至会对一些开源模块和服务器进行重写。学完之后,你所收获的不仅仅是精通一门Python编程语言,而且具备快速学习其他编程语言的能力,无障碍阅读所有Python源码的能力和对计算机与网络的全面认识。对于零基础的小白来说,是入门计算机领域并精通一门编程语言的绝佳教材。对于有一定Python基础的童鞋,相信这套教程会让你的水平更上一层楼。

一 I/O模型介绍

基于我们上一章讲的内容,可以使用gevent模块监测程序中遇到的IO行为,接下来我们要讲解的是由我们自己来监测程序的中IO行为,实现在单线程下遇到IO就会切换任务的目的。在工作中,你自己不一定要去写这个东西,但是这是和高性能息息相关的知识点,把这一部分理解了,以后再遇到类似的东西,你一定有信心去征服它,否则的话,高并发的任务,高性能的处理,可能会成为你以后难以逾越的鸿沟。
IO模型和我们以前说过的生产者消费者模型类似,其实就一种解决问题的思路,我们使用IO模型是为了解决单线程下的IO问题 。IO模型主要有5种,分别是:

  1. blocking IO(阻塞IO模型)
  2. nonblocking IO(非阻塞IO模型)
  3. IO multiplexing(IO多路复用)
  4. signal driven IO(信号驱动IO模型)
  5. asynchronous IO(异步IO模型)

信号驱动IO在实际中不常用,我们主要介绍另外4种IO模型,我们讲解这4种IO模型主要是为了解决网络IO,也就是服务端并发的问题。
先来回顾一下我们TCP协议的套接字通信
服务端

客户端

对于应用程序来说,第10行代码客户端的send的发送结束指的是数据从应用程序内存拷贝的操作系统这就算是send完成了,这段时间虽然很快,快到我们都感知不到明显的阻塞,但其实也是IO操作,你之所以看到它很快的完成主要是基于两点原因:一 发送的数据量比较小,这样的话数据可以很快的复制到操作系统;二 操作系统缓存有大量的空间,如果操作系统缓存占满了,而且拷贝的数据量比较大,就会出现明显的阻塞。客户端和服务端都一样的,TCP协议和UDP协议的底层实现也是一样的理论,他们的区别就不再赘述。

send是一个IO行为,他所经历的阶段只有“copy data”这么一段时间,指的是数据从应用程序拷贝到操作系统。

再来看一下服务端的第219行代码的recv,它的本质就是给服务端操作系统发请求,向操作系统要数据,但是操作系统有没有数据要等客户端是否发过来数据,所以服务端操作系统还要有一个等数据的阶段,这个阶段我们可以叫做“wait data”阶段,当服务端操作系统等到数据了,recv里面不可能直接就有了数据,还要经历一个由操作系统缓存拷贝给应用程序的阶段,这个阶段依然可以叫做“copy data” 阶段。所以,recv要想拿到数据需要经历两个阶段。

recv要先经历“wait data”阶段,再经过“copy data”阶段,这两个阶段都是IO操作

比起“copy data”阶段,“wait data”阶段明显要时间长,因为不仅要经历网络IO(copy data 是本地IO,速度要比网络IO快得多),还要取决于客户端什么时间发送数据。所以,执行recv你会明显的感觉到阻塞。
再来看一下,服务端第215行代码accept,这是一个等待客户端连接的操作,但是其实本质上和recv是一样的,都是操作系统等待客户端发送数据,然后把数据由操作系统拷贝给应用程序。所以,accept也会经历“wait data”和“copy data”阶段。
接下来我们要讲解的IO模型其实就是围绕“copy data”和“wait data”阶段来展开的。

二 阻塞I/O模型

其实阻塞IO模型你们很早就知道了,就是最开始写的套接字程序,就是阻塞IO。阻塞IO指的是“wait data”和“copy data” 这两个阶段全部都要在原地等待,也就是阻塞。

如图所示,按照箭头指示的方向,最初是由应用程序向操作系统内核发起系统调用(recvfrom是UDP协议用的,但其实这个过程中TCP和UDP都是一样的),这时操作系统不会立刻有数据,随着时间的流逝,经历一个“wait data”阶段,直到操作系统有数据,接下来就是“copy data”阶段,把数据从操作系统内核拷贝给应用程序,最后拷贝成功,也就返回给了应用程序。这就是阻塞IO,很明显阻塞IO的效率并不高。send,recv和accept这些都是IO操作,我们以前写的套接字通信只要出现这3个关键词,就会由IO行为。
直到后来我们讲解了多进程和多线程,为了实现并发的效果,你可以开多进程或者多个线程,但是在每个进程或者线程内,依然还是要等,并没有解决IO问题,只不过有多个同时进行,相互之间互不影响,这确实是一个解决思路,但并没有解决本质问题,并没有改变遇到IO操作就要等待的现实。
开多个进程或者线程就会占用资源,一个机器上不可能无限的开启,直到后面我们讲了一个“池”的概念,但是其实“池”并不是直接用来提升效率的,相反,其实“池”是限制你的机器在可承受范围内运行,在保证其它不会瘫痪的前提下,其实就是变相的提升了效率,细水长流总好过油尽灯枯。“池“的概念,其实也是在一定范围内进行控制,但是当数据量非常大的时候,“池”就不够用了,他会明显的拖慢程序的运行效率。
直到我们后来讲解了协程的概念,我们可以使用gevent模块实现在单线程下,遇到IO操作就切换任务,把单线程的效率发挥到极限,然后再结合多进程多线程可以在单台机器上把性能充分利用,最后终极的解决方案就是由架构师把多台机器做集群,工作来共同完成这一个任务。

三 非阻塞I/O模型

非阻塞IO模型就是没有任何阻塞,我们要完成这个非阻塞IO模型其实就像是自己写一个简易的gevent模块。

非阻塞IO模型最初还是由应用程序给操作系统发一个系统调用,操作系统开始是没有数据的,这是会给应用程序发一个信号,然后应用程序就可以去执行其他任务了,隔一会在询问一次,直到操作系统有了数据,后面的所经历的“copy data”其实是一样的,这个也是无法避免的。很明显,非阻塞IO还比阻塞IO效率更高,我们基于这个思路就可以自己监测IO行为,实现单线程下的并发。

从代码第234行开始程序执行到accept,这就是给操作系统发了一个信号,是程序遇到第一个IO操作,我们希望的是操作系统没有数据就要返回一个信号,告诉应用程序不要再等了,去执行其他的任务。这应该怎么处理呢?

from socket import *
s = socket()
s.bind(('127.0.0.1', 8082))
s.listen(5)
s.setblocking(False)  # 默认情况是True,也就是阻塞IO模型
while True:
    conn, address = s.accept()  # 只要遇到阻塞,就会返回错误的提示信号

我们接下来要做的就是捕捉这个错误信号,来做进一步处理。

from socket import *
s = socket()
s.bind(('127.0.0.1', 8082))
s.listen(5)
s.setblocking(False)  # 默认情况是True,也就是阻塞IO模型
while True:
    try:
        conn, address = s.accept()  # 只要遇到阻塞,就会返回错误的提示信号
    except BlockingIOError:
        print("r_list:",len(r_list))  # 打印连接成功的客户端数量
        print("上面的那家伙在IO,我可以去执行其他的任务了")

建立连接是为了实现通信,一旦有了客户端发送建连接请求,就需要对连接进行处理,真正的去执行其他的任务,而不是打印一行内容就结束了,那么执行其他的任务的代码我们是不能直接写在accept下面的,否则这就不是并发建连接了。
这时我们分别启动服务端和多个客户端,就会看到服务端r_list数据的变化,而多个客户端都可以连接成功,这样服务端就基本实现了单线程下,并发的与客户端建连接,并且与通信互不影响。接下来就是要实现通信的任务了,这个任务必须与accept独立开。

from socket import *
s = socket()
s.bind(('127.0.0.1', 8088))
s.listen(5)
s.setblocking(False)
r_list= []  # 用于存储客户端的连接
while True:
    try:
        conn, address = s.accept()
        r_list.append(conn)  # 把新的连接添加至列表
    except BlockingIOError:
        print("r_list:",len(r_list))
        # print("上面的那家伙在IO,我可以去执行其他的任务了")
        for conn in r_list:
            data = conn.recv(1024)
            conn.send(data.upper())

这样写了之后你会发现,程序会报错,因为我们现在写的非阻塞IO模型,recv要分别经历“wait data”和“copy data”阶段才能拿到数据,但是可能recv一开始没有数据,但他总会有数据的,所以我们需要再一次捕获异常,第一次for循环没有数据,这并没有关系,他总会有数据的。
为了让客户端获取到服务端的响应,我们把客户端稍作修改。

这样就能实现服务端并发处理连接和通信了,可能有些细心的同学会有质疑,现在客户端不是并发的发送消息,仍然是一个一个发送的,因为我自己只有一台电脑,无法实现同时发送,为了打破质疑,我们再把客户端修改一下。

至此,不借助任何外部模块已经实现了单线程下的并发通信,原来的实现方式是借助gevent模块来实现的,当时我们要导入一个猴子模块,然后在执行一个猴子点pacthall(),当时我们只知道他就是给所有的IO行为打了一个标记,其实patch_all就是执行了,下面这行代码,把所有的阻塞全部变成了非阻塞,然后gevent模块来检测任务中的异常,一旦一个任务出现异常,他就会知道这个任务遇到IO操作,立马就会跳到另外一个任务,所以其实你就是自己实现了一个gevent模块的功能,只不过现在你自己处理的是网络IO,我们主要讲解和你以后在工作遇到的最常遇到的也是网络IO模型。

接下来我们会对刚才写的非阻塞IO模型进行修正,可能细心的同学会发现,其实程序是有非常明显的bug,当我们关闭其中任何一个客户端的时候,另外的客户端也就崩了,因为服务端也崩了,他崩溃的原因,其实下面这行代码,客户端单方面终止连接程序就会直接报错,而这个错误与我们第241行代码捕获异常的错误是没有关系的。

为了捕获客户端终止连接的异常,我们需要再一次进行异常的捕获,修改如下。

from socket import *
s = socket()
s.bind(('127.0.0.1', 8088))
s.listen(5)
s.setblocking(False)
r_list= []  # 用于存储客户端的连接
while True:
    try:
        conn, address = s.accept()
        r_list.append(conn)  # 把新的连接添加至列表
    except BlockingIOError:
        print("r_list:",len(r_list))
        # print("上面的那家伙在IO,我可以去执行其他的任务了")
        for conn in r_list:
            try:
                data = conn.recv(1024)
                conn.send(data.upper())
            except BlockingIOError:
                continue
            except ConnectionResetError:
                conn.close()  # 先关闭连接
                r_list.remove(conn)  # 再清理监听列表中的连接,注意这里不能用pop

注意!注意!注意!
如果在循环列表的过程中修改列表的结构,现在你虽然这样写,程序中不会报错,这是得益于Python语言出色的优化机制(Python的字典没有这样的优化),但并不是所有的牛奶都叫特仑苏。
所以,我们需要把要删除的conn先记录下来,然后再执行删除的操作,修改如下。

至此,在上图第247行代码还存在一点跨平台的问题,在Windows系统上,如果客户端单方面断连接,服务端会抛出异常,但是在Linux系统上,如果客户端单方面断连接,服务端的data会一直收空,所以我们再进行优化。

到目前为止,我们可以实现监测accept和recv的IO行为,但是上图第255行代码send的IO行为却没有监测,虽然它是本地内存的IO速度非常快,但是也有可能会变慢出现阻塞,这时第256行代码能够捕获到这个异常,但是执行了continue操作就直接跳过去了数据发不出去,涉及到数据安全的问题,这是非常严肃的,这又应该怎么处理呢?
我们不应该再收消息之后紧接着就是执行发消息的任务,而是应该把它们分开进行,修改如下。

# 为了使代码变得规范工整,我修改了部分变量名与代码先后位置
from socket import *
s = socket()
s.bind(('127.0.0.1', 8088))
s.listen(5)
s.setblocking(False)
r_list = []
w_list = []  # 用于存储发消息的列表
while True:
    try:
        conn, address = s.accept()
        r_list.append(conn)
    except BlockingIOError:
        print("r_list:", len(r_list))
        # 收消息
        del_conn_r_list = []
        for conn in r_list:
            try:
                data = conn.recv(1024)
                if not data:
                    conn.close()
                    del_conn_r_list.append(conn)
                    continue
                # conn.send(data.upper())
                w_list.append((conn, data.upper()))  # 用一个小元组存储连接和消息
            except BlockingIOError:
                continue
            except ConnectionResetError:
                conn.close()
                del_conn_r_list.append(conn)
        # 发消息
        del_conn_data_w_list = []
        for item in w_list:
            # 这里也要做IO异常捕获
            try:
                conn = item[0]
                data = item[1]
                conn.send(data)
                del_conn_data_w_list.append(item)  # 同样的资源资源回收操作
            except BlockingIOError:
                continue
        # 回收无用连接
        for conn in del_conn_r_list:
            r_list.remove(conn)
        # 回收无用连接与数据
        for item in del_conn_data_w_list:
            w_list.remove(item)

还有一个可能出现的问题,就是在发送消息过程中,客户端单方面把连接断开了,所以,为了捕获这个异常,我们还需要进行同样的异常捕获,修改如下。

至此,我们把send操作的IO行为也能实现监测了,这就是一个完整的版本了。很明显非阻塞IO模型比起阻塞IO模型效率更高,就相当于是自己实现了一个gevent模块,但是,实现的这个还有问题,是非常大的问题,你可以看到你的服务端程序起来了之后就一直无限循环,因为它叫做非阻塞,程序从始至终没有任何阻塞,程序的效率虽然高了,但是占用的资源非常高,即使没有连接过来也一直这样占用着,这就是占着茅坑不**了。占用资源要工作,像这样无用的占用,这不就是病毒吗(你会写病毒软件了哈)?对于病毒软件,通常系统管理员给你干死(你写的这个病毒软件还毒不死人),很轻易就能给你干死。所以非阻塞IO虽然效率高,但是并不推荐使用。其实想要解决这个问题很简单,只需要如下图所示加一个sleep就好了,但是这样就与非阻塞IO模型相悖了,非阻塞IO模型指的是没有任何阻塞。

四 I/O多路复用

在实际工作中,我们不推荐使用非阻塞IO模型,一方面因为设计这个模型比较复杂,另一方面原因就是占用资源过高,我们推荐使用的是IO多路复用。这个词可能有点陌生,但是如果我说select/epoll,大概就都能明白了。有些地方也称这种IO方式为事件驱动IO(event driven IO)。我们都知道,select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。它的基本原理就是select/epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户。它的流程如图:

如图所示,发送系统调用的不是由原来的receive或者accept,而是由select去发送,这个select是一个模块,后面我们会介绍它的使用,借助这个模块来帮你发起系统调用,它就像是一个中间人一样。按照箭头指示的方向,依次执行的操作是:

select问询内核数据准备情况--内核数据准备未完成--内核数据准备就绪--内核返回就绪信号
--套接字对象发起系统调用--数据拷贝--数据拷贝完成--返回完成信号

我们把IO多路复用和阻塞IO做对比,其实你会发现IO多路复用明显在添加了一个中间人select之后,他要经历的“wait data”和“copy data”的阶段都没有少,但是却多了一个select询问和内核返回信号的过程,这也就是意味着它的效率还没有阻塞IO效率高,那更没有非阻塞IO效率高了,但是我们为什么要用它?
IO多路复用这种IO模型在它只监测一个套接字的情况下,它的效率确实没有阻塞IO高,但是,他可以同时帮你监测多个套接字,它会循环去询问数据准备情况,然后收集一堆准备好的套接字,直接经历“copy data”阶段就可以了。其实IO多路复用就是我们讲的非阻塞IO模型的修正版本,直白一点讲,就是在我们写的非阻塞IO模型中加了一个time.sleep,我们使用这种模型就不需要自己监测IO行为,不需要自己for循环轮询了。
select用法

如上图所示,前两个参数就是和我们自己定义的变量是一个意思,第三个参数是和异常相关的参数,我们可以先忽略,最后一个参数timeout就是每隔多长时间检测一次,就是我们非阻塞IO模型没有添加的time.sleep的时间。

import select
from socket import *
s = socket()
s.bind(('127.0.0.1', 8088))
s.listen(5)
s.setblocking(False)
r_list = [s, ]  # 把套接字对象添加到监测列表
w_list = []  # 用于存储发消息的列表
while True:
    # 这一行代码就实现了我们上面写的一堆代码
    """
    它的返回值就是三个列表,其中rl代表准备好的套接字对象,
    wl代表准备好的要发送的数据
    xl是与异常相关的列表,我们不用它
    rl和wl分别是r_list和w_list的真子集
    """
    rl, wl, xl = select.select(r_list, w_list, [], 3)
    print('rl:', len(rl))
    print('wl:', len(wl))

当我们启动服务端程序,你会发现服务端每过3秒就会轮询一次,超时时间指的就是3秒之后,如果没有准备好的套接字也要执行后面的代码,都会而当我们启动一个客户端连接的时候,服务端就不会再等3秒了,因为已经有了准备好的套接字,直接就执行后面的任务了。

import select
from socket import *
s = socket()
s.bind(('127.0.0.1', 8088))
s.listen(5)
s.setblocking(False)
r_list = [s, ]  # 把套接字对象添加到监测列表
w_list = []  # 用于存储发消息的列表
while True:
    # 这一行代码就实现了我们上面写的一堆代码
    """
    它的返回值就是三个列表,其中rl代表准备好的套接字对象,
    wl代表准备好的要发送的数据
    xl是与异常相关的列表,我们不用它
    rl和wl分别是r_list和w_list的真子集
    """
    rl, wl, xl = select.select(r_list, w_list, [], 3)  # r_list = [s, conn]
    print('rl:', len(rl))
    print('wl:', len(wl))
    for r in rl:
        # r_list中最开始只有s,后来加入了conn,要区别对待
        if r == s:
            conn, address = r.accept()  # 这时的accept只需要经历"copy data"阶段
            r_list.append(conn)
        else:
            data = r.recv(1024)
            r.send(data.upper())

至此,基本就完成了,对于和非阻塞IO模型遇到的同样的问题,我们还是需要自己处理一下。

import select
from socket import *
s = socket()
s.bind(('127.0.0.1', 8088))
s.listen(5)
s.setblocking(False)
r_list = [s, ]  # 把套接字对象添加到监测列表
w_list = []  # 用于存储发消息的列表
w_data = {}  # 定一个发送数据的字典
while True:
    # 这一行代码就实现了我们上面写的一堆代码
    """
    它的返回值就是三个列表,其中rl代表准备好的套接字对象,
    wl代表准备好的要发送的数据
    xl是与异常相关的列表,我们不用它
    rl和wl分别是r_list和w_list的真子集
    """
    rl, wl, xl = select.select(r_list, w_list, [], 3)  # r_list = [s, conn]
    print('rl:', len(rl))
    print('wl:', len(wl))
    for r in rl:
        # r_list中最开始只有s,后来加入了conn,要区别对待
        if r == s:
            conn, address = r.accept()  # 这时的accept只需要经历"copy data"阶段
            r_list.append(conn)
        else:
            try:
                data = r.recv(1024)
                if not data:
                    r.close()
                    r_list.remove(r)
                    continue
                # r.send(data.upper())  # 不能在这直接发
                w_list.append(r)
                w_data[r] = data.upper()
            except ConnectionResetError:
                r.close()
                r_list.remove(r)  # 这里可以直接删
                continue
    # 发消息
    for w in wl:
        w.send(w_data[w])
        w_list.remove(w)
        w_data.pop(w)

五 异步I/O模型

再来看最后一个IO模型,异步IO模型,基于以前对异步的讲解,异步就是一个任务提交完成之后,直接执行后面的操作,等到任务运行完毕会通过回调机制自动把任务传递回去。 很明显异步IO模型效率会比IO多路复用做比较,异步IO只需要提交任务就可以了,它的效率会比IO多路复用更高。

如上图所示,当应用程序发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对应用程序产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到应用程序,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。
上面有一个回调机制,这其实是一个回调函数,我们之前在讲解“池”的时候其实不用这个回调函数用生产者消费者模型也能实现类似的功能,但是如果使用回调函数会给我们写异步提交任务的程序带来极大的方便,在这里我们就把异步IO和回调函数做一个关联讲解。

from concurrent.futures import ThreadPoolExecutor
from threading import current_thread
import time
def task(n):
    print('%s is running' % current_thread().name)
    time.sleep(2)
    return n ** 2
def parse(obj):
    res = obj.result()
    print('结果是:', res)
if __name__ == '__main__':
    t = ThreadPoolExecutor(4)
    for i in range(10):
        """
        # 它的返回值是一个future对象
        obj = t.submit(task, i) 
        # future对象调用回调函数会自动把它自己当作第一个参数传递给回调函数 
        obj.add_done_callback(parse)  
        """
        # 这一行代码就相当于上面两行代码
        t.submit(task, i).add_done_callback(parse)

以上代码是回调函数的讲解,也是异步IO模型,综合我们所讲的IO模型,你会发现其实异步IO是效率最高的一种,很明显,异步IO已经不是单线程了,对比我们的IO多路复用可能在高并发上面会有所不足,但即便如此,异步IO依然有者非常广泛的应用,在对一些数据的抓取和数据的分析处理上,异步IO能够最快拿到结果,有它独特的优势。

posted @ 2019-04-09 18:37  马一特  阅读(87)  评论(0编辑  收藏  举报