如何知道在Ruby中哪些内容是非线程安全的?

96

从Rails 4开始, 所有的内容默认都必须在线程环境下运行。这意味着我们编写的所有代码和使用的所有gem都需要被要求是threadsafe

因此,我有几个问题:

  1. 什么在ruby/rails中不是线程安全的? Vs ruby/rails中哪些是线程安全的?
  2. 是否有已知的线程安全的gem列表或反之列表?
  3. 是否有代码常见模式的不线程安全列表,例如@result ||= some_method
  4. ruby语言核心中的数据结构(如Hash等)是线程安全的吗?
  5. 在MRI上,存在GVL/GIL,这意味着除了IO外,只能同时运行1个ruby线程,线程安全性变化会影响我们吗?

2
你确定所有的代码和 gem 都需要是线程安全的吗?发布说明所说的是 Rails 本身将是线程安全的,而不是必须使用它的所有其他内容都必须是线程安全的。 - enthrops
多线程测试将是最糟糕的线程安全风险。当您必须在测试用例周围更改环境变量的值时,您立即不再是线程安全的。您会如何解决这个问题?是的,所有的 gem 都必须是线程安全的。 - Lukas Oberhuber
3个回答

122

核心数据结构都不是线程安全的。我所知道的唯一一个带有Ruby的是标准库中的队列实现 (require 'thread'; q = Queue.new)。

MRI的GIL并不能保护我们免受线程安全问题的影响。它只能确保两个线程不能同时运行Ruby代码,即在两个不同的CPU上完全相同的时间运行。线程仍然可以在您的代码中的任何时刻暂停和恢复。如果您编写像 @n = 0; 3.times { Thread.start { 100.times { @n += 1 } } } 这样的代码,例如从多个线程更改共享变量,则共享变量之后的值是不确定的。 GIL或多或少是单核系统的模拟,它不会改变编写正确并发程序的基本问题。

即使MRI像Node.js一样是单线程的,你仍然需要考虑并发性。增加变量的示例可以正常工作,但仍然可能出现竞态条件,在非确定性顺序中发生某些事情,一个回调会覆盖另一个回调的结果。单线程异步系统更容易理解,但它们不免于并发问题。只需想象具有多个用户的应用程序:如果两个用户在几乎同时点击编辑Stack Overflow帖子,花费一些时间编辑帖子,然后点击保存,当第三个用户稍后阅读相同帖子时,谁的更改将被看到? 在Ruby中,与大多数其他并发运行时一样,任何超过一个操作的操作都不是线程安全的。`@n += 1`不是线程安全的,因为它是多个操作。`@n = 1`是线程安全的,因为它是一个操作(在幕后有很多操作,如果我试图详细描述为什么它是“线程安全的”,我可能会陷入麻烦,但最终您将不会从分配中获得不一致的结果)。`@n ||= 1`也不是线程安全的,也没有其他简写操作+赋值。我犯过的一个错误是编写`return unless @started; @started = true`,这根本不是线程安全的。
我不知道有关于Ruby线程安全和非线程安全语句的官方列表,但是有一个简单的经验法则:如果一个表达式只执行一个(无副作用的)操作,那么它很可能是线程安全的。例如:a + b 是可以的,a = b 也可以,a.foo(b) 也可以,如果方法foo没有副作用(由于在Ruby中几乎任何东西都是方法调用,即使在许多情况下也适用于其他示例)。在这个上下文中,副作用指的是改变状态的事情。def foo(x); @x = x; end 不是无副作用的。

在Ruby中编写线程安全代码最困难的一点是,所有核心数据结构,包括数组、哈希和字符串,都是可变的。很容易意外泄漏一部分状态,而当这部分状态是可变的时,事情会变得非常混乱。考虑以下代码:

class Thing
  attr_reader :stuff

  def initialize(initial_stuff)
    @stuff = initial_stuff
    @state_lock = Mutex.new
  end

  def add(item)
    @state_lock.synchronize do
      @stuff << item
    end
  end
end

这个类的实例可以在线程之间共享,并且它们可以安全地向其中添加内容,但是存在并发错误(这不是唯一的错误):对象的内部状态通过stuff访问器泄漏。除了从封装角度来看有问题外,它还打开了一个并发蠕虫的罐子。也许有人会获取该数组并将其传递给其他地方,然后那段代码又认为它现在拥有该数组并可以随意使用它。

另一个经典的Ruby示例是:

STANDARD_OPTIONS = {:color => 'red', :count => 10}

def find_stuff
  @some_service.load_things('stuff', STANDARD_OPTIONS)
end

