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, текущий поток ожидает, пока объект не будет разблокирован

Это зависит от того, как реализовано это ожидание.

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

Я держу пари, что ожидание в синхронизированном режиме реализовано разумным образом и оптимизировано для ситуаций с конфликтами одним из описанных выше методов или чем-то подобным, и поэтому оно быстрее в предполагаемом сценарии.

Дело в том, что в неконтролируемых ситуациях это медленнее.

Я до сих пор признаюсь, что у меня нет доказательств.

Другие вопросы по тегам