Python运维之路——协程、事件驱动与异步IO - Go语言中文社区

Python运维之路——协程、事件驱动与异步IO


协程的概念

  • 协程,又称微线程,coroutine。是一种用户态的轻量级线程。
  • 协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,回复先前保存的寄存器上下文和栈。因此,协程能保持上一次调用的状态(即所有局部状态的一个特定组合),每次过程重如时,就相当于进入上一次调用的状态,换种说法:进入上一次离开时所处逻辑流的位置。
  • 协程的好处:
    • 无需线程上下文切换的开销
    • 无需原子操作锁即同步锁的开销
      • 所谓原子操作是指不会被线程调度机制打打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何切换到另一个线程的动作。原子操作可以是一个步骤,也可以是多个操作步骤,但是其顺序是不可以被打乱的,或者割掉只执行的部分。视作整体是原子性的核心。
    • 方便切换控制流,简化编程模型。
    • 高并发+高扩展性+低成本:一个CPU可以支持上万个CPU,适合高并发处理
  • 缺点:
    • 无法利用多核资源:协程的本质是单个线程,他不能同时将单个CPU的多个和用上,协程需要和进程配合才能运行在多CPU上,当然我们日常所编写的绝大部分应用都没有这个必要,除非是CPU密集型应用。
    • 进行阻塞操作,如IO操作时会阻塞掉整个程序。

使用yield实现协程操作例子

#!/usr/bin/env python3
#yield实现协程

import time,queue

def consumer(name):
    print("开始吃包子。。。")
    while True:
        new_baozi = yield #函数暂时在这里停止
        print("%s 开始吃包子 %s " % (name, new_baozi))


def producer():
    c1 = con.__next__() #consumer通过yield变成了迭代器,要通过__next__方法来执行
    c2 = con2.__next__()
    n = 0
    while n < 5:
        n += 1
        con.send(n) #向con中的yield发送n,即把函数中的yield换成n
        con2.send(n)
        print("制作了 包子 %s " % n)

if __name__ == "__main__":
    con = consumer('lalala')
    con2 = consumer('hahaha')
    producer()
  • 协程的定义:
    • 必须只有在单个线程里实现并发
    • 修改共享数据不需加锁
    • 用户程序里自己保存多个控制流上的上下文
    • 一个协程遇到IO操作自动切换到其他协程

greenlet

greenlet是用C实现的协程模块,相比与python自带的yield,他可以使你在任意函数之随意切换。

#!/usr/bin/env python3
#通过greenlet来实现协程

from greenlet import greenlet

def test1():
    print(12)
    gr2.switch() #在这里实现跳转,转到test2
    print(34)
    gr2.switch()

def test2():
    print(56)
    gr1.switch() #在这里实现跳转,转到test1
    print(78)

gr1 = greenlet(test1)
gr2 = greenlet(test2)
gr1.switch()

gevent

gevent是一个第三方库,可以轻松通过gevent实现并发同步或异步编程,在gevent中用到的主要模式是greenlet,它是以C扩展模块形式介入Python的轻量级协程。在greenlet全部运行在主程序操作系统进程的内部,但它们被协作式调度。

#!/usr/bin/env python3
#通过gevent来实现协程

import gevent

def func1():
    print(" the fun1 start...")
    gevent.sleep(2) #遇到堵塞就跳转(根据堵塞的时间长短)
    print(' the fun1 end...')

def func2():
    print("33[31;1m the func2 start 33[0m")
    gevent.sleep(1)
    print('33[31;1m the func2 end 33[0m')

def func3():
    print("33[32;1m the func3 start 33[0m")
    gevent.sleep(5)
    print('33[32;1m the func3 end 33[0m')

def func4():
    print("33[33;1m the func4 start 33[0m")
    gevent.sleep(4)
    print('33[33;1m the func4 end 33[0m')

gevent.joinall(
    [
        gevent.spawn(func1),
        gevent.spawn(func2),
        gevent.spawn(func3),
        gevent.spawn(func4),
    ]
)

同步可异步的性能区别

#!/usr/bin/env python3
#同步和异步性能的区分
import gevent

def tasf(n):
    gevent.sleep(1)
    print("The func print %s" % n)

def tongbu():
    for i in range(10):
        tasf(i)

