Статьи

Переменные блокировки и условия — влияние задержки

В предыдущей статье на межпоточной Latency I было показано , как можно сигнализировать изменение состояния между 2 нитями с менее чем 50ns латентности. Для многих разработчиков написание параллельного кода с использованием блокировок — страшный опыт. Написание параллельного кода с использованием алгоритмов без блокировок, то есть алгоритмов, которые основаны на использовании барьеров памяти и глубокого понимания базовых моделей памяти, может быть совершенно ужасным. Для меня алгоритмы без блокировок / без блокировок — это как игра со взрывчаткой или едкими химическими веществами, если вы не понимаете, что делаете, или проявляете абсолютное уважение, тогда очень плохие вещи могут и, скорее всего, произойдут!

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

Код

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

import static java.lang.System.out;

public final class LockedSignallingLatency
{
    private static final int ITERATIONS = 10 * 1000 * 1000;

    private static final Lock lock = new ReentrantLock();
    private static final Condition sendCondition = lock.newCondition();
    private static final Condition echoCondition = lock.newCondition();

    private static long sendValue = -1L;
    private static long echoValue = -1L;

    public static void main(final String[] args)
        throws Exception
    {
        final Thread sendThread = new Thread(new SendRunner());
        final Thread echoThread = new Thread(new EchoRunner());

        final long start = System.nanoTime();

        echoThread.start();
        sendThread.start();

        sendThread.join();
        echoThread.join();

        final long duration = System.nanoTime() - start;

        out.printf("duration %,d (ns)\n", duration);
        out.printf("%,d ns/op\n", duration / (ITERATIONS * 2L));
        out.printf("%,d ops/s\n", (ITERATIONS * 2L * 1000000000L) / duration);
    }

    public static final class SendRunner
        implements Runnable
    {
        @Override
        public void run()
        {
            for (long i = 0; i < ITERATIONS; i++)
            {
                lock.lock();
                try
                {
                    sendValue = i;
                    sendCondition.signal();
                }
                finally
                {
                    lock.unlock();
                }

                lock.lock();
                try
                {
                    while (echoValue != i)
                    {
                        echoCondition.await();
                    }
                }
                catch (final InterruptedException ex)
                {
                    break;
                }
                finally
                {
                    lock.unlock();
                }

            }
        }
    }

    public static final class EchoRunner
        implements Runnable
    {
        @Override
        public void run()
        {
            for (long i = 0; i < ITERATIONS; i++)
            {
                lock.lock();
                try
                {
                    while (sendValue != i)
                    {
                        sendCondition.await();
                    }
                }
                catch (final InterruptedException ex)
                {
                    break;
                }
                finally
                {
                    lock.unlock();
                }

                lock.lock();
                try
                {
                    echoValue = i;
                    echoCondition.signal();
                }
                finally
                {
                    lock.unlock();
                }
            }
        }
    }
}

Результаты

64-разрядной версии Windows 7 Professional — Oracle JDK 1.6.0 — Nehalem 2,8 ГГц

$ start /AFFINITY 0x14 /B /WAIT java LockedSignallingLatency
duration 41,649,616,343 (ns)
2,082 ns/op
480,196 ops/s

$ java LockedSignallingLatency
duration 73,789,456,491 (ns)
3,689 ns/op
271,041 ops/s

Linux Fedora Core 15 64-разрядная версия — Oracle JDK 1.6.0 — Sandybridge 2,0 ГГц

$ taskset -c 2,4 java LockedSignallingLatency
duration 47,209,549,484 (ns)
2,360 ns/op
423,643 ops/s

$ java LockedSignallingLatency
duration 336,168,489,093 (ns)
16,808 ns/op
59,493 ops/s

Наблюдения

Выше приведен типичный набор результатов, которые я видел в середине диапазона от нескольких прогонов. Есть пара интересных наблюдений, на которых я хотел бы остановиться. 

Во-первых, односторонняя задержка для оповещения об изменении в значительной степени совпадает с тем, что считается современным уровнем техники для сетевых перескоков между узлами через коммутатор. Можно получить ~ 1 μ s задержка с InfiniBand и менее 5 μ s с 10GigE и IP стеки пользовательского пространства, Это на 3 порядка больше, чем то, что я проиллюстрировал в предыдущей статье, используя только барьеры памяти для передачи сигналов между потоками. Эта стоимость возникает из-за того, что ядро ​​должно участвовать в арбитраже между потоками для блокировки, а затем управлять планированием пробуждения потоков, когда сигнализируется условие.

Во-вторых, влияние становится очевидным, если позволить ОС выбирать, на какие ЦП запланированы потоки, а не закреплять их вручную. Я наблюдал эту же проблему во многих случаях использования, когда Linux в конфигурации по умолчанию для своего планировщика будет сильно влиять на производительность системы с низкой задержкой, планируя потоки на разных ядрах, что приводит к загрязнению кэша. По умолчанию Windows лучше справляется с этой задачей.

Недавно у меня была интересная дискуссия с Клифф Кликом об использовании условных переменных и их стоимости. Он указал на проблему, которую он видел. Если вы посмотрите на случай, когда спящий поток получает сигнал в пределах блокировки, он запускается и обнаруживает, что не может получить блокировку, потому что сигнальный поток уже имеет блокировку, поэтому он возвращается в спящий режим, пока сигнальный поток не освободит заблокировать, тем самым вызывая больше работы, чем необходимо. Современные планировщики выиграют, если будут лучше осведомлены о механизмах связи между потоками, чтобы иметь более эффективную логику определения местоположения и переупорядочения. По мере того, как мы будем работать параллельно и параллельно, наши планировщики должны лучше понимать механизмы IPC .

Вывод

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

От http://mechanical-sympathy.blogspot.com/2011/11/locks-condition-variables-latency.html