理解python异步编程与简单实现asyncio

在开始说明异步编程之前,首先先了解几个相关的概念。编程学习资料点击领取阻塞程序未得到所需计算资源时被挂起的状态。程序在等待某个操作完成期间,自身无法继续干别的事情,则称该程序在该操作上是阻塞的。常见的阻塞形式有:网络I/O阻塞、磁盘I/O阻塞、用户输入阻塞等。阻塞是无处不在的,包括CPU切换上下文时,所有的进程都无法真正干事情,它们也会被阻塞。(如果是多核CPU则正在执行上下文切换操作的核不可被利用。)简单的理解的话,阻塞就是A调用B,A会被挂起,一直等待B的结果,什么都...
摘要由CSDN通过智能技术生成

在开始说明异步编程之前,首先先了解几个相关的概念。编程学习资料点击领取

阻塞

程序未得到所需计算资源时被挂起的状态。

程序在等待某个操作完成期间,自身无法继续干别的事情,则称该程序在该操作上是阻塞的。

常见的阻塞形式有:网络I/O阻塞、磁盘I/O阻塞、用户输入阻塞等。

阻塞是无处不在的,包括CPU切换上下文时,所有的进程都无法真正干事情,它们也会被阻塞。(如果是多核CPU则正在执行上下文切换操作的核不可被利用。)

简单的理解的话,阻塞就是 A调用B,A会被挂起,一直等待B的结果,什么都不能干

非阻塞

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

非阻塞并不是在任何程序级别、任何情况下都可以存在的。

仅当程序封装的级别可以囊括独立的子程序单元时,它才可能存在非阻塞状态。

非阻塞的存在是因为阻塞存在,正因为某个操作阻塞导致的耗时与效率低下,我们才要把它变成非阻塞的。

简单理解的话,非阻塞就是 A调用B,A自己不用被挂起来等待B的结果,A可以去干其他的事情

同步

不同程序单元为了完成某个任务,在执行过程中需靠某种通信方式以协调一致,称这些程序单元是同步执行的。

例如购物系统中更新商品库存,需要用“行锁”作为通信信号,让不同的更新请求强制排队顺序执行,那更新库存的操作是同步的。

简言之,同步意味着有序。

简单理解的话,同步就是A调用B,此时只有等B有了结果才返回

异步

为完成某个任务,不同程序单元之间过程中无需通信协调,也能完成任务的方式。

不相关的程序单元之间可以是异步的。

例如,爬虫下载网页。调度程序调用下载程序后,即可调度其他任务,而无需与该下载任务保持通信以协调行为。不同网页的下载、保存等操作都是无关的,也无需相互通知协调。这些异步操作的完成时刻并不确定。

简言之,异步意味着无序。

简单理解的话,异步就是A调用B,B立即返回,无需等待。等B处理完之后再告诉A结果

并发

并发描述的是程序的组织结构。指程序要被设计成多个可独立执行的子任务。

以利用有限的计算机资源使多个任务可以被实时或近实时执行为目的。

并行

并行描述的是程序的执行状态。指多个任务同时被执行。

以利用富余计算资源(多核CPU)加速完成多个任务为目的。

并发提供了一种程序组织结构方式,让问题的解决方案可以并行执行,但并行执行不是必须的。

总的来说,并行是为了利用多核加速多任务的完成;并发是为了让独立的子任务能够尽快完成;非阻塞是为了提高程序的整体运行效率,而异步是组织非阻塞任务的方式。

并发

是指的程序的组织结构,把程序设计成多个可以独立执行的子任务。目的是使用有限的计算机资源使得多个任务可以被实时或者接近实时执行为目的。

并行

指的是程序的执行状态,多个任务同时执行。这样做的目的是为了利用富余的计算资源(多核cpu),加速完成多个任务的目的。

并发提供了一种组织结构方式,让问题的解决方式可以并行执行,但是这不是必须的。

综上

并行是为了利用多核计算机的富余计算资源来加速多任务程序完成的进度。

并发是为了让独立的子任务有机会被尽快执行,但是不一定会加快整体的进度。

非阻塞是为了提高程序执行的整体效率。

异步是组织非阻塞任务的方式。

异步编程

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

异步执行的程序一定是无序的,如果你可以根据已经执行的指令,准确的判断出,它接下里要去执行的某个具体操作,那么它就是同步程序。这是有序和无序的区别。

异步编程的困难在于,因为异步执行的程序,它的执行顺序不可预料,所以在并行情况下变得比较复杂和艰难。