def yibu():
    threads = [gevent.spawn(tasf, i) for i in range(10)]
    gevent.joinall(threads)

print('tongbu')
tongbu()

print('yibu')
yibu()

上面的程序的主要部分时间tasf函数封装到greenlet内部线程的gevent.spawn。初始化的greenlet列表存放在threads列表中,次数组被传给gevent.joinall函数后,后者阻塞当前的流程,并执行所有给定的greenlet。执行流程只会在所有greenlet执行完成后才会继续向下走。

当遇到阻塞是会自动切换任务

#!/usr/bin/env python3
#遇到阻塞是自动区分

from gevent import monkey;monkey.patch_all()
import gevent
from urllib.request import urlopen

def f(url):
    print('GET: %s' % url)
    resp = urlopen(url)
    data = resp.read()
    print('%d bytes received from %s.' % (len(data), url))

gevent.joinall([
    gevent.spawn(f,'https://www.baidu.com'),
    gevent.spawn(f,'https://www.sina.com'),
])

通过gevent实现单线程下的多socket并发

#!/usr/bin/env python3
#通过gevent实现单线程下的多socket并发
#server端
import gevent
from gevent import socket,monkey

monkey.patch_all()#将python标准库中的网络借口不阻塞

def server(port):
    s = socket.socket()
    s.bind(('127.0.0.1',port))
    s.listen(500)
    while True:
        cli, addr = s.accept()
        gevent.spawn(handle_request, cli) #执行handle_request函数,cli是参数

def handle_request(conn):
    try:
        while True:
            data = conn.recv(1024) #接收数据,这里设置成不阻塞
            print("recv:",data)
            conn.send(data)
            if not data:
                conn.shutdown(socket.SHUT_WR) #如果接收为空值,结束

    except Exception as ex:
        print(ex)
    finally:
        conn.close()

if __name__ == '__main__':
    server(8001)
#!/usr/bin/env python3
#通过gevent实现单线程下的多socket并发
#client端
import socket

host = "127.0.0.1"
port = 8001
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((host,port))
while True:
    user_input = bytes(input(),encoding = "utf8")
    s.sendall(user_input)
    data = s.recv(1024)
    print(repr(data))
s.close()

100个并发的socket链接

#!/usr/bin/env python3
#通过gevent实现单线程下的多socket并发
#client端2
import socket
import threading

def sock_conn():
    client = socket.socket()
    client.connect(('127.0.0.1',8001))
    count = 0
    while True:
        client.send(('hello %s % count').encode('utf-8'))
        data = client.recv(1024)
        print('[%s]recv from server:' % threading.get_ident(),data.decode())
        count += 1
    client.close()

for i in range(100):
    t = threading.Thread(target=sock_conn)
    t.start()

事件驱动与异步IO

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

    1. 每收到一个请求,创建一个新的进程,来处理该请求
    2. 每收到一个请求,创建一个新的线程,来处理该请求
    3. 每收到一个请求,放入一个时间列表,让主进程通过非阻塞I/O方式来处理请求
  • 以上几种方式各有优缺点:

    • 第一种方法中,由于创建新的进程开销比较大,会导致服务器性能比较差,但是实现比较简单
    • 第二种方式,由于要设计成线程的同步,有可能会棉铃死锁等问题。
    • 第三种方式中,在写应用程序代码时,逻辑比前两种都复杂。

综合考虑各方面因素,一般普遍认为第三种方式是大多网络服务器采用的方式

事件驱动模型

  • 在UI编程中,常常要对鼠标点击进行响应,对于获取鼠标点击有一下办法:
    1. 创建一个线程,该线程一直循环检测是否有鼠标点击,但是这个方式有一下缺点:
      • cpu资源浪费,可能鼠标的点击频率相当小,但是扫描进程还是会一直循环检测,这回造成很多的CPU资源浪费。
      • 如果是阻塞的,在等待鼠标点击的时候,如果用户想通过按键盘来实现,程序会阻塞等待鼠标点击,不会识别键盘的按键。
      • 如果一个循环扫描的设备非常多,这又会引发响应时间的问题
      • 所以这个方式是非常不好的
    2. 事件驱动模型
      目前大部分的UI编程都是事件驱动模型 ,如很多UI平台都会提供onClick()事件,这个事件就代表鼠标按下事件。事件驱动模型大体思路如下:
      • 有一个事件(消息)队列
      • 鼠标按下时,往这个队列中增加一个点击事件(消息)
      • 有个循环,不断从队列中取出事件,根据不同的事件,调用不同的函数,如onClick()、onKeyDown()等
      • 事件(消息)一般都各自保存各自的处理函数指针,这样,每个消息都有独立的处理函数;

