ReentrantReadWriteLock зависает при использовании в ConcurrentHashMap::compute()

TL;DR. В моем приложении многие потоки захватывают ReentrantReadWriteLock в режиме READ, пока они вставляют записи в ConcurrentHashMap с помощью метода calculate(), и освобождают блокировку READ после завершения lamdba, переданного в calculate(). Есть отдельный поток, который захватывает ReentrantReadWriteLock в режиме WRITE и очень (очень) быстро его освобождает. Пока все это происходит, ConcurrentHashMap изменяет размер (растет и сжимается). Я сталкиваюсь с зависанием и всегда вижу ConcurrentHashMap::transfer(), который вызывается при изменении размера, в трассировках стека. Все потоки заблокированы, ожидая захвата MY ReentrantReadWriteLock. Источник: https://github.com/rumpelstiltzkin/jdk_locking_bug

Я делаю что-то не так в соответствии с задокументированным поведением или это ошибка JDK? Обратите внимание, что я НЕ прошу других способов реализации моего приложения.


Подробности: вот некоторый контекст, объясняющий, почему мое приложение делает то, что делает. Код воспроизводящего устройства представляет собой урезанную версию для демонстрации проблемы.

В моем приложении есть кеш со сквозной записью. Записи вставляются в кеш с отметкой времени, когда они были вставлены, и отдельный поток очистки выполняет итерацию кеша, чтобы найти записи, которые были созданы после последнего сохранения записей потоком очистки на диске, т.е. после времени последней очистки. Кэш — это не что иное, как ConcurrentHashMap.

Теперь возможна гонка, при которой запись создается с временной меткой tX, и пока она вставляется в ConcurrentHashMap, поток флешера перебирает кеш и не находит запись (она все еще вставляется так что он еще не виден в Map::Iterator потока-очистителя), и поэтому он не сохраняет его и увеличивает время последней очистки до tY, так что tY > tX. В следующий раз, когда поток-очиститель выполняет итерацию кеша, он не будет считать запись с отметкой времени tX требующей очистки, и мы пропустим ее сохранение. В конце концов tX станет очень старой отметкой времени, и кеш удалит ее безвозвратно, потеряв это обновление.

Чтобы обойти эту проблему, потоки, обновляющие кеш новыми записями, захватывают ReentrantReadWriteLock в режиме READ внутри лямбда-выражения, создающего запись в кеше внутри метода ConcurrentHashMap::compute(), а поток-очиститель захватывает тот же ReentrantReadWriteLock в режиме WRITE. при захвате времени последнего сброса. Это гарантирует, что когда поток очистки получает отметку времени, все объекты будут «видимы» на карте и будут иметь отметку времени ‹= время последней очистки.


Воспроизведение в моей системе:

$> java -version
openjdk version "1.8.0_191"
OpenJDK Runtime Environment (build 1.8.0_191-b12)
OpenJDK 64-Bit Server VM (build 25.191-b12, mixed mode)
$> ./runtest.sh 
seed is 1571855560640
Main spawning 100 readers
Main spawned 100 readers
Main spawning a writer
Main spawned a writer
Main waiting for threads ... <== hung

Все потоки (читатели и записи) заблокированы в ожидании 0x00000000c6511648

