Статьи

Синхронизированный считается вредным

Вспышка новостей: параллелизм сложен . Каждый раз, когда у вас есть изменяемые данные и несколько потоков, вы просто просите злоупотребления, а синхронизированный просто не собирается их сокращать.

Со мной недавно связался клиент, который тестировал приложение Tapestry 5.3.3 под нагрузкой ; они использовали Tomcat 6.0.32 с 500 рабочими потоками на довольно мощной машине: Intel Xeon X7460 с частотой 2,66 ГГц, 64-разрядная серверная виртуальная машина OpenJDK (14,0-b16, смешанный режим). Это машина с шестью ядрами и 16 МБ кэш-памяти второго уровня.

При всей этой мощности они разыгрывали 450 запросов в секунду. Это не очень хорошо, когда у вас 500 рабочих потоков … это означает, что вы приобрели память и вычислительную мощность просто для того, чтобы увидеть, как блокируются все эти рабочие потоки, и вы видите, что загрузка вашего ЦП остается низкой. Когда синхронизация выполнена правильно, увеличение нагрузки на сервер должно довести загрузку ЦП до 100%, а время отклика должно быть близко к линейному с нагрузкой (то есть все потоки должны равномерно распределять доступные ресурсы обработки) до жесткий предел достигнут.

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

Цель с Гобеленом всегда была построить код прямо на начальном этапе, а также оптимизировать код позже , если это необходимо. За последние пару лет я прошел через несколько циклов, оптимизируя время создания страницы, использование памяти или производительность (как здесь). В общем, я следую совету Брайана Гетца : пишите просто, чисто, кодируйте, и пусть компилятор и Hotspot выяснят все остальное.

Еще один совет Брайана заключается в том, что «неоспоримые синхронизированные звонки очень дешевы». Многие из горячих точек, расположенных моим клиентом, на самом деле были простыми синхронизированными методами, которые выполняли некоторую ленивую инициализацию. Вот пример:

public class InternalComponentResourcesImpl ...

    private Messages messages;

    public synchronized Messages getMessages()
    {
        if (messages == null)
            messages = elementResources.getMessages(componentModel);

        return messages;
    }
}

В этом примере получение сообщений может быть относительно длительным и дорогостоящим, а зачастую и вовсе не обязательным. То есть в большинстве экземпляров класса метод getMessages () никогда не вызывается. Было множество похожих примеров необязательных вещей, которые часто не нужны … но могут быть интенсивно использованы в тех случаях, когда они используются.

Оказывается, что «неоспоримый» на самом деле означает практически отсутствие конфликтов между потоками. Я поговорил об этом с Брайаном в Hacker Bed & Breakfast, и он объяснил, что вы можете быстро перейти от «чрезвычайно дешевого» к «асимптотически дорогому», когда есть вероятность спора. Ключевое слово synchronized очень ограничено в одной области: при выходе из синхронизированного блока все потоки, ожидающие эту блокировку, должны быть разблокированы, но только один из этих потоков получает блокировку; все остальные видят, что блокировка снята, и возвращаются в заблокированное состояние. Это не просто потраченные впустую циклы обработки: часто переключение контекста для разблокировки потока также включает в себя разбиение памяти на диск, и это очень и очень дорого.

Введите ReentrantReadWriteLock : это альтернатива, которая позволяет любому количеству читателей делить блокировку, но только одному писателю. Когда поток пытается получить блокировку записи, поток блокируется, пока все потоки считывателя не снимают блокировку чтения. Стоимость управления состоянием ReentrantReadWriteLock несколько выше, чем синхронизированная, но имеет огромное преимущество, позволяя нескольким потокам чтения работать одновременно. Это означает гораздо большую пропускную способность.

На практике это означает, что вы должны получить общую блокировку чтения для просмотра поля и получить блокировку записи для изменения поля.

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

Использовать синхронизированный легко; с явным ReentrantReadWriteLock есть намного больше кода для управления:

public class InternalComponentResourcesImpl ...

    private final ReadWriteLock lazyCreationLock = new ReentrantReadWriteLock();

    private Messages messages;

    public Messages getMessages()
    {
        try
        {
            lazyCreationLock.readLock().lock();

            if (messages == null)
            {
                obtainComponentMessages();
            }

            return messages;
        } finally
        {
            lazyCreationLock.readLock().unlock();
        }
    }

    private void obtainComponentMessages()
    {
        try
        {
            lazyCreationLock.readLock().unlock();
            lazyCreationLock.writeLock().lock();

            if (messages == null)
            {
              messages = elementResources.getMessages(componentModel);
            }
        } finally
        {
            lazyCreationLock.readLock().lock();
            lazyCreationLock.writeLock().unlock();
        }
    }
}

Мне нравится избегать вложенных блоков try … finally, поэтому я разбил их на отдельные методы.

Обратите внимание на «танец блокировки»: невозможно получить блокировку записи, если какой-либо поток, даже текущий поток, имеет блокировку чтения. Это открывает маленькое окно, в котором может появиться другой поток, захватить блокировку записи и инициализировать поле сообщений. Вот почему желательно дважды проверить, как только блокировка записи была получена, что работа еще не выполнена.

Также обратите внимание, что вещи не совсем симметричны: с ReentrantReadWriteLock текущему потоку разрешается получить блокировку чтения перед снятием блокировки записи. Это помогает минимизировать переключение контекста при снятии блокировки записи, хотя в этом нет особой необходимости.

Стоит ли усилий по конверсии? Что ж, пока, просто преобразовав синхронизированный в ReentrantReadWriteLock и добавив пару дополнительных кэшей (также используя ReentrantReadWriteLock), мы увидели некоторые существенные улучшения; от 450 до 2000 запросов в секунду … и еще есть несколько горячих точек для решения. Я думаю, что это стоило нескольких часов работы!