这里写图片描述

事件驱动编程是一种编程范式,这里程序的执行流由外部事件来决定。它的特点是包含一个时间循环,当外部事件发生时使用回调机制来触发相应的处理。另外两种常见的编程范式是(单线程)同步以及多线程编程。
让我们用例子来比较和对比一下单线程、多线程以及事件驱动模型。下图展示了随着时间的推移,这三种模式下程序所做的工作。这个程序有三个任务需要完成,每个任务都在等待I/O操作室阻塞自身。阻塞在I/O操作桑所花的时间已经用灰色表示出来了。
这里写图片描述

  • 在单线程同步模型中,任务按照顺序执行。如果某个任务因为I/O而阻塞,其他所有的任务都必须等待,知道它完成之后其他任务才能依次执行。这种明确的执行顺序和串行化处理的行为是很容易推断得出的。如果任务之间没有相互依赖的关系,但但仍需要相互等待的话这就使得程序降低了运行速度。
  • 在多线程版本中,这3个任务分别在独立的线程中执行。这些线程有操作系统来管理,在多处理器系统上可以并行处理,或者在单独的处理器上交错执行。这使得当某个线程阻塞的时候,其他的线程可以继续执行。与完成类似功能的同步程序相比,这种方式等有效率,但程序员必须写代码来保护共享资源,防止其被多个线程同时访问。多线程程序更加难以推断,因为这类程序不得不通过线程同步机制,如锁、可重入函数、线程局部存储或其他机制来处理线程安全问题,如果实现不当就会产生BUG。
  • 在事件驱动过程中,三个任务交错执行,但仍在一个单独的线程控制中。当处理I/O或者其他需要阻塞的操作时,注册一个回调事件在循环中,然后在I/O操作完成时继续执行。回调描述了该如何处理某个事件。实践循环轮训所有的事件,当事件到来时将他们分配给等待处理事件的回调函数。这种方式让程序尽可能的执行而不需要额外的线程。事件驱动型程序比对线程更容易推断出行为,因为程序员不需要关心线程安全问题。
  • 当我们面对如下环境时,事件驱动通常是最好的选择:
    1. 程序中有许多任务
    2. 任务之间高度独立(因此他们不需要相互通信或者等待彼此)
    3. 等待事件到来时,某些任务会阻塞

当应用程序需要在任务间共享可变数据时,这也是个不错的选择,因为这不需要采用同步处理。
网络应用通常都符合上述特点,这使得他们能够很好的切合事件驱动模型。

select、pool、epool异步IO

概念说明

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

  • 用户空间和内核空间
  • 进程切换
  • 进程的阻塞
  • 缓存I/O

用户空间和内核空间

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

进程切换

为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并回复以前挂起的某个进程的执行。这种行为称为进程切换。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。

  • 从一个进程的运行到另一个进程的运行,这个过程经过下面这些变化:
    1. 保存处理机上下文,包括程序计数器和其他寄存器。
    2. 更新PCB信息。
    3. 把进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列。
    4. 选择另一个进程执行,并更新其PCB。
    5. 更新内存管理的数据结构。
    6. 回复处理机上下文

总之就是非常消耗资源
注:进程控制块,是操作系统核心中的一种数据结构,主要表示进程状态。其作用是使一个在多道程序环境下不能独立运行的程序(含数据),成为一个能独立运行的基本单位或与其他进程并发执行的进程。或者说,操作系统是根据PCB来对并发执行的进程进行控制可管理的。PCB通常是系统内存占用区中的一个连续存区,它存放着操作系统用户描述进程情况及控制进程运行所需的全部信息

进程的阻塞

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

文件描述符fd

文件描述符(File descriptor)是计算机科学中的一个术语。是一个用于表述指向文件的引用的抽象化概念。
文件描述符在形式上是一个非负整数。实际上,他是一个索引值,指向内核为每一个进程所维护的该进程打开文件 的记录表。当程序大爱一个现有文件或者穿件一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往之适用于UNIX、Linux这样的操作系统。

