[进阶]-Python3异步编程详解(史上最全篇)
⽬录
1 什么是异步编程
1.1 阻塞
程序未得到所需计算资源时被挂起的状态。
程序在等待某个操作完成期间,⾃⾝⽆法继续⼲别的事情,则称该程序在该操作上是阻塞的。
常见的阻塞形式有:⽹络I/O阻塞、磁盘I/O阻塞、⽤户输⼊阻塞等。
阻塞是⽆处不在的,包括CPU切换上下⽂时,所有的进程都⽆法真正⼲事情,它们也会被阻塞。(如果是多核CPU则正在执⾏上下⽂切换操作的核不可被利⽤。)
1.2 ⾮阻塞
程序在等待某操作过程中,⾃⾝不被阻塞,可以继续运⾏⼲别的事情,则称该程序在该操作上是⾮阻塞的。
⾮阻塞并不是在任何程序级别、任何情况下都可以存在的。
仅当程序封装的级别可以囊括独⽴的⼦程序单元时,它才可能存在⾮阻塞状态。
⾮阻塞的存在是因为阻塞存在,正因为某个操作阻塞导致的耗时与效率低下,我们才要把它变成⾮阻塞的。
1.3 同步
不同程序单元为了完成某个任务,在执⾏过程中需靠某种通信⽅式以协调⼀致,称这些程序单元是同步执⾏的。
例如购物系统中更新商品库存,需要⽤“⾏锁”作为通信信号,让不同的更新请求强制排队顺序执⾏,那更新库存的操作是同步的。
简⾔之,同步意味着有序。
1.4 异步
为完成某个任务,不同程序单元之间过程中⽆需通信协调,也能完成任务的⽅式。
不相关的程序单元之间可以是异步的。
例如,爬⾍下载⽹页。调度程序调⽤下载程序后,即可调度其他任务,⽽⽆需与该下载任务保持通信以协调⾏为。不同⽹页的下载、保存等操作都是⽆关的,也⽆需相互通知协调。这些异步操作的完成时刻并不确定。
简⾔之,异步意味着⽆序。
上⽂提到的“通信⽅式”通常是指异步和并发编程提供的同步原语,如信号量、锁、同步队列等等。我们需知道,虽然这些通信⽅式是为了让多个程序在⼀定条件下同步执⾏,但正因为是异步的存在,才需要这些通信⽅式。如果所有程序都是按序执⾏,其本⾝就是同步的,⼜何需这些同步信号呢?
await和async使用方法1.5 并发
并发描述的是程序的组织结构。指程序要被设计成多个可独⽴执⾏的⼦任务。
以利⽤有限的计算机资源使多个任务可以被实时或近实时执⾏为⽬的。
1.6 并⾏
并⾏描述的是程序的执⾏状态。指多个任务同时被执⾏。
以利⽤富余计算资源(多核CPU)加速完成多个任务为⽬的。
并发提供了⼀种程序组织结构⽅式,让问题的解决⽅案可以并⾏执⾏,但并⾏执⾏不是必须的。
1.7 概念总结
并⾏是为了利⽤多核加速多任务完成的进度
并发是为了让独⽴的⼦任务都有机会被尽快执⾏,但不⼀定能加速整体进度
⾮阻塞是为了提⾼程序整体执⾏效率
异步是⾼效地组织⾮阻塞任务的⽅式
要⽀持并发,必须拆分为多任务,不同任务相对⽽⾔才有阻塞/⾮阻塞、同步/异步。所以,并发、异步、⾮阻塞三个词总是如影随形。1.8 异步编程
以进程、线程、协程、函数/⽅法作为执⾏任务程序的基本单位,结合回调、事件循环、信号量等机制,以提⾼程序整体执⾏效率和并发能⼒的编程⽅式。
如果在某程序的运⾏时,能根据已经执⾏的指令准确判断它接下来要进⾏哪个具体操作,那它是同步
程序,反之则为异步程序。(⽆序与有序的区别)
同步/异步、阻塞/⾮阻塞并⾮⽔⽕不容,要看讨论的程序所处的封装级别。例如购物程序在处理多个⽤户的浏览请求可以是异步的,⽽更新库存时必须是同步的。
1.9 异步之难(nán)
控制不住“计⼏”写的程序,因为其执⾏顺序不可预料,当下正要发⽣什么事件不可预料。在并⾏情况下更为复杂和艰难。
所以,⼏乎所有的异步框架都将异步编程模型简化:⼀次只允许处理⼀个事件。故⽽有关异步的讨论⼏乎都集中在了单线程内。
如果某事件处理程序需要长时间执⾏,所有其他部分都会被阻塞。
所以,⼀旦采取异步编程,每个异步调⽤必须“⾜够⼩”,不能耗时太久。如何拆分异步任务成了难题。
程序下⼀步⾏为往往依赖上⼀步执⾏结果,如何知晓上次异步调⽤已完成并获取结果?
回调(Callback)成了必然选择。那⼜需要⾯临“回调地狱”的折磨。
同步代码改为异步代码,必然破坏代码结构。
解决问题的逻辑也要转变,不再是⼀条路⾛到⿊,需要精⼼安排异步任务。
2 苦⼼异步为哪般
如上⽂所述,异步编程⾯临诸多难点,Python 之⽗亲⾃上阵打磨4年才使 asyncio 模块在Python 3.6中“转正”,如此苦⼼为什么?答案只有⼀个:它值得!下⾯我们看看为何⽽值得。
2.1 CPU的时间观
我们将⼀个 2.6GHz 的 CPU 拟⼈化,假设它执⾏⼀条命令的时间,他它感觉上过了⼀秒钟。CPU是计算机的处理核⼼,也是最宝贵的资源,如果有浪费CPU的运⾏时间,导致其利⽤率不⾜,那程序效率必然低下(因为实际上有资源可以使效率更⾼)。
如上图所⽰,在千兆⽹上传输2KB数据,CPU感觉过了14个⼩时,如果是在10M的公⽹上呢?那效率会低百倍!如果在这么长的⼀段时间内,CPU只是傻等结果⽽不能去⼲其他事情,是不是在浪费CPU的青春?
鲁迅说,浪费“CPU”的时间等于谋财害命。⽽凶⼿就是程序猿。
2.2 ⾯临的问题
成本问题
如果⼀个程序不能有效利⽤⼀台计算机资源,那必然需要更多的计算机通过运⾏更多的程序实例来弥补需求缺⼝。例如我前不久主导重写的项⽬,使⽤Python异步编程,改版后由原来的7台服务器削减⾄3台,成本骤降57%。⼀台AWS m4.xlarge 型通⽤服务器按需付费实例⼀年价格约 1.2 万⼈民币。
效率问题
如果不在乎钱的消耗,那也会在意效率问题。当服务器数量堆叠到⼀定规模后,如果不改进软件架构和实现,加机器是徒劳,⽽且运维成本会骤然增加。⽐如别⼈家的电商平台⽀持6000单/秒⽀付,⽽⾃家在下单量才⽀撑2000单/秒,在双⼗⼀这种活动的时候,钱送上门也赚不到。
C10k/C10M挑战
C10k(concurrently handling 10k connections)是⼀个在1999年被提出来的技术挑战,如何在⼀颗1GHz CPU,2G内存,1gbps ⽹络环境下,让单台服务器同时为1万个客户端提供FTP服务。⽽到了2010年后,随着硬件技术的发展,这个问题被延伸为C10M,即如何利⽤8核⼼CPU,64G内存,在10gbps的⽹络上保持1000万并发连接,或是每秒钟处理100万的连接。(两种类型的计算机资源在各⾃的时代都约为1200美元)
成本和效率问题是从企业经营⾓度讲,C10k/C10M问题则是从技术⾓度出发挑战软硬件极限。C10k/C10M 问题得解,成本问题和效率问题迎刃⽽解。
2.3 解决⽅案
《约束理论与企业优化》中指出:“除了瓶颈之外,任何改进都是幻觉。”
CPU告诉我们,它⾃⼰很快,⽽上下⽂切换慢、内存读数据慢、磁盘寻址与取数据慢、⽹络传输慢……
总之,离开CPU 后的⼀切,除了⼀级⾼速缓存,都很慢。我们观察计算机的组成可以知道,主要由运算器、控制器、存储器、输⼊设备、输出设备五部分组成。运算器和控制器主要集成在CPU中,除此之外全是I/O,包括读写内存、读写磁盘、读写⽹卡全都是I/O。I/O成了最⼤的瓶颈。
异步程序可以提⾼效率,⽽最⼤的瓶颈在I/O,业界诞⽣的解决⽅案没出意料:异步I/O吧,异步I/O吧,异步I/O吧吧!
3 异步I/O进化之路
如今,地球上最发达、规模最庞⼤的计算机程序,莫过于因特⽹。⽽从CPU的时间观中可知,⽹络I/O是最⼤的I/O瓶颈,除了宕机没有⽐它更慢的。所以,诸多异步框架都对准的是⽹络I/O。
我们从⼀个爬⾍例⼦说起,从因特⽹上下载10篇⽹页。
3.1 同步阻塞⽅式
最容易想到的解决⽅案就是依次下载,从建⽴socket连接到发送⽹络请求再到读取响应数据,顺序进⾏。
注:总体耗时约为4.5秒。(因⽹络波动每次测试结果有所变动,本⽂取多次平均值)
如上图所⽰,blocking_way() 的作⽤是建⽴ socket 连接,发送HTTP请求,然后从 socket读取HTTP响应并返回数据。⽰例中我们请求了 example 的⾸页。在sync_way() 执⾏了10次,即下载 example ⾸页10次。
在⽰例代码中有两个关键点。⼀是第10⾏的** t((‘example’, 80)),该调⽤的作⽤是向example主机的80端⼝发起⽹络连接请求。 ⼆是第14⾏、第18⾏的v(4096)**,该调⽤的作⽤是从socket上读取4K字节数据。
我们知道,创建⽹络连接,多久能创建完成不是客户端决定的,⽽是由⽹络状况和服务端处理能⼒共同决定。服务端什么时候返回了响应数据并被客户端接收到可供程序读取,也是不可预测的。所以t()和v()这两个调⽤在默认情况下是阻塞的。
注:sock.send()函数并不会阻塞太久,它只负责将请求数据拷贝到TCP/IP协议栈的系统缓冲区中就返回,并不等待服务端返回的应答确认。
假设⽹络环境很差,创建⽹络连接需要1秒钟,那么t()就得阻塞1秒钟,等待⽹络连接成功。这1秒钟对⼀颗2.6GHz的CPU来讲,仿佛过去了83年,然⽽它不能⼲任何事情。v()也是⼀样的必须得等到服务端的响应数据已经被客户端接收。我们下载10篇⽹页,这个阻塞过程就得重复10次。如果⼀个爬⾍系统每天要下载1000万篇⽹页呢?!
上⾯说了很多,我们⼒图说明⼀件事:同步阻塞的⽹络交互⽅式,效率低⼗分低下。特别是在⽹络交互频繁的程序中。这种⽅式根本不可能挑战C10K/C10M。
3.2 改进⽅式:多进程
在⼀个程序内,依次执⾏10次太耗时,那开10个⼀样的程序同时执⾏不就⾏了。于是我们想到了多进程编程。为什么会先想到多进程呢?发展脉络如此。在更早的操作系统(Linux 2.4)及其以前,进程是 OS 调度任务的实体,是⾯向进程设计的OS。
注:总体耗时约为 0.6 秒。
改善效果⽴竿见影。但仍然有问题。总体耗时并没有缩减到原来的⼗分之⼀,⽽是九分之⼀左右,还有⼀些时间耗到哪⾥去了?进程切换开销。
进程切换开销不⽌像“CPU的时间观”所列的“上下⽂切换”那么低。CPU从⼀个进程切换到另⼀个进程,需要把旧进程运⾏时的寄存器状态、内存状态全部保存好,再将另⼀个进程之前保存的数据恢复。对CPU来讲,⼏个⼩时就⼲等着。当进程数量⼤于CPU核⼼数量时,进程切换是必然需要的。
除了切换开销,多进程还有另外的缺点。⼀般的服务器在能够稳定运⾏的前提下,可以同时处理的进程数在数⼗个到数百个规模。如果进程数量规模更⼤,系统运⾏将不稳定,⽽且可⽤内存资源往往也会不⾜。
多进程解决⽅案在⾯临每天需要成百上千万次下载任务的爬⾍系统,或者需要同时搞定数万并发的电商系统来说,并不适合。
除了切换开销⼤,以及可⽀持的任务规模⼩之外,多进程还有其他缺点,如状态共享等问题,后⽂会有提及,此处不再细究。
3.3 继续改进:多线程
由于线程的数据结构⽐进程更轻量级,同⼀个进程可以容纳多个线程,从进程到线程的优化由此展开。
后来的OS也把调度单位由进程转为线程,进程只作为线程的容器,⽤于管理进程所需的资源。⽽且OS级别的线程是可以被分配到不同的CPU核⼼同时运⾏的。

版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。