[Python自学] day-10 (多进程、数据交互、进程锁、__main__、进程池、协程、gevent、简单爬虫、事件驱动异步IO、select poll epoll)

一、多进程

  程序中, 大量的计算占用CPU资源,而IO操作不占CPU资源。当程序需要进行大量计算时,Python采用多线程运行的速度不一定比单线程快多少。但是当程序是IO密集型的,那就应该使用多线程来处理。

  由于Python自身机制,多线程并不是同时运行在不同的CPU核心上的。但是我们可以使用多进程(每个进程默认有一个线程)来达到同时使用多个CPU核心来完成多件事情的目的,例如CPU一共8核,我们可以启动8个Python进程,这样每个进程中默认的线程都可以同时运行了。但是多进程之间数据默认不能通讯,如果需要通讯只能采用其他方式来达到目的。

1.使用multiprocessing库

导入库:import multiprocessing

import multiprocessing
import time

def run(name):
    time.sleep(3)
    print("This is a processing,My name is ", name)

if __name__ =='__main__':
    myProc = multiprocessing.Process(target=run,args=("Leo",))
    myProc.start()

启动多进程:

import multiprocessing
import time

def run(name):  # 进程运行函数(中间可以再起子线程)
    time.sleep(2)
    print("hello", name)

for i in range(10): # 循环启动进程
    proc = multiprocessing.Process(target=run, args=("leo + %s" % i,))
    proc.start()

查看进程ID(父进程ID):

import multiprocessing
import time
import os

def run(name):  # 进程运行函数(中间可以再起子线程)
    time.sleep(2)
    print("parent process: ", os.getppid()) # 获取父进程ID
    print("current process: ", os.getpid()) # 获取当前进程ID
    print("hello", name)

if __name__ == "__main__":
    for i in range(10): # 循环启动进程
        proc = multiprocessing.Process(target=run, args=("leo + %s" % i,))
        proc.start()

   一个进程一定会有一个父进程,在Linux中,所有进程都是从init进程衍生的。

 

二、多进程之间的数据交互

1.方式一

方式一:Queue

在多线程中使用线程queue,子线程可以直接访问主线程中定义的变量。但是在多进程中,子进程无法直接访问主进程的变量。

from multiprocessing import Process, Queue
import time

def run( que):  # 进程运行函数(中间可以再起子线程)
    time.sleep(2)
    print("hello", que.get())   # 获取从主进程传递过来的q

if __name__ == "__main__":
    q = Queue() # 定义一个多进程queue
    q.put([10, 20, 30]) # 往q中填充一组数据
    for i in range(10): # 循环启动进程
        proc = Process(target=run, args=(q,))   # 将主进程的q传入子线程
        proc.start()

上述代码中,主进程只往q中填充了一份数据,而启动了10个子进程,实际上只有一个子进程从q中获取到了数据,其他的都没有获取到。

进程间通过queue传递数据的大体流程:

  主进程定义queue--->克隆一个queue--->使用pickle序列化克隆出来的queue--->传递给子进程

所以,主进程将queue用参数的形式传递给子进程,实际上这个queue不是处于中间内存的(进程间内存是不能互访的),而是通过其他方式,

例如socket等方式将序列化后的数据进行传递,从而达到数据交互的目的。

2.方式二

方式二:Pipe

from multiprocessing import Process, Pipe

def func(conn):
    conn.send("hello dad")  #在子进程中通过管道发送消息
    conn.close()

if __name__ == "__main__":
    parent_pipe, child_pipe = Pipe()    # 生成一个Pipe实例,返回两个对象
    p = Process(target=func, args=(child_pipe,))    # 将其中一个传递给子进程
    p.start()
    print(parent_pipe.recv())   # 在父进程中接收管道消息

定义一个Pipe实例会直接返回两个对象,可以看成一个电话的两头。将两头分别交给两个进程,就可以通过Pipe来通信了,一个send一个recv即可。

