Подтвердить что ты не робот

Почему этот код работает быстрее с блокировкой?

Некоторые предпосылки: Я создал надуманный пример, чтобы продемонстрировать использование VisualVM для моей команды. В частности, у одного метода было ненужное ключевое слово synchronized, и мы видели потоки в блокировании пула потоков, где им не нужно было. Но удаление этого ключевого слова имело удивительный эффект, описанный ниже, а приведенный ниже код - самый простой случай, когда я могу уменьшить этот оригинальный пример, чтобы воспроизвести проблему, а использование ReentrantLock также создает тот же эффект.

Пожалуйста, рассмотрите приведенный ниже код (полный код исполняемого кода в https://gist.github.com/revbingo/4c035aa29d3c7b50ed8b - вам нужно добавить Commons Math 3.4.1 в путь к классам), Он создает 100 задач и отправляет их в пул потоков из 5 потоков. В задаче создаются две матрицы 500x500 случайных значений, а затем умножаются.

public class Main {
private static ExecutorService exec = Executors.newFixedThreadPool(5);

private final static int MATRIX_SIZE = 500;
private static UncorrelatedRandomVectorGenerator generator = 
            new UncorrelatedRandomVectorGenerator(MATRIX_SIZE, new StableRandomGenerator(new JDKRandomGenerator(), 0.1d, 1.0d));

private static ReentrantLock lock = new ReentrantLock();

public static void main(String[] args) throws Exception {

    for(int i=0; i < 100; i++) {

        exec.execute(new Runnable() {
            @Override
            public void run() {
                double[][] matrixArrayA = new double[MATRIX_SIZE][MATRIX_SIZE];
                double[][] matrixArrayB = new double[MATRIX_SIZE][MATRIX_SIZE];
                for(int j = 0; j< MATRIX_SIZE; j++) {
                    matrixArrayA[j] = generator.nextVector();
                    matrixArrayB[j] = generator.nextVector();
                }

                RealMatrix matrixA = MatrixUtils.createRealMatrix(matrixArrayA);
                RealMatrix matrixB = MatrixUtils.createRealMatrix(matrixArrayB);

                lock.lock();
                matrixA.multiply(matrixB);
                lock.unlock();
            }
        });
    }
}
}

ReentrantLock фактически не нужен. Между потоками, которым требуется синхронизация, не существует общего состояния. С блокировкой на месте мы ожидаем наблюдать за потоками в блокировке пула потоков. С удаленным замком мы ожидаем, что больше не будет блокировки, и все потоки могут работать полностью параллельно.

Неожиданным результатом удаления блокировки является то, что код последовательно занимает больше времени, чтобы завершить работу на моей машине (четырехъядерный i7) на 15-25%. Профилирование кода не показывает каких-либо блокировок или ожиданий в потоках, а общий объем использования ЦП составляет всего около 50%, распределенных относительно равномерно по ядрам.

Вторая неожиданная вещь заключается в том, что это также зависит от типа generator, который используется. Если я использую GaussianRandomGenerator или UniformRandomGenerator вместо StableRandomGenerator, ожидаемый результат наблюдается - код работает быстрее (примерно на 10%), удаляя lock().

Если потоки не блокируются, ЦП находится на разумном уровне, и в нем не участвует ИО, как это можно объяснить? Единственный ключ, который у меня есть, состоит в том, что StableRandomGenerator вызывает много тригонометрических функций, поэтому явно намного интенсивнее процессор, чем генераторы Гаусса или Uniform, но почему тогда я не вижу, чтобы CPU был максимальным?

EDIT: Еще один важный момент (благодаря Joop) - создание generator локального для Runnable (т.е. по одному на поток) отображает нормальное ожидаемое поведение, когда добавление блокировки замедляет код примерно на 50 %. Таким образом, ключевыми условиями для нечетного поведения являются: a) использование StableRandomGenerator и b), когда этот генератор делится между потоками. Но, насколько мне известно, этот генератор является потокобезопасным.

EDIT2: Хотя этот вопрос поверхностно очень похож на связанный дублированный вопрос, а ответ правдоподобен и почти наверняка является фактором, я еще не убежден, что он достаточно прост. Вещи, которые заставляют меня подвергать сомнению:

1) Проблема проявляется только при синхронизации в операции multiply(), которая не вызывает никаких вызовов на Random. Моя непосредственная мысль заключалась в том, что эта синхронизация в некоторой степени ошеломляет нити, и поэтому "случайно" улучшает производительность Random#next(). Тем не менее, синхронизация по вызовам generator.nextVector() (которая теоретически имеет тот же эффект, "правильно" ), не воспроизводит проблему - синхронизация замедляет код, как вы могли ожидать.

2) Проблема наблюдается только с помощью StableRandomGenerator, хотя в других реализациях NormalizedRandomGenerator также используется JDKRandomGenerator (который, как указано, просто обернут для java.util.Random). Фактически, я заменил использование RandomVectorGenerator заполнением матриц прямыми вызовами на Random#nextDouble, а поведение снова возвращается к ожидаемому результату - синхронизация любой части кода приводит к снижению общей пропускной способности.

Таким образом, проблема только наблюдается

a) с помощью StableRandomGenerator - нет другого подкласса NormalizedRandomGenerator, а также напрямую использовать JDKRandomGenerator или java.util.Random, отображая то же поведение.

b) синхронизация вызова с RealMatrix#multiply. Такое же поведение не наблюдается при синхронизации вызовов с случайным генератором.

4b9b3361

Ответ 1

та же проблема, что и здесь.

Фактически вы измеряете конфликт внутри PRNG с общим состоянием.

JDKRandomGenerator основан на java.util.Random, который имеет seed, общий для всех ваших рабочих потоков. Потоки конкурируют за обновление seed в цикле сравнения и установки.

Почему lock повышает производительность? Фактически, это помогает уменьшить конфликт внутри java.util.Random путем сериализации работы: в то время как один поток выполняет умножение матрицы, а другой заполняет матрицу случайными числами. Без lock потоки выполняют одну и ту же работу одновременно.

Ответ 2

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

Позвольте мне указать на SecureRandom, в частности класс JavaDoc, где он говорит: "Примечание: в зависимости от реализации, generateSeed и методы nextBytes могут блокироваться по мере сбора энтропии, например, если им нужно читать /dev/random в разных UNIX-подобных операционных системах". Это то, что вы видели, и почему все идет медленно. Используя один генератор, он блокировал. Да, это поточно-безопасный, но он блокируется при получении энтропии (обратите внимание, что у вас возникли разногласия в ваших потоках, поскольку они ждут, когда методы блокировки вернутся от генерации случайных чисел, создающих энтропию и т.д.). Когда вы вставляете свои собственные замки, вы даете им время собирать энтропию и делать свою вещь "вежливо". Он может быть потокобезопасным, но это не значит, что он хорош или эффективен при бомбардировке; -)

Кроме того, для чего-либо, используя java.util.Random, из Random,

Экземпляры java.util.Random являются потокобезопасными. Однако одновременное использование одного и того же экземпляра java.util.Random через потоки может столкнуться с конкуренцией и, как следствие, низкой производительностью. Вместо этого используйте ThreadLocalRandom в многопоточных проектах.