缓存I/O

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

缓存I/O的缺点:

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

I/O模式

对于一次I/O访问(以read举例),数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。所以说,当一个read操作发生时,他会经历两个阶段:
1. 等待数据准备
2. 将数据从内核拷贝到进程中

正式因为这两个阶段,linux系统产生了下面五种网路模式的方案:
- 阻塞I/O(blocking IO)
- 非阻塞I/O(nonblocking IO)
- I/O多路复用(IO multiplexing)
- 信号驱动I/O(signal driven IO)
- 异步I/O(asynchronous IO)

注:信号驱动IO在实际中并不常用,所以下面只提及剩下的四中IO模型

阻塞I/O(blocking IO)

在linux中,没默认情况下所有的socket都是blocking,一个典型的读操作流程是这样的:
这里写图片描述

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

非阻塞IO(nonblocking IO)

linux下,可以通过设置socket使其变为non-blocking。当对一个non-blocking socket执行度操作是,流程是这个样子的:
这里写图片描述

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

I/O多路复用(IO multiplexing)

I/O 多路复用就是本文讲到的select、pool、epool,有些地方也称这种I/O方式为event driven IO(事件驱动IO)。select、epool的好吃就在于单个进程就可以同时处理多个网络连接的IO。他的基本原理就是select、pool、epool这个function会不断地轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。
这里写图片描述

当用户进程调用了select,那么整个进程都会被block,而同时,kernel会“监听”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。
所以,I/O多路复用的特点是通过一种机制,一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态,select()函数就可以返回。

这个图跟阻塞IO的图其实没有太大的不同,事实上,还等差一些。因为这里需要使用两个system call(select和recvfrom),而阻塞IO(blocking IO)只调用了一个system call(recvfrom)。但是,用select的有事在于他可以同时处理多个connection。

所以,如果处理连接数不是很高 的话,使用select、epool的web server不一定比使用multithreading + nlocking IO的web server性能更好,可能延迟还更大。select、epool的优势并不是对于单个链接能处理的更快,而是在于能处理更多的链接。

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

异步I/O(asynchronous IO)

Linux下的asynchronous IO其实用的很少,流程如下:
这里写图片描述

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

总结

blocking和non-blocking的区别
调用blocking IO会一直block住对应的进程直到操作完成,而non-blockig IO在kernel还准备数据的情况下立刻返回

同步IO(synchronous IO)和异步IO(asynchronous)的区别
在说明同步IO和异步IO的区别之前,需要先给出两个定义。POSIX的定义是这个样子的:

  • 同步的输入/输出操作导致请求进程被阻塞,直到该输入/输出操作完成;
  • 异步的输入/输出操作不会导致请求进程被阻塞;

二者的区别就在于同步IO做IO操作的时候会将process阻塞,安装这个定义,之前所述的阻塞IO(blocking IO),非阻塞IO(non-blocking IO),IO多路复用(IO multiiplexing)都属于同步IO。
注:虽然非阻塞IO没有被阻塞,但是也是同步IO,因为定义中提高的IO操作是指真实的IO操作,就是例子中的recvfrom这个system call。非阻塞IO在执行recvform这个system call的时候,如果kernel的数据没有准备好,这时候不会block进程。但是当kernel准备好数据的时候,recvform会将数据从kernel拷贝到 用户内存中,这时候进程是被block了,在这段时间内进程是被block的。

而异步IO不一样,当进程发起IO操作后,就直接返回,再也不理睬了,直到kernel发送一个信号,告诉进程说IO完成。在这整体过程中,进程完全没有被block。

各个IO模型的比较示意图:
这里写图片描述

通过上面的图片,可以发现非阻塞IO和异步IO的区别还是很明显的。在非阻塞IO中,虽然劲曾大部分时间不会被block,但是他仍要求进程去主动的check,并且当数据准备完成后,越要进程主动的再次调用recvfrom来将数据拷贝到用户内存。而异步IO则完全同。他就像是用户进程将整个IO操作交给了其他人(kernel)完成,然后别人做完之后发信号通知。在此期间,用户进程不需要检查IO的操作状态,也不需要主动的拷贝数据。

