首页 > 解决方案 > Ruby:并发/多线程任务的 CPU 负载降级?

问题描述

序言:我正在做一个恢复truecrypt容器的项目。它以最可能的随机顺序被切割成超过 3M 的小文件,目标是找到包含加密密钥的容器的开头或结尾。

为此,我编写了一个小型 ruby​​ 脚本,该脚本启动许多 truecrypt 进程,同时尝试挂载主文件或恢复备份文件头。通过派生的 PTY 与 truecrypt 进行交互:

  PTY.spawn(@cmd) do |stdout, stdin, pid|
    @spawn = {stdout: stdout, stdin: stdin, pid: pid}

    if test_type == :forward
      process_truecrypt_forward
    else
      process_truecrypt_backward
    end

    stdin.puts
    pty_expect('Incorrect password')

    Process.kill('INT', pid)
    stdin.close
    stdout.close
    Process.wait(pid)
  end

这一切都很好,并成功地找到了测试容器所需的部分。为了加快速度(我需要处理超过 3M 块),我首先使用了 Ruby MRI 多线程,并在阅读了有关它的问题后切换到concurent-ruby

我的实现非常简单:

log 'Starting DB test'
concurrent_db = Concurrent::Array.new(@db)

futures = []

progress_bar = initialize_progress_bar('Running DB test', concurrent_db.size)

MAXIMUM_FUTURES.times do
  log "Started new future, total #{futures.size} futures"

  futures << Concurrent::Future.execute do
    my_piece = nil

    run = 1

    until concurrent_db.empty?
      my_piece = concurrent_db.slice!(0, SLICE_PER_FUTURE)
      break unless my_piece
      log "Run #{run}, sliced #{my_piece.size} pieces, #{concurrent_db.size} left"

      my_piece.each {|a| run_single_test(a)}
      progress_bar.progress += my_piece.size
      run += 1
    end

    log 'Future finished'
  end
end

然后我租用了一个具有 74 个 CPU 内核的大型 AWS 实例,然后想:“现在我要快速处理它”。但问题是,无论我同时启动多少个期货/线程(我的意思是 20 或 1000 个),我都不会超过每秒 50 次检查。

当我启动 1000 个线程时,CPU 负载仅在 20-30 分钟内保持 100%,然后下降,直到达到 15% 并保持不变。此类运行中的典型 CPU 负载图。磁盘负载不是问题,我使用 Amazon EBS 存储时最高达到 3MiB/s。

我错过了什么?为什么我不能利用 100% cpu 并获得更好的性能?

标签: rubymultithreadingconcurrencytruecrypt

解决方案


很难说为什么你没有看到多线程的好处。但这是我的猜测。

假设您有一个非常密集的 Ruby 方法,需要 10 秒才能运行,称为do_work. 而且,更糟糕的是,您需要运行此方法 100 次。与其等待 1000 秒,不如尝试对它进行多线程处理。这可能会在您的 CPU 内核之间分配工作,将运行时减半甚至四分之二:

Array.new(100) { Thread.new { do_work } }.each(&:join)

但是不,这可能仍需要 1000 秒才能完成。为什么?

全局虚拟机锁

考虑这个例子:

thread1 = Thread.new { class Foo; end; Foo.new }
thread2 = Thread.new { class Foo; end; Foo.new }

在 Ruby 中创建一个类在后台做了很多事情,例如它必须创建一个实际的类对象并将该对象的指针分配给一个全局常量(以某种顺序)。如果 thread1 注册了该全局常量,在创建实际类对象的过程中完成了一半,然后 thread2 开始运行,说“哦,Foo已经存在。让我们继续运行Foo.new”,会发生什么。由于类尚未完全定义,会发生什么?或者,如果 thread1 和 thread2 都创建了一个新的类对象,然后都尝试将它们的类注册为Foo呢?哪一个赢了?已经创建但现在没有注册的类对象呢?

官方的 Ruby 解决方案很简单:实际上不要并行运行此代码。取而代之的是,有一个称为“全局 VM 锁”的大型互斥锁,它可以保护任何修改 Ruby VM 状态的东西(例如创建一个类)。因此,虽然上面的两个线程可能以各种方式交错,但 VM 不可能最终处于无效状态,因为每个 VM 操作本质上都是原子的。

例子

在我的笔记本电脑上运行大约需要 6 秒:

def do_work
  Array.new(100000000) { |i| i * i }
end

这显然需要大约 18 秒

3.times { do_work }

但是,这也需要大约 18 个,因为 GVL 阻止线程实际并行运行

Array.new(3) { Thread.new { do_work } }.each(&:join)

这也需要 6 秒才能运行

def do_work2
  sleep 6
end

但现在这需要大约 6 秒才能运行:

Array.new(3) { Thread.new { do_work2 } }.each(&:join)

为什么?如果你仔细研究 Ruby 源代码,你会发现它sleep最终调用了 C 函数native_sleep我们看到

GVL_UNLOCK_BEGIN(th);
{
    //...
}
GVL_UNLOCK_END(th);

Ruby 开发人员知道这sleep不会影响 VM 状态,因此他们明确解锁了 GVL 以允许它并行运行。弄清楚究竟是什么锁定/解锁了 GVL 以及何时会看到它的性能优势可能会很棘手。

如何修复您的代码

我的猜测是您的代码中的某些内容正在达到 GVL,因此当您的线程的某些部分并行运行时(通常任何子进程/PTY 的东西都可以),Ruby VM 中它们之间仍然存在争用导致某些部分序列化。

获得真正并行的 Ruby 代码的最佳选择是将其简化为如下所示:

Array.new(x) { Thread.new { do_work } }

您确定这do_work是绝对解锁 GVL 的简单操作,例如生成子进程。您可以尝试将您的 Truecrypt 代码移动到一个小 shell 脚本中,这样 Ruby 就不必再与它进行交互了。

我建议从只启动几个子进程的小基准开始,并通过比较串行运行它们的时间来确保它们实际上是并行运行的。


推荐阅读