Rails Ruby on Rails 线程安全代码

vincent · May 13, 2013 · Last by vincent replied at May 27, 2013 · 13407 hits
Topic has been selected as the excellent topic by the admin.

Ruby on Rails 4.0 的 rc 版本已经 release 了,Rails 4 时代最大的变化当属默认开启多线程模式。Rails 4 的多线程将给大家带来什么好处呢?至少有两方面:

1.更高的吞吐量 Web 应用多是 IO 密集型的,多线程可以让 Application 在等待 IO 操作的同时,还能接收处理请求,大大提升系统吞吐量,增强系统稳定性和安全性。

2.更省内存 Ruby on Rails 是内存消耗大户,一个 Applicaion 占用几百兆是常事,以前仅使用多进程并发模式时,整体内存消耗巨大,使用多进程 + 多线程的并发模式,不单系统吞吐量大大提供,系统整体使用内存也大幅下降。

但是天下没有免费的午餐,在享用这些好处的同时,我们也必须付出一定的代价,代价就是要应付多线程编程带来的复杂性。程序中需要处理多线程可能导致问题的地方,如果程序中出现问题也变得更加难以发现调试。

好在需要注意的地方也不是太多,综合网上的资料和自己的实践,下面把这几个需要注意的地方说明一下。

代码加载

Ruby 的 require 并非线程安全的,在多线程中 require,可能会导致严重的不可预期的错误。例如下面的一个演示程序在 Ruby 1.9 环境下执行会发生死锁。

a.rb 文件:

puts "#{Thread.current}: a.rb"
sleep 1
puts "#{Thread.current}: requiring b"
require 'b'

b.rb 文件:

puts "#{Thread.current}: b.rb"
puts "#{Thread.current}: requiring a"
require 'a'

test.rb 文件:

$LOAD_PATH << "."

t1 = Thread.new { require 'a' }
t2 = Thread.new { require 'b' }
t1.join
t2.join

在 Ruby 1.9 环境下运行 test.rb 将报死锁错误:

#<Thread:0x007fd2da90a268>: b.rb
#<Thread:0x007fd2da90a268>: requiring a
#<Thread:0x007fd2da90a2e0>: a.rb
#<Thread:0x007fd2da90a2e0>: requiring b
test.rb:5:in `join': deadlock detected (fatal)
  from test.rb:5:in `<main>'

因为 Ruby require 不是线程安全的,所以 Rails 中使用多线程环境时,需要对 require 做一定的限制,简单的说就是在 Application 启动的时候,把所有需要加载的代码全部加载完成,避免启动后还 require。Rails 4 的生产环境配置中该选项已经默认生效。需要注意的时,如果你的代码不在 Rails 默认的几个目录中,你需要手动配置你的目录进入 eager_load_path,例如:

config.eager_load_paths << "#{Rails.root}/lib"

全局变量和类变量写操作

在 Rails 多线程环境,所有的全局变量(包括 $var @@var 和 类实例变量),在实例方法中都应该是只读的,尽量应该避免写操作。

下面是一个在实例方法中写类变量导致问题的例子:

class HomeController < ApplicationController
  before_filter :set_site

  def index
  end

  private

  def set_site
    @site = Site.find_by_subdomain(request.subdomains.first)
    if @site.layout?
      self.class.layout(@site.layout_name)
    else
      self.class.layout('default_lay')
    end
  end
end

上面代码的意图是根据域名设置不同的 layout。self.class.layout(value) 中,Rails 把 value 保存在类变量 @@layout_,然后在 render 的时候使用。

设想这样一种情况 UserA 的 subdomain 是 foo1,他的 layout 应该是 foo1, UserB 的 subdomain 是 foo2,他的 layout 应该是 foo2。

UserA 和 UserB 同时请求应用,他们的请求分别在 Thread1 和 Thread2 中执行,执行顺序可能是:

  1. Thread1, 执行进入 set_site 方法,设置 @@layout_ 为 foo1;
  2. Thread2, 执行进入 set_site 方法,设置 @@layout_ 为 foo2;
  3. Thread1, render response,使用最新的 @@layout_ foo2 render.
  4. Thread2,render response,使用最新的 @@layout_ foo2 render.

