Статьи

Форкинг и IPC в Ruby, часть I

Вилка из трех дорог

Мне нравится думать о разветвлении как обделенном мире параллелизма. На самом деле, на данный момент, многие программисты, возможно, даже не слышали об этом. Термин «многопоточный» почти стал синонимом «параллельный» или «параллельный».

Системный вызов fork () создает «копию» текущего процесса. Для наших целей в Ruby он позволяет произвольному коду выполняться асинхронно. Поскольку этот код будет запланирован на уровне операционной системы, он будет выполняться одновременно, как и любой другой процесс.

Цель этой статьи — дать читателю возможность мыслить с точки зрения процессов, а не потоков. Как мы увидим, у такого подхода много преимуществ.

Исходный код этого руководства доступен на github . Если есть сомнения, попробуйте запустить код оттуда.

Примечание. Поскольку fork () является системным вызовом POSIX, код этого руководства мало что даст, если вы используете Ruby в Windows. Я рекомендую virtualbox, если вы хотите повозиться с Linux, BSD или другой UNIX-подобной операционной системой.

Глобальная блокировка интерпретатора

Потратьте более нескольких минут на чтение о параллелизме в Ruby, и вы откроете для себя много спорных вопросов: Global Interpreter Lock. Честно говоря, GIL имеет худшую репутацию, чем заслуживает, из-за большого количества дезинформации о нем, распространенной в сообществе Ruby. Чтобы узнать, что GIL берет от вас, необходимо понять разницу между параллелизмом и параллелизмом :

  • Параллелизм — две задачи выполняются в перекрывающиеся периоды времени (но делают это с такой скоростью, что они чувствуют одновременность), то есть прослушивание музыки при редактировании документа.

  • Параллельность — две задачи выполняются одновременно на разных процессорных ядрах

На момент написания этой статьи CRIL GIL ограничивает виртуальную машину Ruby только одним собственным потоком за раз. Если в виртуальной машине имеется более одного потока, каждый по очереди работает в собственном потоке. Таким образом, потоки могут выполняться одновременно, но не параллельно. Нельзя сказать, что параллелизм возможен, а параллелизм — нет, как мы увидим через минуту.

Если вы хотите узнать больше о GIL, я рекомендую эти посты:

Быстрый Эксперимент

Вы можете запустить этот код из репозитория в CRuby (1.9.3, 2.0.0 и т. Д.), Чтобы увидеть разницу между использованием вилок и потоков.

# thread_fork_comparison.rb
# runs 4 tasks in 4 threads and 4 forks and reports the times for each

def time_forks(num_forks)
  beginning = Time.now
  num_forks.times do 
    fork do
      yield
    end
  end

  Process.waitall
  return Time.now - beginning
end

def time_threads(num_threads)
  beginning = Time.now
  num_threads.times do 
    Thread.new do
      yield
    end
  end

  Thread.list.each do |t|
    t.join if t != Thread.current
  end
  return Time.now - beginning
end

def calculate(cycles)
  x = 0
  cycles.times do
    x += 1
  end
end

cycles = 10000000

threaded_seconds = time_threads(4) {  calculate(cycles) }
puts "Threading finished in #{threaded_seconds} seconds"

forked_seconds = time_forks(4) {calculate(cycles) }
puts "Forking finished in #{forked_seconds} seconds"

выход:
Завершить заправку за 1.670291209 секунд
Форкинг закончен за 0.419124546 секунд

Использование вилок заняло 1/4 времени, по сравнению с потоками. Это значительный прирост производительности.

Примечание: я использовал четырехъядерный процессор для получения этих результатов.

Форкинг против потоков

Несмотря на ограничение параллелизма на уровне потоков, основная команда CRuby решила пока сохранить GIL, и у них есть веская причина: писать многопоточный код, который работает правильно, «проще» с глобальной блокировкой. Кроме того, всякий раз, когда у переводчика есть GIL, функции, как правило, растут вокруг предоставляемых им гарантий, что затрудняет его удаление в будущем.

