Главная » Java, Советы » Синхронизируйте доступ потоков к совместно используемым изменяемым данным

0

 

Использование ключевого слова synchronized дает гарантию, что в данный момент времени некий оператор или блок будет выполняться только в одном потоке. Многие программисты рассматривают синхронизацию лишь как средство блокировки потоков, которое не позволяет одному потоку наблюдать объект в промежуточном состоянии, пока тот модифицируется другим потоком. С этой точки зрения, объект создается с согласованным состоянием (статья 13), а затем блокируется методами, имеющими к нему доступ. Эти методы следят за состоянием объекта и (дополнительно) могут вызывать для него переход состояния (state transition), переводя объект из одного согласованного состояния в другое. Правильное выполнение синхронизации гарантирует, что ни один метод никогда не сможет наблюдать этот объект в промежуточном состоянии.

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

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

Возможно, вы слышали, что для повышения производительности при чтении и записи атомарных данных нужно избегать синхронизации. Это неправильный совет с опасными последствиями. Хотя свойство атомарности гарантирует, что при чтении атомарных данных поток не увидит случайного значения, нет гарантии, что значение, записанное одним потоком, будет увидено другим: синхронизация необходима как для блокирования потоков, так и для надежного взаимодействия между ними. Это является следствием сугубо технического аспекта языка программирования Java, который называется моделью памяти (тетогу model) [JLS, 17]. Вероятно, в ближайшей версии модель памяти будет существенно пересмотрена [Pugh01a], однако описанная особенность скорее всего не поменяется.

Отсутствие синхронизации для доступа к совместно используемой переменной может иметь серьезные последствия, даже если переменная имеет свойство атомарности как при чтении, так и при записи. Рассмотрим следующую функцию генерации серийного номера:

 

// Ошибка: требуется синхронизация private static iпt пехtSегiаlNumЬег = о;

public static int generateSerialNumber() геtuгп nextSerialNumber++;

 

Эта функция должна гарантировать, что при каждом вызове метода generateSerialNumber будет возвращаться другой серийный номер до тех пор, пока не будет произведено вызова.       Для защиты инвариантов данного генератора серийных номеров синхронизация не нужна, поскольку таковых у него нет. Состояние генератора содержит лишь одно атомарно записываемое поле (пехtSегiаlNumЬег), для которого допустимы любые значения. Тем не менее без синхронизации этот метод не работает. Оператор приращения (++) осуществляет чтение и запись в поле пехtSегiаlNumЬег, а потому атомарным не является. Чтение и запись – независимые операции, которые

 

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

Еще более удивительный случай: один поток может несколько раз вызвать метод generateSerialNumber и получить последовательность серийных номеров от 0 до n. После этого другой поток может вызвать метод generateSerialNumber и получить серийный номер, равный нулю. Без синхронизации второй поток может не увидеть ни одного из изменений, произведенных первым потоком. Это следствие применения вышеупомянутой модели памяти.

Исправление метода generateSerialNumber сводится к простому добавлению в его декларацию слова sупсhгопizеd. Тем самым гарантируется, что различные вызовы не будут смешиваться, и каждый новый вызов будет видеть результат обработки всех предыдущих обращений. Чтобы сделать этот метод "железобетонным", возможно, имеет смысл заменить int на long или инициировать какое-либо исключение, если nextSerialNumber будет близко к переполнению.

Рассмотрим процедуру остановки потока. Платформа Java предлагает методы принудительной остановки потока, но они являются устаревшими и по своей сути небезопасны: работа с ними может привести к разрушению объектов. Для остановки потока рекомендуется использовать прием, заключающийся в том, что в классе потока создается некое опрашиваемое поле, которому можно присвоить новое значение, указывающее на то, что этот поток должен остановить себя сам. Обычно такое поле имеет тип Ьооlеаn или является ссылкой на объект. Поскольку чтение и запись этого поля атомарный, у некоторых программистов появляется соблазн предоставить к нему доступ без синхронизации. Нередко можно увидеть программный код такого рода:

 

// Ошибка: требуется синхронизация

public class StoppableThread extends Thread {

private bооlеаn stopRequested = false;

public void run() {

boolean dоnе = false;

while (!stopRequested &&  !dоnе) {

// Здесь выполняется необходимая обработка

}

}

public void requestStop() {

 stopRequested = true;  }

}

 

Проблема приведенного кода заключается в том, что в отсутствие синхронизации нет гарантии (если ее вообще можно дать), что поток, подлежащий остановке, "увидит", что другой поток поменял значение stopRequested. В результате метод requestStop

 

 

 

 

может оказаться абсолютно неэффективным. И хотя вы вряд ли действительно столкнетесь со странным поведением программы, пока не запустите ее в многопроцессорной, системе, гарантировать ее правильную работу нельзя. Разрешить эту проблему можно, непосредственно синхронизировав любой доступ к полю stopRequested:

 

