CAS против синхронизированной производительности
У меня был этот вопрос уже довольно давно, я пытался прочитать много ресурсов и понять, что происходит, но мне так и не удалось понять, почему все так, как есть.
Проще говоря, я пытаюсь проверить, как CAS
будет выполнять против synchronized
в утвержденных и не средах. Я поднял это JMH
тестовое задание:
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 5, time = 5, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 5, timeUnit = TimeUnit.SECONDS)
@State(Scope.Benchmark)
public class SandBox {
Object lock = new Object();
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder().include(SandBox.class.getSimpleName())
.jvmArgs("-ea", "-Xms10g", "-Xmx10g")
.shouldFailOnError(true)
.build();
new Runner(opt).run();
}
@State(Scope.Thread)
public static class Holder {
private long number;
private AtomicLong atomicLong;
@Setup
public void setUp() {
number = ThreadLocalRandom.current().nextLong();
atomicLong = new AtomicLong(number);
}
}
@Fork(1)
@Benchmark
public long sync(Holder holder) {
long n = holder.number;
synchronized (lock) {
n = n * 123;
}
return n;
}
@Fork(1)
@Benchmark
public AtomicLong cas(Holder holder) {
AtomicLong al = holder.atomicLong;
al.updateAndGet(x -> x * 123);
return al;
}
private Object anotherLock = new Object();
private long anotherNumber = ThreadLocalRandom.current().nextLong();
private AtomicLong anotherAl = new AtomicLong(anotherNumber);
@Fork(1)
@Benchmark
public long syncShared() {
synchronized (anotherLock) {
anotherNumber = anotherNumber * 123;
}
return anotherNumber;
}
@Fork(1)
@Benchmark
public AtomicLong casShared() {
anotherAl.updateAndGet(x -> x * 123);
return anotherAl;
}
@Fork(value = 1, jvmArgsAppend = "-XX:-UseBiasedLocking")
@Benchmark
public long syncSharedNonBiased() {
synchronized (anotherLock) {
anotherNumber = anotherNumber * 123;
}
return anotherNumber;
}
}
И результаты:
Benchmark Mode Cnt Score Error Units
spinLockVsSynchronized.SandBox.cas avgt 5 212.922 ± 18.011 ns/op
spinLockVsSynchronized.SandBox.casShared avgt 5 4106.764 ± 1233.108 ns/op
spinLockVsSynchronized.SandBox.sync avgt 5 2869.664 ± 231.482 ns/op
spinLockVsSynchronized.SandBox.syncShared avgt 5 2414.177 ± 85.022 ns/op
spinLockVsSynchronized.SandBox.syncSharedNonBiased avgt 5 2696.102 ± 279.734 ns/op
В необщем случае CAS
гораздо быстрее, что я ожидаю. Но в общем случае все наоборот - и этого я не могу понять. Я не думаю, что это связано с смещенной блокировкой, поскольку это произойдет после того, как потоки удерживают блокировку в течение 5 секунд (AFAIK), и этого не происходит, и тест является лишь доказательством этого.
Я искренне надеюсь, что только мои тесты не верны, и кто-то jmh
опыт придет и просто укажет мне на неправильную настройку здесь.
4 ответа
Основным заблуждением является предположение, что вы сравниваетеCAS
против synchronized
". Учитывая, как сложные JVM реализуют synchronized
Вы сравниваете производительность CAS
алгоритм с использованием AtomicLong
с производительностью CAS
алгоритм, используемый для реализации synchronized
,
Похожий на Lock
внутренняя информация для монитора объекта в основном состоит из int
статус, указывающий, принадлежал ли он и как часто он является вложенным, ссылка на текущий поток владельца и очередь потоков, ожидающих возможности его получения. Дорогой аспект - очередь ожидания. Помещение потока в очередь, удаление его из расписания потоков и, в конечном итоге, его пробуждение, когда текущий владелец освобождает монитор, - операции, которые могут занять значительное время.
Однако в неконтролируемом случае очередь ожидания, конечно, не задействована. Приобретение монитора состоит из одного CAS
изменить статус с "не принадлежащий" (обычно ноль) на "принадлежащий, приобретенный один раз" (угадать типичное значение). В случае успеха поток может приступить к критическому действию с последующим выпуском, который подразумевает просто запись "неизвестного" состояния с необходимой видимостью памяти и пробуждение другого заблокированного потока, если таковой имеется.
Поскольку очередь ожидания в значительно более дорогую вещи, реализация, как правило, старается избегать его, даже в случае утверждали, выполняя некоторое количество прядения, делая несколько повторено CAS
попытки, прежде чем вернуться к постановке в очередь. Если критическое действие владельца так же просто, как однократное умножение, велика вероятность того, что монитор будет выпущен уже во время фазы вращения. Обратите внимание, что synchronized
является "несправедливым", что позволяет вращать нить немедленно, даже если в очереди уже есть очереди, ожидающие намного дольше.
Если вы сравните основные операции, выполняемые synchronized(lock){ n = n * 123; }
когда нет очереди и по al.updateAndGet(x -> x * 123);
Вы заметите, что они примерно на одном уровне. Основное отличие состоит в том, что AtomicLong
подход будет повторять умножение раздора в то время как для synchronized
При таком подходе существует риск помещения в очередь, если во время вращения не было достигнуто никакого прогресса.
Но synchronized
допускает огрубление блокировки для кода, повторно синхронизирующегося на одном и том же объекте, что может иметь значение для цикла тестирования, вызывающего syncShared
метод. Если есть также способ объединить несколько CAS
обновления AtomicLong
, это может дать synchronized
драматическое преимущество. (См. Также эту статью, охватывающую несколько аспектов, обсуждаемых выше)
Обратите внимание, что из-за "несправедливой" природы synchronized
Создание большего количества потоков, чем процессорных ядер, не должно быть проблемой. В лучшем случае потоки "количество потоков минус количество ядер" попадают в очередь, никогда не пробуждаясь, в то время как остальные потоки успешно завершают фазу вращения, по одному потоку на каждое ядро. Но аналогичным образом потоки, не работающие на ядре ЦП, не могут снизить производительность AtomicLong
обновить, поскольку они не могут ни сделать недействительным текущее значение для других потоков, ни сделать сбой CAS
попытка.
В любом случае, когда CAS
в переменной члена общего объекта или при выполнении synchronized
на неразделенном объекте JVM может обнаружить локальный характер операции и исключить большую часть связанных с этим затрат. Но это может зависеть от нескольких тонких экологических аспектов.
Суть в том, что нет простого решения между атомными обновлениями и synchronized
блоки. Вещи становятся намного интереснее с более дорогими операциями, которые могут повысить вероятность того, что потоки будут поставлены в очередь в предполагаемом случае synchronized
, Который может сделать его приемлемым, что операция должна быть повторена в утверждали случае атомного обновления.
Вы должны прочитать, перечитать и принять превосходный ответ @Holger, так как полученные знания гораздо более ценны, чем единый набор тестовых показателей с одной рабочей станции разработчика.
Я подправил ваши тесты, чтобы сделать их немного больше яблоками, но если вы прочитаете ответ @ Хольгера, вы поймете, почему это не очень полезный тест. Я собираюсь включить мои изменения и результаты просто, чтобы показать, как результаты могут варьироваться от одного компьютера (или одной версии JRE) к другому.
Во-первых, моя версия теста:
@State(Scope.Benchmark)
public class SandBox {
public static void main(String[] args) throws RunnerException {
new Runner(
new OptionsBuilder().include(SandBox.class.getSimpleName())
.shouldFailOnError(true)
.mode(Mode.AverageTime)
.timeUnit(TimeUnit.NANOSECONDS)
.warmupIterations(5)
.warmupTime(TimeValue.seconds(5))
.measurementIterations(5)
.measurementTime(TimeValue.seconds(5))
.threads(-1)
.build()
).run();
}
private long number = 0xCAFEBABECAFED00DL;
private final Object lock = new Object();
private final AtomicLong atomicNumber = new AtomicLong(number);
@Setup(Level.Iteration)
public void setUp() {
number = 0xCAFEBABECAFED00DL;
atomicNumber.set(number);
}
@Fork(1)
@Benchmark
@CompilerControl(CompilerControl.Mode.DONT_INLINE)
public long casShared() {
return atomicNumber.updateAndGet(x -> x * 123L);
}
@Fork(1)
@Benchmark
@CompilerControl(CompilerControl.Mode.DONT_INLINE)
public long syncShared() {
synchronized (lock) {
return number *= 123L;
}
}
@Fork(value = 1, jvmArgsAppend = "-XX:-UseBiasedLocking")
@Benchmark
@CompilerControl(CompilerControl.Mode.DONT_INLINE)
public long syncSharedNonBiased() {
synchronized (lock) {
return number *= 123L;
}
}
}
А потом моя первая партия результатов:
# VM version: JDK 1.8.0_60, VM 25.60-b23
Benchmark Mode Cnt Score Error Units
SandBox.casShared avgt 5 976.215 ± 167.865 ns/op
SandBox.syncShared avgt 5 1820.554 ± 91.883 ns/op
SandBox.syncSharedNonBiased avgt 5 1996.305 ± 124.681 ns/op
Напомним, что вы видели synchronized
выходит вперед под большим раздором. На моей рабочей станции атомная версия была лучше. Если вы используете мою версию ваших тестов, какие результаты вы видите? Меня ничуть не удивит, если они существенно различаются.
Вот еще один набор, запущенный под выпуском месячной версии Java 9 EA:
# VM version: JDK 9-ea, VM 9-ea+170
Benchmark Mode Cnt Score Error Units
SandBox.casShared avgt 5 979.615 ± 135.495 ns/op
SandBox.syncShared avgt 5 1426.042 ± 52.971 ns/op
SandBox.syncSharedNonBiased avgt 5 1649.868 ± 48.410 ns/op
Разница здесь менее драматична. Нет ничего необычного в том, чтобы увидеть разницу между основными версиями JRE, но кто сказал, что вы не увидите их и в небольших версиях?
В конце дня результаты близки. Очень близко. Производительность synchronized
прошел долгий путь с ранних версий Java. Если вы не пишете HFT-алгоритмы или что-то еще, что невероятно чувствительно к задержке, вы должны предпочесть решение, которое наиболее легко оказалось правильным. Как правило, легче рассуждать о synchronized
чем безблокировочные алгоритмы и структуры данных. Если вы не можете продемонстрировать ощутимую разницу в вашем приложении, то synchronized
это то, что вы должны использовать.
Обратите внимание, что CAS может дать вам более подробные (не) гарантии упорядочения, чем синхронизированный блок, особенно с помощью варф - дескрипторов java-9, которые предоставляют параметры упорядочения, согласованные с моделью памяти C++11.
Если все, что вам нужно, - это вести статистику по нескольким потокам, тогда цикл чтения-вычисления-обновления с наиболее расслабленными доступными порядками памяти ( обычное чтение; простая и слабая CAS) может работать лучше на слабо упорядоченных платформах, поскольку не будет Нужны какие-либо барьеры, и cas не будет выполнять расточительный внутренний цикл, если он реализован поверх LL/SC. Кроме того, это также даст JIT больше свободы для переупорядочения инструкций вокруг этих атомных элементов. compareAndExchange
может устранить дополнительное чтение на повторение цикла.
Еще одним осложнением также является то, как вы измеряете производительность. Все реализации должны иметь гарантии прогресса, т. Е. Даже в случае конфликта, по крайней мере, одна может закончить за раз. Таким образом, в принципе, вы могли бы тратить циклы ЦП в нескольких потоках, пытаясь одновременно обновлять вашу переменную, но все же лучше измерять задержку в 99-м процентиле, потому что атомарные операции не прибегнут к планированию потока, а хуже в задержке в худшем случае, потому что они ' не справедливо. Так что просто измерение средних значений может не рассказать всю историю здесь.
Прежде всего, код, который вы пишете, - это Java, который создаст Java-байт-код, который преобразуется в различные атомарные операции над разными наборами команд (Arm vs powerpc против X86...), которые могут вести себя по-разному в реализациях разных поставщиков и даже между архитектурами из тот же поставщик (например, Intel Core 2 Duo и Skylake). Поэтому очень сложно ответить на ваш вопрос!
В этой статье говорится, что для протестированных архитектур X86 одно выполнение любой элементарной операции выполняется аналогично (очень небольшая разница между CAS, Fetch и add, swap), в то время как CAS может завершиться сбоем и его необходимо выполнить несколько раз. Однако в случае одного потока он никогда не выйдет из строя.
Эта запись стека overoverflow гласит:
С каждым объектом связан монитор. Поток, который выполняет monitorenter, становится владельцем монитора, связанного с objectref. Если другой поток уже владеет монитором, связанным с objectref, текущий поток ждет, пока объект не будет разблокирован, а затем снова пытается получить право собственности. Если текущему потоку уже принадлежит монитор, связанный с objectref, он увеличивает счетчик в мониторе, указывая количество раз, когда этот поток вошел в монитор. Если монитор, связанный с objectref, не принадлежит ни одному потоку, текущий поток становится владельцем монитора, устанавливая счетчик записей этого монитора равным 1.
Давайте посмотрим на необходимые операции в случае CAS:
public final int updateAndGet(IntUnaryOperator updateFunction) {
int prev, next;
do {
prev = get();
next = updateFunction.applyAsInt(prev);
} while (!compareAndSet(prev, next));
return next;
}
Извлечь x, умножить x, Cas на x, проверить, успешно ли cas
Теперь это эффективно в неконтролируемом случае, потому что требуется минимальное количество операций. Но в случае утверждения строки кэша это не очень эффективно, потому что все потоки активно вращаются, в то время как большинство из них перестают работать. Кроме того, я помню, что вращение на оспариваемую кешлайн с атомарной операцией очень дорого
Теперь важной частью в синхронизации является:
Если другой поток уже владеет монитором, связанным с objectref, текущий поток ожидает, пока объект не будет разблокирован
Это зависит от того, как реализовано это ожидание.
синхронизированный метод может перевести поток в спящий режим на случайное время после сбоя при получении монитора. Кроме того, вместо использования атомарной операции для проверки, свободен ли монитор, он может сделать это простым чтением (это быстрее, но я не смог найти ссылку, чтобы доказать это).
Я держу пари, что ожидание в синхронизированном режиме реализовано разумным образом и оптимизировано для ситуаций с конфликтами одним из описанных выше методов или чем-то подобным, и поэтому оно быстрее в предполагаемом сценарии.
Дело в том, что в неконтролируемых ситуациях это медленнее.
Я до сих пор признаюсь, что у меня нет доказательств.