注意:进程A给进程B发了一次数据,进程B使用一次接受,如果再次调用接受,则会阻塞。

在定义Pipe对象时,Pipe()有一个参数叫duplex,默认值为Ture,即默认Pipe为双工(可收可发),如果设置duplex=False,则为单向的,返回的对象为r,w。

3.方式三

以上两种方式都只能完成进程之间的数据传递,还不能达到线程之间数据共享的效果(同时修改一份数据)。

方式三:Manager

from  multiprocessing import Process, Manager

def func(d, l):
    print(d)    # {}
    print(l)    # [0, 1, 2, 3, 4]

if __name__ == '__main__':
    man = Manager()
    di = man.dict() # 使用Manager来产生一个特殊的dict
    li = man.list(range(5)) # 使用Manager来产生一个特殊的list
    p = Process(target=func, args=(di, li,))
    p.start()
    p.join()    # 必须有,不然会出问题<ListProxy object, typeid 'list' at 0x2d6bd30; '__str__()' failed>

注意:p.join必须存在,不然在func中打印的字典和列表都有问题,不能读写,读写会报错。

三、进程锁

多进程访问同一个资源时需要使用进程锁。

from  multiprocessing import Process, Lock

def func(index,lk):
    lk.acquire()    # 申请锁
    print("hello world",index)
    lk.release()    # 释放锁

if __name__ == '__main__':
    lock = Lock()   # 定义一个进程锁
    for i in range(10):
        p = Process(target=func, args=(i,lock)) # 将锁传递给子进程
        p.start()

代码中,主进程和子进程之间并没有同时操作一个数据,为什么还要加锁,是因为各个进程都在print数据到屏幕上,相当于同时在访问屏幕这个资源,所以为了避免数据打印混乱,要使用进程锁。当然,在操作同一个数据时,更要使用进程锁

四、if __name__ == '__main__'

  这句话的意思是用来区分该模块是直接主动运行的还是被其他模块导入运行的,如果是直接运行的,这个if后面的代码就运行,否者不运行。

  主动执行,print(__name__)打印__main__

  在其他模块中导入执行,则打印__modulename__(例如__process_pool__)

五、进程池

from multiprocessing import Process, Pool
import time

def func(index):
    time.sleep(2)
    print(index)

if __name__ == '__main__':
    pool =  Pool(5) # 允许同时5个进程加入进程池,即同时最多只有5个进程运行到CPU上,其他的都处于挂起状态
    for i in range(10):
        pool.apply(func = func, args=(i,))

pool.apply()是串行的,并不会5个进程一起运行,而是一个一个的运行。

想要并行的运行pool中的5个进程,则需要使用pool.apply_async()

from multiprocessing import Process, Pool
import time

def func(index):
    time.sleep(2)
    print(index)

if __name__ == '__main__':
    pool =  Pool(5) # 允许同时5个进程加入进程池,即同时最多只有5个进程运行到CPU上
    for i in range(10):
        pool.apply_async(func = func, args=(i,))

    pool.close()    #必须加上
    pool.join()     #必须加上

pool.close()和pool.join()必须加上,不然无法运行。具体为什么暂不详。

  

子进程执行完执行回调函数:

from multiprocessing import Process, Pool
import time
import os

def func(index):
    time.sleep(2)
    print(index)

def bar():
    print("执行该回调函数的进程ID:",os.getpid())

if __name__ == '__main__':
    print("主线程ID:",os.getpid())
    pool =  Pool(5) # 允许同时5个进程加入进程池,即同时最多只有5个进程运行到CPU上
    for i in range(10):
        pool.apply_async(func = func, args=(i,), callback=bar())

    pool.close()    #必须加上
    pool.join()     #必须加上

callback=bar():表示在每个进程执行完func后,会回调bar()函数。有什么意义呢?我也可以把bar中的操作放到func的最后去执行,效果上是一样的。

