3 декабря 2017 г.

Про ReentrantReadWriteLock

Отличная история с любимой группы рассылки concurrency-interest.

В библиотеке 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. Поведение будет более интуитивным — ценой большего расхода памяти.

2 комментария:

  1. Интересно, что натолкнуло на то, чтобы пристально посмотреть именно в эту связку ? Инцидент или любопытство (до инцидента)?

    ОтветитьУдалить
    Ответы
    1. Судя по https://github.com/google/guava/issues/2477 они все же обнаружили бажность при использовании, а потом уже начали копать причины

      Удалить