// Правильно синхронизированное совместное завершение потока

 public class StoppableThread extends Thread {

private boolean stopRequested = false;

public void гип() {

boolean done = false;

while (!stopRequested() && !done) {

// Здесь выполняется необходимая обработка }

}

public synchronized void requestStop() {

stopRequested = true;  }

private synchronized boolean stopRequested() {

return stopRequested; }

}

Заметим, что выполнение каждого из синхронизированных методов является атомарным: синхронизация используется исключительно для обеспечения взаимодействия потоков, а не для блокировки. Очевидно, что исправленный программный код работает, а расходы на синхронизацию при каждом прохождении цикла вряд ли можно заметить. Однако есть корректная альтернатива, которая не столь многословна, и ее ПРОИЗВ6Дительность чуть выше. Синхронизацию можно опустить, если объявить stopRequested с модификатором volatile (асинхронно-изменяемый). Этот модификатор гарантирует, что любой поток, который будет читать это поле, увидит самое последнее записанное значение.

Наказание за отсутствие в предыдущем примере синхронизации доступа к полю stopRequested оказывается сравнительно небольшим: результат вызова метода requestStop может проявиться через неопределенно долгое время. Наказание за отсутствие синхронизации доступа к изменяемым, совместно используемым данным может быть более суровым. Рассмотрим идиому двойной проверки (double-check) отложенной инициализации:

// Двойная проверка отложенной инициализации – неправильная!

private static Foo foo = null;

 

public static Foo getFoo() {

if (foo == null) {

synchronized (Foo.class)

if (foo == null)

foo = new Foo(); }

}

return foo; }

Идея, на которой построена эта идиома, заключается в том, чтобы избежать затрат на синхронизацию доступа к уже инициализированному полю foo. Синхронизация используется здесь только для того, чтобы не позволить сразу нескольким потокам инициализировать данное поле. Идиома дает гарантию, что поле будет инициализировано не более одного раза и что все потоки, вызывающие метод getFoo, будут получать правильную ссылку на объект. К сожалению, это не гарантирует, что ссылка на объект будет работать правильно. Если поток прочел ссылку на объект без синхронизации, а затем вызывает в этом объекте какой-либо метод, может оказаться, что метод обнаружит свой объект в частично инициализированном состоянии, а это приведет к катастрофическому сбою программы.

То, что поток может видеть объект с отложенным созданием в частично инициализированном состоянии, кажется диким. Объект был полностью собран прежде, чем его ссылка была "опубликована" в поле (foo), откуда ее получат остальные потоки. Однако в отсутствие синхронизации чтение "опубликованной" ссылки на объект еще не дает гарантии, что соответствующий поток увидит все те данные, которые были записаны в память перед публикацией ссылки на объект. В частности, нет гарантии того, что поток, читающий опубликованную ссылку на объект, увидит самые последние значения данных, составляющих внутреннюю структуру этого объекта. Вообще говоря, идиома двойной проверки не работоспособна, хотя она и может действовать, если переменная, совместно используемая разными потоками, содержит простое значение, а не ссылку на объект [Pugh01b].

Решить эту проблему можно несколькими способами. Простейший из них – полностью отказаться от отложенной инициализации:

// Нормальная статическая инициализация (неотложенная)

 private static final Foo foo = new Foo();

public static Foo getFoo()  {

return foo; }

Этот вариант, безусловно, работает, и метод getFoo оказывается настолько быстр, насколько это возможно. Здесь нет ни синхронизации, ни каких-либо еще вычислений. Как говорилось В статье 37, вы должны писать простые, понятные, правильные

 

 

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

 

// Правильно синхронизированная отложенная инициализация

private static Foo foo = null;

public static synchronized Foo getFoo() {

if (foo == null)

foo = new Foo(); 

return foo; }

 

Этот метод работает, но при каждом вызове теряется время на синхронизацию. для современных реализаций jVM эти потери сравнительно невелики. Однако если, измеряя производительность вашей системы, вы обнаружили, что не можете себе позволить ни обычную инициализацию, ни синхронизацию каждого доступа, есть еще один вариант. Идиому класса, 8ыполняющеzо инициализацию по запросу (initializeon-demand holder class), лучше применять в том случае, когда инициализация статического поля, занимающая много ресурсов, может и не потребоваться, однако если уж поле понадобилось, оно используется очень интенсивно. Указанная идиома представлена ниже:

 

// Идиома класса, выполняющего, инициализацию по запросу

private static class FooHolder {

static final Foo foo = new Foo(); }

public static Foo getFoo() { return FooHolder. foo; }

 

Преимуществом этой идиомы является гарантия того, что класс не будет инициализироваться до той поры, пока он не потребуется [jLS, 12.4.1]. При первом вызове метод getFoo читает поле FooHolder. foo, заставляя класс FooHolder выполнить инициализацию. Красота идиомы заключается в том, что метод getFoo не синхронизирован и всего лишь предоставляет доступ к полю foo, так что отложенная инициализация практически не увеличивает издержек доступа. Единственным недостатком этой идиомы является то, что она не работает с экземплярами полей, а только со статическими полями класса.

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

 

182синхронизации невозможно гарантировать, что изменения в объекте, сделанные одним потоком, будут увидены другим. Несинхронизированный доступ к данным может привести к отказам, затрагивающим живучесть и безопасность системы. Воспроизвести такие отказы бывает крайне сложно. Они могут зависеть от времени и чрезвычайно чувствительны к деталям реализации jVM и к особенностям компьютера.

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

 

Источник: Джошуа Блох, Java TM Эффективное программирование, Издательство «Лори»

По теме:

  • Комментарии