区别在于:回调函数bar()是在子进程执行完func函数后,由主进程调用。在bar()中打印os.getpid()的值与主进程的PID是一致的。

用在什么地方:例如用100个进程去备份远程服务器的日志或数据库,也就是func()函数完成的工作,在执行完毕后,我们需要在数据库中写入一条日志,就可以使用回调函数bar来完成,因为bar是主进程执行的,数据库连接只需要链接一次。而如果分别在子进程的func()中写数据库,就要进行100次数据库连接。

六、协程Coroutine

1.协程简介

协程又称微线程,纤程。英文名Coroutine。是一种用户态的轻量级线程。CPU是不知道协程的存在的,协程完全由用户控制。

协程拥有自己的寄存器上下文和栈(不是CPU寄存器,和线程不同),切换的时候,从寄存器里恢复上下文。因此,协程能保留上一次调用时的状态(即所有局部状态的特定组合),每次过程重入时,就相当于进入上一次调用的状态,换一种说法就是:进入上一次离开时所处逻辑流的位置。

协程的好处:

  • 无需线程上下文切换的开销(因为他处于一个线程内)
  • 无需原子操作锁定及同步的开销(在一个线程中串行执行,当然不用锁定和同步)
  • 方便切换控制流,简化编程模型
  • 高并发+高扩展性+低成本:一个CPU支持上万个协程都不是问题。所以很适合用于高并发处理(例如WEB服务器)。

协程的缺点:

  • 无法利用多核资源(线程都无法利用,更别说使用一个线程模拟出来的多并发),和线程一样,需要配合进程才能利用多CPU,当然我们日常绝大部分应用都没有必要,除非是CPU密集型应用。
  • 进行阻塞(Blocking)操作(如I/O时)会阻塞掉整个程序(因为多协程在线程中实际上是串行执行的,一个阻塞了,后面的就无法执行了)。

2.协程基本原理

import time

def func1():
    print("this is func1")
    time.sleep(5)
    print("func1 done")

def func2():
    print("this is func2")
    time.sleep(2)
    print("func2 done")

def func3():
    print("this is func3")
    time.sleep(2)
    print("func3 done")

  代码中有三个函数,每个函数假设代表一个WEB请求后台执行的过程,func1中有一个耗时5s的IO操作,func2中有一个耗时2s的IO操作,func3中没有耗时的IO操作。那么如果同时收到3个请求,分别要执行这三个函数,但是是在一个单线程中,那么这三个函数必定是串行的执行的。如果才能使他们的执行过程看起来是并行的?办法就是在运行每个函数的时候,只要遇到耗时的IO操作,就切换到下一个函数执行。

  但是,面临一个问题是,什么时候再切换回去?最佳时机就是当IO操作刚好执行完毕的时候切换回去继续执行后面的print操作。如果能够做到这一点,就可以在单线程中模拟出多并发的效果,即实现了协程。

3.安装gevent

安装gevent:

  gevent是一个第三方库。实现协程。

  要使用greenlet,要先安装gevent。

  使用pycharm里的File->setting->Project Interpreter->安装gevent。

4.greenlet(手动挡)

from greenlet import greenlet

def func1():
    print(12)
    gr2.switch()    # 切换到func2
    print(34)
    gr2.switch()    # 切换到func2

def func2():
    print(56)
    gr1.switch()    # 切换到func1
    print(78)

gr1 = greenlet(func1)   # 启动一个协程,这个协程处理func1
gr2 = greenlet(func2)
gr1.switch()    # 切换到func1,即从func1开始执行

  上述代码输出为:  

    12
    56
    34
    78

greenlet是手工进行协程切换,相当于汽车的手动挡。而gevent相当于自动挡。

 

5.gevent(自动挡)

对greenlet进行封装后实现的自动挡切换协程。

import gevent

def func1():
    print("run func1")  # 1
    gevent.sleep(2) # 2.切换到func2 7.切换到func2 10.切换到func2
    print("run func1 again")    # 12