find_stuff第一次使用时工作正常,但第二次返回其他内容。为什么?load_things方法碰巧认为它拥有传递给它的选项哈希,并执行color = options.delete(:color)。现在STANDARD_OPTIONS常量不再具有相同的值。常量只在引用的内容上是恒定的,它们不能保证所引用的数据结构的恒定性。想象一下如果此代码并发运行会发生什么。

如果您避免使用共享的可变状态(例如多个线程访问的对象中的实例变量、哈希和数组等数据结构),那么线程安全并不难实现。尽量减少应用程序中被并发访问的部分,并将重点放在这些部分上。据我所知,在Rails应用程序中,每个请求都会创建一个新的控制器对象,因此它只会被单个线程使用,从该控制器创建的任何模型对象也是如此。但是,Rails也鼓励使用全局变量(User.find(...)使用全局变量User,您可能认为它只是一个类,它确实是一个类,但也是全局变量的命名空间),其中一些是安全的,因为它们只读,但有时您会在这些全局变量中保存一些东西,因为这很方便。在使用任何全局访问的内容时要非常小心。
很长一段时间以来,运行 Rails 在线程环境中已经成为可能,所以即使不是 Rails 专家,我仍然可以说,在涉及到 Rails 本身时,您不必担心线程安全性。但是,您仍然可以通过执行我上面提到的某些操作来创建不支持线程的 Rails 应用程序。对于其他 gem,假设它们不是线程安全的,除非它们声明了自己是线程安全的,如果它们声明了自己是线程安全的,则假定它们不是线程安全的,并查看它们的代码(但只是因为您看到它们执行像 @n ||= 1 这样的操作并不意味着它们不是线程安全的,在正确的上下文中这是完全合法的事情 - 相反,您应该寻找全局变量中的可变状态、它如何处理传递给其方法的可变对象,以及特别是它如何处理选项哈希)。
最后,线程不安全是一种传递属性。使用不支持线程的任何内容本身也不是线程安全的。

很棒的答案。考虑到一个典型的 Rails 应用程序是多进程的(就像你所描述的,许多不同的用户访问同一个应用程序),我想知道线程对并发模型的边际风险是多少...换句话说,如果你已经通过进程处理了一些并发性,那么在线程模式下运行会更加“危险”吗? - gingerlime
2
@Theo 非常感谢。那些常量的东西真的很危险。它甚至不是进程安全的。如果在一个请求中更改了该常量,即使在单个线程中,后续请求也将看到已更改的常量。Ruby 的常量确实很奇怪。 - rubish
6
执行STANDARD_OPTIONS = { ... }.freeze可以防止浅层次的变异并引发异常。 - glebm
真的是非常棒的答案。 - Cheyne
3
如果您编写的代码类似于@n = 0; 3.times { Thread.start { 100.times { @n += 1 } } }[...],共享变量的值之后是不确定的。您知道这在不同版本的Ruby中是否有所不同吗?例如,在1.8上运行您的代码会得到不同的@n值,但在1.9及更高版本中,它似乎始终给出等于300的@n值。 - user200783
这个答案在2022年和Ruby 3.2中是否仍然适用? - Dan S.

10
除了Theo的回答之外,如果你正在切换到config.threadsafe!的话,在Rails中还有一些需要注意的问题领域。
- 类变量:
``` @@i_exist_across_threads ```
- 环境变量:
``` ENV['DONT_CHANGE_ME'] ```
- 线程:
``` Thread.start ```

9
自 Rails 4 开始,默认情况下所有内容都必须在线程环境中运行。

这并不完全正确。Rails 的线程安全只是默认开启。如果您在像 Passenger(社区版)或 Unicorn 这样的多进程应用服务器上部署,则不会有任何区别。只有当您在像 Puma 或 Passenger Enterprise > 4.0 这样的多线程环境中部署时,才会受到此更改的影响。

过去,如果您想要在多线程应用服务器上部署,您必须打开 config.threadsafe,现在已经默认开启了,因为它所做的一切都没有效果,或者也适用于在单个进程中运行的 Rails 应用程序(Prooflink)。

但是,如果你想要所有Rails 4的流式处理优势以及其他多线程部署的实时功能,也许你会觉得这篇文章很有趣。正如@Theo所说,对于Rails应用程序,您实际上只需要在请求期间省略突变的静态状态。虽然这是一种简单的做法,但不幸的是,您无法确定每个gem都符合此规则。就我所记得的,来自JRuby项目的Charles Oliver Nutter在这个播客中提供了一些技巧。

如果您想编写纯并发的Ruby程序,其中需要一些数据结构被多个线程访问,您可能会发现thread_safe gem很有用。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接