Как Gil Работает В Ruby. Часть 2

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

Именно этим мы и займемся сегодня.



Как GIL работает в Ruby. Часть 2

Черновая версия этой статьи была изобиловала кусками кода на языке C, однако из-за этого в деталях терялась суть.

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



В предыдущем эпизоде

После первая часть осталось два вопроса:
  1. Есть ли ГИЛ
      
      
      
      
       

    array << nil

    атомарная операция?
  2. Обеспечивает ли GIL потокобезопасность кода Ruby?
На первый вопрос можно ответить, посмотрев на реализацию, поэтому начнем с нее.

В прошлый раз мы рассматривали следующий код:

array = [] 5.times.map do Thread.new do 1000.times do array << nil end end end.each(&:join) puts array.size

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

Поскольку массив на самом деле не является потокобезопасным, при запуске кода в JRuby или Rubinius вы получаете результат, отличный от ожидаемого (массив, содержащий менее пяти тысяч элементов).

МРТ дает ожидаемый результат, но случайность ли это или закономерность? Давайте начнем наше исследование с небольшого фрагмента кода Ruby.

Thread.new do array << nil end



Давайте начнем с

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

thread*.

c

.

Прежде всего, внутри реализации

Thread.new

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

thread_start_func_2

.

Давайте рассмотрим его, не вдаваясь в подробности.



Как GIL работает в Ruby. Часть 2

Нам сейчас важен не весь код, поэтому я выделил те части, которые нам интересны.

В начале функции новый поток получает GIL и ждет, пока он не будет освобожден.

Где-то в середине функции выполняется блок, с помощью которого был вызван метод

Thread.new

.

В конце концов блокировка снимается и собственный поток завершается.

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

Новый поток должен дождаться, пока основной поток снимет блокировку, прежде чем продолжить выполнение.

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

