yaclib (RWLock, RWMutex) для С++20 coroutines.Что это вообще такое?
mutex, который позволяет параллельные чтения и эксклюзивные записи.А так же может
* требовать приоритета
reader над writer, наоборот, или "равенства" ("честности")* позволять рекурсивный
mutex lock*
upgrade lock, про это подробнее в комментахМне хотелось в первую очередь универсальное решение, так что в первом пункте я выбрал “честность”.
Рекурсивность может быть достигнута внешним кодом, а необходимость в ней на мой взгляд признак плохого дизайна.
upgrade lock – специфичная опция требующая дополнительных усилий, в которой я не заинтересован.А еще меня устраивало ограничение в не более чем
2^32 одновременных вызовов reader/writer lockКак добиться “честности”?
Первое, что приходит в голову, общая очередь всех
lock-ов ожидающих захвата (к слову в rust tokio примерно так и реализован rwlock).Собственно такую же идею реализовали в
arangodb, что и сподвигло меня написать это нормально.Есть ли существенные недостатки в такой имплементации? К сожалению, да.
Когда мы вызываем
writer unlock и следующий, кто залочит, reader, мы хотим разбудить всех reader-ов в очереди, для этого придется пробежаться по всей очереди, что может быть медленно.Поэтому в том же
tokio будят только reader-ов из начала очереди, поэтому не параллельных критических секций может становится значительно больше чем потенциально могло быть.Представьте очередь вида
w r w r … w, то есть, в худшем случае суммарное время исполнения всех критических секций может вырасти в n раз при такой имплементации.На практике все не так плохо, так как худшие случаи редки, а чтения обычно достаточно быстрые.
Будет неправильным не заметить, что у такой имплементации может быть очень полезное свойство, ее можно сделать аналогично
mpsc vyukov queue (примерно также как в mcs spinlock, что впрочем и является в некотором виде первоисточником), то есть получить практически wait-free с полным отсутствие contention.К сожалению я не встречал таких вменяемых реализаций, только экспериментальные
rw spinlock-и на си.То есть чтобы достичь "честности", нам понадобится две "очереди".
В одну будем класть
reader-ов, в другую writer-ов.Тогда
reader unlock всегда будит 0-1 writer, а writer unlock будит либо всех reader-ов, либо 0-1 writer.Собственно именно такая идея является основой для большинства остальных реализации:
golang RWMutex, userver SharedMutex ..."честность" контролируется тем, что мы используем в качестве
reader/writer очередей (fifo/batched lifo) и кого будим в unlock-ах.Как только это заработало, следующее о чем я подумал это
fast-path, в случае отсутствия конкуренции.Начал я с банального, добавил
atomic_uint64_t счётчик – сколько сейчас writer/reader, и соответственно fast-path это cas-loop в котором я пытался его изменить:0 w 0 r -> 1 w 0 r // writer lock
1 w 0 r -> 0 w 0 r // writer unlock
0 w n r -> 0 w n+1 r // reader lock
m w n r -> m w n-1 r // not last reader unlock
0 w 1 r -> 0 w 0 r // last reader unlock
В целом это уже неплохо, но хотелось чтобы множество
reader-ов не конфликтовали в cas-loop, то есть хотелось для reader-ов делать fetch_add/subЯ много пытался, но оно не работало правильно :)
В итоге я подсмотрел идею в
golang RWMutex.Можно завести дополнительный атомарный счетчик (
reader(s) wait), который будет использоваться для синхронизации последнего reader unlock и первого writer lock.Вообще можно подумать, а почему просто не переписать решение с
golang?Основной нюанс, то что решение в
golang может за lock засыпать несколько раз, что невозможно с C++20 stackless coroutines.Ну и как следствие моя реализация оказалась в некоторых местах более оптимальной, нет
slow-path в reader unlock, а fast-path для writer lock всего лишь один cas. В golang это не так потому что реализация сделана из других примитивов (userspace аналога futex и полноценного golang Mutex).