В библиотеке guava есть класс Striped, предоставляющий разные виды локов, в разбивке по ключам. Сценарий использования, как я его понимаю, такой: есть данные, естественным образом разбитые по ключам К, и эти данные нужно защитить от конкурентного доступа. При этом конкурентный доступ к данным разных ключей вполне допускается. Если использовать одну общую блокировку, то это похоронит весь параллелизм, а заводить по блокировке на каждый ключ может быть слишком накладным, если ключей много. Striped — промежуточный вариант: при создании задается, сколько блокировок вы готовы создать, и дальше все ключи отображаются на этот ограниченный список блокировок. Один и тот же ключ всегда отображается на один и тот же лок, поэтому корректность гарантируется. Разные ключи, очевидно, тоже иногда отображаются на один лок, но не очень часто (~1/stripes). (Если вы смотрели код
j.u.c.ConcurrentHashMap
, то должны понимать, откуда эта идея, и как примерно она реализована)Cреди разных вариантов этого
Striped
есть Striped.lazyWeakReadWriteLock()
, где объекты ReentrantReadWriteLock
создаются лениво, и удерживаются через слабые ссылки. Вероятно, для сценариев, где потенциально ключей очень много, но в каждый конкретный момент используется лишь небольшое подмножество, и потому какие-то локи периодически простаивают без толку. И обнаруживается, что в этом варианте корректность нарушается: довольно легко воспроизвести ситуацию, когда к данным одного и того же ключа, защищенным соответствующим локом из
Striped.lazyWeakReadWriteLock()
, тем не менее получают одновременный доступ два разных потока. Каждый из этих потоков захватил соответствующую блокировку — но это оказываются две разных блокировки! То, что
Striped.lazyWeakReadWriteLock().get(key)
может возвращать разные локи для одного и того же ключа — само по себе не удивительно. Если локи создаются лениво, и держатся через слабые ссылки, то в промежутке между двумя вызовами может прийти GC, прибрать тот лок, который был сначала, а на втором вызове взамен его создастся новый лок. Это не кажется проблемой: ведь если GC смог прибрать лок, значит его никто не использовал, никто ничего им не защищал. Но в тестовом сценарии, в котором воспроизводится ошибка, первый лок все это время используется. Как так?Оказывается, что использование объекта
ReentrantReadWriteLock.writeLock()
и использование объекта ReentrantReadWriteLock
— это две разные вещи. ReentrantReadWriteLock
, если посмотреть на его код, выступает лишь фасадом к паре объектов .writeLock()
и .readLock()
. ReentrantReadWriteLock
создает оба этих объекта, связывает их через общий объект Sync
(который и реализует внутри себя всю логику блокировки) — и больше ничего и не делает, кроме как предоставляет к ним доступ. Все взаимодействие между .writeLock()
и .readLock()
идет в обход ReentrantReadWriteLock
, и настолько хорошо в обход, что никто из них даже не нуждается в ссылке на родительский объект. В результате получается, что в коде типа такого
private void readLockedMethod() { final Lock readLock = stripedLock.get(key).readLock(); readLock.lock(); try { //в этот момент со стека есть ссылка только на readLock! } finally { readLock.unlock(); } }внутри блокировки только
readLock
реально используется, и потому достижим (reachable) со стека. Соответствующий же ему ReentrantReadWriteLock
внутри и после блокировки никем не используется, и вполне может быть прибран GC. Что и происходит время от времени. И как только это произойдет, при следующем вызове stripedLock.get(key)
из другого потока будет создан уже новый ReentrantReadWriteLock
, хотя старая блокировка все еще не отпущена. Интересно разобраться, кто именно совершил ошибку. Авторы guava полагали (неявно), что
ReentrantReadWriteLock
вместе с его дочерними объектами это такой неразрывный блок (агрегат), все элементы которого имеют одинаковое время жизни, и они либо все живые (достижимый), либо так же все мусор. На деле же оказалось, что связи между частями ReentrantReadWriteLock
более слабые, и эти части могут функционировать независимо и иметь разное время жизни. Ни то, ни другое не было явно прописано в контракте RRWLock
, но предположить сильную связность (агрегацию) было довольно естественно. И так же естественно для разработчиков j.u.c реализовать слабую связность (ассоциацию): ведь слабое связывание это одна из мантр хорошего дизайна. Как результат обсуждения, вероятно, в новых версиях джавы связность будет усилена:
.writeLock()
и .readLock()
будут иметь обратную ссылку на родительский ReentrantReadWriteLock
. Поведение будет более интуитивным — ценой большего расхода памяти.
Интересно, что натолкнуло на то, чтобы пристально посмотреть именно в эту связку ? Инцидент или любопытство (до инцидента)?
ОтветитьУдалитьСудя по https://github.com/google/guava/issues/2477 они все же обнаружили бажность при использовании, а потом уже начали копать причины
Удалить