I/O多路复用——select、pool、epool

select、pool、epool都是IO多路复用的机制。I/O多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就续或是写就绪),能够通知程序进行相应的读写操作。但select、pool、epool本质上都是同步IO,因为他们都需要在读写事件就绪后自己负责读写,也就是说这个读写的过程是阻塞的,而异步IO则不需要自己负责读写,异步IO的实现会负责把数据从内核拷贝到用户空间。

select

select(rlict, wlist, xlist, timeout = None)

select 函数监视的文件描述符分3类,分别是wtiteds、readfds和exceptfds。调用select函数会阻塞,直到有描述符就绪(有数据可读、可写或者有exxcept),或者超时(timeout指定等待时间,如果立刻返回设为努null即可),函数返回。当select函数返回后,可以通过遍历fdset,来找到就绪的描述符。
select目前几乎早所有平台上支持,其良好的跨平台支持也是他的一个优点。select的一个缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在linux上一般为1024,可以修改宏定义甚至重新编译内核的方式提升这一限制,但这样会造成效率的降低。

poll

int poll(struct pokkfd *fds, unsigned int nfds, int timeout)

不同与select使用三个位图来表示三个fdset的方式,poll使用一个pollfd的指针实现。

struct pollfd{
    int fd; /*文件描述符*/
    short events;/*请求事件查看*/
    short revents;/*返回时间验证*/
};

pollfd结构包含了要监视的event和发生的event,不再使用select“参数-值”传递的方式。同时,pollfd并没有最大数量限制(但是数量过大后性能也会下降)。和select函数一样,poll返回后,需要轮询pollfd来获取就绪的文件描述符。
从上面看,select和poll都需要在返回后,通过遍历文件描述符在获取已经就绪的socket。事实上,同时链接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。

epoll
epoll是在2.6内核中提出的,是之前的select和poll的增强版本,相对于select和poll来说,epoll更加灵活,没有描述符的限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次

epoll操作过程
epoll造作过程需要三个接口,分别如下:

int epoll_create(int size);//创建一个wpoll的句柄,size用来告诉内核这个监听的数目一共有多大
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *event, int maxevents, int timeout);

1.int epoll_create(int size);
创建一个epoll的句柄,size用来高速内核这个监听的数目已共有多大,这个参数不同于select()中的第一个参数给出最大监听的fd+1的值,参数size并不是限制了wpoll佐能监听的描述符的最大个数,只是对内核初始分配内部数据结构的一个建议。(fd = 文件描述符)
当创建好epoll句柄之后,他就会占用一个fd值,在Linux下如果查看/proc/进程id/fd,是能够看到这个fd的,多以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。

2.int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
函数是对指定描述符fd执行op操作。
参数epfd:是epoll_create()的返回值
参数op:表示op操作,用三个宏来表示:添加EPOLL_CTL_ADD,删除EPOLL_CTL_DEL,修改EPOLL_CTL_MOD。分别添加、删除和修改对fd的监听事件
参数fd:是需要监听的fd
参数epoll_event:是告诉内核需要监听什么事

3.int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
等待epfd上的io事件,最多返回maxevents个事件。
参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size,参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时。

epoll select例子

#_*_coding:utf-8_*_

import socket, logging
import select, errno

logger = logging.getLogger("network-server")

def InitLog():
    logger.setLevel(logging.DEBUG)

    fh = logging.FileHandler("network-server.log")
    fh.setLevel(logging.DEBUG)
    ch = logging.StreamHandler()
    ch.setLevel(logging.ERROR)

    formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
    ch.setFormatter(formatter)
    fh.setFormatter(formatter)

    logger.addHandler(fh)
    logger.addHandler(ch)


