WIP Ruby Ractor遇到的问题

背景

我最近刚巧在看一些 函数式编程的东西,lambda演算。函数式可以解决CPU多核心利用的问题实际上在目前的Ruby上多线程无法真正利用多核。

恰巧 @Jakit 在研究 Ractor,这是一个Ruby上正在开发的特性,原理上类似于把 Golang 底层的并发模型 Actor 带到Ruby。

后来在交流Ractor的过程中发现一些问题。

然后我试图把这些问题弄明白,并且记录在这里。

列举一些资料

在讨论这些之间,你可能需要这些资料

Ruby版本的计算机构成原理

  • 《计算的本质》

    这里写下结论,《计算的本质》这本书主要通过 Ruby的方式描述了 图灵机实现、lambda演算 并且证明了 基于状态的图灵机 和 基于lambda演算 他们是等价的。 这是函数式编程的数学基础。 这意味着我们可以用函数式编程范式去替换传统的基于状态编写的程序的范式。

  • Actor模型 《七周七并发模型》Actor模型章节

让我们直观的感受下他们 benchmark 一下

我们想通过 直接执行、线程执行、进程执行、Ractor执行 一段斐波那契数列(计算密集型)以此来观察不同的模型到底执行效率如何?

代码如下:

require 'benchmark'

def fib n
  if n < 2
    1
  else
    fib(n-2) + fib(n-1)
  end
end

# 直接执行
puts Benchmark.measure {
  8.times do
    fib(40)
  end
}

# 进程
puts Benchmark.measure {
  8.times do
    fork { fib(40) }
  end
  Process.wait
}


# 线程
puts Benchmark.measure {
  8.times.map {
    Thread.new{fib(40)}
  }.each{|t| t.value}
}


# Ractor
puts Benchmark.measure {
  8.times.map {
    Ractor.new{ fib(40) }
  }.each{|r| r.take}
}

接下来我简单说明:

进程方式运行,我们会创建8个进程(这里主要是我Apple的M1新品是8核心,我们选择和核心数量匹配。这里使用fork方式创建进程。)

多线程方案,也很简单创建8个线程。Ractor的方案同理,创建8个Ractor实例

上面的代码,执行之后,我记录了benchmark结果和CPU的图

benchmark

简单分析下这个图。主要对应了4部分运行阶段。

方法 user time system time user+system times real time
顺序执行 84.713012 0.131839 84.844851 ( 84.845332)
进程 0.000214 0.002798 14.607795 ( 14.787335)
多线程 84.604395 0.145657 84.750052 ( 84.744966)
Ractor 116.438639 0.148765 116.587404 ( 14.853680)

可以直接看最右侧就是实际使用时间。

1)顺序执行可以简单理解就是 8倍 fib(40) 的时间。

2)进程是以fork的形式分成8个进程,这部分相当于操作系统在作调度。8个核心都用上了。相当于并行8个 fib(40)。操作系统表现正常。

3)多线程 由于存在 GIL 全局解释器锁的问题,实际上只有一个CPU在工作。线程不会真的分散在多核心处理,反而是CPU在8个线程之间切换执行。对于fib这种计算密集型场景,多线程在这里并不会展现优势。从数值结果方面可以看出多线程的速度和顺序执行差不多。

补充说明:顺序执行是 A-B-C-D ...的方式执行。多线程程序存在一个竞争问题,多个线程会争抢CPU资源,所以线程的执行具有随机性比如A-D-F-C-A-D-B- ... 并且如果多个线程之间访问共同的变量,会出现竞争问题、死锁问题。理论上消耗时间会大于顺序执行。

可能有人会问,那为什么还需要多线程程序?因为如果程序是顺序执行的,对于用户来说顺序执行必须等待。我们必须等界面绘制完鼠标再响应,鼠标响应,键盘就不能响应,等待键盘输入,屏幕就不能绘制,一切都必须顺序等待。显然这样子的程序没人想要。多线程可以让很多任务交错、时间分片式进行,这样由于CPU的速度足够快,在用户看来像是所有程序都可以被执行,都可以被响应。虽然我们的程序没有那么高的高度,但是操作系统也作了类似的事情,把所有程序时间分片执行。

另外,如果我们的遇到了IO场景,尤其是需要等待IO的返回,多线程的方式即使有GIL也可以减少每个线程平均等待的时间。等价于提速。

4)Ractor是以多线程的方式工作,但是脱离GIL,每个线程可以被合理的放在CPU的核心上。表现的就像多进程一样。这就是Ractor的威力。虽然还在实验中。Ractor意味着Ruby也有了类似Golang的Actor模型,可以进行高并发编程。来弥补多线程的不足。

多线程程序是非常难测试的,Ractor让这部分简单了许多。对于 IO 场景,Ractor可以减少等待时间,并且可以充分利用多核心CPU。在未来的多核心CPU时代,高并发编程领域,Ruby也可以扮演重要的角色。

小结

一切都很美好。由于 Ractor 的出现,可以让Ruby也具备轻松创建多线程并且使用CPU多核心。

但是后面会介绍,由于一些写法,可能会出现,你无法正确的获得 Ractor的能力。

情况1 数组?

其实前面的 fib 函数良好的遵循了 函数式编程 的习惯: 无状态、一切皆是表达式。没有状态的存在。

保持这种编程的风格,可以让程序良好的运作,尤其是多线程程序中。无状态意味着没有竞争。没有副作用。彼此隔绝,各自运行相安无事。

# 函数式,无状态,表达式
def fib n
  if n < 2
    1
  else
    fib(n-2) + fib(n-1)
  end
end

下面我们改变一下 fib 的写法,然后我们直接测试 Ractor 就好,如下所示:

def fib(n)
  return n if [0,1].include?(n)
  fib(n-1) + fib(n-2)
end

使用了4个核(1半)

消耗时间: 213.800529 39.743517 253.544046 (104.313164)

Why?

分析这段

code = <<-CODE
def fib(n)
  return n if [0,1].include?(n)
  fib(n-1) + fib(n-2)
end
CODE

compile = RubyVM::InstructionSequence.compile(code)

puts compile.disasm

改进 1.0

def fib(n)
  return n if [0,1].freeze.include?(n)
  fib(n-1) + fib(n-2)
end

使用了4个核(1半)

消耗时间 249.007863 45.051512 294.059375 (113.910791)

改进 2.0

SHARE = [0,1].freeze
def fib(n)
  return n if SHARE.include?(n)
  fib(n-1) + fib(n-2)
end

使用了 8 个核心,一切又正常了。

193.183082 0.234250 193.417332 ( 24.710143)

尝试改进 3.0

SHARE = [0,1]
def fib(n)
  return n if SHARE.include?(n)
  fib(n-1) + fib(n-2)
end

结果是报错,无法继续

can not access non-shareable objects in constant Object::SHARE by non-main Ractor. (  from ./state-benchmark.rb:14:in `block (3 levels) in <main>'
Ractor::IsolationError)

Mark24

Everything can Mix.