什么是线程安全?

很难讲清楚什么是线程安全. 那么线程不安全会怎么样呢?

如果线程不安全, 那么在多线程条件下, 你的数据将很可能遭到错误.

这里的数据并非数据库的数据, 而是在你脚本中的数据结构(保存在内存中).

例子

demo code 1

counter = 0
10.times.map do
  Thread.new do
    tmp = counter + 1
    counter = tmp
  end
end.each(&:join)
puts "\ncounter:#{counter}"
=begin

counter:10
=end

counter 是个计数器, 现在开10个线程, 分别在每个线程中对counter做加一计算.

看起来结果很正常, 并没有什么异常.

如果我们修改一下代码, 在 counter = tmp 之前打印一个 * :

demo code 2


counter = 0
10.times.map do
  Thread.new do
    tmp = counter + 1
    print "*"
    counter = tmp
  end
end.each(&:join)
puts "\ncounter:#{counter}"
=begin
**********
counter:6
=end

结果显示, 打印了十个 * 但是计数器只加到了6, 数据错了.

这是为什么呢?

先解释为什么counter会出错

我们启动了10个线程之后, 各个线程就各自工作了, 我们并不能参与他们, 线程的管理都由调度器完成.

调度器具体怎么安排每个线程的执行顺序和执行时间, 我们并不能影响也无从知道其中的细节.

比如说此时counter为3, 那么就有可能发生在线程A刚打印完 * 的时候被暂停下来(此时tmp为4), CPU交给线程B继续使用.

此时counter还是3, 线程B将counter加一之后, counter变成4. 线程B完成了自己的任务后就退出了. 这时调度器将CPU又交给线程A使用, 在 counter = tmp 处恢复, tmp 之前的值为4, counter 被赋值为4. 如此一来, 经过了两个线程的计算, counter 只累加了1, 很明显是出错了.

这就是典型的多线程竞争同一个资源(counter)时, 线程不安全出错的例子.

再解释为什么一开始没有错

demo code 2demo code 1 多了一句 print "*", 为什么加了这句话就让错误暴露了呢?

这里的原因就要涉及到大名鼎鼎的GIL.

GIL 全称 Global Interpreter Lock, 是MRI的一个 “feature” .

GIL 阻止Ruby代码并行运行, 同一个Ruby进程中, 有且只有一个GIL锁, Ruby代码只有获得这把锁之后才能运行, 也就是说同一个时刻, 只有一个Ruby线程在运行.

需要特别解释的是 GIL 阻止代码并行运行, 而不是并发运行, 单核CPU单进程也可以并发运行多线程的程序(时分复用).

还有一点需要说明” IO阻塞的时候不运行Ruby代码, 也就是说GIL不会阻塞IO. 典型的IO包括网络IO, 磁盘IO, sleep, 各种打印.

print 就是一种IO Block.

GIL 会趁着一个线程IO阻塞的时候赶忙切换另一个线程来使用CPU, 以提高CPU的使用效率; 调度器也会在计算密集的时候有意减少线程切换, 以减少线程调度带来的不必要开销.

这就解释了为什么 demo code 1 很少出错, demo code 2 几乎总是出错的原因了:)

小结, 对于 IO-Bound 的程序, 可以多开几个线程来减少IO堵塞的时间浪费, 对于 CPU-Bound 的程序, 多线程只会让调度器白下力做无用功, 因为GIL阻止了并行执行 ~~

解决竞争条件

对于上面线程不安全的demo, 由资源竞争而起, 那么只要解决了counter的竞争就可以写出线程安全的代码了:


semapore = Mutex.new

counter = 0
10.times.map do
  Thread.new do
    semapore.synchronize do
      tmp = counter + 1
      print "*"
      counter = tmp
    end
  end
end.each(&:join)
puts "\ncounter:#{counter}"
=begin
**********
counter:10
=end