更多信息请访问 rubyonrails.org:

Rails 中的线程和代码执行

阅读本指南后,您将了解

  • Rails 会自动并发执行哪些代码
  • 如何将手动并发与 Rails 内部集成
  • 如何包装所有应用程序代码
  • 如何影响应用程序重新加载

1 自动并发

Rails 自动允许各种操作同时执行。

当使用线程化的 Web 服务器时,例如默认的 Puma,多个 HTTP 请求将同时被服务,每个请求都将提供自己的控制器实例。

线程化的 Active Job 适配器,包括内置的 Async,也将同时执行多个作业。Action Cable 通道也是以这种方式管理的。

这些机制都涉及多个线程,每个线程都管理着某个对象(控制器、作业、通道)的唯一实例的工作,同时共享全局进程空间(例如类及其配置,以及全局变量)。只要您的代码不修改任何共享内容,它就可以在很大程度上忽略其他线程的存在。

本指南的其余部分描述了 Rails 使用的机制来使其“在很大程度上可忽略”,以及具有特殊需求的扩展和应用程序如何使用它们。

2 执行器

Rails 执行器将应用程序代码与框架代码分离:框架每次调用您在应用程序中编写的代码时,都会被执行器包装。

执行器包含两个回调:to_runto_complete。Run 回调在应用程序代码之前调用,Complete 回调在之后调用。

2.1 默认回调

在默认的 Rails 应用程序中,执行器回调用于

  • 跟踪哪些线程处于自动加载和重新加载的安全位置
  • 启用和禁用 Active Record 查询缓存
  • 将获取的 Active Record 连接返回到池中
  • 限制内部缓存的生命周期

在 Rails 5.0 之前,其中一些由单独的 Rack 中间件类(例如 ActiveRecord::ConnectionAdapters::ConnectionManagement)处理,或直接使用 ActiveRecord::Base.connection_pool.with_connection 等方法包装代码。执行器用一个更抽象的接口取代了这些方法。

2.2 包装应用程序代码

如果您正在编写一个将调用应用程序代码的库或组件,您应该使用对执行器的调用来包装它

Rails.application.executor.wrap do
  # call application code here
end

如果您从长时间运行的进程中重复调用应用程序代码,您可能希望使用 重新加载器 进行包装。

每个线程都应该在运行应用程序代码之前进行包装,因此,如果您的应用程序手动将工作委托给其他线程,例如通过 Thread.new 或使用线程池的并发 Ruby 特性,您应该立即包装块

Thread.new do
  Rails.application.executor.wrap do
    # your code here
  end
end

并发 Ruby 使用 ThreadPoolExecutor,它有时会使用 executor 选项进行配置。尽管名称相同,但它与之无关。

执行器是安全的可重入的;如果它已经在当前线程上处于活动状态,wrap 将是一个空操作。

如果无法将应用程序代码包装在块中(例如,Rack API 使这变得很麻烦),您还可以使用 run! / complete!

Thread.new do
  execution_context = Rails.application.executor.run!
  # your code here
ensure
  execution_context.complete! if execution_context
end

2.3 并发

执行器将在 加载互锁 中将当前线程置于 running 模式。如果另一个线程当前正在自动加载常量或卸载/重新加载应用程序,此操作将暂时阻塞。

3 重新加载器

与执行器类似,重新加载器也包装应用程序代码。如果执行器尚未在当前线程上处于活动状态,重新加载器将为您调用它,因此您只需要调用一个。这也保证了重新加载器所做的一切,包括它所有回调的调用,都发生在执行器内部的包装中。如果它已经处于活动状态,它将是一个空操作。

Rails.application.reloader.wrap do
  # call application code here
end

重新加载器只适合长时间运行的框架级进程重复调用应用程序代码的地方,例如 Web 服务器或作业队列。Rails 自动包装 Web 请求和 Active Job 工作者,因此您很少需要自己调用重新加载器。始终考虑执行器是否更适合您的用例。

3.1 回调

在进入包装的块之前,重新加载器将检查正在运行的应用程序是否需要重新加载 - 例如,因为模型的源文件已被修改。如果它确定需要重新加载,它将等到安全,然后重新加载,然后再继续。当应用程序配置为始终重新加载,而不管是否检测到任何更改时,重新加载将改为在块的末尾执行。

重新加载器还提供 to_runto_complete 回调;它们在与执行器相同的点调用,但仅在当前执行启动了应用程序重新加载时调用。当不需要重新加载时,重新加载器将调用包装的块,而没有其他回调。

3.2 类卸载

重新加载过程最重要的部分是类卸载,其中所有自动加载的类都被删除,准备重新加载。这将在 Run 或 Complete 回调之前立即发生,具体取决于 reload_classes_only_on_change 设置。

通常,需要在类卸载之前或之后执行其他重新加载操作,因此重新加载器还提供 before_class_unloadafter_class_unload 回调。

3.3 并发

只有长时间运行的“顶级”进程应该调用重新加载器,因为如果它确定需要重新加载,它将阻塞,直到所有其他线程完成所有执行器调用。

