English|我感觉不到异步的压力【1】原创|ArminRonacher,2020.01.01译者|CCBY-NC-SA4.0【2】许可协议,内容略有改动,转载请保留原始出处,请勿用于商业或非法用途。异步风靡一时。异步Python,异步Rust,go,node,.NET,选择你喜欢的语言生态,它正在使用一些异步。这种异步的好坏在很大程度上取决于语言的生态及其运行时,但总的来说它有一些不错的好处。它使这种事情变得非常简单:等待可能需要一些时间才能完成的操作。它是如此简单,以至于人们创造了无数新的方法来让人们大吃一惊。我想讨论的一种情况是直到系统过载时您才意识到自己踩了脚,这是背压管理的主题。协议设计中的一个相关术语是流量控制。什么是背压?关于背压有很多解释。我推荐阅读的一个很好的解释是:Backpressureexplained—theresistedflowofdatathroughsoftware[3]。因此,我不想深入讨论什么是背压,而是给出一个非常简短的定义和解释:背压是数据流通过系统的阻力。背压听起来很消极——任何人都可以想象浴缸从堵塞的管道中溢出——但它是为了节省你的时间。(译注:背压,除了背压,还有人翻译成“背压”、“背压”)这里,我们要处理的在所有情况下都差不多:我们有一个系统,将不同的组件组合成一个管道,管道需要接收一定数量的传入消息。你可以想象这就像在机场模拟行李运送。行李到达、分拣、装上飞机,最后卸下。在此过程中,一件行李与其他行李一起被扔进一个容器中进行运输。当容器装满时,需要将其运走。这是没有容器剩余时背压的自然示例。现在,行李托架不能,因为没有容器。此时必须做出决定。一种选择是等待:这通常称为排队或缓冲。另一种选择是在容器到达之前丢弃一些袋子——这称为丢弃。这听起来很糟糕,但我们稍后会探讨为什么它有时很重要。但是,还有一件事。想象一下,负责将行李放入容器的人没有等待容器很长时间(比如一周)。最终,如果他们不放下行李,他们将有大量行李散落在身边。最终,他们被迫拆开太多袋子,用尽了物理空间来存放它们。到那时,他们最好告诉机场,在解决容器问题之前,他们不能接受新行李。这通常被称为流量控制)[4],并且是一个重要的网络概念。通常这些处理管道在任何时候只能容纳一定数量的消息(如本例中的主干)。如果数量超过它,或者更糟的是,管道停滞,就会发生可怕的事情。一个真实的例子是伦敦希思罗机场5号航站楼的启用,由于其IT基础设施出现故障,该航站楼未能在10天内交付42,000件行李。他们不得不取消500多个航班,有一段时间,航空公司决定只允许随身携带行李。背压很重要我们从希思罗机场灾难中学到的是,能够传达背压是至关重要的。在现实生活和计算中,时间总是有限的。最终人们放弃等待某事。尤其是即使有些事情对内可以永远等待,对外却不行。举个真实的例子:如果你的行李需要经过伦敦希思罗机场到达目的地巴黎,而你只能在那里停留7天,那么你的行李迟到10天就没有意义了。实际上,您希望将您的行李重新送回您的家乡机场。事实上,承认失败(你超负荷了)比假装正常工作并保持缓冲要好,因为在某些时候,这只会让事情变得更糟。那么,当我们多年来一直在编写基于线程的软件时,为什么背压突然成为讨论的话题,甚至没有人提出来呢?有多种因素的组合,其中一些很容易使人陷入困境。错误的默认值为了理解为什么背压在异步代码中很重要,我想为您提供一段看似简单的Pythonasyncio代码,它演示了一些我们无意中忘记了背压的情况:fromasyncioimportstart_server,runasyncdefon_client_connected(reader,writer):whileTrue:data=awaitreader.readline()ifnotdata:breakwriter.write(data)asyncdefserver():srv=awaitstart_server(on_client_connected,'127.0.0.1',8888)与srv异步:awaitsrv.serve_forever()run(server())如果您不熟悉异步/等待概念,想象一下当调用await时,该函数将挂起,直到表达式被解析。在这里,Python的asyncio库提供的start_server函数运行一个隐藏的接受循环。它监听套接字,并为每个连接的套接字生成一个单独的任务来运行on_client_connected函数。现在,这看起来很简单。您可以删除所有await和async关键字,最终代码看起来与使用线程编写的代码非常相似。然而,它隐藏了一个非常关键的问题,这是我们所有问题的根源:在某些函数调用之前没有等待。在线程代码中,任何函数都可以产生。在异步代码中,只有异步函数可以。在这种情况下,这意味着writer.write方法不能阻塞。那么它是怎样工作的?它将尝试将数据直接写入操作系统的非阻塞套接字缓冲区。但是,如果缓冲区已满并且套接字会阻塞,会发生什么情况?在线程的情况下,我们可以在这里阻塞它,这是理想的,因为这意味着我们正在施加一些背压。但是,由于这里没有线程,我们不能这样做。因此,我们只能在这里缓冲或删除数据。因为删除数据不好,Python选择了缓冲。现在,如果有人向它发送大量数据但没有读取它会怎样?那么在那种情况下,缓冲区会增长、增长、增长。这个API缺陷就是为什么Python文档说,不仅要单独使用write,还要使用drain:writer.write(data)awaitwriter.drain()drain会耗尽缓冲区的多余部分。它不会耗尽整个缓冲区,只是到目前为止事情不会失控。那么为什么write不进行隐式消耗呢?好吧,这将是大规模的API监控,但我不确定该怎么做。这里非常重要的一点是,大多数套接字都是基于TCP的,而TCP有内置的流量控制。编写者只会以读者可以接受的速度编写(给予或占用一些缓冲空间)。这对开发人员来说是完全隐藏的,因为即使是BSD套接字库也没有公开这种隐式流控制操作。那么我们这里的背压问题解决了吗?好吧,让我们看看线程世界中会发生什么。在线程世界中,我们的代码可能会运行固定数量的线程,并且接受循环会等到线程可用后才接管请求。但是,在我们的异步示例中,要处理的连接数量是无限的。这意味着我们可能会收到大量的连接,即使这意味着系统可能会过载。在这个非常简单的示例中,可能不是问题,但想象一下如果我们进行数据库访问会发生什么。想象一个最多提供50个连接的数据库连接池。当大多数连接都阻塞在连接池时,接受10000个连接有什么用?等啊等啊等啊,终于回到了当初想讨论的地方。在大多数异步系统中,尤其是我在Python中遇到的那些,即使你在套接字层修复了所有的缓冲行为,你最终也会陷入这样一种情况,即你将一堆异步函数链接在一起,而不考虑世界的背压。如果我们以数据库连接池为例,假设只有50个可用连接。这意味着我们的代码最多可以有50个并发数据库会话。假设我们要处理4倍以上的请求,因为我们希望应用程序执行的许多操作独立于数据库。一种解决方法是制作一个包含200个令牌的信号量,并在开始时获取一个。如果我们用完了令牌,我们需要等待信号量发出令牌。但是等一下。现在我们又重新排队了!我们就在前排。如果系统严重超载,我们将从一开始就排队。所以现在大家愿意等就等,然后放弃。更糟糕的是:服务器可能还会处理这些请求一段时间,直到它意识到客户端已经离开并且不再对响应感兴趣。因此,我们不想等待,而是希望立即获得反馈。想象一下,您在邮局,正在从一台显示轮到您的时间的机器上取票。这张票可以很好??地表明您需要等待多长时间。如果等待时间太长,您决定放弃并走开,稍后再回来。请注意,您在邮局排队等候的时间与您的请求实际得到处理所需的时间无关(例如,因为有人需要取件、检查文件和收集签名)。所以这是天真的版本,我们只知道我们正在等待:fromasyncio.syncimportSemaphoresemaphore=Semaphore(200)asyncdefhandle_request(request):awaitsemaphore.acquire()try:returngenerate_response(request)finally:semaphore。release()到handle_request异步函数的调用者,我们只能看到我们在等待,没有任何反应。我们看不到我们是因为过载而等待,还是因为生成响应需要很长时间。基本上,我们在这里缓冲,直到服务器最终耗尽内存并崩溃。这是因为我们没有关于背压的沟通渠道。那么我们将如何解决呢?一种选择是添加中间层。现在不幸的是,asyncio信号量在这里没用,因为它只会让我们等待。但是假设我们可以询问信号量还剩下多少令牌,那么我们可以这样做:fromhypothetical_asyncio.syncimportSemaphore,Servicessemaphore=Semaphore(200)classRequestHandlerService(Service):asyncdefhandle(self,request):awaitsemaphore.acquire()try:returngenerate_response(request)finally:semaphore.release()@propertydefis_ready(self):returnsemaphore.tokens_available()现在我们对系统做一些改变。我们现在有一个包含更多信息的RequestHandlerService。特别是它具有准备就绪的概念。可以询问该服务是否准备就绪。该操作本质上是非阻塞的,是最好的猜测。现在,调用者将把这个:response=awaithandle_request(request)变成这个:request_handler=RequestHandlerService()ifnotrequest_handler.is_ready:response=Response(status_code=503)else:response=awaitrequest_handler.handle(request)withThere有多种方法可以完成,但想法是一样的。在我们真正开始做某事之前,我们有一种方法可以计算出成功的可能性,如果我们超负荷,我们会向上沟通。现在,我没有想到如何定义这种服务。它的设计来自于Rust的tower[5]和Rust的actix-service[6]。两者都定义了与它非常相似的服务特性。现在,由于它是如此活泼,所以仍然可以堆积信号量。现在,您可以冒这个风险,否则在调用handle时仍然会失败。一个比asyncio更好地解决这个问题的库是trio,它在信号量上公开内部计数器,并提供一个CapacityLimiter,这是一个针对容量限制优化的信号量,可以防止一些常见的陷阱。数据流和协议现在,上面的示例为我们解决了RPC样式的情况。对于每次呼叫,如果系统过载,我们会尽早知道。许多协议都有非常直接的方式来传达“服务器正在加载”消息。例如,在HTTP中,您可以在标头中发出一个带有重试后字段的503,它告诉客户端何时可以重试。这在下一次重试时增加了一个自然的重新评估点,决定是用相同的请求重试,还是改变一些东西。例如,如果您不能在15秒内重试,向用户展示这种能力比展示无休止的加载图标要好。然而,请求/响应(request/response)协议并不是唯一的协议。许多协议打开持久连接,允许您传输大量数据。传统上,这些协议中有许多都基于TCP,如前所述,TCP具有内置的流量控制。然而,这种流量控制并没有真正通过套接字库公开,这就是为什么更高级别的协议通常需要向它添加自己的流量控制。例如,在HTTP2中,有一个自定义的流量控制协议,因为HTTP2在单个TCP连接上复用了多个独立的数据流(流)。因为TCP在幕后默默地管理流量控制,这可能会导致开发人员走上一条危险的道路,即在错误的假设下从套接字读取字节,认为这就是所有已知信息。但是,TCPAPI具有误导性,因为从API的角度来看,流量控制对用户完全隐藏。当你设计自己的基于流的协议时,你需要绝对确定有一个双向通信通道,发送者不仅发送,而且读取以查看是否允许它们继续。对于数据流,关注点通常不同。许多数据流只是字节流或数据帧流,您不能只在它们之间丢弃数据包。更糟糕的是:发件人通常不容易注意到他们是否应该放慢速度。在HTTP2中,您需要不断地在用户级别交错读取和写入。你一定要处理那里的流量控制。当您正在写入并被允许写入时,服务器将向您发送WINDOW_UPDATE帧。这意味着数据流代码变得更加复杂,因为你首先需要编写一个可以控制传入流量的框架。例如,hyper-h2[7]Python库有一个非常复杂的文件上传服务器示例,[8]它基于curio的流控制,但尚未完成。新的rifleasync/await很棒,但它鼓励编写的内容在超载时可能会导致灾难。部分原因是排队很容易,但也因为在使函数异步后,它破坏了API。我只能假设这就是Python仍然在流编写器上使用非等待写入函数的原因。不过,最大的原因是async/await允许您编写许多人最初无法使用线程编写的代码。我认为这是一件好事,因为它降低了实际编写大型系统的门槛。不利的一面是,这也意味着许多以前对分布式系统没有经验的开发人员现在即使只编写一个程序也会遇到分布式系统的许多问题。由于多路复用的性质,HTTP2是一个非常复杂的协议,唯一合理的实现方式是基于async/await示例。不仅仅是async/await代码会遇到这些问题。例如,数据科学程序员使用的Python并行库Dask[9],尽管没有使用async/await,[10]仍然有一些错误报告表明系统由于缺乏背压而导致内存不足。但这些问题是非常根本的。然而,缺少的是一支火箭筒大小的步枪。如果你意识到你已经构建了一个怪物,为时已晚,如果不对代码库进行重大更改,几乎不可能修复它,因为你可能忘记了在某些应该具有的功能上使用异步。其他编程环境在这里也无济于事。人们在所有编程环境中都会遇到同样的问题,包括最新版本的go和Rust。即使在已经开源了很长时间的非常受欢迎的项目中,也经常会发现关于“处理流量控制”或“处理背压”的开放问题,因为事实证明,事后添加这个真的很困难。例如,自2014年以来,go就有一个关于向所有文件系统IO添加信号量的未解决问题,[11]因为它可能会使主机过载。aiohttp有一个问题可以追溯到2016年,[12]关于客户端由于背压不足导致服务器崩溃。有很多很多例子。如果您查看Python的hyper-h2文档,您会看到大量令人震惊的示例,例如“不处理流量控制”、“它不遵守HTTP/2流量控制,这是一个缺陷,但除此之外没关系”,等等。流量控制刚出现的时候,我觉得很复杂。很容易假装这不是问题,这就是我们陷入困境的根本原因。流量控制还增加了很多开销,并且在基准测试中效果不佳。因此,对于异步库开发人员,这里有一个新年决心:在文档和API中给予背压和流量控制应有的关注。相关链接[1]感觉不到异步压力:https://lucumr.pocoo.org/2020...[2]CCBY-NC-SA4.0:https://creativecommons.org/l...[3]背压解释——通过软件的阻力数据流:https://medium.com/@jayphelps...[4]流量控制:https://en.wikipedia.org/wiki...[5]塔:https://github.com/tower-rs/t...[6]actix-service:https://docs.rs/actix-service/[7]hyper-h2:https://github.com/python-hyp...[8]文件上传服务器示例:https://python-hyper.org/proj...[9]Dask:https://dask.org/[10]背压:https://github.com/dask/distr...[11]关于给所有文件系统IO添加信号量:https://github.com/golang/go/...[12]有问题可追溯By2016,:https://github.com/aio-libs/a...公众号【Python猫】,本号连载一系列优质文章,包括喵星哲学猫系列、Python进阶系列、好书推荐系列、技术写作、优质英文推荐及翻译等,欢迎关注。