def func2():
    print("run func2")  # 3
    gevent.sleep(1) # 4.切换到func3 8.切换到func3
    print("run func2 again")    # 11

def func3():
    print("run func3")  # 5
    gevent.sleep(0) # 6.切换到func1 
    print("run func3 again")    # 9

gevent.joinall([
    gevent.spawn(func1),    # 生成一个协程处理func1
    gevent.spawn(func2),    # ...
    gevent.spawn(func3)     # ...
])

从1-12是处理的流程顺序。其中gevent.sleep()用来模拟IO操作。

七、协程爬网页

1.基本url访问模块

from urllib import request as req
 
def func(url):
    print("GET url: ", url)
    resp = req.urlopen(url) # 发送请求
    data = resp.read()  # 读取网页数据
    print(" %d bytes received from %s." % (len(data), url))
    f = open("baidu.html", "wb")
    f.write(data)   # 保存到文件中
    f.close()
 
func("http://www.baidu.com")    # 抓取百度首页

2.利用协程(gevent)来抓取数据

import gevent
from urllib import request as req

def func(url):
    print("GET url: ", url)
    resp = req.urlopen(url) # 发送请求
    data = resp.read()  # 读取网页数据
    print(" %d bytes received from %s." % (len(data), url))

# 启动三个协程来同步抓取不同的网页数据
gevent.joinall([
    gevent.spawn(func, 'http://www.baidu.com'),
    gevent.spawn(func, 'http://www.163.com'),
    gevent.spawn(func, 'http://www.sohu.com')
])

但是发现问题,使用gevent协程并行,反而比直接串行还慢,为什么呢?

是因为urllib与gevent没有关系,urllib本来就是阻塞的,gevent无法知道urllib进行了IO操作,所以根本没有进行切换操作。包括前面学习的socket也是这样。所以,urllib和socket直接交给gevent使用,不好使。

让gevent能够监控到urllib执行的IO操作:

from urllib import request as req
import gevent
from gevent import monkey

monkey.patch_all()  # 把当前程序(urllib中)的所有的可能的IO操作做上标记,类似gevent.sleep(0)

def func(url):
    print("GET url: ", url)
    resp = req.urlopen(url) # 发送请求
    data = resp.read()  # 读取网页数据
    print(" %d bytes received from %s." % (len(data), url))

# 启动三个协程来同步抓取不同的网页数据
gevent.joinall([
    gevent.spawn(func, 'http://www.baidu.com'),
    gevent.spawn(func, 'http://www.163.com'),
    gevent.spawn(func, 'http://www.sohu.com')
])

导入monkey:from gevent import monkey   

添加:monkey.patch_all()

就可以实现真正的协程操作。

3.gevent实现高并发socket server

import gevent

from gevent import socket, monkey
monkey.patch_all()

def server(port):
    s = socket.socket()
    s.bind(('0.0.0.0', port))
    s.listen(500)
    while True:
        cli, addr = s.accept()  # 将监听到链接实例
        gevent.spawn(handle_request, cli)   # 启动一个协程,并将链接实例交给handle_request处理

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)

测试客户端:

import socket

HOST = 'localhost'
PORT = 8001
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((HOST, PORT))
while True:
    msg = bytes(input(">>:"), encoding='utf8')
    s.sendall(msg)
    data = s.recv(1024)
    print('received', data)

s.close()

八、事件驱动与异步IO

前面,我们已经知道协程在什么时候切换,但我们还不知道什么时候切换回来。具体是怎么实现的。这里先了解事件驱动和异步IO的知识。

 

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

  1. 每收到一个请求,创建一个新的进程来处理请求。
  2. 每收到一个请求,创建一个新的线程来处理请求。
  3. 每收到一个请求,放入一个事件列表,让主进程通过非阻塞I/O方式来处理请求。(协程)