if __name__ == "__main__":
    InitLog()

    try:
        # 创建 TCP socket 作为监听 socket
        listen_fd = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0)
    except socket.error as  msg:
        logger.error("create socket failed")

    try:
        # 设置 SO_REUSEADDR 选项 对unix套接字的设置
        listen_fd.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    except socket.error as  msg:
        logger.error("setsocketopt SO_REUSEADDR failed")

    try:
        # 进行 bind -- 此处未指定 ip 地址,即 bind 了全部网卡 ip 上
        listen_fd.bind(('', 2003))
    except socket.error as  msg:
        logger.error("bind failed")

    try:
        # 设置 listen 的 backlog 数
        listen_fd.listen(10)
    except socket.error as  msg:
        logger.error(msg)

    try:
        # 创建 epoll 句柄
        epoll_fd = select.epoll()
        # 向 epoll 句柄中注册 监听 socket 的 可读 事件
        #登记一个新的文件描述符,如果文件描述符已经被创建则引发一个OSError错误
        #fd是目标文件描述符的操作
        #register(fd[, eventmask])
        #events是由不同的EPOLL常熟组成的,EPOLLIN | EPOLLOUT | EPOLLPRI
        epoll_fd.register(listen_fd.fileno(), select.EPOLLIN)
    except select.error as  msg:
        logger.error(msg)

    connections = {}
    addresses = {}
    datalist = {}
    while True:
        # epoll 进行 fd 扫描的地方 -- 未指定超时时间则为阻塞等待
        #poll([timeout=-1[, maxevents=-1]]) -> [(fd, events), (...)]
        #Wait for events on the epoll file descriptor(文件描述符) for a maximum time of timeout
        #in seconds (as float). -1 makes poll wait indefinitely.
        #Up to maxevents are returned to the caller.
        epoll_list = epoll_fd.poll()

        for fd, events in epoll_list:
            # 若为监听 fd 被激活
            if fd == listen_fd.fileno():
                # 进行 accept -- 获得连接上来 client 的 ip 和 port,以及 socket 句柄
                conn, addr = listen_fd.accept()
                logger.debug("accept connection from %s, %d, fd = %d" % (addr[0], addr[1], conn.fileno()))
                # 将连接 socket 设置为 非阻塞
                conn.setblocking(0)
                # 向 epoll 句柄中注册 连接 socket 的 可读 事件
                epoll_fd.register(conn.fileno(), select.EPOLLIN | select.EPOLLET)
                # 将 conn 和 addr 信息分别保存起来
                connections[conn.fileno()] = conn
                addresses[conn.fileno()] = addr
            elif select.EPOLLIN & events:
                # 有 可读 事件激活
                datas = ''
                while True:
                    try:
                        # 从激活 fd 上 recv 10 字节数据
                        data = connections[fd].recv(10)
                        # 若当前没有接收到数据,并且之前的累计数据也没有
                        if not data and not datas:
                            # 从 epoll 句柄中移除该 连接 fd
                            epoll_fd.unregister(fd)
                            # server 侧主动关闭该 连接 fd
                            connections[fd].close()
                            logger.debug("%s, %d closed" % (addresses[fd][0], addresses[fd][1]))
                            break
                        else:
                            # 将接收到的数据拼接保存在 datas 中
                            datas += data
                    except socket.error as  msg:
                        # 在 非阻塞 socket 上进行 recv 需要处理 读穿 的情况
                        # 这里实际上是利用 读穿 出 异常 的方式跳到这里进行后续处理
                        if msg.errno == errno.EAGAIN:
                            logger.debug("%s receive %s" % (fd, datas))
                            # 将已接收数据保存起来
                            datalist[fd] = datas
                            # 更新 epoll 句柄中连接d 注册事件为 可写
                            epoll_fd.modify(fd, select.EPOLLET | select.EPOLLOUT)
                            break
                        else:
                            # 出错处理
                            epoll_fd.unregister(fd)
                            connections[fd].close()
                            logger.error(msg)
                            break
            elif select.EPOLLHUP & events:
                # 有 HUP 事件激活
                epoll_fd.unregister(fd)
                connections[fd].close()
                logger.debug("%s, %d closed" % (addresses[fd][0], addresses[fd][1]))
            elif select.EPOLLOUT & events:
                # 有 可写 事件激活
                sendLen = 0
                # 通过 while 循环确保将 buf 中的数据全部发送出去
                while True:
                    # 将之前收到的数据发回 client -- 通过 sendLen 来控制发送位置
                    sendLen += connections[fd].send(datalist[fd][sendLen:])
                    # 在全部发送完毕后退出 while 循环
                    if sendLen == len(datalist[fd]):
                        break
                # 更新 epoll 句柄中连接 fd 注册事件为 可读
                epoll_fd.modify(fd, select.EPOLLIN | select.EPOLLET)
            else:
                # 其他 epoll 事件不进行处理
                continue

