Logo
Section 1 / 5

简介

本节将涵盖的内容简介。

导入

简介

本节将涵盖的内容简介。

我们将探讨 BEAM 如何管理不同的系统资源,并讨论实时应用与传统应用在需求上的差异。

运行应用程序需要两种主要资源:

  1. CPU
  2. 内存

我们将了解 BEAM 如何使用这些资源,以及如何构建应用程序以最佳利用系统可用的资源。BEAM 是一个可靠的虚拟机,可用于运行应用程序,但在部署高负载应用时,我们必须关注其工作调度方式和内存使用情况。

我们将从 BEAM 如何执行工作开始。首先了解调度器的工作原理,然后探讨应用程序如何高效利用系统的 CPU 资源。接着,我们将学习 BEAM 如何分配和进行垃圾回收内存。最后,我们将介绍一个工具,用于检查生产环境中资源的实际使用情况。

现在开始——我们先从 Elixir 进程如何执行工作说起。

熟悉 Elixir 的调度器

任何软件最重要的职责就是完成工作。这一点看似理所当然:我们编写代码,它就在 CPU 上运行。然而,工作是如何完成的,会直接影响我们对语言的体验。有些语言要求我们对所有细节都显式控制——虽然拥有完全的控制权,但容易遇到问题。另一些语言则减少部分控制,以换取易用性和稳定性。Elixir 属于后者,其虚拟机具备适合软实时应用的调度特性。

...

调度是指将任务分配给某个资源(如 CPU)来执行的方法。我们将首先了解 Elixir 调度器的设计及其为应用程序带来的特性。随后,分析调度器对实时应用的影响,并探讨如何确保应用保持高性能。

Elixir 调度器设计

理解虚拟机调度器的工作机制可能看起来复杂,但实际上只需掌握几个关键概念。我们将介绍第一个概念:工作如何被切分并在 CPU 上执行。第二个概念是:如何在执行 CPU 密集型代码时避免系统完全冻结。

以下是本节中需要了解的一些术语定义:

调度器

调度器负责选择一个进程并执行该进程的代码。

运行队列

一个包含有待执行工作的进程列表。每个进程仅存在于一个运行队列中。

现在我们进入工作如何被切分并在 CPU 上执行的环节。

工作的调度方式

有多种方式可以将工作安排到 CPU 上执行。首先来看最简单的情况:单个调度器配合单一运行队列。

...
单个调度器

这是一个易于理解的模型。虚拟机只有一个工作队列,按照先进先出的顺序执行任务,且仅使用单个 CPU 核心。这种设计无需处理软件锁或复杂的分布式问题,但也存在明显缺陷:无法利用多核 CPU。引入多个调度器并共享一个运行队列,可以让应用在多个 CPU 核心上并行执行任务。

这种设计能充分利用所有可用核心,但代价是运行队列需要使用可锁定的数据结构。随着调度器数量增加,锁竞争会成为显著瓶颈。

...
多个调度器

下图展示的是 BEAM 当前采用的设计:多个调度器,每个调度器拥有自己的运行队列。

...
调度器

BEAM 还具备迁移逻辑,支持跨核心更优的工作负载均衡,这被称为“工作窃取”(work-stealing)。这种设计使得每个核心都能高效利用,同时减少了锁的使用。Elixir 常被称作能“神奇地”在多核 CPU 上自动扩展,正是得益于这一设计。

接下来我们看看 CPU 密集型任务如何执行,而不会导致整个应用冻结。

工作的执行方式

在某些语言中,很容易陷入无限循环——无论等待多久,系统都无法跳出循环,必须通过外部信号强制终止。BEAM 可以让 CPU 达到满载,但它仍能在这种情况下继续执行新任务,不会卡死在无限循环中。这是如何实现的?

BEAM 使用的是抢占式调度器,可以在执行过程中中断当前任务,并切换到另一个需要执行的任务。这与协作式调度不同,后者要求代码主动释放控制权给调度器。

抢占式调度并非魔法,必须存在一个清晰的中断点。在 BEAM 中,这个中断点出现在函数执行结束后。这在实践中非常有效,因为函数通常不会长时间执行大量 CPU 密集型操作而不调用其他函数。