我们期望 Thread1 使用 foo1 layout render,这样的执行结果和期望的不相符。

线程安全的写法是:

class HomeController < ApplicationController
  before_filter :set_site
  layout :site_layout

  def index
  end

  private

  def set_site
    @site = Site.find_by_subdomain(request.subdomains.first)
  end

  def site_layout
    if @site.layout?
      @site.layout_name
    else
      'default_lay'
    end
  end
end

程序在每次需要使用 layout 时,调用实例方法 site_layout,避免写类变量。

IO Connection

Rails 应用通常会用到多个 IO Connection,比如 ActiveRecord 的数据库 Connection,缓存 Memcached 的 Connection,Redis 的 Connection 等等。这些 IO Connection 在 Rails 多线程环境下并不都是线程安全的。

ActiveRecord 的 Connection 是线程安全的,而且 ActiveRecord 还可配置 Connection Pool,这样可以更高效率的利用数据库连接。

Memcached 的 Connection memchached-client 并不是线程安全的,最新的 dalli 是线程安全的。不过 dalli 的线程安全机制是在每个读写操作时加上互斥信号控制,这意味着同一时间只有一个线程可以操作,如果操作非常频繁的话,可能有性能上的问题,这个时候可以使用一个单独的 Connection Pool Gem 解决。

这个 Connection Pool Gem 的地址是 https://github.com/mperham/connection_pool

Redis 的 Connection 和 dalli 类似,本身通过加上互斥信号控制保证线程安全,可以通过 Connection Pool 增强效率。

使用互斥信号控制非线程安全操作

在程序中,如果存在某些不希望多个线程同时执行的操作,可以使用互斥信号控制其执行,这样当已经有一个线程进入执行时,其他进入的 thread 都会被 block 住,等前面的进程执行结束后才会进入执行,从而保证在一个时间只有一个线程会执行。

示例代码如下:

class HomeController < ApplicationController
  @@lock = Mutex.new

  def index
    @@lock.synchronize do
      thread_unsafe_code
    end
  end

  private

  def thread_unsafe_code
    if @@something == 'hello'
      do_hello
    elsif @@something == 'world'
      do_world
    else
      @@something = 'nothing'
    end
  end
end

总之,Rails 的多线程为我们提供了简便的提升系统伸缩性的能力,这也意味的程序复杂性的增加,有几处地方使我们需要注意的,只有这样才能很好的利用 Rails 多线程能力。

参考:

Ruby 能提升 Web 应用吞吐量的原因,见我的另一个帖子: 实例说明 Ruby 多线程的潜力和弱点 http://ruby-china.org/topics/11248

dalli 和 redis-rb 都是线程加锁的,但实际用起来,其实不会成为瓶颈,因为这些访问速度非常快,而且你一个进程不会开到几百个线程同时并发的,所以正常用就好了。

如果非要用 connection pool 去调度 dalli 和 redis-rb,pool 本身也有很多调度开销,反而可能得不偿失。

#2 楼 @robbin 嗯,dalli 和 redis-rb 读写一般都很快,访问时都是加了线程锁的,如果访问不频繁,完全可以不使用 connection pool。加不加 pool 有何影响,读写并发到多少量级才有影响,回头我搭个测试观察一下。

#3 楼 @vincent pool 本身有一定的调度开销,每个缓存请求先从 pool 里面分配一个连接,使用完毕再归还连接。多个线程同时访问缓存的时候,pool 的调度分配操作其实也是要加锁的。所以你最后计算 poll 的加锁调度,pool 的分配归还,可能还不如多线程加锁用一个连接划算呢。

#4 楼 @robbin 所以关键问题,是否使用 Connection Pool 取决于 Connection 的 IO 等待时间?比如 ActiveRecord 的 Connection,SQL 的 IO 等待可能很长,所以有必要用 Connection Pool 管理。但是 NoSQL 的 IO 等待很短,不使用 Connection Pool 还更高效。