python select解析

select
select最早于1983年出现在4.2BSD中,它通过一个select()系统调用来监视多个文件描述符的数组,当select()返回后,该数组中就绪的文件描述符便会被内核修改标志位,使得进程可以获得这些文件描述符从而进行后续的读写操作。

select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点,事实上从现在看来,这也是它所剩不多的优点之一。

select的一个缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024,不过可以通过修改宏定义甚至重新编译内核的方式提升这一限制。

另外,select()所维护的存储大量文件描述符的数据结构,随着文件描述符数量的增大,其复制的开销也线性增长。同时,由于网络响应时间的延迟使得大量TCP连接处于非活跃状态,但调用select()会对所有socket进行一次线性扫描,所以这也浪费了一定的开销。

poll
poll在1986年诞生于System V Release 3,它和select在本质上没有多大差别,但是poll没有最大文件描述符数量的限制。

poll和select同样存在一个缺点就是,包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。

另外,select()和poll()将就绪的文件描述符告诉进程后,如果进程没有对其进行IO操作,那么下次调用select()和poll()的时候将再次报告这些文件描述符,所以它们一般不会丢失就绪的消息,这种方式称为水平触发(Level Triggered)。
epoll
直到Linux2.6才出现了由内核直接支持的实现方法,那就是epoll,它几乎具备了之前所说的一切优点,被公认为Linux2.6下性能最好的多路I/O就绪通知方法。

epoll可以同时支持水平触发和边缘触发(Edge Triggered,只告诉进程哪些文件描述符刚刚变为就绪状态,它只说一遍,如果我们没有采取行动,那么它将不会再次告知,这种方式称为边缘触发),理论上边缘触发的性能要更高一些,但是代码实现相当复杂。

epoll同样只告知那些就绪的文件描述符,而且当我们调用epoll_wait()获得就绪文件描述符时,返回的不是实际的描述符,而是一个代表就绪描述符数量的值,你只需要去epoll指定的一个数组中依次取得相应数量的文件描述符即可,这里也使用了内存映射(mmap)技术,这样便彻底省掉了这些文件描述符在系统调用时复制的开销。

另一个本质的改进在于epoll采用基于事件的就绪通知方式。在select/poll中,进程只有在调用一定的方法后,内核才对所有监视的文件描述符进行扫描,而epoll事先通过epoll_ctl()来注册一个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似callback的回调机制,迅速激活这个文件描述符,当进程调用epoll_wait()时便得到通知。

python select

python的select()方法直接调用操作系统的IO接口,它监控sockets、open、files和pipes(所有带fileno()方法的文件句柄)何时变成readable和writeable,或者通信错误,select()是得同时监控多个连接变得简单,并且这比写一个长循环来等待和监控多客户端连接要高效,因为select直接通过操作系统提供的C的网络接口进行操作,而不是通过python解释器。
注:select()只用于Unix的文件对象,不适用于windows
下面通过echo server例子来理解select是如何通过单进程实现同时处理多个非阻塞的socket连接的
服务端代码:

#!/usr/bin/env python3

import select
import socket
import sys
import queue

#创建cosket连接
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
#设置socket连接为非阻塞
server.setblocking(False)

#设置主机的ip和端口
server_address = ('127.0.0.1', 10000)
#打印信息
print(sys.stderr, 'starting up on %s port %s' % server_address)
#绑定
server.bind(server_address)

#监听的最大连接数为5
server.listen(5)

#将想要从socket客户端接收来的数据放到一个列表中
inputs = [ server ]