所以几乎所有的异步框架都将异步编程模型简化:一次只允许处理一个事件。因此关于异步的讨论基本上都集中在了单线程中。

如果某个事件的处理过程过长,那么其他部分就会被阻塞。

所以异步编程的异步调度必须要“足够小”,不能耗时太久。

合理的用异步编程的方式可以提高 cpu 的利用率,提高程序效率。

同步->异步I/O

以一个爬虫为例,下载10篇网页,用几个例子来展示从同步->异步。

同步阻塞方式

以同步阻塞方式来写这个程序也是最容易想到的方式,即依次下载好10篇网页。

import socket

def blocking_way():
    sock = socket.socket()
    # 阻塞
    sock.connect(('example.com', 80))
    request = 'GET / HTTP/1.0\r\nHost: example.com\r\n\r\n'
    sock.send(request.encode('ascii'))
    response = b''
    chunk = sock.rev(4096)
    while chunk:
        response += chunk
        # 阻塞
        chunk = sock.rev(4096)
    return response

def sync_way():
    res = []
    for i in range(10):
        res.append(blocking_way())
    return len(res)
复制代码

这段代码的执行事件大概为4.5秒。(取多次平均值)

上述代码中, blocking_way()这个函数的作用主要是建立连接,发送HTTP请求,然后从socket读取HTTP响应请求到并返回数据。

sync_way()将blocking_way()执行了10次,也就是说,我们执行了10次访问下载 example.com

由于网络情况和服务端的处理各不相同,所以服务端什么时候返回了响应数据并被客户端接收到可供程序读取,也是不可预测的。所以 sock.connect() 和 sock.recv() 这两个调用在默认情况下是阻塞的。

注:sock.send()函数并不会阻塞太久,它只负责将请求数据拷贝到TCP/IP协议栈的系统缓冲区中就返回,并不等待服务端返回的应答确认。

如果是说网络环境很差的话,创建网络连接的TCP/IP握手需要1秒,那么 sock.connect() 就得阻塞1秒。这一秒时间对CPU来说就被浪费了。同理,sock.recv() 也一样的必须得等到服务端的响应数据已经被客户端接收,才能进行后续的程序。目前的例子上只有只需要下载一篇网页,阻塞10次看起来好像没有什么问题,可是如果需求是1000w篇的话,这种阻塞的方式就显得很蠢,效率也很低下。

改进:多进程

在一个程序中,依次执行10次好像有些耗时,那么我们使用多进程,开10个同样的程序一起处理的话,也许会好一些?于是第一个改进方式便出来了:多进程编程。发展脉络也是如此。在更早的操作系统(Linux 2.4)及其以前,进程是 OS 调度任务的实体,是面向进程设计的OS。

import socket
from concurrent import futures

def blocking_way():
    sock =socket.socket()
    # 阻塞
    sock.connect(('example.com', 80))
    request = 'GET /HTTP/1.0\r\nHost: example.com\r\n\r\n'
    sock.send(request.encode('acsii'))
    response = b''
    chunk = sock.recv(4096)
    while chunk:
        response += chunk
        chunk = sock.recv(4096)
    return response

def process_way():
    workers = 10
    with futures.ProcessPoolExecutor(workers) as executor:
    futs = {executor.sumbit(blocking_way) for i in range(10)}
    return len([fut.result() for fut in futs])
复制代码

这段代码执行时间大概为0.6秒。

按理说,使用10个相同的进程来执行这段程序,其执行时间应该是会缩短到原来的1/10,然而并没有。这里面还有一些时间被进程的切换所消耗掉了。

CPU从一个进程切换到另一个进程的时候,需要把旧进程运行时的寄存器状态,内存状态都保存好,然后再将另一个进程之前保存的数据恢复。当进程数量大于CPU核心数的时候,进程切换是必须的。

一般来说,服务器在能够稳定运行的前提下,可以同时处理的进程数在数十个到数百个规模。如果进程数量规模更大,系统运行将不稳定,而且可用内存资源往往也会不足。除了切换开销大,以及可支持的任务规模小之外,多进程还有其他缺点,如状态共享等问题。

改进:多线程

线程的数据结构比进程更加的轻量级,同一个进程可以容纳好几个线程。

后来的OS也把调度单位由进程转为线程,进程只作为线程的容器,用于管理进程所需的资源。而且OS级别的线程是可以被分配到不同的CPU核心同时运行的。

import socket
from concurrent import futures