上面几种方式,各有千秋:

  第一种,由于创建新的进程开销比较大,所以会导致服务器性能差,但实现简单。

  第二种,由于要涉及到线程的同步,有可能会面临死锁等问题。

  第三种,在写应用程序代码时,逻辑比前面两种复杂。

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

 

事件驱动模型:

在UI编程中,常常要对鼠标点击进行相应,如何获得数据点击呢?

方式一:创建一个线程,一直循环检查是否有鼠标点击,这种方式有以下几个缺点

  1. 浪费CPU资源,可能鼠标点击频率非常低,但是该线程还是会一直循环检测。如果鼠标点击是阻塞的(点一下需要花时间去处理事情),那么点完后,下次点击就无法检测到了。
  2. 如果一个循环需要扫描的设备非常多,又会导致响应时间长的问题。

方式二:事件驱动模型

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

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

   

  点击鼠标或按下键盘时,会将一个事件注册到事件列表中,并将点击的什么地方,调用什么函数来处理都一并写入列表中,列表遵循FIFO先进先出原则。

  有一个线程会一直循环的读取列表中的事件,读取到一个事件后,按照事件里的处理要求进行处理。而不会阻塞点击动作。

 

回调函数:

  当我们点击了页面的某个按钮,预期是弹出一个对话框,但是对话框没有弹出,我们不知道处理函数是否已经处理完了。所以,我们需要在注册事件时添加一个回调函数,用于处理线程在执行完处理函数后调用回调函数,这样我们就知道事件处理完毕了。

  

协程什么时候切换回来:

  当协程遇到一个IO操作时(IO操作时由操作系统完成的),需要知道操作系统是否已经完成了IO操作,但又不可能一直去询问操作系统,那么就在调用IO操作接口时,给操作系统一个回调函数(操作系统的事件队列),当操作系统完成IO操作后,执行回调函数通知协程。

  

IO多路复用:

  讨论前提:Linux下的Network IO。(Windows可能不太一样)

  

几个重要概念:

  • 用户空间和内核空间
  • 进程切换:即我们讲的线程切换。
  • 进程的阻塞
  • 文件描述符
  • 缓存I/O

用户空间与内核空间:

  现在的操作系统都是采用虚拟存储器,对32位的操作系统而言,寻址空间(虚拟存储空间)为2的32次方,即4G。操作系统的核心是内核,独立于普通的应用程序,可以访问受保护得内存空间(内核运行需要的内存空间,用户只能使用除开这部分内存的空间),也有访问底层硬件设备的所有权限。

  为了保证用户进程不能直接操作内核,保证内核的安全,操作系统将虚拟空间划分为两个部分,一部分为内核空间,一部分为用户空间。针对Linux操作系统而言,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,成为内核空间,而将较低的3G字节(从0x00000000到0xBFFFFFFF),提供给各进程使用,称为用户空间。

  

进程的阻塞:

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

 

文件描述符:

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

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

 

缓存I/O:

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

  缓存I/O缺点:

  数据在传输过程中需要在应用程序地址空间和内核空间进行多次数据拷贝操作,这些拷贝操作带来的CPU以及内存开销是非常大的。例如socket的缓冲区,我们为什么要使用flush来一次性发送数据,就是减少拷贝次数。

I/O模式:

  以一次IO访问(read举例),数据会先被拷贝到操作系统的内核缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。所以说,当一个read操作发成时,它会经历两个阶段:

  1. 等待数据准备(Waiting for the data to be ready)
  2. 将数据从内核拷贝到进程中(Copying the data from the kernel to the process)

正是因为这两个阶段,Linux系统产生了下面五种网络模式的方案:

  • 阻塞I/O(blocking IO)
  • 非阻塞I/O(nonblocking IO)
  • I/O多路复用(IO Multiplexing)
  • 信号驱动I/O(signal driven IO):实际中应用不多
  • 异步I/O(Asynchronous IO)

