Мне нравится думать о разветвлении как обделенном мире параллелизма. На самом деле, на данный момент, многие программисты, возможно, даже не слышали об этом. Термин «многопоточный» почти стал синонимом «параллельный» или «параллельный».
Системный вызов fork () создает «копию» текущего процесса. Для наших целей в Ruby он позволяет произвольному коду выполняться асинхронно. Поскольку этот код будет запланирован на уровне операционной системы, он будет выполняться одновременно, как и любой другой процесс.
Цель этой статьи — дать читателю возможность мыслить с точки зрения процессов, а не потоков. Как мы увидим, у такого подхода много преимуществ.
Исходный код этого руководства доступен на github . Если есть сомнения, попробуйте запустить код оттуда.
Примечание. Поскольку fork () является системным вызовом POSIX, код этого руководства мало что даст, если вы используете Ruby в Windows. Я рекомендую virtualbox, если вы хотите повозиться с Linux, BSD или другой UNIX-подобной операционной системой.
Глобальная блокировка интерпретатора
Потратьте более нескольких минут на чтение о параллелизме в Ruby, и вы откроете для себя много спорных вопросов: Global Interpreter Lock. Честно говоря, GIL имеет худшую репутацию, чем заслуживает, из-за большого количества дезинформации о нем, распространенной в сообществе Ruby. Чтобы узнать, что GIL берет от вас, необходимо понять разницу между параллелизмом и параллелизмом :
-
Параллелизм — две задачи выполняются в перекрывающиеся периоды времени (но делают это с такой скоростью, что они чувствуют одновременность), то есть прослушивание музыки при редактировании документа.
-
Параллельность — две задачи выполняются одновременно на разных процессорных ядрах
На момент написания этой статьи CRIL GIL ограничивает виртуальную машину Ruby только одним собственным потоком за раз. Если в виртуальной машине имеется более одного потока, каждый по очереди работает в собственном потоке. Таким образом, потоки могут выполняться одновременно, но не параллельно. Нельзя сказать, что параллелизм возможен, а параллелизм — нет, как мы увидим через минуту.
Если вы хотите узнать больше о GIL, я рекомендую эти посты:
- Merbist — о параллелизме и GIL — Джесси Стоример — никто не понимает GIL
- Игвита — Параллелизм — миф о рубине
- Иегуда Кац — Темы (на Ruby): хватит уже
Быстрый Эксперимент
Вы можете запустить этот код из репозитория в 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"
Ранее я говорил, что разветвления не завершатся сами по себе, когда основной процесс завершится, в отличие от потоков. Это верно, если основной процесс заканчивается нормально. Если он получает сигнал прерывания ( SIGINT
ctrl-c
Итак, если вы используете Process#waitall
ctrl-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
.
Если вы используете ловушку для особого поведения, убедитесь, что вы не забыли завершить дочерние процессы ловушкой. В противном случае сигнал не убил бы процесс, так как поведение по умолчанию было бы переопределено. Если вы оказались в этой ситуации, используйте другой сигнал убийства. Например, если ловушка обрабатывает SIGTERM
SIGKILL
SIGINT
У 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. Во второй части мы рассмотрим межпроцессное взаимодействие.