В отличие от «threadsafe», вы редко встретите слово «forksafe». Потоки совместно используют одну и ту же память, поэтому они могут работать с данными одновременно, что может привести к их повреждению. С другой стороны, разветвленным процессам дается новое пространство виртуальной памяти, поэтому любые изменения в данных в ответвлении будут происходить в новом пространстве, а не в оригинале. Эта концепция известна как процесс изоляции .

Простое сравнение выглядит так:

  • нарезание резьбы:
    • глобальные данные легко повреждаются из-за параллелизма
    • необходимо выборочно блокировать данные для предотвращения повреждения
    • дешевле, чем раздвоение
    • потоки убиты при выходе из программы
  • разветвление:
    • труднее испортить данные через параллелизм
    • необходимо выборочно обмениваться данными для обеспечения сотрудничества
    • несколько дорого, особенно если Copy-on-Write не используется
    • дочерние процессы не уничтожаются, когда основной процесс завершается нормально

Оставив в стороне предварительные сведения, давайте посмотрим, как работает разветвление в Ruby.

Избежание зомби

Создать форк в Ruby легко. Kernel#fork Поскольку форк наследует терминал от своего родителя, его выходные данные можно увидеть в том же терминале.

 # basic_fork.rb
# A fork inherits the terminal of its parent process

fork do
  sleep 2
  puts "Fork: finished"
end

puts "Main: finished"

Одной из самых больших опасностей форкирования является потеря контроля над вашими рабочими процессами. В отличие от потоков, дочерние процессы не будут уничтожены при нормальном завершении основного процесса. Хотя это может быть полезно в некоторых ситуациях, легко создать коллекцию процессов зомби, которые должны быть убиты вручную. Если вы собираетесь создавать много процессов, возможно, вы захотите получить удобный доступ к менеджеру процессов. Мне лично нравится хтоп .

 # zombie_process.rb
# creates a process that won't end on its own. 
# Terminate it in the console with: 
#   $ kill [whatever pid the zombie has]

fork do
  puts "Zombie: *comes out of grave*"
  puts "Zombie: rahhh...kill me with: $ kill #{$$}"
  loop do
    puts "Zombie (#{$$}): brains..."
    sleep 1
  end
end

puts "Main (#{$$}): finished"

$$ возвращает pid текущего процесса. Если вы хотите, чтобы родительский процесс знал pid любого дочернего процесса, который он создает, вы можете использовать значение, возвращаемое fork

 # pid.rb
# Shows different ways of getting pids for parent and child processes

fork_pid = fork do
  puts "child: my pid is #{$$}"
  puts "child: my parent's pid is #{Process.ppid}"
end

puts "parent: my pid is #{Process.pid}"
puts "parent: my child's pid is #{fork_pid}"

Иногда дочерние процессы выполняются в бесконечном цикле. Вы можете сохранить pids, сгенерированный вызовами fork, и использовать Process#kill

 # process_kill.rb
# Shows how to terminate processes programmatically

puts "initializing worker processes..."

pids = 5.times.map do |i|
  fork do
    trap("TERM") do
      puts "Worker#{i}: kill signal received...shutting down"
      exit
    end

    loop do
      puts "Worker#{i}: *crunches numbers*"
      sleep rand(1..3)
    end
  end
end

sleep 5
puts "killing worker processes..."
pids.each { |pid| Process.kill(:TERM, pid) }

Один из способов предотвратить появление зомби-процессов — дождаться завершения дочерних процессов. Таким образом, если ребенок повиснет, это будет очевидно в терминале. Для этого просто добавьте вызов Process#waitall Если вы знаете pid любого процесса, от которого хотели бы ждать, вы можете использовать Process#wait

 # process_wait.rb
# Sometimes it's useful to wait until all processes have finished

fork do
  3.times do
    puts "Zombie: brains..."
    sleep 1
  end
  puts "Zombie: blehhh *dies*"
end

Process.waitall

puts "Main: finished"