阻塞I/O:

  Linux中,默认情况下所有的socket都是阻塞的(例如recv()的时候),一个典型的读操作如下图所示:

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

 

非阻塞I/O:

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

 

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

(在non-blocking模式下,数据从内核拷贝到用户内存时,同样是阻塞的,只是时间比较短)

 

I/O多路复用: 

  IO multiplexing就是我们说的select、poll、epoll,也称这种IO方式为事件驱动IO。select/epoll的好处就是一个process可以同时处理多个网络连接IO。他的原理是select、poll、epoll这个function会不断的轮询它所负责的所有socket,当某个(哪怕就只有其中一个)socket有数据到达,他就通知用户进程。

 

  当用户进程调用了select,那么整个进程就会被block掉(但实际上多路复用IO里的socket一般设置为non-blocking),这个block是select导致的阻塞。同时,kernel会“监视”所有select负责的socket,当任何一个socket的数据到达,select就会返回。这个时候用户进程再调用read操作,数据就从kernel拷贝回用户内存了。

  所以,IO多路复用的特点是通过一种机制让一个进程能够同时等待多个文件描述符(或套接字描述符等)。

  当连接数(即socket数量)不是很多时,IO多路复用模式的效率可能还比不上多线程+阻塞模式的效率,可能延迟还更大。多路复用的优势并不在于处理单个连接更快,而是能够处理更多的连接。

  (这种模式下,任然没有避免数据从内核拷贝到用户内存这个过程的blocking)   

 

异步I/O(Asynchronous IO):

  Linux下的异步IO使用得比较少,但很牛逼。

  

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

  以去银行办卡举例,用户向银行发起办法请求(异步read)。银行收到请求后,给用户一个反馈说请求已收到,并让用户留下电话号码和地址,然后反馈给用户一个凭条(直接return)。这时,用户可以去办其他事情,例如逛超市。银行方面等卡办好后(数据到达),直接按照用户留下的地址,将银行卡邮寄给用户,等银行卡到达用户住址后(拷贝数据完毕),银行再打电话通知用户卡已办好(发送signal给用户),用户直接接受就可以了。

  (异步IO真正实现了无任何blocking。)

  

总结:

  synchronous IO和asynchronous IO的定义:

  synchronous IO:A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes;

  asynchronous IO:An aynchronous I/O operation does not cause the requesting process to be blocked;

  synchronous IO和asynchronous IO的区别:

  两者的区别就在于是否blocking,按这种说法,前面的blocking IO,non-blocking IO,IO multiplexing都存在blocking(数据准备阶段或数据拷贝阶段),所以都属于synchronous I/O。而最后一个异步IO,是完全实现了无blocking,所以它才是名副其实的asynchronous I/O。

   

下面是几种模式的对比图:

  

 

select、poll、epoll区别:

select:

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

  select目前几乎在所有的平台上支持(包含windows),良好的跨平台是它的一个优点,也是它所剩不多的优点之一。

  select的一个缺点是在于单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024(Linux默认用户能打开的文件数就是1024),不过可以通过修改宏定义或者重新编译内核的方式提升这个限制。

  select()所维护的存储大量文件描述符的数据结构,随着文件描述符数量的增大,其复制的开销也线性增长。

  另外,当内核监视到某个文件描述符有数据到达时,并不是告知用户具体哪个描述符有数据,而是笼统的告诉用户所监视的描述符中有数据到达,这样select就只能再次循环遍历(non-blocking模式读取)所有描述符来获取数据,如果被监视的描述符数量很多,性能直线下降。

poll:

  1986年诞生于System V Release3,和select没多大区别,但是poll没有最大文件描述符数量限制。  

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

  另外,select()和poll()将就绪的文件描述符告诉进程后,如果进程没有对其进行IO操作,那么下次调用select()或poll()的时候会再次报告这些文件描述符,所以它们一般不会丢失就绪信息,这种方式叫水平触发(Level Triggered)。

  (poll为过度技术,用的很少)

