Архитектура Аудит Военная наука Иностранные языки Медицина Металлургия Метрология
Образование Политология Производство Психология Стандартизация Технологии


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



 

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

 

177

 

 

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

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

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

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

 

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

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

 

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

 

 

178

 

выполняются последовательно. Несколько параллельных потоков могут наблюдать в поле 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

 

 

 

 

179

 

может оказаться абсолютно неэффективным. И хотя вы вряд ли действительно столк­нетесь со странным поведением программы, пока не запустите ее в многопроцессорной, системе, гарантировать ее правильную работу нельзя. Разрешить эту проблему можно, непосредственно синхронизировав любой доступ к полю 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;

 

180

 

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, вы должны писать простые, понятные, правильные

 

 

181

 

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

 

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

private static Foo foo = null;

public static synchronized Foo getFoo() {

if (foo == null)

foo = new Foo(); 

return foo; }

 

Этот метод работает, но при каждом вызове теряется время на синхронизацию. для современных реализаций jVM эти потери сравнительно невелики. Однако если, измеряя производительность вашей системы, вы обнаружили, что не можете себе по­зволить ни обычную инициализацию, ни синхронизацию каждого доступа, есть еще один вариант. Идиому класса, 8ыполняющеzо инициализацию по запросу (initialize­on-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 представляет собой реальную альтернативу обычной синхронизации, однако это пока новаторский прием. Более того, границы его применимости станут известны лишь по завершении ведущихся ныне работ над моделью памяти.

 


Поделиться:



Последнее изменение этой страницы: 2019-04-11; Просмотров: 220; Нарушение авторского права страницы


lektsia.com 2007 - 2024 год. Все материалы представленные на сайте исключительно с целью ознакомления читателями и не преследуют коммерческих целей или нарушение авторских прав! (0.03 с.)
Главная | Случайная страница | Обратная связь