$> ps -ef | grep java | grep -v grep
user   54896  54895  0 18:32 pts/1    00:00:07 java -ea -cp target/*:target/lib/* com.hammerspace.jdk.locking.Main

$> jstack -l 54896 > jstack.1

$> grep -B3 'parking to wait for  <0x00000000c6511648>' jstack.1  | grep tid | head -10
"WRITER" #109 ...
"READER_99" ...
...

'top' показывает, что мой java-процесс спал в течение нескольких минут (он постепенно использует крошечный бит ЦП для возможного переключения контекста, а что нет - см. справочную страницу top для получения дополнительных объяснений, почему это происходит)

$> top -p 54896
   PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND                                                                               
 54896 user      20   0 4630492 103988  12628 S   0.3  2.7   0:07.37 java -ea -cp target/*:target/lib/* com.hammerspace.jdk.locking.Main

person Anand Ganesh    schedule 23.10.2019    source источник


Ответы (1)


Примечание. Ниже представлен список наблюдений, предлагаемый подход и совет сообщить об ошибке в Oracle. Не решение.

Наблюдения

  1. У Concurrent Maps есть встроенный механизм блокировки, нам не нужно приобретать его самостоятельно.

  2. Классы Atomic* возвращаются в течение «одного» цикла процессора и, следовательно, не требуют блокировки при работе с ними.

  3. В Cache.java вы получаете (ваш собственный) ReadLock для обновления кеша (строка 34) и (ваш собственный) WriteLock для чтения из карты (строка 58) и не получаете никакой блокировки, когда вы фактически удаляете сопоставление (строка 71).

  4. Итераторы Concurrent Maps слабо согласованы, и они не увидят ваши обновления, даже если вставка завершена. это по дизайну.

  5. Я восстановил AtomicInteger, так как не хотел использовать Holder (из jax-ws) и не смог воспроизвести блокировку вашего потока.

  6. Учитывая, что вы запускаете потоки получения ReadLock до запуска потоков получения WriteLock. Потоки, получающие WriteLock, никогда не смогут запуститься, так как уже существует множество потоков, которые уже получили блокировки чтения.

  7. Введение 1-секундного сна в методе Cache#update после освобождения ReadLock дало возможность запускать потоки, запрашивающие WriteLock.

  8. Я отменил свои обновления и могу воспроизвести вашу проблему. Но я увидел закономерность.

    а. Использование Holder для lockCount позволило системе быстро сканировать данные.

    б. Использование AtomicInteger для lockCount продлило жизнь еще на несколько секунд

    в. Введение консольного оператора о получении и снятии блокировок с идентификатором runnable продлило жизнь на минуту или две.

    д. Замена идентификатора на имя текущего потока в выводе консоли полностью устранила проблему.

При этом это явно похоже на проблему синхронизации, когда происходит гонка за получением блокировки чтения и записи, поскольку и читатели, и писатели ожидают получения соответствующих блокировок, что приводит к взаимоблокировке, а задержка, вызванная дополнительными операторами, снижает вероятность этого.

Предлагаемый подход

  1. Учитывая, что ConcurrentHashMap поставляется со своим собственным механизмом блокировки, вы можете отказаться от использования собственной повторной блокировки при работе с ней.

  2. Обновите свой код, чтобы позволить эквайерам WriteLock запуститься :)

  3. Проверьте свою версию Java, так как я никогда не попадал в заблокированное состояние при работе на Java 1.8.0_201.

person Elisha Ebenezer    schedule 23.10.2019
comment
Спасибо за комментарий Елисей. Я не ищу другой реализации моего подхода. Я ищу, чтобы узнать, почему репродуктор зависает. #1 - однозначно, но подозреваю, что вы не поняли, почему я использую свои блокировки (это не для гарантии атомарности вставки в кеш) Акк на #2 - опять же, блокировка не для атомарности. Позвольте мне удалить его. №3 правильное наблюдение. Подтвердите наблюдение № 4. Я не возражаю против того, чтобы не видеть обновление в данной итерации, если следующая итерация позволит мне его увидеть, и пока временная метка этой записи находится после временной метки моего автора. - person Anand Ganesh; 23.10.2019
comment
Я принудительно ввел изменение, которое удаляет AtomicInteger для подсчета количества применений лямбда-выражения. Теперь это простой держатель (который я должен использовать, потому что я обновляю значение в лямбда-выражении). Кстати, вы можете удалить 3-ю фиксацию, которая вводит счетчик (чтобы проверить теорию о том, что реализация ConcurrentHashMap может применять мою лямбду более одного раза) и по-прежнему легко воспроизвести зависание. - person Anand Ganesh; 23.10.2019
comment
Я клонировал ваш репозиторий и запустил его без какой-либо блокировки потоков. Странно, что вы это видите. Я работаю на Java 1.8.0_201 64-бит. Одно важное замечание заключается в том, что блокировка записи никогда не была получена, и это ожидаемо, поскольку нельзя получить блокировку записи, пока есть какие-либо приобретенные блокировки чтения. - person Elisha Ebenezer; 23.10.2019
comment
Версия Java может быть разницей в нашем опыте. - person Anand Ganesh; 23.10.2019
comment
Я обновил свой вопрос выводом команд, воспроизводящих проблему. Я попытаюсь воспроизвести его на версии 1.8.0_201, но это поможет, если вы запустите мою программу без каких-либо изменений и покажете мне, что вы получаете с теми же самыми командами. Если все ваши потоки спят и вообще нет активности, это означает, что они зависли и столкнулись с проблемой, о которой я сообщаю. Спасибо! - person Anand Ganesh; 23.10.2019
comment
Хорошие эксперименты, Элиша. Спасибо, что попробовали их и опубликовали свои результаты. Вполне возможно, что (d) не устраняет проблему полностью, а продлевает ее дольше, чем вы ожидали. Я также рад, что мне не нужно воспроизводить 1.8.0_201. Итак, учитывая все это, есть какие-нибудь идеи относительно того, что вызывает тупик? :-) Обратите внимание, что в выводе jstack -l, который показывает все потоки, постоянно ожидающие/зависшие на ReentrantReadWriteLock, нет ни одного потока, который говорит, что он удерживает эту блокировку. - person Anand Ganesh; 24.10.2019
comment
Я ждал почти 2 часа с 8 (d), и проблема не возникла. Вы можете попробовать это с последней сборкой 1.8 и сообщить об ошибке в Oracle. - person Elisha Ebenezer; 24.10.2019
comment
Элиша, не могли бы вы обновить свой ответ, указав, что вы не даете никаких ответов? Я ценю сделанные вами наблюдения и попытки воспроизведения, но предложенный вами подход вводит в заблуждение и не является ответом на мой вопрос. Я хочу убедиться, что люди, которые посещают мой вопрос, не думают, что на него уже дан ответ, и продолжают двигаться дальше, не глядя на него, особенно потому, что для предоставления необходимых деталей требуется долгое чтение. Спасибо. - person Anand Ganesh; 30.10.2019