我觉得 require 碰到线程问题真的容易死得很惨。。都没法重现

话说 4.0 以后是不是开发环境下也开启多线程了呢?

@vincent 再问下楼主 何为eager_load_paths?谢谢

#7 楼 @iBachue

config.eager_load_paths accepts an array of paths from which Rails will eager load on boot if cache classes is enabled. Defaults to every folder in the app directory of the application.

把目录加入 eager_load_paths 后,Rails 在启动时会 require 该目录下的所有文件,这样目录下的文件就不会在启动后再次加载了,从而规避了 Ruby require 非线程安全的问题。

#5 楼 @vincent 是这个道理,因为访问 memcached/redis 足够快,所以很大程度上可能没有必要用 pool,不过我也期待你的测试结果来说明问题。

#6 楼 @iBachue 开发环境通常是不会开启多线程的,因为开发环境需要主动加载变化的代码。

所以线程的问题通常只发生在生产环境,非常难以测试和重现,这是使用多线程的代价之一。

.NET 的桌面程序较多地涉及多线程,感觉又回到线程编辑了,唉!

#11 楼 @shatle 还好,其实需要注意的地方也就几个,不是特别麻烦。

关键还是 require,这个最头疼,其它问题不大,另外其实线程池是个很无奈的做法,不要一遇到问题就想线程池

14 Floor has deleted

#14 楼 @simonykq Ruby 的 类变量类似于全局变量,对同一进程内的所有线程来说,看到的自然是同一份变量,所以它不是线程安全的。举例子说明比较清晰:

class A
   @@var = 'ok'

   def method1
      @@var = 'ok1'
      sleep(2)
      puts @@var
   end

   def method2
      @@var = 'ok2'
      sleep(1)
      puts @@var
   end
end

t1 = Thread.new { A.new.method1 }
t2 = Thread.new { A.new.method2 }
t1.join
t2.join

期望代码的结果是

ok1
ok2

但实际结果是

ok2
ok2

原因是,t1 和 t2 访问的是同一个变量 @@var。 要避免这个问题,方法有:

  1. 尽量避免是使用读写型全局变量,尤其是类变量,类变量本就是 Ruby 中很迷惑人的一种变量,行为怪异,本就不推荐使用。如果全局变量在启动的时候初始化,后面多线程只是读,不涉及写,无此影响;
  2. 如果实在要写全局变量,在使用该变量的时候加锁。加锁版的代码如下:

    class A
     @@var = 'ok'
    
     def method1
        @@var = 'ok1'
        sleep(2)
        puts @@var
     end
    
     def method2
        @@var = 'ok2'
        sleep(1)
        puts @@var
     end
    end
    

lock = Mutex.new t1 = Thread.new { lock.synchronize{ A.new.method1 } } t2 = Thread.new { lock.synchronize{ A.new.method2 } } t1.join t2.join

这个时候,结果是 
```ruby
ok1
ok2

Ruby 能提升 Web 应用吞吐量的原因,见我的另一个帖子: 实例说明 Ruby 多线程的潜力和弱点 http://ruby-china.org/topics/11248

#15 楼 @vincent 我记得在 Java 里,无论是类变量还是实例变量,只有当变量被声明成 volatile 之后,此变量才被所有线程共享,否则默认情况下 JVM 会给每个线程分配一小块内存。在此内存上会存有所有共享变量的一个副本。在每个线程对那些共享变量的操作都是先对其对应的副本进行操作,然后再把共享变量的副本写道主内存上的。这样的话就当多线程操作一些共享变量的时候会导致一些共享变量的不一致性。

如果 Ruby 的类变量类似于全局变量的话,是不是说它默认情况下就是被声明成 volatile(易变的)?所以所有的 Ruby 线程将看到一致的共享变量值?

#17 楼 @simonykq Ruby 的类变量本质上是全局变量,设置类实例变量都可以认为是全局变量,只不过限制了一定范围而已。Ruby 线程里有 Thread.current hash 用于保存只属于当前线程的变量。

You need to Sign in before reply, if you don't have an account, please Sign up first.