Ruby on Rails中的线程安全问题操作实例

308 阅读9分钟

如果你想构建一个高性能的Rails应用,确保线程安全是至关重要的。不幸的是,与线程有关的错误往往是偷偷摸摸的,只有在高并发的生产环境中才会表现出来。在这篇博文中,我们将讨论那些不具备线程安全的代码实例。我还将描述一个用于调试的工具包,并讨论可能的解决方案。在发货到生产环境之前,培养一双发现这些错误的眼睛可以为你省去很多麻烦。

100%的线程安全保证...

我敢说,仅仅通过观察一段Ruby代码,你永远无法判断它是否没有隐藏任何多线程相关的错误。一个典型的Ruby项目是一路的宝石,通常有过多的外部依赖性。因此,即使是一个简单的+ 方法也可能被猴子修补,从而引入一个线程安全问题。

然而,有一种方法可以确保你的Rails应用对任何并发性错误都是无懈可击的。但这是一个可怕的代价。让我们用一个例子来解释一下。

app/controllers/numbers_controller.rb

class NumbersController < ApplicationController
  def index
    @@per_page = params[:per_page]

    sleep 0.1

    if @@per_page == params[:per_page]
      render json: [1,2,3,4,5].first(@@per_page.to_i)
    else
      render plain: "It should never happen!", status: 500
    end
  end
end

这个端点根据作为参数收到的per_page 值渲染一个数字数组。我们添加了一个错误路径,从理论上讲,它不应该发生。你不会期望一个变量在前几行被赋值时改变其值。

这里有一个sleep 0.1 ,用来模拟一个阻塞的IO。Ruby使用全局解释器锁(GIL),其工作原理类似于全局互斥。它确保两个Ruby线程永远不能并行运行。但阻塞式IO意味着实际工作被委托给一个外部进程,例如SQL数据库或HTTP客户端。因此,当线程A在等待其阻塞的IO完成时,线程B可以接管。我在另一篇博文中更深入地介绍了Ruby线程、GIL和阻塞式I/O,如果你需要回顾一下,请参考它。

如果你熟悉Ruby中的线程问题,你会注意到我们正在使用一个类变量。它是在一个进程中的所有线程之间共享的。因此,当线程A在睡觉时,线程B会启动并覆盖导致错误路径的@@per_page 值。结果,用户会收到与要求不同数量的对象。这是我能想到的最直接的线程安全错误。详细分析它的意义在于描述一个处理更复杂情况的工具箱。

我已经用Rails 7、Ruby 3.1.0在Puma服务器上进行了所有测试,该服务器在生产环境中以单一模式运行,有5个最小/最大线程。

我们的目标是触发"It should never happen!" 错误。我们需要模拟一个高并发的环境来实现这个目标。你不太可能快速敲击CMD+R,因此,让我们使用Siege这个负载测试工具

brew install siege

现在创建一个urls.txt 文件,内容如下。

http://localhost:3000/numbers?per_page=1
http://localhost:3000/numbers?per_page=2
http://localhost:3000/numbers?per_page=3

现在你可以通过运行这个命令开始你的第一次测试。

siege --time=5s --concurrent=10 -f urls.txt -i

你应该得到一个类似的结果。

Lifting the server siege...
Transactions:               84 hits
Availability:               45.65 %
Elapsed time:               4.30 secs
Data transferred:           0.00 MB
Response time:              0.49 secs
Transaction rate:           19.53 trans/sec
Throughput:                 0.00 MB/sec
Concurrency:                9.65
Successful transactions:    84
Failed transactions:        100
Longest transaction:        0.26
Shortest transaction:       0.14

你可以看到,我们已经成功地在超过50%的请求中触发了错误。你可以通过运行以下命令来确认多个并发线程的存在导致了这个错误。

siege --time=5s --concurrent=1 -f urls.txt -i

而现在你应该得到100%的成功率。

那么,如何使我们的应用程序对我之前提到的线程错误防不胜防呢?你可以通过在config/environments/production.rbconfig/environments/development.rb 添加一行来实现。

config.middleware.insert_before 0, Rack::Lock

不要忘记在修改配置文件后重新启动你的服务器。

让我们重新运行我们的测试。

siege --time=5s --concurrent=10 -f urls.txt -i

并且你应该期待一个类似的结果。

Lifting the server siege...
Transactions:               41 hits
Availability:               100.00 %
Elapsed time:               4.48 secs
Data transferred:           0.00 MB
Response time:              0.97 secs
Transaction rate:           9.15 trans/sec
Throughput:                 0.00 MB/sec
Concurrency:                8.86
Successful transactions:    41
Failed transactions:        0
Longest transaction:        1.11
Shortest transaction:       0.15

所以我们得到了100%的成功率,但代价是我们的吞吐量减少了50%以上。我们的点击率从84次下降到41次,最长的交易现在超过1秒,而不是0.26秒。为了理解为什么添加Rack::Lock 有这种效果,让我们快速看一下它的源代码

def initialize(app, mutex = Mutex.new)
  @app, @mutex = app, mutex
end

def call(env)
  @mutex.lock
  begin
    response = @app.call(env)
    returned = response << BodyProxy.new(response.pop) { unlock }
  ensure
    unlock unless returned
  end
end

你可以看到,这个中间件将传入的请求包装成一个突变器,每个进程初始化一次。所有的Puma工作线程共享同一个mutex变量。这意味着两个线程不能同时运行,即使是阻塞的I/O。Rack::Lock ,使你的应用程序在每个进程中只使用一个线程。

image.png

你可以看到,虽然有效,但Rack::Lock 并不是一个可行的解决方案。

我们已经验证了,你需要模拟一个并发环境来触发哪怕是最简单的线程安全漏洞。如果你要猎取这些在生产中发现的奇怪的*"有时 "*bug,Siege是一个完美的工具,可以在本地测试你的应用程序。