epoll:(nginx等使用这个)

  Linux内核2.6(CentOS6,Windows不支持epoll)出现了由内核直接支持的实现方法,就是epoll,它几乎具备了之前所说的一切优点,被公认为最高的多路复用IO就绪通知方法。

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

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

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

  (不准确信息:一个单纯的socket,不干其他事情,连接大概需要4k的内存空间,用户态内存1G的情况下,可以支持到10W个,这有多牛逼?早期的apache就是用多进程多线程实现的,3000个连接就不行了,现在好多了)

  

epoll和异步IO谁更牛逼:

  技术上说,异步IO更牛逼,但是由于异步IO实现复杂,内核模块(aio)支持不是特别好,所以使用的比较少。目前使用最多的还是epoll,例如nginx等目前热门的web服务器。我们可能平时叫nginx等为异步IO,实际上他是IO多路复用,并不是真正意义上的异步IO。

九、select使用

import socket
import select

server = socket.socket()
server.bind(('localhost',9000))
server.listen()
server.setblocking(False)   # 将阻塞模式设置为non-blocking模式

inputs = [server,]  # 定义一个监视列表,首先监控server,即是否有新连接进来
outputs = []    # 暂时不管这个

while True: # 每操作一次就需要循环调用select
    # 系统调用select()应该是在有数据到达时返回所有的监视列表,但是这里的select
    # 进行了封装,直接返回有数据的列表,我们用readable接收。
    readable, writable, exceptional = select.select(inputs, outputs, inputs)
    for r in readable:
        # 判断这次select返回的列表中描述符的种类,如果是server,则表示有新连接
        if r is server:
            conn, addr = server.accept()
            inputs.append(conn) # 建立连接后,将这个描述符也加入监视列表
        else:   # 如果是其他情况,则表示某个描述符有数据到达
            data = r.recv(1024) # 这里一定要使用r来进行接收,千万不能用conn
            print('recv:',data)
            r.send(data)

  select.select()的参数有三个,第一个表示需要监控的列表,里面放的是文件描述符(select,conn等)。第二个是用于服务器给客户端返回数据时存放文件描述符的。第三个是Exception监控,我们同样设置为这个文件描述符列表。该方法返回3个列表,第一个是活动的描述符(封装后只返回活动的),第二个是我们想要反馈客户端的描述符(和outouts里面是一样的,只是走了个形式),第三个是出现异常的描述符(例如断开)。

  

返回数据给客户端、处理异常:

import socket
import select
import queue

server = socket.socket()
server.bind(('localhost', 9001))
server.listen()
server.setblocking(False)   # 将阻塞模式设置为non-blocking模式

inputs = [server,]  # 定义一个监视列表,首先监控server,即是否有新连接进来
outputs = []    # 暂时不管这个

msg_dict = {}   # 这个字典用来存放每个链接对应的发送数据的queue

while True:  # 每操作一次就需要循环调用select
    # 系统调用select()应该是在有数据到达时返回所有的监视列表,但是这里的select
    # 进行了封装,直接返回有数据的列表,我们用readable接收。
    readable, writable, exceptional = select.select(inputs, outputs, inputs)
    for r in readable:
        # 判断这次select返回的列表中描述符的种类,如果是server,则表示有新连接
        if r is server:
            conn, addr = server.accept()
            inputs.append(conn) # 建立连接后,将这个描述符也加入监视列表

            msg_dict[conn] = queue.Queue()  # 为新链接创建一个queue,并存放在msg_dict中
        else:   # 如果是其他情况,则表示某个描述符有数据到达
            data = r.recv(1024) # 这里一定要使用r来进行接收,千万不能用conn
            print('recv:', data)
            msg_dict[r].put(data)   # 将要发送的数据放进队列中

            outputs.append(r)   # 想返回数据给客户端,就把这个描述符加入outputs

    for w in writable:
        data_to_client = msg_dict[w].get()  # 获取需要发送给client的数据
        w.send(data_to_client)  # 发送数据
        outputs.remove(w)   # 发完一次数据要从outputs中删除,免得下次循环又发送

    for e in exceptional:
        if e in outputs:    # 出错的描述符是否在outputs中
            outputs.remove(e)   # 从outputs中删除

        inputs.remove(e)    # 从inputs中删除(一定在里面,因为链接已经建立)
        del msg_dict[e] # 链接异常的话,从字典中删除该描述符对应的queue

  代码中,每次收到数据后,都会在下一次循环的时候将数据原封不动发回client。在接收的时候就将描述符加入outputs中,并且将数据写到描述符对应key的queue中,下次循环就会在writable中发现这个描述符需要发送数据给client,就以描述符为key去msg_dict中查找自己对应的queue,再从中取出数据发送给Client。

  在处理异常描述符的过程中,需要从inputs,outputs,exceptional三个列表以及msg_dict字典中删除异常描述符相关的信息。

 