#将想要发送到客户端的数据放在一个列表中
outputs = [ ]
#信息队列:接收和发送的数据都会存在这里,有select取出来在发出去
message_queues = {}
while inputs:
    print( 'nwaiting for the next event')
    #调用select时会阻塞和等待新的连接或数据进来
    #readable代表有可接收数据的socket连接
    #writable代表可进行发送操作的socket连接
    #exceptional代表当连接出错时的报错信息
    readable, writable, exceptional = select.select(inputs, outputs, inputs)
    #循环取出接收socket
    for s in readable:
        #如果是一开始的server(监听所有连接的socket),代表已经准备接收一个新的连接了
        if s is server:
            #准备接收新的连接
            connection, client_address = s.accept()
            #打印客户端的地址
            print('new connection from', client_address)
            #为了这个监听的socket可以处理多个连接,将其设置为非阻塞
            connection.setblocking(False)
            #将接收的socket连接放进inputs列表中
            inputs.append(connection)

            #在消息字典中创建一个队列,用来装接收的信息
            message_queues[connection] = queue.Queue()
        else:
            #如果不是初始的监听socket,表示socket已经要接收信息了,首先先接受信息
            data = s.recv(1024)
            #如果接收到了数据
            if data:
                #打印信息
                print(sys.stderr, 'received "%s" from %s' % (data, s.getpeername()) )
                #在消息字典的对应队列中将接收的信息添加
                message_queues[s].put(data)
                #如果循环的这个socket连接不在要发送的socket连接列表里
                if s not in outputs:
                    #将这个socket连接添加到需要发送的socket连接列表里
                    outputs.append(s)
            #如果没有接收到信息,说明已经接受完了,可以断开连接了
            else:
                #打印信息
                print('closing', client_address, 'after reading no data')
                #如果没接收到信息,那也就不需要向客户端返回信息,所以如果在发送表中这个socket连接还存在,就把他删除了
                if s in outputs:
                    outputs.remove(s)  #既然客户端都断开了,我就不用再给它返回数据了,所以这时候如果这个客户端的连接对象还在outputs列表中,就把它删掉
                inputs.remove(s)    #inputs中也删除掉
                s.close()           #把这个连接关闭掉

                #连接删掉了,信息字典中相应的队列信息也就没用了,删掉
                del message_queues[s]
    #当socket连接在发送列表里的时候
    for s in writable:
        try:
            #获取消息字典里相应的队列信息
            next_msg = message_queues[s].get_nowait()
        except queue.Empty:
            #当字典为空的时候,就是信息都取完了,将连接送发送列表中删除
            print('output queue for', s.getpeername(), 'is empty')
            outputs.remove(s)
        else:
            #获取成功的时候,将消息发送出去
            print( 'sending "%s" to %s' % (next_msg, s.getpeername()))
            s.send(next_msg)
    #当连接报错的时候
    for s in exceptional:
        #打印信息
        print('handling exceptional condition for', s.getpeername() )
        #将错误的socket连接从接收表中删除
        inputs.remove(s)
        #如果在发送表中也有,就把发送表中的也清了
        if s in outputs:
            outputs.remove(s)
        #关闭连接
        s.close()

        #在消息字典中删除相应的信息
        del message_queues[s]

客户端完整代码

#!/usr/bin/env python3

import socket
import sys

#消息文本模板 
messages = [ 'This is the message. ',
             'It will be sent ',
             'in parts.',
             ]
#需要连接的主机地址
server_address = ('localhost', 10000)

#创建客户端的socket连接列表
socks = [ socket.socket(socket.AF_INET, socket.SOCK_STREAM),
          socket.socket(socket.AF_INET, socket.SOCK_STREAM),
          ]

#打印信息并将socket连接列表中的全部链接连接到目标主机
print(sys.stderr, 'connecting to %s port %s' % server_address)
for s in socks:
    s.connect(server_address)
#遍历消息模板 
for message in messages:

    #向服务端发送信息
    for s in socks:
        print(sys.stderr, '%s: sending "%s"' % (s.getsockname(), message))
        s.send(message)

    #接收服务端返回的信息
    for s in socks:
        data = s.recv(1024)
        print >>sys.stderr, '%s: received "%s"' % (s.getsockname(), data)
        if not data:
            print >>sys.stderr, 'closing socket', s.getsockname()
            s.close()

拓展

selectors模块
该模块允许基于select模块原语构建的高级别和高效的/输出多路复用。鼓励用户使用这个模块,除

版权声明:本文来源CSDN,感谢博主原创文章,遵循 CC 4.0 by-sa 版权协议,转载请附上原文出处链接和本声明。
原文链接:https://blog.csdn.net/kill0383/article/details/76037786
站方申明:本站部分内容来自社区用户分享,若涉及侵权,请联系站方删除。
  • 发表于 2020-03-08 17:00:51
  • 阅读 ( 1452 )
  • 分类:运维

0 条评论

请先 登录 后评论

官方社群

GO教程

猜你喜欢