Содержание

вторник, 28 июня 2011 г.

Почему данный код работает в многопоточной среде?

Часто читая незнакомый код, я удивляюсь, как он может всегда корректно работать в многопоточной среде. Иногда, в нем, действительно, есть проблема, которая приводит к очень редким вероятностным багам, но в большинстве случаев, данный баг бы уже давно выловили и пофиксили, поэтому причина корректной работы кода, который с первого взгляда непотокобезопасен, может крыться в различных малозаметных деталях. О таких деталях я и хочу поговорить в данном топике. В частности, я рассмотрю наиболее частый случай, как мне кажется, где это проявляется, я именно Swing/AWT приложения, в которых программисты создают свои потоки, дополнительно к Event Dispatcher Thread (EDT).


Гарантия happens-before
Основная деталь, которая наиболее вероятно может ускользнуть из вида, это гарантия happens-before, которая дает намного больше, чем это интуитивно просится. Надо четко осознавать, что данное отношение распространяется не только на volatile поля, блоки synchronized и код защищенный одним и тем же локом, но и на все, что случилось до записи в volatile переменную и после её чтения, до выхода из synchronized блоки и после входа в него, до отпускания лока и после его захвата.
Так в примере ниже, хотя у нас только одна переменная volatile, второй поток всегда увидит значение переменной value, после того как первый поток записал значение в volatile переменную ready, а второй поток его прочитал.
private Object value;
private volatile boolean ready = false;
...
//Thread 1
value = calcResult();
ready = true; 
...
//Thread 2
if (ready){
    processResult(value);
}
Более подробно о Java Memory Model я писал в одном из своих предыдущих топиков.
Swing/AWT приложения
Когда мы создаем свои потоки в Swing приложении, а потом передаем из них данные на отрисовку в ETD, используя AWT Event Queue (метод SwingUtils.invokeLater), то мы обычно не заботимся о том, чтобы передать их потокобезопасно. Почему же это тогда работает? Давайте рассмотрим два примера, в которых это работает корректно, хотя с виду и не очень очевидно.
final String avg = getAvgFromServer();//Worker Thread
SwingUtilities.invokeLater(new Runnable() {//Worker Thread
    public void run() {
        label.setText(avg);//EDT
    }
});
В данном примере важно понимать, как работаю локальные final переменные, которые передаются в анонимные классы. Если вы скомпилируете данный код, то в байткоде будет видно, что в данном анонимном классе появилось final поле, которое инициализируется во время создания экземпляра этого класса. Таким образом, согласно JMM значение поля avg гарантированно видно из любого потока.

Второй пример:
private String avg;
void method(){   
    avg = getAvgFromServer();//Worker Thread
    SwingUtilities.invokeLater(new Runnable() {//Worker Thread
        public void run() {
            label.setText(avg);//EDT
        }
    });
}
Здесь уже никаких final полей нет. Почему же это всегда работает. Ответ кроется в способе передачи данного анонимного класса на выполнение в EDT. Если посмотреть реализацию класса EventQueue, то все сразу же становится ясно и понятно: при вызове метода SwingUtils.invokeLater, внутри EventQueue мы заходим в synchronized блок, чтобы положить в очередь событие для выполнения кода нашего анонимного класса. В свою очередь EDT тоже всегда заходит в synchronozed блок, защищенный этим же монитором, чтобы выполнить данное событие. Таким образом мы имеем отношение happens-before, между EDT потоком и нашим кодом, выставляющим значение переменной avg, которое её видимость EDT потоку и отсутсвие reordering.
BlockingQueue и Executors
Еще один неявный способ, сделать объекты видимыми при передачи их из одного потока в другой - это использовать BlockingQueue. Если взглянуть на реализацию ArrayBlockingQueue, то как методы добавления объекта в очередь (offer, put), так и методы извлечения (take, poll) используют один и тот же Lock для синхронизации внутри этих методов, так что принцип happens-before при работе с этими методами соблюден, и о видимости и reordering можно не беспокоится.

Немного сложнее обстоит дело с реализацией блокирующей очереди LinkedBlockingQueue, так как здесь методы добавления и извлечения из очереди используют разные локи. Но если присмотреться повнимательнее, то станет видно, что они используют один и тот же экземпляр класса AtomicInteger (поле count), содержащий внутри volatile поле value, которое пишется и читается обоими методами, так что happens-before отношение опять соблюдено.

Передача объектов на обработку в Executors тоже является безопасной, так как для их передача из основного потока в потоки Executor'a осуществляется посредством вышеописанных блокирующих очередей.
Spring
Не могу не упомянуть в данном топике и об IoC фреймоворках. Когда я работал с Spring, то поначалу немного беспокоился о потокобезопасности его работы, ведь он инициализирует большинство объектов вашего приложения и проставляет между ними dependency, причем все это происходит без каких либо синхронизаций. Как же все великое множество потоков вашего приложения всегда видит всю структуру инициализированную Spring? Данной гарантии легко добиться следуя двум правилам: не использовать lazy dependency, стартовать потоки только в методах init (не в конструкторах) или же после инициализации всего контекста. Тогда с вами всегда будет happens-beforе, возникающий при вызове метода start() экземпляра класса Thread.

Итак, как мы видим из нескольких приведенных примерах, что гарантия видимости между потоками может немного маскироваться. Иногда это делается, нарочно, из соображений производительности (в этом случае я призываю всех писать внятный комментарий по этому поводу), иногда так получается случайно. Основная проблема, что тестами такую вещь как видимость и reordering не проверишь, поэтому надо четко понимать работу отношения happens-before и уметь читать чужой код. Вообще я считаю, что такой техникой увлекаться не стоит, так как она не сильно очевидна, и там легко смогут посадить ошибку программисты мало знакомые с деталями этого кода, так что всегда старайтесь гарантировать видимость явно, за исключением мест, где этой дейтсвительно необходимо. А необходимость должна быть показана нагрузочными тестами на реальной системе.