Ruby: снижение нагрузки на процессор для параллельной / многопоточной задачи? - PullRequest
0 голосов
/ 11 июня 2018

Преамбула: я работаю над проектом по восстановлению truecrypt контейнера.Он был разрезан на более чем 3М маленьких файлов в наиболее вероятном случайном порядке, и цель состоит в том, чтобы найти начало или конец контейнера, содержащего ключи шифрования.

Для этого я написал небольшой скрипт ruby, который запускает множество процессов truecrypt, одновременно пытающихся смонтировать основной или восстановить резервные заголовки.Взаимодействие с truecrypt происходит через порожденные PTY:

  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

Все это прекрасно работает и успешно находит необходимые части тестового контейнера.Чтобы ускорить процесс (и мне нужно обрабатывать более 3 млн. Штук), я сначала использовал многопоточность 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

Чем я арендовал большой экземпляр AWS с 74 ядрами процессора и подумал: «Теперь я собираюсь обработать его быстро».Но проблема в том, что независимо от того, сколько фьючерсов / потоков (а я имею в виду 20 или 1000), которые я запускаю одновременно, я не достигаю более ~ 50 проверок в секунду.

Когда я запускаю 1000 потоков, загрузка ЦП сохраняется на уровне 100% только в течение 20-30 минут, а затем снижается, пока не достигнет примерно 15%, и так и останется. График типичной загрузки процессора при таком прогоне .Загрузка диска не является проблемой, я использую максимум 3 МБ / с при использовании хранилища Amazon EBS.

Чего мне не хватает?Почему я не могу использовать 100% ЦП и добиться лучшей производительности?

1 Ответ

0 голосов
/ 12 июня 2018

Трудно сказать, почему именно вы не видите преимуществ многопоточности.Но вот мое предположение.

Допустим, у вас есть действительно интенсивный метод Ruby, запуск которого занимает 10 секунд и называется do_work.И, что еще хуже, вам нужно запустить этот метод 100 раз.Вместо того, чтобы ждать 1000 секунд, вы можете попробовать многопоточность.Это может разделить работу между ядрами вашего процессора, сократив вдвое или, возможно, даже на четверть времени выполнения:

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 для этого простое: на самом деле не запускайте этот код параллельно.Вместо этого существует один массивный мьютекс, называемый «глобальной блокировкой виртуальной машины», который защищает все, что изменяет состояние виртуальной машины Ruby (например, создание класса).Таким образом, хотя два указанных выше потока могут чередоваться различными способами, виртуальная машина не может оказаться в недопустимом состоянии, поскольку каждая операция виртуальной машины является по существу атомарной.

Пример

Это занимает около 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 не влияет на состояние виртуальной машины, поэтому они явно разблокировали GVL, чтобы позволить ему работать параллельно.Может быть непросто выяснить, что именно блокирует / разблокирует GVL, и когда вы собираетесь увидеть выигрыш в производительности.

Как исправить свой код

Я думаю, что-тов вашем коде есть GVL, поэтому, хотя некоторые части ваших потоков работают параллельно (как правило, любые подпроцессы / PTY), между ними в виртуальной машине Ruby все еще существует конфликт, заставляющий некоторые части сериализоваться.

ВашЛучший способ получить действительно параллельный код Ruby - это упростить его до чего-то вроде этого:

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

, где вы уверены, что do_work - это нечто простое, которое определенно разблокирует GVL, например, порождает подпроцесс.Вы можете попробовать переместить ваш код Truecrypt в небольшой сценарий оболочки, чтобы Ruby больше не приходилось с ним взаимодействовать, когда он начал работать.

Я рекомендую начать с небольшого теста, который просто запускает несколько подпроцессов, иубедитесь, что они действительно работают параллельно, сравнив время с последовательным запуском.

...