В коде ниже определите причину зависания при попытке перейти от чтения к записи:
import java.util.concurrent.locks.ReentrantReadWriteLock;
class Demo {
public static void main(String[] args) {
ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
lock.readLock().lock();
try {
lock.writeLock().lock();
System.out.println("updated");
} finally {
lock.writeLock().unlock();
lock.readLock().unlock();
}
}
}
Какой механизм приводит к зависанию и как корректно спроектировать такую операцию?
Код зависает на writeLock().lock(): ReentrantReadWriteLock не поддерживает безопасное повышение read lock до write lock. Поток продолжает удерживать read lock, а запись может начаться только после освобождения всех read lock, включая удерживаемый этим же потоком.
Нельзя решить проблему простым изменением порядка unlock(): перед захватом write lock нужно сначала освободить read lock, после чего повторно проверить условие под write lock. Если повторная проверка невозможна или операция часто изменяет данные, следует сразу захватывать write lock.
Обычный synchronized предоставляет взаимоисключение: одновременно внутри защищённой области находится только один поток. Для структур с большим числом чтений и редкими изменениями это может излишне ограничивать параллелизм.
ReentrantReadWriteLock разделяет доступ на два режима: несколько потоков могут одновременно читать, но запись требует исключительного доступа. Такая модель решает проблему избыточной сериализации чтений, однако усложняет переход между режимами блокировки.
Read lock совместим с другими read lock, но несовместим с write lock. Пока поток удерживает read lock, его попытка получить write lock не может завершиться: для выдачи write lock необходимо, чтобы число читателей стало равно нулю.
Это создаёт самоблокировку даже в однопоточном примере. В многопоточной программе ситуация ещё сложнее: другие читатели могут продолжать работать, поэтому поток, ожидающий запись, не может определить, когда безопасно продолжить.
ReentrantReadWriteLock является реентерабельным для одного и того же режима: поток, уже владеющий write lock, может захватить его повторно. Но реентерабельность не означает поддержку повышения read lock до write lock.
Безопасный общий шаблон выглядит так:
Если сначала требуется дешёвая проверка под read lock, поток должен освободить его, захватить write lock и выполнить проверку заново. Повторная проверка обязательна: после освобождения read lock другой поток мог изменить состояние. Поэтому схема с повторным захватом может привести к лишней работе или требует дополнительной проверки версии данных.
Режим справедливости блокировки не устраняет проблему повышения: он влияет на порядок предоставления lock ожидающим потокам, но не делает несовместимые режимы совместимыми. tryLock() позволяет избежать бесконечного ожидания, однако отказ от захвата нужно обработать, а не считать успешным переходом.
Обратный переход — понижение write lock до read lock — поддерживается. Сначала поток захватывает read lock, пока ещё держит write lock, и только затем освобождает write lock. Благодаря этому между режимами не возникает окна, в котором другой поток мог бы изменить данные.
В кэше поток под read lock проверяет наличие записи. При отсутствии записи он пытается загрузить значение и записать его в кэш. Прямое повышение блокировки приводит к зависанию, а простое снятие read lock с последующим захватом write lock позволяет нескольким потокам одновременно начать одну и ту же загрузку.
Варианты решения:
StampedLock с попыткой преобразования read stamp в write stamp. Это может уменьшить блокировки, но API сложнее, преобразование может не удаться, а код обязан корректно обрабатывать stamp и повторять проверку.Для обычного кэша с короткой критической секцией разумный выбор — повторная проверка под write lock, а внешнюю долгую загрузку организовать отдельно с дедупликацией запросов. Такой подход не удерживает блокировку во время медленного I/O и не допускает запись без повторной проверки состояния.
new ReentrantReadWriteLock(true)?Нет. Справедливый режим регулирует очередность ожидающих потоков и снижает риск голодания, но поток всё равно удерживает read lock, несовместимый с требуемым write lock. Освобождение собственного read lock и повторный захват write lock остаются обязательными.
Обычно нельзя. Между двумя захватами другой поток может изменить данные, поэтому исходное решение уже не гарантирует корректность. Правильный шаблон — захватить write lock и заново проверить условие; иначе возможны потеря обновления, перезапись более нового значения или дублирование операции.
Поток должен сначала захватить read lock, продолжая удерживать write lock, затем освободить write lock и оставить read lock до окончания чтения. Если сначала освободить write lock, а потом пытаться получить read lock, другой поток может изменить состояние между этими действиями, и атомарность перехода будет потеряна.