深入了解Ruby的执行模型

158 阅读16分钟

搓线或不搓线—深入了解Ruby的执行模型

近年来,使用线程服务器部署Ruby应用程序已被广泛认为是标准做法。根据2022年Ruby on Rails社区调查,全球Rails社区的2600多名成员回答了一系列关于他们使用Rails的经验的问题,到目前为止,Puma等线程网络服务器是最受欢迎的部署目标。同样,在谈到作业处理器时,基于线程的Sidekiq似乎代表了大多数的部署情况。

在这篇文章中,我将探讨这种做法背后的机制和原因,并分享知识和建议,以帮助你在是否应该在你的应用程序中利用线程方面做出明智的决定(以及在这一点上--多少)。

为什么线程是流行的默认值?

虽然线程服务器的流行有许多不同的因素,但它们的主要卖点是在不增加内存用量的情况下增加应用程序的吞吐量。因此,为了充分了解线程和进程之间的权衡,了解内存使用情况是很重要的。

网络应用程序的内存使用情况

从概念上讲,网络应用的内存使用可以分为两部分。

静态内存和处理内存是网络应用程序中内存使用的两个关键部分。

静态内存 是所有你需要运行你的应用程序的数据。它包括Ruby虚拟机本身,所有在加载应用程序时产生的虚拟机字节码,以及可能的一些静态Ruby对象,如I18n数据等。这一部分就像一个固定的成本,意味着无论你的服务器运行1个还是10个线程,这一部分都会保持稳定,可以认为是只读的。

请求处理内存 是处理一个请求所需的内存量。在那里你会发现数据库查询结果、渲染模板的输出等。这些内存不断被垃圾收集器释放和重用,所需的数量与你的应用程序运行的线程数量成正比。

基于这个简化的模型,我们把一个网络应用的内存使用量表示为。

processes * (static_memory + (threads * processing_memory))

因此,如果你只有512MB的可用空间,应用程序使用200MB的静态内存,需要150MB的处理内存,使用两个单线程进程需要700MB的内存,而使用一个有两个线程的单进程只需要500MB的内存,并且适合Heroku dyno。

一个有两个线程的单进程比两个单线程的进程使用的内存更少。

然而这个模型,像大多数模型一样,是对现实的简化描述。让我们通过增加另一层复杂性使其更接近现实。写时拷贝(CoW)。

进入写时拷贝

CoW是一种常见的资源管理技术,涉及共享资源,而不是复制资源,直到其中一个用户需要改变它,这时复制才真正发生。如果改变没有发生,那么复制也没有发生。

在70年代或80年代的旧UNIX系统中,分叉进程涉及将其整个可寻址内存复制到新的进程地址空间,有效地使内存用量增加一倍。但是从90年代中期开始,这种情况就不复存在了,因为现在大多数(如果不是全部的话)分叉的实现都足够复杂,可以欺骗进程,使其认为它们有自己的私有内存区域,而实际上它们是与其他进程共享的。

当子进程被分叉时,其页面表被初始化为指向父进程的内存页面。以后,如果父进程或子进程试图在这些页面中写东西,操作系统就会收到通知,并在修改之前实际复制该页面。

这意味着,如果在分叉发生后,子代和父代都不在这些共享页中写,分叉的进程基本上是自由的。

写时复制允许通过分叉的父进程来共享资源。

因此,在一个完美的世界里,我们的内存使用公式现在将是:

static_memory + (processes * threads * processing_memory)

意味着线程对进程完全没有优势。

但当然,我们不是在一个完美的世界里。一些共享页面可能会在某个时候被写入,问题是有多少?为了回答这个问题,我们需要知道如何准确地测量一个应用程序的内存使用情况。

谨防欺骗性的内存指标

由于CoW和其他内存共享技术的存在,现在有许多不同的方法来衡量一个应用程序或进程的内存使用情况。根据不同的环境,一些指标可能会有更多或更少的相关性。

为什么RSS不是你要找的指标?

最经常被各种管理工具显示的内存指标,如ps,是常驻集大小(RSS)。虽然RSS有它的用途,但在处理分叉服务器时,它确实会产生误导。如果你分叉了一个100MB的进程,并且从来没有在任何内存区域写过东西,RSS将报告两个进程都使用了100MB。这是不准确的,因为100MiB是 两个进程之间 共享的--相同的内存被报告了两次。

一个稍好的指标是比例集大小(PSS)。在PSS中,共享内存区域的大小被共享的进程数量所除。因此,我们的100MB的进程被分叉一次,实际上应该有50MB的PSS。如果你想知道你是否接近内存耗尽,这已经是一个更有用的指标了,因为如果你把所有的PSS数字加起来,你就会得到实际使用的内存是多少--但是我们可以更深入地研究。

在Linux上,你可以通过cat /proc/$PID/smaps_rollup ,得到一个进程内存使用的详细情况。下面是我们生产中的一个应用上的Unicorn工作者的情况。

以及父进程的情况。

