关于Python异步编程的一些心得(一)

引言

由于在Django项目中使用了基于异步的websocket框架,故而打算对异步的工作原理进行一波深入的了解。

热身

回顾一下与异步编程相关的一些概念

阻塞

  • 程序未得到所需计算资源时被挂起的状态。
  • 程序在等待某个操作完成期间,自身无法继续干别的事情,则称该程序在该操作上是阻塞的。
  • 常见的阻塞形式有:网络I/O阻塞、磁盘I/O阻塞、CPU上下文切换、用户输入阻塞等。

非阻塞

  • 程序在等待某操作过程中,自身不被阻塞,可以继续运行干别的事情,则称该程序在该操作上是非阻塞的。

同步

  • 不同程序单元为了完成某个任务,在执行过程中需靠某种通信方式以协调一致
  • 同步意味着有序

异步

  • 为完成某个任务,不同程序单元之间过程中无需通信协调,也能完成任务的方式。
  • 异步意味着无序
  • e.g.爬虫下载网页。调度程序调用下载程序后,即可调度其他任务,而无需与该下载任务保持通信以协调行为。不同网页的下载、保存等操作都是无关的,也无需相互通知协调。这些异步操作的完成时刻并不确定。

进程通信层面, 阻塞/非阻塞, 同步/异步基本是同义词, 但是需要注意区分讨论的对象是发送方还是接收方。
这是由于进程间的通信是通过 send()receive() 两种基本操作完成的。消息的传递有可能是阻塞的或非阻塞的 —— 也被称为同步或异步的

异步编程
进程、线程、协程、函数/方法作为执行任务程序的基本单位,结合回调、事件循环、信号量等机制,以提高程序整体执行效率和并发能力的编程方式。

异步编程的难点

  1. 无法准确判断代码执行时的行为(不可预测性)
  2. 异步任务必须足够小,不能耗时太久,如何拆分是个问题
  3. 目前大多数异步编程模型都是经过简化的,及一次只允许处理一个事件,因此有关异步编程的讨论都集中在单线程内

为什么需要异步编程

  • 了解CPU的时间观念
操作真实延迟CPU体验
执指0.38ns1s
读l1缓存0.5ns1.3s
分支纠错5ns13s
读l2缓存7ns18.2s
加解互斥锁25ns1min 5s
内存寻址100ns4min 20s
上下文切换/系统调用1.5us1h
1Gpbs网络传输2kb数据20us14.4h
从RAM读取1M数据块250us7.5day
Ping单一IDC主机500us15day
从SSD读1M数据1ms1month
从硬盘读1M数据20ms20month
Ping不同城市主机150ms15year
虚拟机重启4s300year
服务器重启5min25000year

如上表所示,在千兆网上传输2KB的数据,CPU感觉过了大约14个小时。在10M的共网上,效率又会降低100倍,这段时间CPU干不了任何事情。

因此,通过异步编程实现效率的提升是十分值得的一件事情。

是什么阻塞了程序的执行

阻塞,非阻塞描述的是进程的一个操作是否会使得进程转变为“等待状态”,除了我们主动调用 wait()sleep() 等挂起自己的操作, 另一种就是它调用 System Call, 而 System Call 因为涉及到了 I/O 操作,不能立即执行,所以内核会将进程挂起,调度其他进程的运行。

其中,网络I/O是最大的的I/O瓶颈

小试牛刀

了解异步编程,我们可以用简单的例子去一步步实现,从而观察到它的工作原理。

一个同步阻塞的例子

说好的是异步,为什么要写同步的呢,万事开头难,由浅入深才能有更好的理解。

import os
import socket
import time
import random
from tech_share.time_deco import TimeLogger
'''
Python中 time.sleep 是阻塞的,都知道使用它要谨慎,但在多线程编程中,time.sleep 并不会阻塞其他线程。
可以通过time sleep 模拟阻塞操作
'''
# 一个简单的同步socket应用
def blocking_way(number):
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    # 向baidu主机的443端口发起网络连接请求 --> blocking
    sock.connect(('www.baidu.com', 443))
    time.sleep(random.random() * 3)  # 模拟耗时操作
    request = b'GET / HTTP/1.0\r\n Host: www.baidu.com\r\n\r\n'
    # sock.send()函数并不会阻塞太久,它只负责将请求数据拷贝到TCP/IP协议栈的系统缓冲区中就返回,并不等待服务端返回的应答确认。
    sock.send(request)
    response = b''
    # socket上读取4K字节数据 --> blocking
    chunk = sock.recv(4096)
    while chunk:
        response += chunk
        # blocking
        chunk = sock.recv(4096)
    print('task {} end ({}) time: {}'.format(number, os.getpid(), time.time()))
    return response

