Отрывок. Потокобезопасность
Возможно, вас удивит, что конкурентное программирование связано с потоками или замками (1) не более, чем гражданское строительство связано с заклепками и двутавровыми балками. Разумеется, строительство мостов требует правильного использования большого количества заклепок и двутавровых балок, и то же самое касается построения конкурентных программ, которое требует правильного использования потоков и замк?ов. Но это всего лишь механизмы — средства достижения цели. Написание потокобезопасного кода — это, по сути, управление доступом к состоянию и, в частности, к совместному (shared) мутируемому состоянию (mutable state).
В целом состояние объекта — это его данные, хранящиеся в переменных состояния (state variables), таких как экземплярные и статические поля или поля из других зависимых объектов. Состояние хеш-массива HashMap частично хранится в самом объекте HashMap, но также и во многих объектах Map.Entry. Состояние объекта включает любые данные, которые могут повлиять на его поведение.
(1) В тексте встречаются термины lock и block, которые часто переводятся одним словом «блокировка», что может подразумевать и объект, и процесс. В английском языке для процесса блокирования как приостановки продвижения есть термин blocking. Под термином lock имеется в виду «замок», «замковый защитный механизм». Во избежание путаницы термин lock переводится, как замок, кроме устоявшихся выражений, где принят перевод «блокировка». Замок — это механизм контроля доступа к данным с целью их защиты. В программировании замки часто используются для того, чтобы несколько программ или программных потоков могли использовать ресурс совместно, например, обращаться к файлу для его обновления на поочередной основе. — Примеч. науч. ред.
К совместной переменной могут обратиться несколько потоков, мутируемая — меняет свое значение. На самом деле мы пытаемся защитить от неконтролируемого конкурентного доступа не код, а данные.
Создание потокобезопасного объекта требует синхронизации для координации доступа к мутируемому состоянию, невыполнение которой может привести к повреждению данных и другим нежелательным последствиям.
Всякий раз, когда более чем один поток обращается к переменной состояния и один из потоков, возможно, в нее пишет, все потоки должны координировать свой доступ к ней с помощью синхронизации. Синхронизацию в Java обеспечивают ключевое слово synchronized, дающее эксклюзивную блокировку, а также волатильные (volatile) и атомарные переменные и явные замки.
Удержитесь от соблазна думать, что существуют ситуации, не требующие синхронизации. Программа может работать и проходить свои тесты, но оставаться неисправной и завершиться аварийно в любой момент.
Если многочисленные потоки обращаются к одной и той же переменной, имеющей мутируемое состояние, без соответствующей синхронизации, то ваша программа неисправна. Существует три способа ее исправить:
- не использовать переменную состояния совместно во всех потоках;
- сделать переменную состояния немутируемой;
- при каждом доступе к переменной состояния использовать синхронизацию.
Исправления могут потребовать значительных проектных изменений, поэтому гораздо проще проектировать класс потокобезопасным сразу, чем модернизировать его позже.
Будут или нет многочисленные потоки обращаться к той или иной переменной, узнать сложно. К счастью, объектно?ориентированные технические решения, которые помогают создавать хорошо организованные и удобные в сопровождении классы — такие как инкапсуляция и сокрытие данных, — также помогают создавать потокобезопасные классы. Чем меньше потоков имеет доступ к определенной переменной, тем проще обеспечить синхронизацию и задать условия, при которых к данной переменной можно обращаться. Язык Java не заставляет вас инкапсулировать состояние — вполне допустимо хранить состояние в публичных полях (даже публичных статических полях) или публиковать ссылку на объект, который в иных случаях является внутренним, — но чем лучше инкапсулировано состояние вашей программы, тем проще сделать вашу программу потокобезопасной и помочь сопроводителям поддерживать ее в таком виде.
При проектировании потокобезопасных классов хорошие объектно?ориентированные технические решения: инкапсуляция, немутируемость и четкая спецификация инвариантов — будут вашими помощниками.
Если хорошие объектно?ориентированные проектные технические решения расходятся с потребностями разработчика, стоит поступиться правилами хорошего проектирования ради производительности либо обратной совместимости с устаревшим кодом. Иногда абстракция и инкапсуляция расходятся с производительностью — хотя и не так часто, как считают многие разработчики, — но образцовая практика состоит в том, чтобы сначала делать код правильным, а затем — быстрым. Старайтесь задействовать оптимизацию только в том случае, если измерения производительности и потребности говорят о том, что вы обязаны это сделать (2).
(2) В конкурентном коде следует придерживаться этой практики даже больше, чем обычно. Поскольку ошибки конкурентности чрезвычайно трудно воспроизводимы и не просты в отладке, преимущество небольшого прироста производительности на некоторых редко используемых ветвях кода может вполне оказаться ничтожным по сравнению с риском, что программа завершится аварийно в условиях эксплуатации.
Если вы решите, что вам необходимо нарушить инкапсуляцию, то не все потеряно. Вашу программу по?прежнему можно сделать потокобезопасной, но процесс будет сложнее и дороже, а результат — ненадежнее. Глава 4 характеризует условия, при которых можно безопасно смягчать инкапсуляцию переменных состояния.
До сих пор мы использовали термины «потокобезопасный класс» и «потокобезопасная программа» почти взаимозаменяемо. Строится ли потокобезопасная программа полностью из потокобезопасных классов? Не обязательно: программа, которая состоит полностью из потокобезопасных классов, может не быть потокобезопасной, и потокобезопасная программа может содержать классы, которые не являются потокобезопасными. Вопросы, связанные с компоновкой потокобезопасных классов, также рассматриваются в главе 4. В любом случае понятие потокобезопасного класса имеет смысл только в том случае, если класс инкапсулирует собственное состояние. Термин «потокобезопасность» может применяться к коду, но он говорит о состоянии и может применяться только к тому массиву кода, который инкапсулирует его состояние (это может быть объект или вся программа целиком).
2.1. Что такое потокобезопасность?
Дать определение потокобезопасности непросто. Быстрый поиск в Google выдает многочисленные варианты, подобные этим:
… может вызываться из многочисленных потоков программы без нежелательных взаимодействий между потоками.
… может вызываться двумя или более потоками одновременно, не требуя никаких других действий с вызывающей стороны.
Учитывая подобные определения, неудивительно, что мы находим потокобезопасность запутанной! Как отличить потокобезопасный класс от небезопасного? Что мы вообще подразумеваем под словом «безопасный»?
В основе любого разумного определения потокобезопасности лежит понятие правильности (correctness).
Правильность подразумевает соответствие класса своей спецификации. Спецификация определяет инварианты (invariants), ограничивающие состояние объекта, и постусловия (postconditions), описывающие эффекты от операций. Как узнать, что спецификации для классов являются правильными? Никак, но это не мешает нам их использовать после того, как мы убедили себя, что код работает. Поэтому давайте допустим, что однопоточная правильность — это нечто видимое. Теперь можно предположить, что потокобезопасный класс ведет себя правильно во время доступа из многочисленных потоков.
Класс является потокобезопасным, если он ведет себя правильно во время доступа из многочисленных потоков, независимо от того, как выполнение этих потоков планируется или перемежается рабочей средой, и без дополнительной синхронизации или другой координации со стороны вызывающего кода.
Многопоточная программа не может быть потокобезопасной, если она не является правильной даже в однопоточной среде (3). Если объект реализован правильно, то никакая последовательность операций — обращения к публичным методам и чтение или запись в публичные поля — не должна нарушать его инварианты или постусловия. Ни один набор операций, выполняемых последовательно либо конкурентно на экземплярах потокобезопасного класса, не может побудить экземпляр находиться в недопустимом состоянии.
(3) Если нестрогое использование термина правильность здесь вас беспокоит, то вы можете думать о потокобезопасном классе как о классе, который неисправен в конкурентной среде, как и в однопоточной среде.
Потокобезопасные классы инкапсулируют любую необходимую синхронизацию сами и не нуждаются в помощи клиента.
2.1.1. Пример: сервлет без поддержки внутреннего состояния
В главе 1 мы перечислили структуры, которые создают потоки и вызывают из них компоненты, за потокобезопасность которых ответственны вы. Теперь мы намерены разработать сервлетную службу разложения на множители и постепенно расширить ее функционал, сохраняя потокобезопасность.
В листинге 2.1 показан простой сервлет, который распаковывает число из запроса, раскладывает его на множители и упаковывает результаты в отклик.
Листинг 2.1. Сервлет без поддержки внутреннего состояния
@ThreadSafe
public class StatelessFactorizer implements Servlet {
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
encodeIntoResponse(resp, factors);
}
}
Класс StatelessFactorizer, как и большинство сервлетов, не имеет внутреннего состояния: не содержит полей и не ссылается на поля из других классов. Состояние для конкретного вычисления существует только в локальных переменных, которые хранятся в потоковом стеке и доступны только для выполняющего потока. Один поток, обращающийся к StatelessFactorizer, не может повлиять на результат другого потока, делающего то же самое, поскольку эти потоки не используют состояние совместно.
Объекты без поддержки внутреннего состояния всегда являются потокобезопасными.
Тот факт, что большинство сервлетов могут быть реализованы без поддержки внутреннего состояния, значительно снижает бремя по обеспечению потокобезопасности самих сервлетов. И только когда сервлеты должны что-то запомнить, требования к их потокобезопасности возрастают.
2.2. Атомарность
Что происходит при добавлении элемента состояния в объект без поддержки внутреннего состояния? Предположим, мы хотим добавить счетчик посещений, который измеряет число обработанных запросов. Можно добавить в сервлет поле с типом long и приращивать его при каждом запросе, как показано в UnsafeCountingFactorizer в листинге 2.2.
Листинг 2.2. Сервлет, подсчитывающий запросы без необходимой синхронизации. Так делать не следует
@NotThreadSafe
public class UnsafeCountingFactorizer implements Servlet {
private long count = 0;
public long getCount() { return count; }
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
++count;
encodeIntoResponse(resp, factors);
}
}
К сожалению, класс UnsafeCountingFactorizer не является потокобезопасным, даже если отлично работает в однопоточной среде. Так же, как UnsafeSequence, он предрасположен к потерянным обновлениям (lost updates). Хотя операция приращения ++count имеет компактный синтаксис, она не является атомарной (atomic), то есть неделимой, а представляет собой последовательность из трех операций: доставки текущего значения, прибавления к нему единицы и записи нового значения обратно. В операциях «прочитать, изменить, записать» результирующее состояние является производным от предыдущего.
На рис. 1.1 показано, что может произойти, если два потока попытаются увеличить счетчик одновременно, без синхронизации. Если счетчик равен 9, то из?за неудачной временной координации оба потока увидят значение 9, добавят в него единицу, и установят значение 10. Так счетчик посещений начнет отставать на единицу.
Вы можете подумать, что наличие немного неточного счетчика посещений в веб?службе является приемлемой потерей, и иногда это так. Но если счетчик используется для создания последовательностей или уникальных идентификаторов объектов, то возвращение одного и того же значения из многочисленных активаций может привести к серьезным проблемам целостности данных. Возможность появления неправильных результатов из-за неудачной временн?ой координации возникает при состоянии гонки.
2.2.1. Состояния гонки
Класс UnsafeCountingFactorizer имеет несколько состояний гонки (4). Наиболее распространенным типом состояния гонки является ситуация «проверить и затем действовать», где потенциально устаревшее наблюдение используется для принятия решения о том, что делать дальше.
(4) Термин состояние гонки часто путают с родственным термином гонка данных (data race). Гонка данных возникает, когда синхронизация не используется для координации всего доступа к общему нефинальному полю. Вы рискуете попасть в гонку данных всякий раз, когда поток пишет переменную, которая затем может быть прочитана другим потоком, либо считывает переменную, которая в последний раз могла быть записана другим потоком, если оба потока не используют синхронизацию. Код с гонками данных не имеет полезной формально определенной семантики в рамках модели памяти Java. Не все состояния гонки являются гонками данных, и не все гонки данных являются состояниями гонки, но оба типа ситуаций могут вызывать аварийный сбой конкурентных программ самым непредсказуемым образом. UnsafeCountingFactorizer содержит оба типа. Подробнее гонки данных описаны в главе 16.
Мы часто сталкиваемся с состоянием гонки в реальной жизни. Допустим, вы планируете встретиться с другом в полдень в кафе «Старбакс» на Университетском проспекте. Но вы узнаете, что на Университетском проспекте находятся два «Старбакса». В 12:10 вы не видите своего друга в кафе A и идете в кафе B, но там его тоже нет. Либо ваш друг опаздывает, либо он прибыл в кафе A сразу после того, как вы ушли, либо он был в кафе B, но пошел вас искать и теперь находится на пути к кафе A. Примем последний, то есть самый худший вариант. Сейчас 12:15, и вы оба задаетесь вопросом, а сдержал ли друг обещание. Вы вернетесь в другое кафе? Сколько раз вы будете ходить туда и обратно? Если вы не согласовали протокол, то можете провести весь день, гуляя по Университетскому проспекту в кофеиновой эйфории.
Проблема подхода «прогуляться и посмотреть, не находится ли он там» заключается в том, что прогулка по улице между двумя кафе занимает несколько минут, и за это время состояние системы может измениться.
Пример со «Старбаксом» иллюстрирует зависимость результата от относительной временной координации событий (от того, как долго вы ждете друга, находясь в кафе, и т. д.). Наблюдение, что он не находится в кафе A, становится потенциально утратившим силу: как только вы выходите из парадной двери, он может войти через заднюю дверь. Большинство состояний гонки вызывают такие проблемы, как неожиданное исключение, перезаписанные данные и повреждение файла.
2.2.2. Пример: состояния гонки в ленивой инициализации
Распространенным приемом, использующим подход «проверить и затем действовать», является ленивая инициализация (LazyInitRace). Ее цель — отложить инициализацию объекта до тех пор, пока он не понадобится, и обеспечить, чтобы он инициализировался только один раз. В листинге 2.3 метод getInstance убеждается в выполнении инициализации ExpensiveObject и возвращает существующий экземпляр, или, в противном случае, создает новый экземпляр и возвращает его после сохранения ссылки на него.
Листинг 2.3. Состояние гонки в ленивой инициализации. Так делать не следует
@NotThreadSafe
public class LazyInitRace {
private ExpensiveObject instance = null;
public ExpensiveObject getInstance() {
if (instance == null)
instance = new ExpensiveObject();
return instance;
}
}
Класс LazyInitRace содержит состояния гонки. Предположим, что потоки A и B выполняют метод getInstance в одно и то же время. A видит, что поле instance равно null, и создает новый ExpensiveObject. Поток B также проверяет, равно ли поле instance тому же значению null. Наличие в поле значения null в этот момент зависит от временн?ой координации, включая капризы планирования и количество времени, нужного для создания экземпляра объекта ExpensiveObject и установки значения в поле instance. Если поле instance равно null, когда B его проверяет, два элемента кода, вызывающих метод getInstance, могут получить два разных результата, даже если метод getInstance предположительно должен всегда возвращать один и тот же экземпляр.
Счетчик посещений в UnsafeCountingFactorizer тоже содержит состояния гонки. Подход «прочитать, изменить, записать» подразумевает, что для приращения счетчика поток должен знать его предыдущее значение и убедиться, что в процессе обновления никто другой не изменяет и не использует это значение.
Как и большинство ошибок конкурентности, состояния гонки не всегда приводят к сбою: временная координация бывает удачной. Но если класс LazyInitRace используется для инстанциации реестра всего приложения, то, когда из многочисленных активаций он будет возвращать разные экземпляры, регистрации будут утеряны либо действия получат противоречивые представления набора зарегистрированных объектов. Или если класс UnsafeSequence используется для генерирования идентификаторов сущностей в структуре консервации данных, то два разных объекта могут иметь один и тот же идентификатор, нарушая ограничения идентичности.
2.2.3. Составные действия
И LazyInitRace, и UnsafeCountingFactorizer содержат последовательность операций, которые должны быть атомарными. Но для предотвращения состояния гонки должно существовать препятствие тому, чтобы другие потоки использовали переменную, пока один поток ее изменяет.
Операции A и B являются атомарными, если, с точки зрения потока, выполняющего операцию A, операция B либо была целиком выполнена другим потоком, либо не выполнена даже частично.
Атомарность операции приращения в UnsafeSequence позволила бы избежать состояния гонки, показанного на рис. 1.1. Операции «проверить и затем действовать» и «прочитать, изменить, записать» всегда должны быть атомарными. Они называются составными действиями (сompound actions) — последовательностями операций, которые должны выполняться атомарно, для того чтобы оставаться потокобезопасными. В следующем разделе мы рассмотрим блокировку — встроенный в Java механизм, который обеспечивает атомарность. А пока мы исправим проблему другим способом, применив существующий потокобезопасный класс, как показано в Countingfactorizer в листинге 2.4.
Листинг 2.4. Сервлет, подсчитывающий запросы с помощью AtomicLong
@ThreadSafe
public class CountingFactorizer implements Servlet {
private final AtomicLong count = new AtomicLong(0);
public long getCount() { return count.get(); }
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
count.incrementAndGet();
encodeIntoResponse(resp, factors);
}
}
Пакет java.util.concurrent.atomic содержит атомарные переменные (atomic variable) для управления состояниями классов. Заменив тип счетчика с long на AtomicLong, мы гарантируем, что все действия, которые обращаются к состоянию счетчика, являются атомарными1. Поскольку состояние сервлета является состоянием счетчика, а счетчик является потокобезопасным, наш сервлет становится потокобезопасным.
При добавлении единственного элемента состояния в класс, который не поддерживает внутреннее состояние, результирующий класс будет потокобезопасным, если состояние полностью управляется потокобезопасным объектом. Но, как мы увидим в следующем разделе, переход от одной переменной состояния к следующим будет не так прост, как переход от нуля к единице.
Там, где это удобно, используйте существующие потокобезопасные объекты, такие как AtomicLong, для управления состоянием вашего класса. Возможные состояния существующих потокобезопасных объектов и их переходы в другие состояния легче поддерживать и проверять на потокобезопасность, нежели произвольные переменные состояния.
» Более подробно с книгой можно ознакомиться на сайте издательства
» Оглавление
» Отрывок
Для Хаброжителей скидка 25% по купону — Java
По факту оплаты бумажной версии книги на e-mail высылается электронная книга.
pullover
Книге скоро 14 лет стукнет, долго «Питер» раскачивался.