Анекдотически, я обнаружил, что многие программисты ошибочно полагают, что "без блокировки" просто означает "одновременное программирование без мьютексов". Как правило, существует также коррелированное недоразумение в том, что цель написания кода без блокировки - это лучшая параллельная производительность. Разумеется, правильное определение блокировки фактически означает гарантии прогресса. Алгоритм блокировки гарантирует, что по крайней мере один поток способен продвигать вперед независимо от того, что делают другие потоки.
Это означает, что алгоритм без блокировки никогда не может иметь код, в котором один поток зависит от другого потока, чтобы продолжить. Например, код без блокировки не может иметь ситуации, когда Thread A устанавливает флаг, а затем Thread B продолжает цикл, ожидая, пока Thread A отключит флаг. Подобный код в основном реализует блокировку (или то, что я бы назвал мьютексом в маскировке).
Однако другие случаи более тонкие, и есть некоторые случаи, когда я честно не могу сказать, соответствует ли алгоритм блокируемому или нет, потому что понятие "прогресс" иногда кажется мне субъективным.
Один из таких случаев находится в библиотеке (хорошо оцененный, afaik) concurrency, liblfds. Я изучал реализацию очереди с несколькими продюсерскими/многопользовательскими ограничениями в liblfds - реализация очень проста, но я не могу сказать, должно ли оно квалифицироваться как незаблокированное.
Соответствующий алгоритм находится в lfds711_queue_bmm_enqueue.c
. Liblfds использует пользовательские атомы и барьеры памяти, но алгоритм достаточно прост для описания в абзаце или около того.
Сама очередь представляет собой ограниченный непрерывный массив (ringbuffer). Существует общий read_index
и write_index
. Каждый слот в очереди содержит поле для пользовательских данных и значение sequence_number
, которое в основном похоже на счетчик эпох. (Это позволяет избежать проблем с ABA).
Алгоритм PUSH выглядит следующим образом:
- Атомно LOAD
write_index
- Попытайтесь зарезервировать слот в очереди в
write_index % queue_size
, используя цикл CompareAndSwap, который пытается установитьwrite_index
наwrite_index + 1
. - Если CompareAndSwap успешно, скопируйте данные пользователя в зарезервированный слот.
- Наконец, обновите
sequence_index
на чтобы сделать его равнымwrite_index + 1
.
Фактический исходный код использует специальные помехи и барьеры памяти, поэтому для большей ясности в отношении этого алгоритма я кратко перевел его в (непроверенную) стандартную атомику С++ для лучшей читаемости, следующим образом:
bool mcmp_queue::enqueue(void* data)
{
int write_index = m_write_index.load(std::memory_order_relaxed);
for (;;)
{
slot& s = m_slots[write_index % m_num_slots];
int sequence_number = s.sequence_number.load(std::memory_order_acquire);
int difference = sequence_number - write_index;
if (difference == 0)
{
if (m_write_index.compare_exchange_weak(
write_index,
write_index + 1,
std::memory_order_acq_rel
))
{
break;
}
}
if (difference < 0) return false; // queue is full
}
// Copy user-data and update sequence number
//
s.user_data = data;
s.sequence_number.store(write_index + 1, std::memory_order_release);
return true;
}
Теперь поток, который хочет POP-элемент из слота в read_index
, не сможет этого сделать, пока не увидит, что слот sequence_number
равен read_index + 1
.
Хорошо, поэтому здесь нет мьютексов, и алгоритм, вероятно, работает хорошо (это только один CAS для PUSH и POP), но является ли это незаблокированным? Причина, по которой мне непонятно, состоит в том, что определение "прогресс" кажется мутным, когда существует вероятность того, что PUSH или POP всегда могут просто терпеть неудачу, если очередь считается полной или пустой.
Но для меня сомнительно, что алгоритм PUSH существенно резервирует слот, а это означает, что слот никогда не может быть POP'd, пока нисходящий поток не приблизится к обновлению порядкового номера. Это означает, что поток POP, который хочет получить значение, зависит от потока PUSH, завершившего операцию. В противном случае поток POP всегда будет возвращать false
, потому что он считает, что очередь ПУСТОЙ. Мне кажется спорным, действительно ли это относится к определению "прогресса".
Как правило, поистине алгоритмы блокировки включают фазу, в которой предварительно выпущенный поток фактически пытается связать другой поток при завершении операции. Поэтому, чтобы быть действительно свободным от блокировки, я бы подумал, что поток POP, который наблюдает за PUSH в процессе выполнения, действительно должен попытаться завершить PUSH, а затем только после этого выполнить первоначальную операцию POP. Если поток POP просто возвращает, что очередь является ПУСТОЙ, когда выполняется PUSH, поток POP в основном блокируется до тех пор, пока поток PUSH не завершит операцию. Если нить PUSH замирает или уходит спать в течение 1000 лет или иначе заходит в забвение, поток POP ничего не может сделать, кроме как постоянно сообщать, что очередь ПУСТОЙ.
Так ли это подходит для отказа от блокировки? С одной стороны, вы можете утверждать, что поток POP всегда может прогрессировать, потому что он всегда может сообщить, что очередь является ПУСТОЙ (что, по крайней мере, является некоторой формой прогресса, я думаю). Но для меня это не делает прогресс, поскольку единственная причина, по которой наблюдение считается пустой, заключается в том, что мы заблокированы параллельной операцией PUSH.
Итак, мой вопрос: этот алгоритм действительно заблокирован? Или система резервирования индексов в основном скрывает мьютекс?