如何使用selectors库:

import selectors
import socket

sel = selectors.DefaultSelector()

def accept(sock, mask):
    conn, addr = sock.accept()
    print('accepted', conn, 'from', addr)
    conn.setblocking(False)
    # 将连接描述符注册到selector中,只要有数据到达,就执行read函数
    sel.register(conn, selectors.EVENT_READ, read)

def read(conn, mask):
    data = conn.recv(1024)
    if data:
        print('echoing', repr(data), 'to', conn)
        conn.send(data)
    else:   # 收不到数据说明连接断了
        print('closing', conn)
        sel.unregister(conn)    # 取消注册
        conn.close()    #关闭连接

sock = socket.socket()
sock.bind(('localhost', 10000))
sock.listen(100)
sock.setblocking(False)
# 将定义的socket注册到selector中,只要来一个新连接,就执行accept函数
sel.register(sock, selectors.EVENT_READ, accept)

while True:
    # 这个select有可能底层调用的是epoll,也可能是select,默认是阻塞的,有连接或有数据时返回
    event = sel.select()
    # 从返回的列表中遍历
    for key, mask in event:
        callback = key.data # callback接受的就是accept或read函数
        callback(key.fileobj, mask) # 这里调用accept或read函数

selectors底层可能使用epoll实现的,所以在sel.select()的时候,底层可能是epoll(不确定)。

使用简单的多连接客户端测试效果:

import socket
import sys

HOST = 'localhost'
PORT = 10000

# 每个连接发送5条数据
msgs = ['I love you',
        '你好中国,我是Leo',
        '牛鼻不牛鼻',
        '克鲁赛德交话费乐山大佛就开始对方呢',
        'sdhfjdksoifnweifm,msd'
        ]

# 列表生成式自动生成10000个连接
socks = [socket.socket(socket.AF_INET, socket.SOCK_STREAM) for i in range(500)]
print('Connecting to %s port %s' % (HOST, PORT))

# 同时连接服务器
for s in socks:
    s.connect((HOST, PORT))

# 每条数据都用所有的连接发一次
for msg in msgs:
    # 循环发送
    for s in socks:
        print('%s: sending "%s"' % (s.getsockname(), msg))
        s.send(bytes(msg, encoding='utf8'))

    # 循环接受服务器反馈
    for s in socks:
        data = s.recv(1024)
        print('%s: received "%s"' % (s.getsockname(), data))
        if not data:
            print(sys.stderr, 'closing socket', s.getsockname())

for s in socks:
    s.close()

在pycharm中运行,几百条连接没有问题。太多会被服务器积极拒绝连接,可能是系统问题。

在Linux中运行server,修改Linux内核参数ulimit -SHn 100000(默认是1024,也就是用户只能同时打开1024个文件描述符),修改后,运行没有问题,效率还不错。

posted @ 2018-03-21 17:00  风间悠香  阅读(393)  评论(0编辑  收藏  举报