# 同步方式(大约耗时13~17s)
@TimeLogger()
def sync_way():
    res = []
    for i in range(10):
        res.append(blocking_way(i))
    return len(res)

其中使用Python的time.sleep模拟了阻塞状态,让我们来运行一下看看

start time: 1565678320.9846358
Parent process 35404.
task 0 end (35404) time: 1565678322.719079
task 1 end (35404) time: 1565678323.2454321
task 2 end (35404) time: 1565678323.853733
task 3 end (35404) time: 1565678325.221901
task 4 end (35404) time: 1565678327.269089
task 5 end (35404) time: 1565678328.531013
task 6 end (35404) time: 1565678329.869162
task 7 end (35404) time: 1565678330.091727
task 8 end (35404) time: 1565678330.9530041
task 9 end (35404) time: 1565678333.785743
use time:  12.80132007598877

可以看到,大约执行了13秒左右,多次执行的时间区间大约在(13~17秒)
其中sock.connect(('www.baidu.com', 443))的作用是向www.baicu.com主机的443端口发起网络连接请求。
sock.recv(4096)的作用是从socket上读取4K字节数据。

创建网络的过程有时候不可能是一帆风顺的,网络状况不佳,服务器性能不够均可能导致网络创建缓慢。

此外,服务端什么时候返回了响应数据并被客户端接收到可供程序读取,也是不可预测的。

所以sock.connect()sock.recv()这两个调用在默认情况下是阻塞的。

代码中的简单socket应用只运行了10次,而阻塞的过程也就重复的10次,这在网络交互十分频繁的程序和系统中,是无法忍受的。

改进,使用多进程

如果顺序执行过于耗时,我们可以理所当然的这么想,如果开10个进程去处理刚才socket应用,那么速度会不会快很多?
来看看多进程下改写的代码

...
from multiprocessing import Process

# 一个简单的同步socket应用
def blocking_way(number): ...

# 多进程方式(大约耗时3~6s)
def process_way():
    processes = []
    for i in range(10):
        p = Process(target=blocking_way, args=(i,))
        processes.append(p)
    for p in processes:
        p.start()
        # p.join()  # join() 方法可以等待子进程结束后再继续往下运行,通常用于进程间的同步
    return len(processes)

同样,去执行一下

start time: 1565680474.7305129
Parent process 35704.
task 6 end (35711) time: 1565680475.795624
task 9 end (35714) time: 1565680476.0854862
task 0 end (35705) time: 1565680476.407744
task 7 end (35712) time: 1565680476.4183
task 2 end (35707) time: 1565680476.474165
task 3 end (35708) time: 1565680476.8856
task 8 end (35713) time: 1565680476.997073
task 1 end (35706) time: 1565680477.266001
task 5 end (35710) time: 1565680477.470528
task 4 end (35709) time: 1565680477.756924
use time:  3.0275731086730957

可以看到,效果是非常的明显的,但是仍然存在一些问题,照理说,10个进程执行的效率应该是同步情况下的10倍左右,然而从我们运行的实际情况来看,效率只提升了7~8倍,那么损耗的时间到哪里去了,答案是进程间的切换,因为任意一个时刻上,单个CPU核心只能执行一个进程。当进程数量大于核心数量时,进程的切换是不可避免的。

回到上面观察一下CPU的时间观念表格,我们发现,CPU的上下文切换也是需要话费一定的时间的,而在实际运行过程中,这个时间的消耗是要比表格所列的时间要大的多。

下面给出 知乎 上一个大神给出的关于进程切换的时序图
进程的切换
从上图可以看出

  • 当一个程序正在执行的过程中,如果发生中断或者系统调用的时候,CPU的控制权将会由当前进程转向CPU内核。
  • 内核将当前进程P0的在CPU的上下文(程序计数器,寄存器)保存到PCB0
  • 然后CPU从PCB1中取出进程P1的上下文的,执行P1的指令

这么一系列的读写操作下来,浪费的时间是可想而知的,在并发不高的情况下还能hold的住,但是面对高并发的场景,进程的切换开销将会变的十分巨大。