如果这发生在“子”线程中,并且父线程在执行器中等待,则会导致不可避免的死锁:重新加载必须在子线程执行之前发生,但它不能在父线程执行过程中安全地执行。子线程应该使用执行器代替。

4 框架行为

Rails 框架组件也使用这些工具来管理自己的并发需求。

ActionDispatch::ExecutorActionDispatch::Reloader 是 Rack 中间件,它们分别使用提供的执行器或重新加载器包装请求。它们被自动包含在默认应用程序堆栈中。重新加载器将确保任何到达的 HTTP 请求都使用应用程序的最新加载副本进行服务,如果出现任何代码更改。

Active Job 也使用重新加载器包装其作业执行,在每个作业从队列中取出时加载最新的代码以执行它。

Action Cable 使用执行器代替:因为电缆连接链接到类的特定实例,所以不可能为每个到达的 WebSocket 消息重新加载。但是,只包装消息处理程序;长时间运行的电缆连接不会阻止由新传入请求或作业触发的重新加载。相反,Action Cable 使用重新加载器的 before_class_unload 回调断开所有连接。当客户端自动重新连接时,它将与代码的新版本通信。

以上是框架的入口点,因此它们负责确保其各自的线程受到保护,并决定是否需要重新加载。其他组件只需要在生成其他线程时使用执行器。

4.1 配置

重新加载器仅在 config.enable_reloadingtrueconfig.reload_classes_only_on_changetrue 时检查文件更改。这些是 development 环境中的默认设置。

config.enable_reloadingfalse(在 production 中,默认情况下)时,重新加载器只是一个对执行器的直通。

执行器始终有重要的工作要做,例如数据库连接管理。当 config.enable_reloadingfalseconfig.eager_loadtrueproduction 默认设置)时,不会发生重新加载,因此它不需要加载互锁。在 development 环境中的默认设置下,执行器将使用加载互锁以确保仅在安全时加载常量。

5 加载互锁

加载互锁允许在多线程运行时环境中启用自动加载和重新加载。

当一个线程通过从相应的文件中评估类定义来执行自动加载时,重要的是,没有其他线程会遇到对部分定义的常量的引用。

类似地,只有在没有应用程序代码正在执行时才能安全地执行卸载/重新加载:重新加载后,例如,`User` 常量可能指向不同的类。如果没有这个规则,时间不当的重新加载将意味着 `User.new.class == User` 甚至 `User == User` 可能为假。

加载互锁解决了这两个限制。它跟踪哪些线程当前正在运行应用程序代码、加载类或卸载自动加载的常量。

一次只能有一个线程加载或卸载,要执行任一操作,它必须等到没有其他线程运行应用程序代码。如果一个线程正在等待执行加载,它不会阻止其他线程加载(实际上,它们会协作,并且每个线程都会按顺序执行其排队的加载,然后所有线程一起恢复运行)。

5.1 permit_concurrent_loads

执行器会自动在代码块执行期间获取 `running` 锁,并且自动加载知道何时升级到 `load` 锁,以及之后如何切换回 `running`。

然而,在执行器代码块内执行的其他阻塞操作(包括所有应用程序代码)可能会无谓地保留 `running` 锁。如果另一个线程遇到必须自动加载的常量,这会导致死锁。

例如,假设 `User` 尚未加载,以下代码将导致死锁

Rails.application.executor.wrap do
  th = Thread.new do
    Rails.application.executor.wrap do
      User # inner thread waits here; it cannot load
           # User while another thread is running
    end
  end

  th.join # outer thread waits here, holding 'running' lock
end

为了防止此死锁,外部线程可以调用 `permit_concurrent_loads`。通过调用此方法,线程保证它不会在提供的代码块内解引用任何可能自动加载的常量。满足该承诺的最安全方法是将它尽可能靠近阻塞调用。

Rails.application.executor.wrap do
  th = Thread.new do
    Rails.application.executor.wrap do
      User # inner thread can acquire the 'load' lock,
           # load User, and continue
    end
  end

  ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
    th.join # outer thread waits here, but has no lock
  end
end

另一个使用并发 Ruby 的例子

Rails.application.executor.wrap do
  futures = 3.times.collect do |i|
    Concurrent::Promises.future do
      Rails.application.executor.wrap do
        # do work here
      end
    end
  end

  values = ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
    futures.collect(&:value)
  end
end

5.2 ActionDispatch::DebugLocks

如果您的应用程序出现死锁,并且您认为加载互锁可能参与其中,您可以暂时将 ActionDispatch::DebugLocks 中间件添加到 `config/application.rb` 中

config.middleware.insert_before Rack::Sendfile,
                                  ActionDispatch::DebugLocks

如果您重新启动应用程序并重新触发死锁条件,`/rails/locks` 将显示当前已知的所有线程摘要,包括它们持有的锁级别或等待的锁级别,以及它们当前的回溯。

通常,死锁是由加载互锁与其他外部锁或阻塞 I/O 调用冲突造成的。一旦找到它,您就可以使用 `permit_concurrent_loads` 将其包装起来。



返回顶部