Хочу поделиться опытом использования ReadWriteLock — имплементации, поставляемой в JDK, нашим «разочарованием» в ней и о том, как мы обошли проблему ограничения scalability.
Но начнем мы издалека.
Существует два типа блокировок — spin lock и lock, обрашающийся к OS для переключения между потоками. Spin lock, как, я думаю, вы уже догадались, не переводит поток в состояние ожидания, а ждёт в цикле до тех пор, пока требуемый ресурс не осовбодится. Существует мнение, что spin lock — это прошлый век, и что надо пользоваться блокирущими lock’ами. На самом деле это не так.
Если даже не брать во внимание, что блокирующий lock вызывает переключение контекстов потоков, в нём также производится interpocessing method call — дорогая операция. Вызов метода OS может привести к высокой latency во время захвата монитора объекта.
Таким образом, если у вас время выполнения самого метода очень маленькое, то spin lock будет повышать скорость выполения программы сильнее, чем блокирующий lock. Если же у вас сам метод выполняется очень долго, то блокирущий lock выгоднее, так как он позволяет эфективнее перераспределить ресурсы OS.
ReadWriteLock использует в чем-то похожий подход. Сначала используется spin lock, и если получить доступ к ресурсу невозможно, вызывается метод java.util.concurrent.locks.LockSupport#park(java.lang.Object)
для передачи ресурсов OS другому потоку. Причем данный подход относится также к реализации exclusive lock на уровне JDK (java.util.concurrent.locks.ReentrantLock) и JVM (synchronized).
Теперь давайте расмотрим в деталях, как это реализовано.
Начнём с очень примитивной эксклюзивной блокировки (так называемый test-and-set lock):
public class TestAndSetLock { AtomicBoolean state = new AtomicBoolean(false); public void lock() { while (state.getAndSet(true)) {} } public void unlock() { state.set(false); } }
Операция getAndSet является атомарной т.е. между get
и set
значение переменной гарантированно не поменяется, что и позволяет использовать её для данного spin lock.
Сделаем небольшой, далеко не идеальный, но показательный бенчмарк.
Основной код этого бенчмарка здесь:
public final class Countdown implements Callable<Long> { private final long iterations; public Countdown(long iterations) { this.iterations = iterations; } @Override public Long call() throws Exception { final long start = System.nanoTime(); long cnt = iterations; while (cnt > 0) { spinLock.lock(); cnt--; spinLock.unlock(); } final long end = System.nanoTime(); return end - start; } } }
Таким образом, мы выполняем N операций в каждом потоке (в нашем случае 2^24), сумируем время выполнения и смотрим среднее для одной операции.
Давайте сначала посмотрим время выполнения операции для одного потока:
Average execution time : 1 ns per operation.
Теперь посмотрим результат для 8 потоков:
Average execution time : 1163 ns per operation.
Ого! Разница просто невероятная. Такое увеличение времени операции связано, грубо говоря, с тем, что потоки постоянно конкурируют за один и тот же участок памяти.
Можно немного уменшить уровень contention
между потоками (test-test-and-set-lock):
public class TestTestAndSetLock { AtomicBoolean state = new AtomicBoolean(false); public void lock() { while (true) { while (state.get()) {}; if (!state.getAndSet(true)) return; } } public void unlock() { state.set(false); } }
И результаты его бенчмарка:Average execution time : 948 ns per operation.
Что, собственно, и стоило ожидать.
В общем, я думаю, вы поняли основную идею оптимизации. В идеале каждый поток должен иметь свою собственную ячейку памяти, которая меняется только раз для индикации того, что ресурс должен захватить поток. Это приводит нас к CLH-очереди (как это часто бывает, название очереди — это абревиатура имен её создателей).
Собственно имплементация:
public class CLHQueueLock { private final AtomicReference<Qnode> tail = new AtomicReference<Qnode>(); private final ThreadLocal<Qnode> myNode = new ThreadLocal<Qnode>() { @Override protected Qnode initialValue() { return new Qnode(); } }; private final ThreadLocal<Qnode> myPred = new ThreadLocal<Qnode>(); public CLHQueueLock() { final Qnode qnode = new Qnode(); qnode.locked = false; tail.set(qnode); } public void lock() { final Qnode localNode = myNode.get(); localNode.locked = true; final Qnode pred = tail.getAndSet(localNode); myPred.set(pred); while (pred.locked) ; } public void unlock() { myNode.get().locked = false; myNode.set(myPred.get()); } static final class Qnode { volatile boolean locked = true; } }
И результаты бенчмарка:
Average execution time : 681 ns per operation.
Сначала сделаем небольшую таблицу сравнения результатов, а затем я дам объяснения по деталям реализации CLH-блокировки:
Test-and-set test-test-and-set CLH Time/ op, ns 1163 948 681
Вообще-то наш бенчмарк совсем не идален — например, мы не смотрели, как изменятся показатели при росте количества потоков, не смотрели на девиацию результатов. Хотелось бы делать что-то более долгое, чем декрементирование счётчика. Кроме того, ведь System.nanoTime() тоже не бесплатный, однако его вполне достаточно, чтобы понять основную идею.
Теперь давайте посмотрим на примере, как работает CLH queue (хотя бы потому, что именно её модификация используется в OpenJDK), на примере трёх потоков.
Для каждого потока у нас есть QNode, которая показывает, отпустил ли данный поток необходимый ресурс. Состояние этой QNode мониторится потоком, который стоит за ним в очереди.
То есть при захвате ресурса потоки выстраиваются в очередь, и поток в голове очереди устанавливает флаг захвата ресурса, который мониторит поток, стоящий за ним, и определяет, может ли ожидающий поток использовать необходимый ресурс.
Итак, по шагам.
Thread_1
вызывает метод lock, получает фейковый QNode передыдущего потока (предыдущего потока просто нет) и помещает свой QNode в tail. И начинает выполнять необходимые ему действия. Причём фейковый QNode становится QNode данного потока при осовобождении блокировки.
Thread_2
получает QNode предыдущего потока. И ждёт, пока предыдущий поток не овободит ресурс, установив свой флаг захвата ресурса (locked) в false. Так же в tail помещается QNode с флагом locked, установленным в true.
Thread_3
Получает QNode из tail и ждёт уже второй поток.
Thread_1
устанаваливает locked флаг в false.Thread_2
выполняет работу и устанаваливает locked флаг в false.Thread_3
выполняет работу и устанаваливает locked флаг в false.
И на этом выполнение потоков заканчивается.
CLH queue на самом деле не всегда выигрывает по сравнению с test-test-and-set lock , но более детальный анализ блокировок — это уже другая истроия.
К чему, собственно, мы всё это рассмотрели? Хотя бы потому, что OpenJDK использует аналог CLH queue, но, как это ни странно, использует его для пробуждения заблокрированных потоков, а не для spin lockов. То есть когда поток освобождает ресурс, он пробуждает потоки, стоящие в очереди. Мы не будем рассматривать полностью, как работет RW lock, но в контексте этой статьи посмотрим, как рабоает read часть данной блокировки.
Когда мы пытаемся заблокировать ресурс на чтение, вызывается метод
public final void acquireShared(int arg) { if (tryAcquireShared(arg) < 0) doAcquireShared(arg); }
В контексте ReadWriteLock метод работает следующим образом (будем рассматривать только отдельные участки кода):
ReadWriteLock содержит переменную, хранящую в себе количесвто readers и writers, которые захватили данный lock. Она инкрементируется и декрементируется атомарно, то есть представляет собой, по сути, flag для spin lock.
java.util.concurrent.locks.ReentrantReadWriteLock.Sync#fullTryAcquireShared for (;;) { int c = getState(); //получили колличество writers if (exclusiveCount(c) != 0) { //если у нас кто то захватил exclusive lock, но не мы придётся заснуть if (getExclusiveOwnerThread() != current) return -1; } else if (readerShouldBlock()) { //всё равно спим например для того, чтобы writers могли получить доступ //при огромном колличестве readers return -1; } //инкрементируем колличество reades. если writers нет будем делать до //победы if (compareAndSetState(c, c + SHARED_UNIT)) { return 1; } }
doAcquireShared
кладет поток в очередь ожидания, пытается захватить read lock (как описано выше), не получается — он засыпает, просыпается, и т.д.
final Node node = addWaiter(Node.SHARED); for (;;) { final Node p = node.predecessor(); if (p == head) { int r = tryAcquireShared(arg); if (r >= 0) { return; } if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) interrupted = true; }
Так вот, если посмотреть внимательно, то можно увидеть, что в случае наличия только readers наш ReadWriteLock очень похож на test-and-write lock со всеми вытекающими последствиями по потери производительности.
Теперь давайте немного изменим бенчмарк и сделаем следующее: посчитаем, скажем, с 16 000 000 до 0 на одном потоке, потом выполним то же число операций (8 потоков по 2 000 000 операций на каждом) на 8 потоках. А затем посчитаем суммарное время выполнения для всех потоков в наносекундах и время выполнения всей программы в милисекундах. Ведь читающие потоки не блокируют друг друга, правда?
Итак, на 1 потоке:
Count down for : 361 172 637 ns.
Execution time is : 362 ms.
На 8 потоках:
Count down for : 21 824 811 292 ns.
Execution time is : 2 780 ms.
То есть, грубо говоря, 8 читающих потоков работали в сумме в 9 раз медленее, чем один.
Тест несколько спекулятивный, но очень показательный, так как мы в своём проекте (NoSQL базы данных) столкнулись с подобной же ситуацией. Когда несколько потоков работали на чтение медленее, чем один.
Вообще довольно грустная перспектива, с учётом того, что нам очень хотелось получить масшатабирование системы на чтение.
И мы решили сделать свой собственный ReadWriteLock.
У нас есть некоторые особенности: мы редко используем глобальные блокировки, и глобальная блокировка на запись нам нужна была в тех, случаях когда:
1. Скорость записи была для нас не так критична, как чтение.
2. У нас было много частых и быстрых операций чтения.
3. Мы работаем с долгоживущими потоками (хотя, на мой взгляд, это не совсем обязательно).
Сразу приведу результат того сумашедшего бенчмарка, который мы прогнали:
Count down for : 1 221 961 202 ns.
Execution time is : 166 ms.
Что в два раза быстрее, чем на одном потоке, не говоря уже о втором тесте.
Если у вас те же условия, вы можете воспользоваться нашим опытом и, собственно, самой блокировкой com.orientechnologies.common.concur.lock.OReadersWriterSpinLock
в проекте github.com/...logies/orientdb.
Как работает данный lock:
Write lock так же изначально представляет собой CLH-lock.
final WNode node = myNode.get(); node.locked = true; final WNode pNode = tail.getAndSet(myNode.get()); predNode.set(pNode); while (pNode.locked) { pNode.waitingWriter = Thread.currentThread(); if (pNode.locked) LockSupport.park(this); } pNode.waitingWriter = null;
Дальше нам придется переключиться на чтение. Здесь ситуация несколько иная:
каждый reader имеет совю собсвенную thread local переменную, считающую колличество readers для данного потока.
threadCountersHashTable.increment();
Затем мы проверяем, захвачен ли write lock. Если да, то мы выполняем back off, декрементируя counter для текущего readerа.
WNode wNode = tail.get(); while (wNode.locked) { threadCountersHashTable.decrement(); while (wNode.locked) { wNode.waitingReaders.add(Thread.currentThread()); if (wNode == tail.get() && wNode.locked) LockSupport.park(this); wNode = tail.get(); } threadCountersHashTable.increment(); wNode = tail.get(); }
Ждём, пока write lock освободится, и снова инкрементируем read lock counter для текущего потока.
Очень важно отметить, что таким образом каждый reader инкрементирует свой read counter, и readers не конкурируют между собой.
Теперь возвратимся к захвату write lock.
while (!threadCountersHashTable.isEmpty()) ; setExclusiveOwnerThread(Thread.currentThread());
Собственно говоря, write lock ждёт, пока все readers не закончат свою работу.
Освобождение write lock включает в себя сброс флага и нотификацию всех ожидающих readers, которые зарегистрировали себя в данном потоке.
Кстати, ситуация, когда мы зарегистрировали себя во wirter потоке, а он уже пробудил всех зарегистрированных readers, не возникнет, так как мы сначала добавляем себя в очередь ожидания
wNode.waitingReaders.add(Thread.currentThread());
,
а засыпаем только если контролирующий поток всё ещё не разблокировал ресурс.
if (wNode == tail.get() && wNode.locked) LockSupport.park(this);
Сами по себе thread local counters содержатся в HashMap, которая реализована на основе алгоритма lock free cuckoo hashing. К тому же, это HashMap рассматривает ячейку с local thread counter, для которой значение Thread.isAlive() = false
как пустую, т.е. у вас никогда не будет переполнения памяти.
Вот и всё. Если вы найдёте ошибку в том, что мы сделали или написали, мы вам будем очень благодарны. Если Вам интересно узнать намного больше о многопоточном программировании, регистрируйтесь на наш курс.
Кроме того, успешно закончившие курс получат возможность трудоустройства в orientechnologies в качестве core developer, а часть прибыли с курса будет отправлена на благотворительность.
Все исходные коды вы можете найти здесь.