此外,每创建一个进程都会消耗一定的内存空间,一般服务器能够同时处理的进程规模也就在数十到数百个,当进程超过一定的数量,系统的运行将会变的不稳定。

使用多线程

和多进程的方案比较类似,但是多线程的方案更加的轻量级,线程是依赖于进程而存在,同一个进程可以容纳多个线程,并且不同线程共享同一个进程空间。
那么继续来看看代码吧

...
from threading import Thread

# 一个简单的同步socket应用
def blocking_way(number): ...

# 多线程方式(大约耗时2.5~5.5s)
def threading_way():
    threads = []
    for i in range(10):
        p = Thread(target=blocking_way, args=(i,))
        threads.append(p)
    for t in threads:
        t.start()
        # t.join()
    return len(threads)

再看看执行时间

start time: 1565683676.944701
Parent process 36035.
task 4 end (36035) time: 1565683677.234011
task 3 end (36035) time: 1565683677.269881
task 6 end (36035) time: 1565683677.542932
task 1 end (36035) time: 1565683678.26774
task 5 end (36035) time: 1565683678.5796092
task 0 end (36035) time: 1565683679.201456
task 7 end (36035) time: 1565683679.384767
task 2 end (36035) time: 1565683679.392001
task 8 end (36035) time: 1565683679.526201
task 9 end (36035) time: 1565683679.5626528
use time:  2.6182520389556885

从执行时间上可以看出,比多进程耗时要少一些,另外线程可以支持的任务数量,也有了质的提升。
在高并发场景下,线程带来的性能提升是十分明显的。这是由于线程的上下文开销是低于进程的。

值得一提的是,虽然执行效率得到了极大的提升,但是在单个线程或者进程中,阻塞调用依旧是阻塞的

至于为什么,已经偏离了本篇的主题,这里放两个链接,可以参考:
进程切换与线程切换的代价比较
啃碎并发(三):Java线程上下文切换

非阻塞方案

在来看看非阻塞的方案,我们继续对上面的代码进行改写

import os
import socket
import time
import random

from tech_share.time_deco import TimeLogger

# 一个简单的同步socket应用
@TimeLogger()
def non_blocking_way(number):
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    # 将socket调用设置为非阻塞
    sock.setblocking(False)
    try:
        # time.sleep(random.random() * 3)  # 此处加time_sleep模拟耗时操作不合适
        sock.connect(('www.baidu.com', 443))
    except BlockingIOError:
        # 忽略非阻塞连接中抛出的异常
        pass
    request = b'GET / HTTP/1.0\r\n Host: www.baidu.com\r\n\r\n'
    while True:
        try:
            sock.send(request)
            # 当send不抛异常时,则发送完成
            break
        except OSError:
            pass
    response = b''
    # 此时并不知晓socket何时就绪,所以需要不断尝试发送
    while True:
        try:
            chunk = sock.recv(4096)
            while chunk:
                response += chunk
                chunk = sock.recv(4096)
            break
        except OSError:
            pass
    print('task {} end ({}) time: {}'.format(number, os.getpid(), time.time()))
    return response

# 非阻塞方式(大约耗时13~17秒)
def run():
    res = []
    for i in range(10):
        res.append(non_blocking_way(i))
    return len(res)

其中:
sock.setblocking(False)将socket上的阻塞调用都改为非阻塞,非阻塞在运行时,不妨碍调用它的程序做别的事情。
上述代码在执行完sock.connect()sock.recv()后的确不再阻塞,可以继续往下执行请求准备的代码或者是执行下一次读取。
比较麻烦的是,socket在发送非阻塞连接的过程中,系统底层会抛出异常,需要通过try语句包裹,connect()被调用之后,立即可以往下执行代码。

后面写两个while循环是由于socket已经变成了非阻塞,在执行send()receive()的时候,程序并不知道socket是否已经就绪,所以需要不停的循环尝试发送和接收

执行,看看什么效果

task 0 end (38062) time: 1565692133.393855
task 1 end (38062) time: 1565692134.593825
task 2 end (38062) time: 1565692135.383206
task 3 end (38062) time: 1565692136.024352
task 4 end (38062) time: 1565692136.091566
task 5 end (38062) time: 1565692136.897728
task 6 end (38062) time: 1565692137.917314
task 7 end (38062) time: 1565692138.845514
task 8 end (38062) time: 1565692141.672517
task 9 end (38062) time: 1565692143.136748
use time:  12.892203092575073