def blocking_way():
    sock = socket.socket()
    # 阻塞
    sock.connect(('example.com', 80))
    request = 'GET / HTTP/1.0\r\nHost: example.com\r\n\r\n'
    sock.send(request.encode('acsii'))
    response = b''
    chunk = sock.recv(4096)
    while chunk:
        response += chunk
        # 阻塞
        chunk = sock.recv(4096)
    return response

def thread_way():
    wokers = 10
    with futures.ThreadPoolExecutor(workers) as executor:
        futs = {executor.sumbit(blocking_way) for i in range(10)}
    return len([fut.result(fut.result() for fut in futs)])
复制代码

总运行时间大概为0.43秒。

从运行时间上来看,多线程好像已经解决了进程切换开销大的问题,而且可支持的任务数量规模,也变成了数百个到数千个。

但是由于CPython中的多线程因为GIL的存在,它们并不能利用CPU多核优势,一个Python进程中,只允许有一个线程处于运行状态。

在做阻塞的系统调用时,例如sock.connect(),sock.recv()时,当前线程会释放GIL,让别的线程有执行机会。但是单个线程内,在阻塞调用上还是阻塞的。

Python中 time.sleep 是阻塞的,都知道使用它要谨慎,但在多线程编程中,time.sleep 并不会阻塞其他线程。

除了GIL之外,所有的多线程还有通病。它们是被OS调度,调度策略是抢占式的,以保证同等优先级的线程都有均等的执行机会,那带来的问题是:并不知道下一时刻是哪个线程被运行,也不知道它正要执行的代码是什么。所以就可能存在竞态条件。如果在一个复杂的爬虫系统中,要抓取的URL由多个爬虫线程来拿,那么URL如何分配,这就需要用到“锁”或“同步队列”来保证下载任务不会被重复执行。多线程最主要的问题还是竞态条件。

非阻塞方式

千呼万唤使出来,下例是最原始的非阻塞。

import socket

def noblock_way():
    sock = socket.socket()
    sock.setblocking(False)

    try:
        sock.connect(('example.com', 80))
    except BlockingIOError:
        # 非阻塞过程也会抛出异常
        pass
    
    request = 'GET / HTTP /1.0\r\nHost: example.com\r\n\r\n'
    data = request.encode('ascii')
    # 不断重复尝试发送
    while True:
        try:
            sock.send(data)
            # send不出现异常,停止
            break
        except OSError:
            pass

    response = b''
    while True:
        try:
            chunk = sock.recv(4096)
            while chunk:
                response += chunk
                chunk = sock.recv(4096)
            break
        except OSError:
            pass
    
    return response

def sync_way():
    res = []
    for i in range(10):
        res.append(noblock_way())
    return len(res)
复制代码

程序总耗时约4.3秒。

执行完这段代码的时候,感觉好像是被骗了,代码的执行时间和非阻塞方式差不多,而且程序更复杂了。要非阻塞何用?

代码sock.setblocking(False)告诉OS,让socket上阻塞调用都改为非阻塞的方式。非阻塞就是在做一件事的时候,不阻碍调用它的程序做别的事情。上述代码在执行完 sock.connect() 和 sock.recv() 后的确不再阻塞,可以继续往下执行请求准备的代码或者是执行下一次读取。第8行要放在try语句内,是因为socket在发送非阻塞连接请求过程中,系统底层也会抛出异常。connect()被调用之后,立即可以往下执行第12和13行的代码。

虽然 connect() 和 recv() 不再阻塞主程序,空出来的时间段CPU没有空闲着,但并没有利用好这空闲去做其他有意义的事情,而是在循环尝试读写 socket (不停判断非阻塞调用的状态是否就绪)。还得处理来自底层的可忽略的异常。也不能同时处理多个 socket。 所以总体执行时间和同步阻塞相当。

非阻塞改进

epoll

其实判断非阻塞调用是否就绪可以交给OS来做,不用应用程序自己去等待和判断,可以用这个空闲时间去做其他的事情。

OS将O/I的变化都封装成了事件,比如可读事件、可写事件。而且提供了相应的系统模块以供调用来接收事件通知。这个模块就是select,让应用程序可以通过select注册文件描述符和回调函数。当文件描述符的状态发生变化时,select 就调用事先注册的回调函数。

select因其算法效率比较低,后来改进成了poll,再后来又有进一步改进,BSD内核改进成了kqueue模块,而Linux内核改进成了epoll模块。这四个模块的作用都相同,暴露给程序员使用的API也几乎一致,区别在

  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值