让我们来解读每个元素的含义。首先,共享和私有字段。顾名思义,共享内存 是被多个进程使用的内存区域的总和。而私有内存 是为一个特定的进程分配的,不被其他进程共享。在这个例子中,我们看到在771,912 kB的可寻址内存中,只有437,928 kB(56.7%)是真正属于Unicorn工作者的,其余的都是从父进程继承的。

至于 "干净 "和 "肮脏","干净 "内存 是已经分配但从未写入的内存(如Ruby二进制和各种本地共享库)。脏内存 是已经被至少一个进程写入的内存。它可以被共享,只要它是在父进程分叉其子进程之前被写入的。

衡量和提高写入效率的拷贝

我们已经确定,共享内存是最大限度提高进程效率的关键,所以这里的重要问题是有多少静态内存是真正共享的。为了近似这个问题,我们将工作者的共享内存与父进程的RSS进行比较,在这个应用程序中是508,544 kB,所以:

worker_shared_mem / master_rss >>(18288 + 315648) / 508544.0 * 100 >>65.66

这里我们看到,大约三分之二的静态内存是共享的。

通过比较工作者共享内存和父进程RSS,我们可以看到这个应用的三分之二的静态内存是共享的。

如果我们看的是RSS,我们会认为每个额外的工作者会花费750MB,但实际上它更接近427MB,而一个额外的线程会花费~257MB。这仍然是明显的多,但远远低于最初的天真模型所预测的情况。

应用程序所有者有很多方法可以提高CoW的效率,一般的想法是在服务器分叉之前尽可能多地加载启动过程中的东西。这个话题非常广泛,可以单独写成一整篇文章,但这里有几个快速的提示。

首先要做的是配置服务器以完全加载应用程序。Unicorn、Puma和Sidekiq Enterprise都有一个preload_app 的选项。一旦这样做了,降低CoW性能的一个常见模式是备忘类变量,比如说。

这种延迟评估既阻止了该内存的共享,也导致了第一个调用该方法的请求的速度减慢。简单的解决方案是使用一个常量,但当它不可能时,下一个最好的办法是利用Railseager_load_namespaces ,如这里所示。

现在,定位这些懒惰加载的常量是棘手的部分。Ruby heap-profiler是一个有用的工具。你可以用它在fork之后转储整个堆,然后在处理几个请求之后,看看进程增长了多少,这些额外的对象是在哪里分配的。

基于进程的服务器的案例

因此,虽然使用基于进程的服务器会增加内存成本,但使用更准确的内存指标和CoW这样的优化来在进程之间共享内存可以缓解一些问题。但是,既然内存成本增加,为什么还要使用基于进程的服务器,如Unicorn或Resque呢?实际上,基于进程的服务器有一些不应该被忽视的优势,所以我们来看看这些优势。

清洁的超时机制

当运行大型应用程序时,你可能会遇到一些错误,导致一些请求的时间比理想的要长得多。这可能有很多原因--它们可能是由恶意行为者专门制作的,试图对你的服务进行DOS,或者它们可能正在处理一个意想不到的大量数据。当这种情况发生时,能够干净地中断这个请求是弹性的首要条件。基于进程的服务器可以杀死工作进程,并分叉一个新的进程来取代它,确保请求被干净地中断。

然而,线程则不能被干净地中断。因为它们直接与其他线程共享可变资源,如果你试图杀死一个线程,你可能会让一些资源,如互斥或数据库连接处于无法恢复的状态,导致其他线程遇到各种无法恢复的错误。

全局虚拟机锁延迟的黑匣子

改善延迟是Ruby(和其他有类似限制的语言,如Python)中进程比线程的另一个主要优势。一个典型的网络应用程序将做两种类型的工作。CPU和I/O。因此,两个Ruby进程可能看起来像这样。

在一个Ruby应用程序的两个进程中,CPU和IO。

但是在一个Ruby进程中,由于臭名昭著的全局虚拟机锁(GVL), 每次只有一个线程可以执行Ruby代码,当垃圾收集器(GC)触发时,所有线程都会暂停。因此,如果我们要使用两个线程,画面可能反而是这样的。

全局体积锁(GVL)增加了Ruby线程的延迟。

因此,每当两个线程需要同时执行Ruby代码时,服务延迟就会增加。这种情况发生的程度在不同的应用程序之间,甚至在不同的请求之间都有很大的不同。如果你想一想,要使一个有N个线程的进程完全饱和,一个应用程序只需要花费少于1/N的时间来等待I/O。因此,两个线程有50%的I/O,四个线程有75%的I/O,等等。这只是饱和极限,考虑到一个请求对I/O和CPU的使用是非常不可预测的,一个应用程序用两个线程做75%的I/O仍然会经常等待GVL。

在Ruby社区的普遍看法是,Ruby应用程序的I/O相对较多,但根据我的经验,这并不完全正确,特别是一旦你考虑到GC暂停也会获得GVL,而Ruby应用程序往往在GC中花费相当多的时间。