BEAM 通过一个“还原计数器”来跟踪每个进程已完成的工作量,每当(大致)执行一次函数时,该计数器就会递增。当计数达到一定阈值后,该进程会被抢占,移至运行队列末尾,其他进程得以执行。这种抢占机制防止了单个进程独占整个 CPU 核心,从而避免其他进程因饥饿而无法运行。因此,若要了解某个进程完成了多少工作,追踪其还原次数至关重要。

下面我们通过一个 iex 会话中的小例子来实际演示这一机制。
我们将定义一个递归函数,模拟无限循环。启动多个进程并发执行该函数,使每个调度器都处于繁忙状态。然后观察 BEAM 是否仍能执行新的任务。打开 iex 会话并输入以下代码:

1iex 2iex(1)> defmodule Test do def recurse(), do: recurse() end 3iex(2)> :observer.start 4iex(3)> schedulers = :erlang.system_info(:schedulers_online) 5iex(4)> Enum.each((1..schedulers), fn _ -> Task.async(&Test.recurse/0) end) 6iex(5)> Enum.map((1..10000), & &1 + &1) |> Enum.sum()

如果我们观察 :observer 提供的调度器负载图表和进程列表,会发现所有调度器均已满负荷运行。进程列表显示这些递归进程的还原次数非常高,说明它们正在持续执行大量工作。此时 :observer 窗口的样子,正如标题 实时应用中的 CPU 所示。

尽管调度器已完全满载,BEAM 依然能够快速执行我们的 Enum.map 与 Enum.sum 操作。这正是由于 BEAM 的抢占式调度机制。

需要注意的是,也存在例外和边缘情况:通过 NIF(原生实现函数)可以执行原生代码。NIF 无法被抢占调度,因为它运行在 Erlang 函数式范式之外。这意味着调度器可能在整个 NIF 调用期间被占用,从而导致虚拟机不稳定。本课程不深入探讨此话题,但 NIF 确实可以在 BEAM 上良好运行,且已有专门的调度器类型(脏调度器)用于处理较慢的 NIF。

接下来我们看看应用程序如何受到 BEAM 调度器设计的影响。

实时应用中的 CPU

实时应用在 CPU 使用方面与普通 Elixir 应用非常相似。最常见的性能问题是单进程瓶颈。我们将探讨如何在应用中避免此类问题。

...
观察器窗口

我们刚刚看到,Elixir 的调度器基于进程级别将工作分发到各个 CPU 核心。我们必须确保所编写的程序不会出现单进程瓶颈,以便工作能尽可能快速处理。当存在大量进程(如 Channels)同时向同一个进程发送请求时,就会发生瓶颈。因为进程一次只能处理一条消息,所有消息都需排队等待,最终导致响应变慢。

避免单进程瓶颈的一种方法是根据某个键或其他标准对进程进行分片(sharding)。例如,我们可以将存储不同团队数据的单一进程拆分为每个团队一个独立进程。这样每个键对应一个进程,如下图所示。

...
分片

采用这种方式后,进程数量远超调度器数量。Elixir 会将这些进程均匀分布在所有调度器上,从而在所有核心上保持一致的 CPU 吞吐量。如果只使用单个进程,则所有工作都将集中在一个核心上。

我们也可以调整策略,减少进程数量。例如,Phoenix Tracker 将其 CRDT 进程按配置的分片数量进行拆分。每个主题通过哈希转换为整数,映射到对应的分片进程。这就是 Tracker 在多核之间分布自身的方式。虽然并非所有场景都适用这种分片技术,但在构建应用时,应始终考虑如何避免单进程瓶颈。

对于高负载的应用,完全避免热点进程是不可能的。我们将在本节稍后介绍一些方法,用于检查正在运行的应用,识别热点进程和性能瓶颈。

虽然 CPU 使用在实时与非实时应用之间差异不大,但内存方面则完全不同。

动手实践

  • 运行以下命令:
1iex
1defmodule Test do def recurse(), do: recurse() end
1:observer.start
1schedulers = :erlang.system_info(:schedulers_online)
1Enum.each((1..schedulers), fn _ -> Task.async(&Test.recurse/0) end)
1Enum.map((1..10000), & &1 + &1) |> Enum.sum()
  • 打开链接并进入“进程”标签页。

No configuration found for ID: f27a2d27a5d9263ddf4f9bbc66a82903366cfe6f72a143adf463bcdef390401c

使用 Phoenix 构建实时应用

有效管理应用程序的内存