Ruby on Rails

По следам Ruby Conference: Concurrency and Parallelism

Олег К. 16 мая 2016

На каждой руби тусовке вы всегда услышите 2 вещи: руби медленный и в нем есть GIL.

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

И основной довод был такой, если там GIL и все потоки выполняются по очереди, то зачем мне пытаться это распараллелить?

Что же, я попробую показать, в каких случаях будет выгода.

Восстановление данных с карты памяти

Так уж случилось, что мне понадобилось восстановить удаленные данные c карты памяти экшнкамеры.

Т.к. в ubuntu с программами для восстановления данных все очень плачевно, я решил написать ее сам.

Первым делом с помощью dd снял дамп карты, с которым и собирался работать дальше.

dd if=/dev/mmcblk0p1 of=/image.img

Алгоритм поиск крайне простой - у нужных мне файлов есть определенный идентификатор начала "\x00\x00\x00 ftypavc1", а для определения конца файла достаточно найти начало следующего.

Выдумывать что-то более сложное нет необходимости. И т.к. на карте нет фрагментации, то весь файл сплошной.

 

Единственная сложность - размер образа 30гб, поэтому читать его надо частями.

Даволи быстро был получен готовый скрипт

https://github.com/nerf-qh/xiaomi_video_restore/blob/master/video_restore.rb

 

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

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

Для этого я сгенерировал 7 тестовых файлов для основных узких мест, и один оригинальный файл с камеры.

ls spec/support/
1_test_100b.mp4  2_test_111b.mp4  3_test_2x100b.mp4  4_test_70b_80b.mp4  5_test_273b.mp4  6_test_ori.mp4  7_test_with_r.mp4  8_test_44b_100b.mp4

Протестировав все, удалось восстановить большинство нужных данных.

 

Но после доклада Thijs Cadier (AppSignal, Netherlands), на котором была показана разница в организации чата на forks, threads и callbacks, я решил попробовать, как можно распараллелить выполнение этого скрипта.

 

В руби сделать это можно двумя путями:

 1. Нативные ОС потоки, threads c его GIL, в котором переключение происходит по событиям IO.

 2. Forks, который создает полностью отдельный subprocess.

 

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

 

Threads

https://github.com/nerf-qh/xiaomi_video_restore/blob/thread/video_restore.rb

Для того чтобы запустить отдельный поток достаточно этого кода:

threads = []
options.threads.times do |i|
 threads << Thread.new { process_thread(options, i, last += 1,  [last += (part_size - 1), max_offset].min) }
end
threads.each { |thr| thr.join }

Потоки хороши тем, что все выполняется в рамках одной программы, оверхед памяти низкий, но надо учитывать GIL, поэтому потоки синхронизировать очень просто.

 

 

 Forks

https://github.com/nerf-qh/xiaomi_video_restore/blob/fork/video_restore.rb

Все практически аналогично, единственное надо было обернуть входящие параметры в лямбду.

l = -> (_start, _end) { process_thread(options, i, _start, _end) }
forks << fork { l[start, end_part] }
Process.waitall

Fork создает просто subprocess, при этом их синхронизация уже становится большой проблемой.

 

 

Для тестирования я сгенерировал файл со случайными данными размером 1 GiB:

dd if=/dev/urandom of=image.random.iso bs=64M count=32

И добавил 10 идентификаторов начала

irb(main):001:0> start = "\x00\x00\x00 ftypavc1"
irb(main):002:0> input = 'image.random.iso'
irb(main):005:0> p = File.size(input)/10
=> 107374179
irb(main):006:0> 10.times {|i| IO.write('image.random.iso', start, i * p)}

Конфигурация:

Образ расположен на HDD

Данные извлекаются на SSD

Процессор: Intel(R) Core(TM) i3-2130 CPU @ 3.40GHz, 2 cores + Hyper-Threading

Размер считываемого фрагмента: 5Mb

 

Ну и собственно тестирование всего этого:

Во всех случаях выигрыша по времени не произошло. Почему так вышло?

А все очень банально - бутылочным горлышком оказалась связка HDD - SSD, т.е. обработка файлов проводилась так быстро, что фактически это было близко к простому копированию, поэтому выжать больше не представлялось возможным.

 

Fork

Так где-же все же можно было использовать subprocess?

Процессы дали бы выигрыш в случае бОльшего затрачиваемого процессором времени, поэтому добавим код, который будет его подгружать.

Лучше всего для этого подойдет модуль bcrypt

Уменьшим размер считываемой части до 5Mb и добавим такой расчет

10.times { BCrypt::Password.create('secret') }

 

Это даст такое соотношение обработки части файла к вычислению 0.0169/0.0883s

 

Результаты предсказуемые:

А вот и видны невооруженным взглядом плюсы использования fork, т.к. теперь нагрузка перешла на процессор, то разделение на 4 отдельных процесса хоть и загружает полностью CPU, но дает заметный выигрыш.

Threads опять же ничего не дает и спасибо за это GIL, т.е. у нас по факту работает только один поток в одно время, переключая выполнение при чтении каждой части.

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

 

Threads

Так, когда же будет выигрыш в использовании threads с GIL?

При этом надо осознавать, что обогнать fork вряд-ли получится, т.к. всегда будет происходить переключение контекста, можно только это отставание свести к минимуму.

GIL переключает выполнения потока при любом IO, следовательно для того чтобы был какой-то выигрыш от такого решения в нашем случае -  необходимо чтобы узким горлышком у нас стал именно IO записи. Таким образом, удастся время выполнения bcrypt как-бы размазать по IO (Идеальный вариант продемонстрировать было бы одновременная работа с несколькими источниками ввода/вывода, но это  потребовало бы значительного изменения кода)

Самый простой способ, который пришел мне в голову - работать напрямую с картой памяти, которая бы выступала как приемник извлеченных файлов.

Т.к. скорость у карты памяти все же намного ниже, количество потоков уменьшим до 2х, и при записи файла добавим опцию fsync. Эта опция дает команду записывать данные сразу же, без использования кэширования.

Вот и получилось, что forks и threads получили очень близкие результаты, при этом само время вызова bcrypt было частично спрятано за чтением/записью.

 

Что в итоге?

Если наш алгоритм использует процессор + память, то поможет только forks с его оверхедом по памяти и магией в попытках синхронизации потоков.

Если же у нас узкое место - входящий IO, то threads даст возможность только размазать время выполнения за этим чтением, при этом синхронизировать все данные будет очень даже легко. И GIL при этом позволяет не беспокоиться о thread safe, т.к. в одно и то же время будет выполняться только один поток (конечно же если вы не решите это все запустить на jruby).

Ну и конечно же, если вы где-то уперлись в IO, то зачастую смысл использования как forks так и threads сводится к нулю.

 

Все варианты скрипта размещены на Github и помещены в отдельные ветки:

https://github.com/nerf-qh/xiaomi_video_restore

Олег К.

Олег К.

RoR разработчик в iKantam

Почитать другие посты