emmmmm,那么问题来了,好像非阻塞的执行效果和同步阻塞的耗时没多大区别。
这段代码有以下几个问题:

  1. 虽然 connect()recv()不再阻塞主程序,但是CPU本身还是没有得到有效的使用,主要体现在程序在while中不断循环尝试读写socket(因为不知道socket是否就绪)。
  2. 需要处理来自底层的可忽略的异常
  3. 无法同时处理多个 socket

非阻塞的改进(其实是I/O多路复用)

在上面的程序中,socket状态的判断是交由程序来执行的,导致代码效率十分低下,那么,如果我们能够把这一步交给操作系统去判断,我们就能充分利用非阻塞空闲的时间来做别的事情。

实现这一功能,我们需要用到Python的 selector'模块,这个模块在底层封装了系统模块selectselect是用来监视文件描述符的变化情况——读写或是异常的一个底层函数)
程序可以通过select注册文件描述符(形式上是一个非负的整数,内核kernel利用它来访问文件。打开现存文件或新建文件时,内核会返回一个文件描述符。读写文件也需要使用文件描述符来指定待读写的文件。)和回调函数,当文件描述符发生变化时,select就调用先前注册好的回调函数

select因其算法效率比较低,后来改进成了poll,再后来又有进一步改进,BSD内核改进成了kqueue模块,而Linux内核改进成了epoll模块