Ранее я говорил, что разветвления не завершатся сами по себе, когда основной процесс завершится, в отличие от потоков. Это верно, если основной процесс заканчивается нормально. Если он получает сигнал прерывания ( SIGINTctrl-c

Итак, если вы используете Process#waitallctrl-c

 # shutup_kids.rb
# If a process receives an interrupt signal, it will pass it on to its children
# Send SIGINT with ctrl-c to make the kids shut up

kids = %w{Bubba Montana}

kids.each do |kid|
  fork do
    loop do
      puts "#{kid}: when.will.we.get.there."
      sleep 1
    end
  end
end

Process.waitall

Иногда прекращение процессов напрямую, как это, нежелательно. К счастью, вы можете корректно завершить процесс при получении сигналов с помощью Kernel#trap .

Если вы используете ловушку для особого поведения, убедитесь, что вы не забыли завершить дочерние процессы ловушкой. В противном случае сигнал не убил бы процесс, так как поведение по умолчанию было бы переопределено. Если вы оказались в этой ситуации, используйте другой сигнал убийства. Например, если ловушка обрабатывает SIGTERMSIGKILLSIGINT У gnu.org есть отличная страница о сигналах.

 # i_said_shutup_kids.rb
# Signal responses can be customized using Kernel#trap or Signal#trap
# Send interrupt with ctrl-c to shutup

kids = %w{Bubba Montana}

kids.each do |kid|
  fork do
    @whiny = true
    trap("INT") do
      puts "#{kid}: Ugh! Shutup signal RECEIVED, dad!"
      @whiny = false
    end

    loop do
      puts "#{kid}: when.will.we.get...there"
      sleep rand(1..2)
      break if not @whiny
    end

  end
end

Process.waitall

Общая память

После выполнения форка объекты, созданные заранее, будут доступны новому процессу.

 # shared_memory.rb
# Forks have access to objects created before the fork

data = [1,2,3]

fork do
  puts "data in child: #{data}"
end

puts "data in parent: #{data}"

выход:

 data in parent: [1,2,3]
data in child: [1,2,3]

Вилка может видеть данные, потому что вилки наследуют состояние от своих родительских процессов. Это включает в себя переменные и дескрипторы открытых файлов. Первоначально информация передается, а не копируется. Как только происходит запись, данные копируются в дочерний процесс. Если бы изменения в информации передавались после разветвления, не было бы изоляции процесса.

 # copy_on_write.rb
# Changes to memory after the fork do not cross the process barrier

data = [1,2,3]

fork do
  sleep 1
  puts "data in child: #{data}"
end

data[0] = "a"
puts "data in parent: #{data}"

Process.waitall

выход:
данные в родителе: [«а», 2, 3]
данные у ребенка: [1, 2, 3]

Совместное использование данных процесса до тех пор, пока не произойдет запись, называется оптимизацией копирования при записи . Использование данных из родительского процесса значительно снижает стоимость создания дочерних процессов, позволяя разветвлению конкурировать с потоками.

Вот почему достижение параллелизма через несколько процессов популярно в Unix .

К сожалению, хотя это очень актуально, когда дело доходит до разветвления в целом, это не относится к Ruby в течение долгого времени. До изменения в 2.0 алгоритм Ruby сборщика мусора пометкой и очисткой вносил изменения в сами объекты, заставляя операционную систему копировать память. Проблема была исправлена ​​в Ruby Enterprise Edition, но в течение долгого времени большинство пользователей Ruby оставались с неэффективным разветвлением.

Таким образом, для целей программирования разветвленные данные на самом деле не являются «общими», и это не должно происходить из-за необходимости изоляции процессов. Тем не менее, для многих сценариев нам нужен способ, чтобы наши родительские и дочерние процессы обменивались изменениями данных, которые происходят после разветвления. Наивный подход может заключаться в том, чтобы писать по очереди на ресурс. Однако в POSIX существуют надежные механизмы межпроцессного взаимодействия, которые позволяют нашим процессам отправлять данные туда и обратно.

Вывод

На этом этапе вы должны иметь общее представление о том, почему fork () полезна и как ее можно использовать в Ruby. Во второй части мы рассмотрим межпроцессное взаимодействие.