网络应用通常是专门设计的,以避免在网络请求周期中的长I/O操作。任何潜在的慢的或不可靠的I/O操作,如调用第三方API或发送电子邮件通知,通常被推迟到后台作业队列,所以网络请求中的剩余I/O大多是合理快速的数据库和缓存查询。一个必然的结果是,应用程序的作业处理方面往往比网络方面的I/O密集得多。因此,像Sidekiq这样的作业处理器可以更频繁地从更多的线程数中受益。但是,即使是网络服务器,使用线程也可以被看作是在每美元的吞吐量和延迟之间的一个完全可以接受的权衡。

主要的问题是,到今天为止,还没有一个很好的方法来衡量服务延迟受GVL的影响有多大,所以服务所有者被蒙在鼓里。由于Ruby没有提供任何方法来测量GVL,我们只能用代理指标,比如逐渐增加或减少线程数量并测量对延迟指标的影响,但这远远不够。

这就是为什么我最近为Ruby 3.2提出了一个功能请求和一个概念验证实现,以提供一个GVL工具化API。 这是一个非常低级和难以使用的API,但如果它被接受,我计划发布一个宝石来暴露简单的指标,以确切地知道有多少时间是在等待GVL,我希望应用性能监控服务包括它。

Ractors和Fibers--不是一个银弹解决方案

在过去的几年中,Ruby社区一直在大力试验其他的并发结构,以取代线程,即Ractors和Fibers。

Ractors可以并行地执行Ruby代码,而不是有一个单一的GVL,每个Ractor都有自己的锁,所以理论上它们可以改变游戏。然而,Ractors不能共享任何全局可变的状态,所以即使在Ractors之间共享一个数据库连接池或一个记录器也是不行的。这是一个重大的架构挑战,需要对大多数库进行大量的重构,其结果很可能是无法使用的。我希望被证明是错误的,但我不期望Ractors在短期内被用作大型网络应用的执行单元。

至于Fibers,它们本质上是被合作调度的较轻的线程。因此,前面关于线程和GVL的部分所说的一切也适用于它们。它们非常适用于I/O密集型应用,这些应用主要是移动字节流,并不花太多时间执行代码,但任何不受益于超过几个线程的应用都不会从使用纤维中受益。

YJIT可能会改变现状

虽然目前还不是这样,但YJIT的出现可能会在未来大大增加运行线程服务器的需求。由于及时编译器(JIT)以牺牲不可共享的内存使用为代价来加速代码的执行,JIT化的Ruby会降低CoW性能,但也会使应用按比例地提高I/O密集度。

现在,YJIT只提供了适度的速度提升,但如果将来它能够提供哪怕是2倍的速度提升,它肯定会让应用程序的所有者将他们的网络线程的数量增加到同样多,以补偿增加的内存成本。

要记住的提示

最终,在基于进程的服务器和基于线程的服务器之间的选择涉及到许多权衡,因此,如果不先看一下应用程序的指标,就推荐其中一种是不合理的。

但从抽象的角度来看,这里有一些快速的经验需要记住:

  • 始终启用应用程序预加载,以尽可能地从CoW中受益。

  • 除非你的应用程序适合在最小的产品或你的主机供应商上使用,否则应使用较小数量的大型容器,而不是较大数量的小型容器。例如,一个4CPU 2GiB的盒子比4个1CPU 512MiB的盒子更有效。

  • 如果延迟对你来说比保持低成本更重要,或者你有足够的可用内存,那么使用Unicorn就能从可靠的请求超时中获益。

  • 注意:Unicorn必须通过一个缓冲请求的反向代理来防止缓慢的客户端攻击。如果这是个问题,Puma可以被配置为每个工作者使用一个线程运行。

  • 如果使用线程,开始时只用两个线程,除非你确信你的应用程序确实在I/O操作上花费了一半以上的时间来等待。这不适用于作业处理器,因为它们往往是更多的I/O密集型,而且对延迟不那么敏感,所以它们很容易从更高的线程数中受益。

展望未来—未来对Ruby生态系统的改进

我们正在探索一些途径来改善基于进程和线程的服务器的情况。

首先,前面提到的GVL仪表API ,希望能让应用程序所有者在吞吐量和延迟之间做出更明智的权衡。我们甚至可以尝试使用它来自动应用背压,当GVL争用超过某个阈值时,动态调整并发性。

此外,线程网络服务器理论上可以实现一个可靠的请求超时机制。当一个请求花费的时间超过预期时,他们可以停止向受影响的工作者转发请求,并等待所有其他请求完成或超时,然后再杀死该工作者并重新工作。这是Matthew Draper几年前探索的东西 ,似乎是可以做到的。

然后,Ruby本身的CoW性能可能会得到进一步改善。多年来,已经有几个补丁被合并用于此目的,但我们可能还能做得更多。值得注意的是,我们怀疑Ruby的内联缓存导致大部分的虚拟机字节码在执行后都是不共享的。我认为我们还可以从Instagram工程团队为改善Python的CoW性能所做的工作 中获得一些灵感 例如,他们引入了一个 gc.freeze() 方法,指示 GC 所有现有的内存区域都将成为共享的。Python利用这一信息对内存的使用做出更明智的决定,比如不使用这些共享区域中的任何空位,因为分配一个新的页面比弄脏一个旧的页面更有效率。