这里主要是使用基于linux的epoll模块,我们目前只需要知道,高并发情况下并且有大量空闲连接时,epoll的性能是是要高于selectpoll,但是它们都有一个共同点,三者都是I/O多路复用机制(既可以监视多个描述符

至于它们的区别,这里不过多的赘述,可以看下面这篇总结。
select、poll、epoll之间的区别总结[整理]

python标注库select模块提供了IO多路复用支持,包括select,poll,epoll。

OK,利用epoll回调进行改写,我们来继续看看代码

import socket
import time
from tech_share.time_deco import TimeLogger
from selectors import DefaultSelector, EVENT_READ, EVENT_WRITE

# 根据环境选择最佳模块
selector = DefaultSelector()
stopped = False
count = 10

class Creeper:

    def __init__(self, task):
        self.sock = None
        self.response = b''
        self.task = task

    def fetch(self):
        # 初始化的两个参数含义分别为地址簇和套接字类型(TCP/UDP)
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.sock.setblocking(False)
        try:
            # time.sleep(random.random() * 3)  # 这里再模拟阻塞就不合适了,应为time.sleep并不会立即返回
            self.sock.connect(('www.baidu.com', 443))
            # self.sock.connect(('www.google.com', 443))
        except BlockingIOError:
            pass
        selector.register(self.sock.fileno(), EVENT_WRITE, self.connected)

    def connected(self, key):
        """
        :param key: 一个具名元祖,内容包括文件对象,文件描述符,事件,回调
        :return:
        """
        selector.unregister(key.fd)
        request = b'GET / HTTP/1.0\r\n Host: www.baidu.com\r\n\r\n'
        self.sock.send(request)
        selector.register(key.fd, EVENT_READ, self.read_response)

    def read_response(self, key):
        global stopped, count
        # 如果响应大于4kb, 下一次循环会继续
        chunk = self.sock.recv(4096)
        if chunk:
            self.response += chunk
        else:
            selector.unregister(key.fd)
            print('task {} end time: {}'.format(self.task, time.time()))
            count -= 1
            if count == 0:
                stopped = True

# 建立事件循环(Event loop)
@TimeLogger()
def loop():
    while not stopped:
        # 这个地方,其实还是阻塞的,直到一个事件发生
        event = selector.select()
        for event_key, event_mask in event:
            callback = event_key.data
            callback(event_key)

def run():
    for task_id in range(count):
        creeper = Creeper(task_id)
        creeper.fetch()
        
if __name__ == '__main__':
    # 启动10个socket应用
    run()
    # 事件循环
    loop()

简单分析一下这段代码
首先,加入了select的I/O多路复用机制之后,之前的while循环总算是没有了,socket状态的监听交给了epoll去执行
另外,可以看到,原来的socket应用的不同阶段被拆分成了不同的任务,每个任务划分的也很明确, 我们来一个一个看一下

  1. fetch()
    创建socket连接,并在selector上注册可写事件
  2. connect()
    首先将该socket的文件描述符对应的事件注销(后续绑定可读事件,不注销会报已注册的错误)
    之后发送请求
  3. read_response()
    接收数据,如果chunk没有数据了,则注销对应的事件
    如果所有任务结束(count == 0),则将中止变量stop置为True

另外两个方法run()loop(),其中run()比较好理解,就是开10个任务,每个任务调用一下fetch()方法,剩下的交给epoll去处理就行了。

至于loop(),他的作用是创建了一个事件循环(Event loop),在这个循环下,我们去访问select模块,不断的去询问当前是否有事件发生,如果没有,则会返回一个空列表。
当事件发生变化时,我们在事件循环中获取当前的事件(下面简写为key)和事件类型(mask),本例中,我们只关注key。

通过观察selector的源代码可知,key是一个包含了事件详情的具名元组,
key中包含的内容分别为

  1. fileobj :文件对象
  2. fd :文件描述符
  3. event:事件类型
  4. data:回调函数

根据不同的事件,调用不同的回调函数,我们实现了基于I/O多路复用的socket应用。下面来看看代码的执行效果:

task 0 end time: 1565851610.7112331
task 5 end time: 1565851610.711699
task 1 end time: 1565851610.7117581
task 2 end time: 1565851610.711793
task 6 end time: 1565851610.716775
task 7 end time: 1565851610.716991
task 9 end time: 1565851610.7170382
task 3 end time: 1565851610.722931
task 4 end time: 1565851610.723006
task 8 end time: 1565851610.7242408
use time:  0.06382608413696289

从结果上可以看出,I/O多路复用使得程序的性能获得了极大的提升,线程的切换开销也省了,同时能够支持的任务规模也能够达到数万到数十万。

为什么性能提升了

观察上面的代码,我们发现,在loop()函数中,这段代码仍然是阻塞的:
event = selector.select()
那么为什么代码的执行效率仍然能够获得极大提升呢,这得益于I/O多路复用机制的强大。
我们在事件循环中监听socket事件的过程和同步阻塞的I/O模型并没有多大的区别

但是,使用selector最大的优势就是我们可以在一个线程内同时处理多个socket的I/O请求,注册多个socket,通过不断的调用select()方法读取被激活的socket,我们就实现了在同一个线程内同时处理多个I/O请求的目的。

继续纠结

但是这么做仍然存在一个不好的地方,那就是回调

之前的例子都很简单,然而在实际生产项目当中,我们的代码要复杂的多,相应的,回调函数的设计和调试难度也会大大增加。

最直接的,如果我们的回调函数中又嵌套了回调,我们可能会面临经典的回调地狱的问题,代码的可读性差不说,回调函数出错排查的代价也是十分高昂。

此外,共享状态管理也变得十分困难,在上面的代码中,我们使用OOP的变成风格,在creeper实例化之后主动保存了自己socket对象,这只是一个简单的实例,实际生产过程中,回调之间需要共享的数据可能要多得多,我们需要仔细考虑哪些数据需要共享,在链式回调的过程中,共享数据就像接力似的,从一个回调传递给另一个回调。

假如我们已经精心设计好了一个看似完美的调用链,在实际运行过程中,万一调用的某一个环节出现了错误,调用链不幸断掉了,回调函数的状态也会丢失,然后就是一连串的报错,从异常的那一层开始,自底向上不断抛出异常。此时我们只能看到最顶层的异常,真正出错的那一层被隐藏了起来!这种情况称为调用栈断裂

所以,为了防止上述情况,我们必须捕获每一个可能出现的回调异常,将异常以数据的形式返回,而不是直接抛出异常,然后每个回调中需要检查上次调用的返回值,以防错误吞没。

总体来说,基于回调的异步编程真的是困难重重。

升级,协程~

我们使用框架和Python,目的就是让开发更加的高(舒)效(坦),一个困难重重的开发模式,终究会被开发者们干掉的。随着Python生态的不断的演化,在事件循环+回调的基础上,我们有了新的选择:

协程

比较著名的有tornado,asyncio等。

基于协程的介绍,我们放到下一篇 :)

参考

深入理解Python异步编程
怎样理解阻塞非阻塞与同步异步的区别?
Python实现socket的非阻塞式编程
Python网络编程-IO阻塞与非阻塞及多路复用
妹子

  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值