SQL数据库和线程安全

数据库的互动为可能的线程错误的范围增加了一个全新的维度。考虑一下这个例子。

class NumbersController < ApplicationController
  def counter
    current_user.update!(
      counter: current_user.counter + 1
    )
  end

  def current_user
    @current_user ||= User.find(session[:user_id])
  end
end

这个端点计算了一些用户的交互。你可以用下面的Siege命令来测试它。

siege --time=5s --concurrent=10 http://localhost:3000/counter

你应该看到一个类似的输出。

Transactions:         341 hits
Availability:         100.00 %

你会期望counter 用户属性值增加同样的数字。但是(除非你仍然启用了Rack::Lock ),你会发现它只增加了所统计的API点击量的三分之一左右。

尽管没有明显的阻塞式IO(如前面例子中的sleep ),我们还是设法触发了一个线程安全错误。我们的线程从数据库中获取counter 属性的当前值,并并发地增加它,也就是说,多次提交同一变化。这就是为什么我们会丢失一些值。

你可以通过将读取和更新操作包裹到一个具有正确隔离级别的数据库事务中来解决这个问题。

def counter
  User.transaction(isolation: :repeatable_read) do
    current_user.update!(
      counter: current_user.counter + 1
    )
  end
end

SQL事务隔离级别的细节不在本教程的讨论范围之内。但长话短说,repeatable_read 取得锁,阻止并发的读和写。因此,更新操作是按顺序执行的,现在counter 属性的值等于成功的HTTP API调用的数量。请记住,获取具有高隔离级别的数据库事务会大大降低性能。在对生产瓶颈应用这一变化之前,一定要测量其影响。

这个特殊例子的另一个解决方案是利用一个设计上是线程安全的API方法。

def counter
  current_user.increment!(:counter)
end

找到一个对线程错误免疫的API方法并不总是可能的。但是,在未知库的文档中搜索*"线程安全"、* *"并发 "等关键词,*有时可以将你从不必要的错综复杂的解决方案中拯救出来。例如,concurrent-ruby gem是一个很棒的线程安全编程基元的集合。所以,如果你发现自己为了修补一个线程错误而在变戏法似的使用互斥,熟悉这个宝石所提供的工具可能会成为你的救星。顺便说一句,我目前正在写一篇关于它的博文,如果你想在博文发表时得到通知,请订阅。

坏的globals

人们常说*"全局性的东西对线程安全不利"。我想说的是,这一切都归结为这样的权衡:变量的全局性越低,在作用域之间传递它就越麻烦。我推荐DHH的这个截屏,他在那里讨论了"当价格合适时 "全局变量的潜在使用情况。*

关于在哪里以及如何使用globals,并没有简单的答案。因此,让我来介绍一种在多线程环境中使全局值安全的技术。

线程安全的globals

Ruby为所谓的线程本地变量提供了内置支持。每个线程都可以作为一种哈希,用于存储应用程序中全局可访问的值,但只能从这个单一的线程中访问。

解释它的最好方法是运行下面的代码示例。

Thread.current[:value] = "parent"

child = Thread.new do
  puts "Initial value in child: #{Thread.current[:value].inspect}"
  Thread.current[:value] = "child"
end
child.join

puts "Value in main: #{Thread.current[:value]}"
puts "Value in child: #{child[:value]}"

# Output:
# Initial value in child: nil
# Value in main: parent
# Value in child: child

这些*"线程本地全局 "*有时有助于在应用程序中传递数据。

然而,在使用裸机Thread.current 来存储数值时,有一个关键问题。Puma服务器似乎在回收它的线程。这意味着,除非你很小心,否则你会在请求之间泄漏数据。你可以通过实现下面的端点来确认这种行为。

class ExampleController < ApplicationController
  @@assignment_lock = Mutex.new

  def index
    @@assignment_lock.synchronize {
      if $assigned == nil
        Thread.current[:value] = "assigned"
        $assigned = true
      end
    }

    render plain: Thread.current[:value].to_s
  end
end

我们使用@@assignment_lock 类变量mutex,它是每个进程的全局变量,以防止所谓的*"检查时间到使用时间 "*错误。在一个高度并发的环境中,理论上有可能线程A检查$assigned 全局变量的值,然后线程B启动并检查它仍然是nil ,然后线程A将其设置为true 。这种情况将导致两个不同的线程进入if ,我们要防止它。

这个端点的第一个响应将显示文本assigned 。当你再请求它几次时,你会发现每点击几次就会随机返回相同的文本。这意味着最初的线程被重复使用了。忽视Thread.current 的这一特点可能会带来关键的安全漏洞,因为敏感数据可能会在不同的用户会话之间共享。

一个内置的Rails ActiveSupport::CurrentAttributes类也能达到同样的目的,而且有更好的安全保障。让我们重新实现我们的例子。

app/models/current.rb

class Current < ActiveSupport::CurrentAttributes
  attribute :value
end

和端点。

class ExampleController < ApplicationController
  @@assignment_lock = Mutex.new

  def index
    @@assignment_lock.synchronize {
      if $assigned == nil
        Current.value = "assigned"
        $assigned = true
      end
    }

    render plain: Current.value.to_s
  end
end

你现在会看到,在第一次请求时,assigned 的文本只会显示一次。之后的每个请求都会得到一个Current 对象的新副本,所以不再有数据泄露的风险。

总结

Rails中的线程安全是一个很重要的电子书的主题,而不是一篇博文。但我希望上述信息涵盖了基础知识,可以帮助你发现和调试代码库中的潜在问题。如果你知道更多关于Ruby中线程不安全代码的有趣例子,请在评论中告诉我,这样我就可以把它们纳入这篇文章中。