Давным давно уже уже была написана статья о том что такое GVL (или GIL, кому как привычнее) и как он работает, однако с того времени некоторые вещи поменялись (к примеру, в Ruby 3.0 завезли Ractor'ы - новую абстракцию для реальной параллельной работы тредов) и мне стало интересно узнать что поменялось в планировщике ruby для реализации множества GVL. В этой статье я попытаюсь понять алгоритм, по которому GVL передается от одного треда к другому, как блокирующие операции не блокируют другие треды, а так же выяснить до сих пор ли операция добавления элемента в массив является атомарной операцией
array = []
array << "new value" # атомарная операция?
Прежде чем начать разбираться с новым GVL я захотел узнать как обстоят дела в 2.7 ветке, т.к. там GVL все еще один на всю ВМ, то сделать это должно быть проще. Все нижеизложенное является моим изучением исходного кода руби, всегда есть шанс ошибиться, если я что-то понял не так, или вы где-то увидели неточность - приветствую конструктивную критику.
GVL мне всегда казался какой-то магической, сложной структурой данных, для обработки которой используются невероятно сложны алгоритмы. Если заглянуть на инициализацию GVL, которая происходит во время объявления констант и методов по работе с многопоточностью (thread.c), то можно увидеть совсем крохотную функцию
static void
gvl_init(rb_vm_t *vm)
{
rb_native_mutex_initialize(&vm->gvl.lock);
rb_native_cond_initialize(&vm->gvl.switch_cond);
rb_native_cond_initialize(&vm->gvl.switch_wait_cond);
list_head_init(&vm->gvl.waitq);
vm->gvl.owner = 0;
vm->gvl.timer = 0;
vm->gvl.timer_err = ETIMEDOUT;
vm->gvl.need_yield = 0;
vm->gvl.wait_yield = 0;
}
Так что же получается, GVL это всего лишь мьютекс, пара condition variable (неплохая статья которая описывает эти примитивы) и несколько служебных полей?
typedef struct rb_global_vm_lock_struct {
const struct rb_thread_struct *owner;
rb_nativethread_lock_t lock; /* AKA vm->gvl.lock */
/*
* slow path, protected by vm->gvl.lock
* - @waitq - FIFO queue of threads waiting for GVL
* - @timer - it handles timeslices for @owner. It is any one thread
* in @waitq, there is no @timer if @waitq is empty, but always
* a @timer if @waitq has entries
* - @timer_err tracks timeslice limit, the timeslice only resets
* when pthread_cond_timedwait returns ETIMEDOUT, so frequent
* switching between contended/uncontended GVL won't reset the
* timer.
*/
struct list_head waitq;
const struct rb_thread_struct *timer;
int timer_err;
/* yield */
rb_nativethread_cond_t switch_cond;
rb_nativethread_cond_t switch_wait_cond;
int need_yield;
int wait_yield;
} rb_global_vm_lock_t;
Кажется все не так уж и сложно (к тому же разраотчики MRI оставили комментарии), сразу можно предположить что для манипулирования GVL'ом треды просто отправляют друг другу сигналы, осталось только выяснить по какому принципу они это делают. Вот уж и правда: простые принципы на страже сложных систем.
Дополнение
На самом деле condition variables чуть больше чем две. Помимо того, что руби имеет структуру, описывающую тред для виртуальной машины rb_thread_t, он так же имеет еще одну для своих внутренних нужд native_thread_data_struct. Внутри этой структуры лежит еще одна condition variable, которая используется для отправки сигналов конкретному потоку. Зачем это нужно будет рассказано ниже.
Забегая вперед
Можно отметить один любопытный комментарий @timer - it handles timeslices for @owner. It is any one thread
До версии 2.6 в руби помимо основных рабочих потоков существовал скрытый от посторонних глаз таймерный поток, который отвечал за квантование времени и выставление флага TIMER_INTERRUPT, который в свою очередь должен был сообщить треду, удерживающему GVL, что его время вышло, пора дать поработать другим. Теперь же отдельного треда нету, роль таймера на себя берет любой поток, который хочет взять гвл в случае если нету другого потока, который уже стал таймерным
Сразу после инициализации GVL main-поток сразу же берет его в свое пользование путем вызова gvl_acquire, которая в свою очередь оборачивает основную функцию по работе с GVL в пару mutex_lock/mutex_unlock. В gvl_acquire_common
сосредоточена вся логика по работе с сигналами и таймером (но об этом чуть позже)
static void
gvl_acquire_common(rb_vm_t *vm, rb_thread_t *th)
{
if (vm->gvl.owner) {
// не торопим события, разберемся чуть дальше :)
}
else /* reset timer if uncontended */
vm->gvl.timer_err = ETIMEDOUT;
vm->gvl.owner = th;
...
}
Когда других потоков, кроме main, еще нету, gvl.owner = NULL, поэтому первым делом сбрасываем таймер, т.к. в будущем таймер увидит таймаут он заново запустит отсчет кванта времени. В конце, на 10 строке поток непосредственно захватывает GVL.
Когда GVL'ом уже кто-то владеет захватить лок сложнее (код, что сразу под if)
native_thread_data_t *nd = &th->native_thread_data;
...
// встаем в очередь на захват GVL
list_add_tail(&vm->gvl.waitq, &nd->node.gvl);
do {
if (!vm->gvl.timer)
do_gvl_timer(vm, th);
else
// ждем пока нам разрешат взять GVL
rb_native_cond_wait(&nd->cond.gvlq, &vm->gvl.lock);
} while (vm->gvl.owner);
list_del_init(&nd->node.gvl);
// Отправляем сигнал о том что овнер GVL сменился
if (vm->gvl.need_yield) {
vm->gvl.need_yield = 0;
rb_native_cond_signal(&vm->gvl.switch_cond);
}
В принципе весь алгоритм захвата GVL представлен в комментариях и он действительно сводится к ожиданию определенных событий от других тредов. Так же интересно отметить, как таковой борьбы за GVL нету, т.к. все треды просто выстраиваются в очередь и ждут пока им отправят сигнал о том, что лок можно захватить.
Отдельного внимания заслуживает функция do_gvl_timer
static void
do_gvl_timer(rb_vm_t *vm, rb_thread_t *th)
{
static rb_hrtime_t abs;
native_thread_data_t *nd = &th->native_thread_data;
// сообщаем другим потокам что таймер уже есть
vm->gvl.timer = th;
...
if (vm->gvl.timer_err == ETIMEDOUT) {
abs = native_cond_timeout(&nd->cond.gvlq, TIME_QUANTUM_NSEC);
}
// Засыпаем на 100мс, либо пока не придет событие по nd->cond.gvlq
// которое говорит о том, что текущему треду уже можно взять GVL
vm->gvl.timer_err = native_cond_timedwait(&nd->cond.gvlq, &vm->gvl.lock, &abs);s);
...
// Овнера у гвл может и не быть, т.к. пришло событие по nd->cond.gvlq
// а если проснулись по таймеру, то овнер есть и его надо сменить
if (vm->gvl.owner)
timer_thread_function()
vm->gvl.timer = 0;
}
static void
timer_thread_function(void)
{
volatile rb_execution_context_t *ec;
ec = ACCESS_ONCE(rb_execution_context_t *, ruby_current_execution_context_ptr);
if (ec)
RUBY_VM_SET_TIMER_INTERRUPT(ec);
}
Таким образом таймер это тоже ожидание события по condition variable, но с таймаутом. Тут есть важный нюанс: функция таймера не в том, чтобы отобрать GVL, а в том, чтобы сообщить потоку, который его держит, что его время вышло и пора бы отдать лок другим. Непосредственно отдавать GVL должен тот поток, который его удерживает и тогда, когда посчитает нужным. После выставления TIMER_INTERRUPT его должен кто-то проверить, происходит это в нескольких местах:
Такие команды YARV как jump, branchif, branchunless, branchnil в сишной реализации вызывают RUBY_VM_CHECK_INTS()
В конце функции vm_call0_body, которая ответственна за обработку вызова методов ruby, перед возвратом значения есть есть вызов RUBY_VM_CHECK_INTS()
Сам вызов RUBY_VM_CHECK_INTS сводится к этому
static inline void
rb_vm_check_ints(rb_execution_context_t *ec)
{
VM_ASSERT(ec == GET_EC());
if (UNLIKELY(RUBY_VM_INTERRUPTED_ANY(ec))) {
rb_threadptr_execute_interrupts(rb_ec_thread_ptr(ec), 0);
}
}
Если есть какие-то прерывания, то руби их обработает, если ничего нету - работаем дальше. Таким образом можно быть уверенным, что передача GIL осуществляется только тогда, когда мы уверены в том, что ни одна операция не осталась в "подвешенной", и никакая внутренняя структура данных не осталась в "половинчатом" состоянии.
Отдельного внимания заслуживает непосредственно код передачи GVL другому треду. Внутри вызова rb_threadptr_execute_interrupts есть обработка прерывания от таймера - проверка на то превысил ли текущий тред отведенный для него квант времени, и если превысил, то происходит "переключение контекста"
static void
gvl_yield(rb_vm_t *vm, rb_thread_t *th)
{
const native_thread_data_t *next;
...
// освобождаем лок
next = gvl_release_common(vm);
...
if (next) {
...
// ждем пока лок возьмет кто-то другой
rb_native_cond_wait(&vm->gvl.switch_cond, &vm->gvl.lock);
}
else {
rb_native_mutex_unlock(&vm->gvl.lock);
native_thread_yield(); // разворачивается в sched_yield (https://man7.org/linux/man-pages/man2/sched_yield.2.html)
rb_native_mutex_lock(&vm->gvl.lock);
...
}
// Заново встаем в очередь на GVL
gvl_acquire_common(vm, th);
rb_native_mutex_unlock(&vm->gvl.lock);
}
static const native_thread_data_t *
gvl_release_common(rb_vm_t *vm)
{
native_thread_data_t *next;
vm->gvl.owner = 0;
next = list_top(&vm->gvl.waitq, native_thread_data_t, node.ubf);
if (next) rb_native_cond_signal(&next->cond.gvlq);
return next;
}
Первым делом нужно отдать GVL, для этого вызывается функция gvl_release_common, которая достает из очереди самый первый тред который в нее встал и отправляет ему сигнал о том, что GVL свободен, можно забирать. После того, как отдали лок, на 22 строке мы сразу же становимся в очередь на его ожидание, однако перед этим обязательно нужно дождаться пока им завладеет другой тред, иначе возможна ситуация, когда мы лок отдали, но тот тред, который должен был этот лок взять просто не успел это сделать и мы его заберем его снова.
Вот, в принципе, и вся работа GVL, все треды между собой очень дружат и делятся локом друг с другом, активно сообщают его текущее состояние :) Так же хочется упомянуть тот факт, что если ruby работает в однопоточном режиме, либо другие потоки "спят" из-за IO то вся эта цепочка работать не будет, т.к. руби достаточно интеллектуален и понимает, раз других тредов нету, то и забирать гвл некому, пусть побудет у меня.
Ну и раз уж заговорили про IO и блокирующие операции, то стоит сказать, что перед тем как уйти в "сон" поток любезно отдает GVL, чтобы своим ожиданием не заставлять ждать и других. Такие методы как File#read/write, TCPServer#accept, Socket#connect внутри себя вызывают макрос BLOCKING_REGION, который делает примерно следующее
gvl_release()
result = blocking_io() // тут спим пока не завершится IO
gvl_acquire()
return result
Ruby 3.0 и Ractor
Настало время взглянуть на Ractor'ы и понять, что произошло с GVL. А произошло по сути следующее: алгоритм захвата/отпускания GVL не поменялся, поменялось лишь расположение самого GVL (можно ли теперь его вообще называть Global?). До 3й версии рубей в структуре rb_vm_t находился GVL, теперь же он переехал в rb_ractor_t. Все функции, которые работали с GVL раньше принимали указатель rb_vm_t*
, теперь принимают указатель rb_global_vm_lock_t*
, таким образом все треды, которые созданы внутри какого-то рактора будут биться за лок, который находится внутри именно этого рактора.
Т.к. интерпретатор должен отслеживать состояние ВМ, ему нужно знать такие данные как количество активных ракторов, общее количество ракторов, текущий активный поток и рактор, а это в свою очередь глобальные переменные, теперь к ним имеют доступ несколько потоков и нужно заботиться о синхронизации. К примеру, при старте треда в функции thread_start_func_2 помимо захвата GVL появился такой код
...
if (rb_ractor_status_p(th->ractor, ractor_blocking))
{
RB_VM_LOCK();
{
rb_vm_ractor_blocking_cnt_dec(th->vm, th->ractor, __FILE__, __LINE__);
rb_ractor_t *r = th->ractor;
r->r_stdin = rb_io_prep_stdin();
r->r_stdout = rb_io_prep_stdout();
r->r_stderr = rb_io_prep_stderr();
}
RB_VM_UNLOCK();
}
...
Так же операции Ractor#receive и Ractor#send требуют синхронизации, т.к. теперь они работают в реальном параллельном мире. Для этого был добавлена новая блокировка RACTOR_LOCK. Вообще взаимодействие ракторов заслуживает отдельной статьи, в этой я лишь хотел сосредоточиться на механизмах GVL, а добавление ракторов мало что изменило, лишь расположение самого лока.
Итог
Когда только появилось желание разобраться с GVL я думал, что это будет очень трудно, что это займет кучу времени, на практике же я встретил вполне себе понятный код, более менее предсказуемое поведение, понятные алгоритмы. Ковыряться во внутренностях руби увлекательное занятие, желаю всем это попробовать, возможно тогда станет появляться больше таких статей и многие вещи в используемом нами языке перестанут быть для нас загадкой.
synacker
Отдавать лок потока спустя 100мс - ну это же как то очень не эффективно. А есть возможность из руби кода отдать лок другим потокам?
motoroller95 Автор
По факту даже системный планировщик выделяет квант времени для каждого треда, рубишный планировщик делает то же самое. Так же если учесть что каждое IO отдает лок, то получается не так уж и мало времени (если говорим про веб). Отдать гвл можно либо через блокирующее IO, либо через sleep, он тоже отдает гвл
synacker
Да, но 100 мс - это как то очень много. sleep тоже так себе решение, хотелось бы что-нибудь вроде этого на стороне ruby кода. Жаль, что 100 мс захардкожены по сути и нельзя поменять.
motoroller95 Автор
Возможно, если сделать меньше то много времени будет уходить на взятие мьютексов, ожидание сигналов и на связанное с этим переключение контекста