static void gvl_acquire_common(rb_vm_t *vm) { if (vm->gvl.acquired) { vm->gvl.waiting++; if (vm->gvl.waiting == 1) { rb_thread_wakeup_timer_thread_low(); } while (vm->gvl.acquired) { native_cond_wait(&vm->gvl.cond, &vm->gvl.lock); }

Это часть функции

gvl_acquire_common

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

Если удерживается, то атрибут

waiting

увеличивается.

В случае нашего кода оно становится равным

1

.

Следующая строка проверяет, равен ли атрибут

waiting



1

.

Оно равно, поэтому следующая строка пробуждает поток таймера.

Поток таймера поддерживает работу потоков MRI, не позволяя одному из них постоянно удерживать GIL. Но прежде чем мы перейдем к описанию потока таймера, давайте посмотрим на GIL.

Как GIL работает в Ruby. Часть 2

Я уже несколько раз упоминал, что за каждым потоком в МРТ стоит родной поток.

Это так, но данная схема предполагает, что потоки МРТ работают параллельно, как и нативные.

GIL предотвращает это.

Давайте дополним схему и приблизим ее к реальности.



Как GIL работает в Ruby. Часть 2

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

В предыдущем проекте потоки Ruby могли параллельно использовать собственные потоки.

Вторая схема более близка к реальности в случае с МРТ — только один поток может удерживать GIL в определенный момент времени, поэтому параллельное выполнение кода полностью исключено.

Для команды разработчиков МРТ GIL защищает внутреннее состояние системы .

Благодаря GIL внутренние структуры данных не требуют блокировок.

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

Для вас как разработчика вышеизложенное означает, что параллелизм в MRI очень ограничен.



Таймер потока

Как я уже говорил, поток таймера не позволяет одному потоку удерживать GIL навсегда.

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

rb_thread_create_timer_thread

.

Когда MRI только что запустился и работает только основной поток, поток таймера переходит в режим ожидания.

Но как только какой-либо поток начинает ждать освобождения GIL, поток-таймер просыпается.



Как GIL работает в Ruby. Часть 2

Эта диаграмма еще более точно иллюстрирует, как GIL реализован в МРТ.

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

Каждые 100 мс поток таймера устанавливает флаг прерывания для потока, который в данный момент удерживается GIL, с помощью макроса

RUBY_VM_SET_TIMER_INTERRUPT

.

Эти детали важны для понимания того, является ли выражение атомарным.



array << nil

.

Это похоже на концепцию разделения времени ОС, если она вам знакома.

Установка флага не прерывает поток немедленно (если бы это было так, мы могли бы с уверенностью сказать, что выражение

array << nil

не атомный).



Обработка флагов прерываний

В глубине файла

vm_eval.c

— это код для обработки вызова метода в Ruby. Он настраивает среду для вызова метода и вызывает требуемую функцию.

В конце функции

vm_call0_body

, непосредственно перед возвратом метода проверяется флаг прерывания.

Если установлен флаг прерывания потока, выполнение кода приостанавливается перед возвратом значения.

Прежде чем выполнять дальнейший код Ruby, текущий поток освобождает GIL и вызывает функцию

sched_yield

.



sched_yield

— это системная функция, которая запрашивает планировщик ОС возобновить следующий поток в очереди.

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

Вот ответ на первый вопрос:

array << nil

это атомарная операция.

Благодаря GIL все методы Ruby, реализованные исключительно на C, являются атомарными.

То есть этот код:

array = [] 5.times.map do Thread.new do 1000.times do array << nil end end end.each(&:join) puts array.size

гарантированно даст ожидаемый результат при проведении МРТ (речь идет только о предсказуемости длины массива; никаких гарантий относительно порядка элементов нет - прим.

пер.

) Но имейте в виду, что из кода Ruby это не следует. .

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

Хорошо знать, что делает GIL, но писать код, основанный на GIL, — не лучшая идея.

Сделав это, вы окажетесь в такой ситуации, как замок продавца .

GIL не предоставляет общедоступный API. Для GIL нет документации или спецификаций.

Однажды команда разработчиков MRI может изменить поведение GIL или вообще избавиться от него.

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



А как насчет методов, реализованных в Ruby?

Итак, мы знаем, что

array << nil

- атомарная операция.

Это выражение вызывает один метод

Array#<<

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

А как насчет чего-то вроде этого?

array << User.find(1)

Прежде чем вызвать метод

Array#<<

, вам необходимо вычислить значение параметра, то есть вызвать

User.find(1)

.

Как вы, наверное, знаете,

User.find(1)

в свою очередь вызывает множество методов, написанных на Ruby. Но GIL создает только методы, реализованные на C атомарно.

Для методов в Ruby нет никаких гарантий.

Это звонок

Array#<<

все еще атомарный в новом примере? Да, но не забывайте, что вам еще нужно выполнить правостороннее выражение.

Другими словами, сначала вам нужно вызвать метод

User.find(1)

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

Array#<<

.



Что все это значит для меня?

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

GIL предотвращает такие ситуации — даже если произойдет переключение контекста, другие потоки не смогут продолжить выполнение, так как будут вынуждены ждать освобождения GIL. Все это происходит только при условии, что метод реализован на C, не обращается к Ruby-коду и не освобождает сам GIL. ( в комментариях к оригинальной статье приводят пример - добавление элемента в ассоциативный массив (Hash), реализованный на C, не является атомарным, поскольку для получения хеша элемента обращается к коду Ruby - прим.

переулок ) GIL предотвращает состояния гонки в реализации MRI, но не обеспечивает потокобезопасность кода Ruby. Можно сказать, что GIL — это просто функция МРТ, предназначенная для защиты внутреннего состояния интерпретатора.

Переводчик будет рад услышать комментарии и конструктивную критику.

Теги: #ruby #gil #mri #jruby #rubinius #многопоточность #ruby #программирование #Параллельное программирование

Вместе с данным постом часто просматривают:

Автор Статьи


Зарегистрирован: 2019-12-10 15:07:06
Баллов опыта: 0
Всего постов на сайте: 0
Всего комментарий на сайте: 0
Dima Manisha

Dima Manisha

Эксперт Wmlog. Профессиональный веб-мастер, SEO-специалист, дизайнер, маркетолог и интернет-предприниматель.