Ruby 代码线程安全的一些编写原则
避免修改全局共享的对象
非必要的情况下尽量避免修改全局共享的对象,包括 $
开头的全局变量、单实例对象、AST、类变量/方法等。
下面的写法是线程安全的,因为它没有修改全局的状态:
class RainyCloudFactory
def self.generate
cloud = Cloud.new
cloud.rain!
cloud
end
end
下面的写法不是线程安全的,因为它修改了全局的状态:
class RainyCloudFactory
def self.generate
cloud = Cloud.new
@@clouds << cloud
cloud
end
end
但是,如果非用全局共享的对象不可,也不是不能用,只要保证线程安全即可,例如:
require 'thread'
class Counter
def initialize
@counter = 0
@mutex = Mutex.new
end
def increment
@mutex.synchronize do
@counter += 1
end
end
end
$counter = Counter.new
AST
上面提到的 AST 指的是程序的抽象语法树里的指令,Ruby 作为一门动态语言,是允许在运行时修改它的。所有的线程共享一份 AST, 因此在多线程环境下,同样存在线程安全的问题——虽然很罕见。kaminari 这个 Gem 曾经就出过这样的一个问题:https://github.com/kaminari/kaminari/issues/214
代码里动态地定义了一个方法,然后用 alias_method
为其创建别名,接着又删除了初始定义的那个方法。在多线程环境下,就会出现这样的情况:线程 A 定义了一个方法,并且为其创建了别名。接着线程 B 也定义了这个方法,覆盖了线程 A 定义的方法。然后线程 A 删除了该方法。线程 B 再为它创建别名时,就抛出了 NoMethodError
异常。
所以,为了线程安全,应该避免在运行时修改 AST. 这里的“运行时”,指的是应用程序运行的过程中。或者换句话说,AST 的修改应该在程序的启动过程中完成。
创建更多的对象,而不要共享同一个
有时候我们免不了还是需要使用全局对象,例如数据库连接。这时候有两种办法解决线程安全问题。
Thread-locals
Thread-locals 的方案,是让对象仅在当前线程里全局可见。下面是个例子:
# 把这样的代码:
$redis = Redis.new
# 用这种方式替代:
Thread.current[:redis] = Redis.new
Thread-locals 的方案会为每个线程创建一个对应实例。但是当线程数很大的时候,资源池可能是一个更好的方案。
资源池
假设有 N 个线程需要连接 Redis, 连接池里有 M 个连接可供使用,M < N. 这种情况下资源池仍然能够保证线程安全。
一个资源池通常维护着一定数量的资源供多个线程使用。当一个线程需要使用该资源(比如 Redis 连接)时,资源池会取出一个资源供其使用,并在使用完毕后将该资源放回池中以待下一个线程使用。这样保证了线程安全。
避免延迟加载
在 3.0 版本之前,Ruby on Rails 中的一个习惯用法是在运行时加载常量,用的是类似 Ruby 的 autoload
的方法。但这不是个好的做法,因为 MRI Ruby 的 autoload
并不是线程安全的。虽然在现在的 JRuby 里,autoload
是线程安全的,但最好的做法还是在派生 worker 线程之前预先加载需要的常量。
优先使用(线程安全的)数据结构而不是 Mutex
Mutex 不容易正确地使用。在使用 Mutex 的时候你要考虑许多问题:
- 这个 Mutex 的粒度应该有多大?
- 这里会不会发生死锁?
- 我应该为每个对象创建一个 Mutex 还是使用全局的 Mutex 对象?
以上问题只是需要考虑的其中一部分。当然对于熟练掌握 Mutex 用法的程序员来说,这些问题不难回答。
但使用数据结构(例如 Queue)可以免掉这些考量。与其担心这些问题,还不如干脆不用 Mutex.
另外,这里还有一些来自 JRuby wiki 的关于编写并发代码的“安全路径”:
- 不要编写并发代码,除非无法避免;
- 如果你非得编写并发代码不可,不要在线程间共享数据;
- 如果非得在线程间共享数据不可,不要共享可变数据;
- 如果非共享可变数据不可,在访问这些数据时做同步处理。