При участии Тима Перлса, Джошуа Блоха, Джозева Боубира, Дэвида Холмса и Дага Ли Параллельное программирование в java на
Скачать 4.97 Mb.
|
Глава 10 Предотвращение возникновения угроз живучести Между требованиями безопасности и живучести часто возникает противоречие. Мы используем блокировку для обеспечения потокобезопасности, но беспорядочное использование блокировок может привести к взаимоблокировкам, вызванным порядком блокировок (lock-ordering deadlocks). Точно так же мы используем пулы потоков и семафоры для ограничения потребления ресурсов, но непонимание механизмов ограничения активностей может привести к взаимоблокировкам ресурсов (resource deadlocks). Приложения Java не восстанавливаются после возникновения взаимоблокировок, поэтому стоит убедиться, что ваш проект исключает условия, которые могут привести к их появлению. В этой главе рассматриваются некоторые из причин возникновения сбоев живучести и подходы для их предотвращения. 10.1 Взаимоблокировки Взаимоблокировка иллюстрируется классической, хотя и немного антисанитарной, проблемой “обедающих философов”. Пять философов выходят за китайской едой и садятся за круглый стол. Есть пять палочек для еды (не пять пар), по одной между каждой парой обедающих. Философы чередуют процессы размышления и приёма пищи. Каждый из них должен захватить две палочки для еды достаточно надолго, но затем может положить палочки обратно и вернуться к размышлениям. Существует несколько алгоритмов управления доступностью палочек, результатом применения которых является то, что каждый философ ест более или менее своевременно (голодный философ пытается схватить обе соседние палочки, но если одна уже используется, кладёт обратно ту, которую взял, и ждет минуту или около того, прежде чем вновь попытаться взять палочки), это может привести к тому, что некоторые или все философы умрут от голода (каждый философ немедленно хватается за палочку слева от него и ожидает, пока станет доступна палочка справа, прежде чем вернуть палочку слева). В последующей ситуации, при которой каждый из них имеет ресурс, необходимый другому, и ожидает возможности захвата ресурса, удерживаемого другим, и не освободит тот, который у него есть, пока не приобретёт тот, которого у него нет, иллюстрирует принцип взаимоблокировки. Когда поток удерживает блокировку навсегда, другие потоки, пытающиеся получить ту же блокировку, также блокируются навсегда. Когда поток A удерживает блокировку L и пытается получить блокировку M, но в то же время поток B удерживает блокировку M и пытается получить блокировку L, оба потока будут ждать вечно. Приведённая ситуация является самым простым случаем взаимоблокировки (или смертельного объятия, deadly embrace), когда несколько потоков находятся в вечном ожидании из-за циклической зависимости блокировок. (Представим, что потоки - это узлы ориентированного графа, ребра которого представляют собой отношение “поток A ожидает ресурс, удерживаемый потоком B”. Если получившийся граф цикличен, значит, произошла взаимоблокировка.) Системы баз данных проектируются для обнаружения состояния взаимоблокировки и восстановления при выходе из него. Транзакция может затребовать наложения множества блокировок, и блокировки будут удерживаться до момента фиксации транзакции. Таким образом, вполне возможна ситуация, и на самом деле не является редкостью, при которой две транзакции попадают в состояние взаимоблокировки. Без внешнего вмешательства они будут вечно ожидать завершения друг друга (удерживая блокировки на ресурсах, которые, вероятно, требуются и другим транзакциям). Но сервер баз данных не позволит этому случиться. Когда сервер обнаруживает, что набор транзакций находится в состоянии взаимоблокировки (это осуществляется путем поиска циклов в графе ожидания (is-waiting-for)), он выбирает жертву и прерывает выбранную транзакцию. Это приводит к освобождению блокировок, удерживаемых жертвой, позволяя другим транзакциям продолжить своё выполнение. Позже, после завершения конкурирующих транзакций, приложение может повторить выполнение прерванной транзакции. В отличие от серверов баз данных, JVM не оказывает помощи в разрешении ситуаций возникновения взаимоблокировок. Когда набор Java потоков попадает в состояние взаимоблокировки, это конец игры - эти потоки мгновенно выходят из строя. В зависимости от того, что делают эти потоки, приложение может полностью зависнуть или может зависнуть определенная подсистема, или может пострадать производительность. Единственный способ восстановить работоспособность приложения, это прервать его выполнение и перезапустить - и надеяться, что то же самое впредь не повторится. Как и многие другие угрозы параллелизма, взаимоблокировки редко проявляются сразу. Тот факт, что класс имеет потенциальные взаимоблокировки, не означает, что он всегда будет попадать в состояние взаимоблокировки, а только то, что это может произойти. Когда взаимоблокировки все-таки проявляют себя, то зачастую это случается в самый неподходящий момент - под большой производственной нагрузкой. 10.1.1 Взаимоблокировки, вызванные порядком наложения блокировок Класс LeftRightDeadlock , представленный в листинге 10.1, подвержен риску возникновения взаимоблокировки. Каждый из методов leftRight и rightLeft пытается захватить блокировки на объектах left и right . Если один поток вызывает метод leftRight , а другой поток вызывает метод rightLeft , и их действия чередуются, как показано на рисунке 10.1, они попадут в состояние взаимоблокировки. // Warning: deadlock-prone! public class LeftRightDeadlock { private final Object left = new Object(); private final Object right = new Object(); public void leftRight() { synchronized (left) { synchronized (right) { doSomething(); } } } public void rightLeft() { synchronized (right) { synchronized (left) { doSomethingElse(); } } } } Листинг 10.1 Простая взаимоблокировка, вызванная порядком наложения блокировок. Не делайте так. Рисунок 10.1 Неудачный момент времени для класса LeftRightDeadlock Взаимоблокировка в классе LeftRightDeadlock произошла потому, что два потока пытались захватить блокировки в различном порядке (different order). Если бы они запрашивали блокировки в одном и том же порядке, не было бы никакой циклической зависимости между блокировками и, следовательно, никакой взаимоблокировки бы не произошло. Если вы сможете гарантировать, что каждый поток, которому одновременно нужны блокировки L и M, всегда захватывает L и M в одном и том же порядке, взаимоблокировки возникать не будут. Программа будет свободна от взаимоблокировок вызванных порядком наложения блокировок, если все потоки будут получать необходимые им блокировки в глобально зафиксированном порядке. Проверка согласованности порядка блокировок требует глобального анализа поведения блокировок в программе. Недостаточно проверить ветки кода, которые получают несколько блокировок по отдельности; и метод leftRight и метод rightLeft представляют собой “разумные” способы получения двух блокировок, они просто не совместимы. Когда дело доходит до блокировки, левая рука должна знать, что делает правая рука. 10.1.2 Взаимоблокировки, вызванные динамическим порядком блокировок Иногда не очевидно, что у вас достаточно контроля над порядком наложения блокировок, для предотвращения взаимоблокировки. Рассмотрим выглядящий безобидно код, представленный в листинге 10.2, который переводит средства с одного счета на другой. Он получает блокировки на обоих объектах Account перед выполнением перемещения, гарантируя, что балансы обновляются атомарно и без нарушения инвариантов, подобных утверждению “счет не может иметь отрицательный баланс”. // Warning: deadlock-prone! lock left wait forever lock right A B try to lock left try to lock right wait forever public void transferMoney(Account fromAccount, Account toAccount, DollarAmount amount) throws InsufficientFundsException { synchronized (fromAccount) { synchronized (toAccount) { if (fromAccount.getBalance().compareTo(amount) < 0) throw new InsufficientFundsException(); else { fromAccount.debit(amount); toAccount.credit(amount); } } } } Листинг 10.2 Взаимоблокировки, вызванные динамическим порядком наложения блокировок. Не делайте так. Каким образом метод transferMoney может попасть в состояние взаимоблокировки? Может показаться, что все потоки получают блокировки в одном и том же порядке, но на самом деле порядок блокировки зависит от порядка аргументов, передаваемых методу transferMoney , и они, в свою очередь, могут зависеть от внешних входных данных. Взаимоблокировка может возникнуть, если два потока одновременно вызывают метод transferMoney , один из которых производит перемещение из X в Y, а другой-наоборот: A: transferMoney(myAccount, yourAccount, 10); B: transferMoney(yourAccount, myAccount, 20); В момент неудачного стечения обстоятельств, поток A захватит блокировку на объекте myAccount и будет ожидать захвата блокировки на объекте yourAccount , в то время как поток B удерживает блокировку на объекте yourAccount и ожидает захвата блокировки на myAccount Взаимоблокировки, подобные приведённым выше, можно обнаружить точно так же, как и в листинге 10.1 – путём поиска вложенных блокировок. Поскольку порядок аргументов находится вне нашего контроля, чтобы решить проблему, мы должны индуцировать (induce) упорядочение блокировок и захватывать их во всем приложении последовательно, в соответствии с индуцированным порядком. Одним из способов индуцирования упорядочивания объектов, является использование метода System.identityHashCode , который возвращает то же значение, которое будет возвращено методом Object.hashCode . В листинге 10.3 приведена версия метода transferMoney , использующая метод System.identityHashCode , для индуцирования упорядочения блокировок. Листинг включает в себя несколько дополнительных строк кода, но исключает возможность возникновения взаимоблокировки. private static final Object tieLock = new Object(); public void transferMoney(final Account fromAcct, final Account toAcct, final DollarAmount amount) throws InsufficientFundsException { class Helper { public void transfer() throws InsufficientFundsException { if (fromAcct.getBalance().compareTo(amount) < 0) throw new InsufficientFundsException(); else { fromAcct.debit(amount); toAcct.credit(amount); } } } int fromHash = System.identityHashCode(fromAcct); int toHash = System.identityHashCode(toAcct); if (fromHash < toHash) { synchronized (fromAcct) { synchronized (toAcct) { new Helper().transfer(); } } } else if (fromHash > toHash) { synchronized (toAcct) { synchronized (fromAcct) { new Helper().transfer(); } } } else { synchronized (tieLock) { synchronized (fromAcct) { synchronized (toAcct) { new Helper().transfer(); } } } } } Листинг 10.3 Индуцирование упорядочивания блокировок для исключения возможности возникновения взаимоблокировки В тех редких случаях, когда два объекта имеют один и тот же хэш-код, мы должны использовать произвольные средства упорядочения захвата блокировок, и это вновь приводит к возможности возникновения взаимоблокировки. Чтобы в этой ситуации избежать несогласованности в порядке захвата блокировок, используется третья “разрывающая связь” (tie breaking) блокировка. Захватывая блокировку разрывающую связь до получения любой из блокировок на объекте Account , мы гарантируем, что только один поток одновременно выполняет рискованную задачу получения двух блокировок в произвольном порядке, исключая возможность взаимоблокировки (если этот механизм используется согласованно). Если бы конфликты хэша были распространены, этот метод мог бы стать узким местом параллелизма (так же, как и наличие единственной блокировки), но в связи с тем, что коллизии хэша, возвращаемого методом System.identityHashCode исчезающе редки, этот метод обеспечивает наличие последнего бита безопасности при небольших затратах. Если класс Account имеет уникальный, неизменяемый, сопоставимый ключ, такой как номер учетной записи, индуцирование упорядочения наложения блокировки еще проще: упорядочивайте объекты по их ключу, тем самым устраняя необходимость в использовании блокировки, разрывающей связь. Вы можете решить, что мы завышаем риск возникновения взаимоблокировки, потому что блокировки обычно удерживаются на очень краткий промежуток времени, но взаимоблокировки являются серьезной проблемой в реальных системах. Производственное приложение может выполнять в день миллиарды циклов захвата-освобождения блокировок. Достаточно одной из них быть установленной неправильно, чтобы приложение попало в ситуацию взаимоблокировки, и даже тщательный режим нагрузочного тестирования не может выявить все латентные взаимоблокировки 104 . Класс DemonstrateDeadlock представленный в листинге 10.4 105 , довольно быстро попадает в состояние взаимоблокировки на большинстве систем. public class DemonstrateDeadlock { private static final int NUM_THREADS = 20; private static final int NUM_ACCOUNTS = 5; private static final int NUM_ITERATIONS = 1000000; public static void main(String[] args) { final Random rnd = new Random(); final Account[] accounts = new Account[NUM_ACCOUNTS]; for (int i = 0; i < accounts.length; i++) accounts[i] = new Account(); class TransferThread extends Thread { public void run() { for (int i=0; i } } } for (int i = 0; i < NUM_THREADS; i++) new TransferThread().start(); } 104 По иронии судьбы, удержание блокировок в течение коротких периодов времени, что, как вы предполагаете, должно привести к уменьшению конкуренции между блокировками, увеличивает вероятность того, что тестирование не раскроет латентные риски возникновения взаимоблокировки. 105 В целях упрощения, класс DemonstrateDeadlock игнорирует проблему отрицательного баланса на счёте. } Листинг 10.4 Управляющий цикл, индуцирующий взаимоблокировку при типичных условиях 10.1.3 Взаимоблокировки между взаимодействующими объектами Захват нескольких блокировок далеко не всегда так очевиден, как в методах LeftRightDeadlock или transferMoney ; две блокировки не обязательно должны быть захвачены одним и тем же методом. Рассмотрим взаимодействующие классы из листинга 10.5, которые могут быть использованы в приложении диспетчеризации машин такси. Класс Taxi представляет индивидуальное такси, с указанием местоположения и пункта назначения, класс Dispatcher представляет парк такси. // Warning: deadlock-prone! class Taxi { @GuardedBy("this") private Point location, destination; private final Dispatcher dispatcher; public Taxi(Dispatcher dispatcher) { this.dispatcher = dispatcher; } public synchronized Point getLocation() { return location; } public synchronized void setLocation(Point location) { this.location = location; if (location.equals(destination)) dispatcher.notifyAvailable(this); } } class Dispatcher { @GuardedBy("this") private final Set @GuardedBy("this") private final Set } public synchronized void notifyAvailable(Taxi taxi) { availableTaxis.add(taxi); } public synchronized Image getImage() { Image image = new Image(); for (Taxi t : taxis) image.drawMarker(t.getLocation()); return image; } } Листинг 10.5 Взаимоблокировка, вызванная порядком наложения блокировок между взаимодействующими объектами. Не делайте так. Хотя ни один метод явно не захватывает две блокировки, объекты, вызывающие методы setLocation и getImage могут всё же могут попытаться захватить две одинаковых блокировки. Если поток вызывает метод setLocation в ответ на обновление, пришедшее от приемника GPS, он сначала обновляет местоположение такси, а затем проверяет, достигло ли оно места назначения. Если это так, он сообщает диспетчеру, что ему нужен новый пункт назначения. Так как оба метода - setLocation и notifyAvailable – объявлены как synchronized , поток, вызывающий метод setLocation , захватывает блокировку экземпляра Taxi и затем блокировку экземпляра Dispatcher . Аналогично, поток, вызывающий метод getImage , захватывает блокировку экземпляра Dispatcher , а затем блокировку на каждом экземпляре Taxi (по одному). Также как в классе LeftRightDeadlock , две блокировки будут захвачены двумя потоками в различном порядке, что приводит к риску возникновения взаимоблокировки. В методах LeftRightDeadlock и transferMoney было достаточно легко обнаружить возможность возникновения взаимоблокировки, просто найдя методы, захватывающие две блокировки. Обнаружить возможность возникновения взаимоблокировки в классах Taxi и Dispatcher немного сложнее: предупреждающим знаком может служить то, что чужой метод вызывается, пока удерживается блокировка. Вызов чужого метода с удерживаемой блокировкой напрашивается на проблемы с живучестью. Чужой метод может захватывать другие блокировки (рискуя вызвать взаимоблокировку) или заблокироваться на неожиданно долгое время, вынуждая останавливаться другие потоки, которым нужна блокировка, которую вы удерживаете. 10.1.4 Открытые вызовы Конечно, классы Taxi и Dispatcher не знали, что каждый из них был половиной взаимоблокировки, ожидающей своего часа. И они не должны этого делать; вызов метода - это абстрактный барьер, предназначенный для защиты вас от знания деталей того, что происходит на другой стороне. Но, поскольку вы не знаете, что происходит на другой стороне вызова, вызов чужого метода в процессе удержания блокировки сложно проанализировать, и поэтому он довольно рискован. Вызов метода без удерживаемых блокировок называется открытым вызовом (open call)[CPJ 2.4.1.3], а классы, использующие открытые вызовы, более корректны и компонуемы, чем классы, выполняющие вызовы с удерживаемыми блокировками. Использование открытых вызовов для того, что избежать возникновения взаимоблокировок, аналогично использованию инкапсуляции для обеспечения потокобезопасности: хотя можно, конечно, создать потокобезопасную программу без инкапсуляции, анализ потокобезопасности программы, которая эффективно использует инкапсуляцию, намного проще, чем той, которая этого не делает. Аналогичным образом, анализ живучести программы, которая полагается исключительно на открытые вызовы, намного проще, чем той, которая этого не делает. Самоограничение использованием открытых вызовов позволяет значительно упростить определение веток кода, которые получают множество блокировок, и, следовательно, обеспечивает согласованный порядок получения блокировок 106 Классы Taxi и Dispatcher , приведённые в листинге 10.5 можно легко отрефакторить с использованием открытых вызовов, и, таким образом, устранить риск возникновения взаимоблокировки. Это влечёт за собой сокращение блоков synchronized только для защиты операций затрагивающих совместно используемое состояние, как показано в листинге 10.6. Очень часто причиной проблем, подобных описанным в листинге 10.5, является использование синхронизированных методов вместо меньших, по размеру, синхронизированных блоков по причине компактного синтаксиса или простоты, а не потому, что весь метод должен быть защищен блокировкой. (В качестве бонуса, сокращение синхронизированного блока также может улучшить масштабируемость; за более подробными сведениями о размере синхронизируемых блоков см. раздел 11.4.1 .) @ThreadSafe class Taxi { @GuardedBy("this") private Point location, destination; private final Dispatcher dispatcher; ... public synchronized Point getLocation() { return location; } public void setLocation(Point location) { boolean reachedDestination; synchronized (this) { this.location = location; reachedDestination = location.equals(destination); } if (reachedDestination) dispatcher.notifyAvailable(this); } } @ThreadSafe class Dispatcher { @GuardedBy("this") private final Set @GuardedBy("this") private final Set 106 Необходимость полагаться на открытые вызовы и тщательный порядок блокировок отражает фундаментальный беспорядок в компоновке синхронизированных объектов, а не в синхронизации компонуемых объектов. } public Image getImage() { Set synchronized (this) { copy = new HashSet } Image image = new Image(); for (Taxi t : copy) image.drawMarker(t.getLocation()); return image; } } Листинг 10.6 Использование открытых вызовов для исключения взаимоблокировки между взаимодействующими объектами Старайтесь в своей программе использовать открытые вызовы. Программы, которые полагаются на открытые вызовы, гораздо легче анализировать на отсутствие взаимоблокировок, чем те, которые допускают вызов чужих методов в процессе удержания блокировки. Реструктуризация блока synchronized , с целью разрешения открытых вызовов, иногда может иметь нежелательные последствия, так как она берёт атомарную операцию и делает её не атомарной. Во многих случаях потеря атомарности вполне приемлема; нет никаких причин, по которым обновление местоположения такси и уведомление диспетчера о том, что оно готово получить новое место назначения, должны быть атомарными операциями. В других случаях потеря атомарности заметна, но семантические изменения все еще приемлемы. В версии, подверженной взаимоблокировкам, метод getImage , в момент вызова, создает полный снимок местоположений парка автомобилей такси; в переработанной версии, метод getImage получает местоположение каждого такси в немного различающиеся моменты времени. Однако, в некоторых случаях, потеря атомарности является проблемой, и в этой ситуации вам придется использовать другой подход для достижения атомарности. Одним из таких подходов является структурирование параллельного объекта таким образом, чтобы только один поток мог выполнить ветку кода, следующую за открытым вызовом. Например, при завершении работы службы может потребоваться дождаться завершения текущих операций, а затем освободить ресурсы, используемые службой. Процесс удержания блокировки службы во время ожидания завершения операций, по самой своей сути, подвержен взаимоблокировке, но снятие блокировки со службы до завершения работы службы может позволить другим потокам начать новые операции. Решение состоит в том, чтобы удерживать блокировку достаточно долго для того, чтобы установить флаг состояния службы в “завершение работы”, и чтобы другие потоки, желающие начать новые операции - включая завершение работы службы - видели, что служба недоступна, и не предпринимали подобных попыток. Затем, после завершения выполнения открытого вызова, можно дождаться завершения работы, зная, что только поток завершения работы имеет доступ к состоянию службы. Таким образом, вместо того, чтобы использовать блокировку, для удержания других потоков вне критической части кода, этот подход основан на построении таких протоколов, при которых другие потоки не будут пытаться в неё войти. 10.1.5 Взаимоблокировки ресурсов Точно так же, как потоки могут попасть в ситуацию взаимоблокировки, когда каждый из них ожидает блокировку, удерживаемую другим потоком, и не освобождает свою, они также могут попасть в ситуацию взаимоблокировки при ожидании ресурсов. Предположим, у вас есть два объединяющих ресурса, например пулы соединений для двух разных баз данных. Пулы ресурсов, как правило, реализуются с использованием семафоров (см. раздел 5.5.3 ) для облегчения блокирования, когда пул пуст. Если задача требует подключения к обеим базам данных и оба ресурса не всегда запрашиваются в одном и том же порядке, поток A может удерживать подключение к базе D 1 , при ожидании подключения к базе D 2 , а поток B может удерживать подключение к базе D 2 , при ожидании подключения к базе данных D 1 (Чем больше размер пулов, тем реже это происходит; если каждый пул имеет N соединений, для возникновения взаимоблокировки требуется N наборов циклически ожидающих потоков и множество неудачных моментов времени.) Другой формой взаимоблокировки на основе ресурсов является взаимоблокировка, вызванная голоданием потоков (thread-starvation deadlock). Мы видели пример реализации этой угрозы в разделе 8.1.1, в котором задача, отправляющая другую задачу и ожидающая от неё результата, выполняется однопоточным экземпляром Executor . В этом случае, первая задача будет ожидать вечно, мгновенно останавливая эту задачу и все остальные, ожидающие выполнения в том же экземпляре Executor . Задачи, ожидающие результатов других задач, являются основным источником взаимоблокировки, вызванной голоданием потоков; ограниченные пулы и взаимозависимые задачи сочетаются плохо. 10.2 Предотвращение и диагностика взаимоблокировок Программа, которая никогда не получает более одной блокировки за один раз, не может столкнуться с взаимоблокировкой, вызванной порядком захвата блокировок. Конечно, это не всегда практически целесообразно, но если вам это сойдет с рук, в результате будет намного меньше работы. Если вам необходимо захватить несколько блокировок, упорядочение блокировок должно быть частью дизайна программы: попробуйте свести к минимуму число потенциальных взаимодействий между блокировками, а также следуйте протоколу упорядочения блокировок и документируйте его для блокировок, которые могут быть захвачены совместно. В программах, использующих детальную блокировку, выполняйте аудит кода на отсутствие взаимоблокировок, используя стратегию, состоящую из двух частей: сначала определите места, в которых можно получить несколько блокировок (попробуйте сделать собрать небольшой набор), а затем выполните глобальный анализ всех таких экземпляров, чтобы обеспечить согласованность порядка блокировок во всей программе. Использование открытых вызовов там, где это возможно, существенно упрощает проведение такого анализа. При отсутствии не открытых вызовов, найти экземпляры, в которых получено несколько блокировок, довольно легко, либо путем ревю кода (code review), либо с помощью автоматического анализа байт-кода или исходного кода. 10.2.1 Блокировки, ограниченные по времени Другим подходом, применяемым для обнаружения взаимоблокировок и восстановления при их возникновении, является использование ограниченной по времени версии метода tryLock , предоставляемой явными классами блокировок Lock (см. главу 13 ), вместо внутренней блокировки. В той ситуации, когда внутренние блокировки ожидают вечно, если не могут получить блокировку, явные блокировки позволяют указать время ожидания, по истечении которого метод tryLock возвращает ошибку. При использовании тайм-аута, значение которого превышает время, в течение которого вы ожидаете захватить блокировку, в случае, если что-то неожиданно пойдёт не так, вы сможете восстановить контроль. (В листинге 13.3 приведён альтернативный вариант реализации метода transferMoney , с использованием опрашиваемой версии tryLock , повторяющей попытки для избегания вероятного возникновения взаимоблокировки.) Когда ограниченная по времени попытка захвата блокировки провалится, вам не обязательно знать, почему это произошло. Может быть, произошла взаимоблокировка; может быть, поток по ошибке вошел в бесконечный цикл, пока удерживал эту блокировку; или, может быть, просто какая-то активность работает намного медленнее, чем вы того ожидали. Но, по крайней мере, у вас есть возможность оставить запись о том, что попытка не удалась, логировать любую полезную информацию о том, что вы пытались сделать, и изящно перезапустить вычисление, вместо того, чтобы убить весь процесс. Использование ограниченного по времени захвата блокировки, для захвата нескольких блокировок, может быть эффективным средством против возникновения взаимоблокировки, даже если блокировка по времени используется во всей программе не согласованно. Если время захвата блокировки истекло, вы можете освободить блокировки, отступить и подождать некоторое время, а затем повторить попытку, возможно, очистив условие взаимоблокировки и позволив программе восстановиться. (Этот подход работает только тогда, когда две блокировки захватываются вместе; если несколько блокировок захватываются во вложенных вызовах методов, вы не можете просто освободить внешнюю блокировку, даже если вы знаете о том, что удерживаете ее вы.) 10.2.2 Анализ взаимоблокировок с использованием дампов потоков Несмотря на то, что предотвращение взаимоблокировок является, в основном, вашей проблемой, JVM может помочь идентифицировать их, когда они происходят, с помощью использования дампа потоков. Дамп потока содержит трассировку стека для каждого запущенного потока, аналогичную трассировке стека, сопровождающей исключение. Дампы потоков также включают в себя информацию о блокировании, например о том, какие блокировки удерживаются каждым потоком, в каких кадрах стека они были захвачены, и какую блокировку ожидает захватить заблокированный поток. 107 Перед созданием дампа потока, JVM просматривает граф ожидания (is-waiting-for graph) в поисках циклов, для 107 Эта отладочная информация полезна даже в том случае, если у вас нет взаимоблокировки; периодический сброс дампов потоков позволяет наблюдать за поведением блокировки в программе. определения наличия взаимоблокировок. Если среда JVM находит цикл, она включает информацию о взаимоблокировке, идентифицирующую, какие блокировки и потоки участвуют, и где в программе нарушен захват блокировки. Для инициирования сброса дампа потока, можно отправить процессу JVM сигнал SIGQUIT ( kill -3 ) на платформах Unix или нажать комбинацию клавиш Ctrl-\ в Unix или Ctrl-Break на платформах Windows. Многие IDE также могут запросить дамп потока. Если вы используете явные классы блокировок Lock, вместо встроенной блокировки, среда Java 5.0 не поддерживает связывание информации класса Lock с дампом потока; явные блокировки вообще не отображаются в дампах потоков. Среда Java 6 включает поддержку дампа потока и обнаружение взаимоблокировки с явными блокировками класса Lock , но информация о том, где блокировки были захвачены, будет неизбежно менее точна, чем для встроенных блокировок. Встроенные блокировки связаны с кадром стека, в котором они были захвачены; явные блокировки Lock связаны только с захватившим их потоком. В листинге 10.7 приведена часть дампа потока, взятая из рабочего приложения J2EE. Сбой, вызвавший взаимоблокировку, включает три компонента - приложение J2EE, контейнер J2EE и драйвер JDBC, каждый из них принадлежит разным поставщикам. (Имена были изменены, в целях защиты виновных.) Все трое были коммерческими продуктами, которые прошли через интенсивные циклы тестирования; каждый из них имел ошибку, которая сама по себе была безвредна, пока все они не начинали взаимодействовать друг с другом и не вызывали фатальный сбой сервера. Found one Java-level deadlock: ============================= "ApplicationServerThread": waiting to lock monitor 0x080f0cdc (a MumbleDBConnection), which is held by "ApplicationServerThread" "ApplicationServerThread": waiting to lock monitor 0x080f0ed4 (a MumbleDBCallableStatement), which is held by "ApplicationServerThread" Java stack information for the threads listed above: "ApplicationServerThread": at MumbleDBConnection.remove_statement - waiting to lock <0x650f7f30> (a MumbleDBConnection) at MumbleDBStatement.close - locked <0x6024ffb0> (a MumbleDBCallableStatement) "ApplicationServerThread": at MumbleDBCallableStatement.sendBatch - waiting to lock <0x6024ffb0> (a MumbleDBCallableStatement) at MumbleDBConnection.commit - locked <0x650f7f30> (a MumbleDBConnection) Листинг 10.7 Часть дампа потока, после наступления взаимоблокировки Мы показали только ту часть дампа потока, что относится к идентификации взаимоблокировки. Среда JVM проделала для нас большую работу, в процессе диагностики взаимоблокировки - какие блокировки вызывают проблему, какие потоки участвуют, какие другие блокировки они удерживают, и причиняются ли другим потокам косвенные неудобства. Один поток удерживает блокировку на объекте MumbleDBConnection и ожидает получения блокировки на объекте MumbleDBCallableStatement ; другой удерживает блокировку на объекте MumbleDBCallableStatement и ожидает захвата блокировки на объекте MumbleDBConnection Драйвер JDBC, используемый здесь, имеет явную ошибку в порядке захвата блокировок: различные цепочки вызовов захватывают несколько блокировок в различном порядке, с использованием драйвера JDBC. Но эта проблема не проявилась бы, если бы не другая ошибка: несколько потоков пытались одновременно использовать один и тот же экземпляр JDBC Connection Приложение работало совсем не так, как ожидалось - разработчики были удивлены, увидев один и то же экземпляр Connection , используемый одновременно двумя потоками. В спецификации JDBC нет явного требования о том, чтобы реализация Connection была потокобезопасной, и в обычной практике использование экземпляра Connection ограничивается одним потоком, как предполагалось и в этом случае. В свою очередь, поставщик попытался предоставить потокобезопасный драйвер JDBC, о чем свидетельствует синхронизация множества объектов JDBC в коде драйвера. К сожалению, поскольку поставщик не принял во внимание порядок захвата блокировок, драйвер был подвержен взаимоблокировке, но проблема обнаруживала себя только при взаимодействии драйвера, подверженного взаимоблокировке, и неправильном совместном использовании экземпляра Connection в приложении. Поскольку ни одна ошибка, в изоляции одна от другой, не была фатальной, обе сохранялись, несмотря на обширное тестирование. 10.3 Прочие угрозы живучести В то время как взаимоблокировка представляет собой наиболее широко встречающуюся угрозу живучести, существует несколько других угроз живучести, с которыми вы можете столкнуться в параллельных программах, включая голодание (starvation), пропущенные сигналы (missed signals), и динамическую взаимоблокировку (livelock). (Пропущенные сигналы рассматриваются в разделе 14.2.3). 10.3.1 Голодание Голодание возникает тогда, когда потоку постоянно отказывают в доступе к ресурсам, в которых он нуждается для достижения прогресса; наиболее подверженный голоданию ресурс - циклы ЦП. Голодание в приложениях Java может быть вызвано неправильным использованием приоритетов потоков. Оно также может быть вызвано выполнением непрерывающихся конструкций (бесконечные циклы или ожидания ресурсов, которые не завершаются) с удерживаемой блокировкой, в связи с чем, другие потоки, которым нужна эта блокировка, никогда не смогут ее захватить. Приоритеты потоков, определенные в API потоков, представляют собой просто советы по планированию. В API потоков определено десять уровней приоритета, которые среда JVM, по своему усмотрению, может сопоставить приоритетам планирования операционной системы. Это сопоставление является специфичным для платформы, таким образом, два приоритета Java могут быть сопоставлены с одним и тем же приоритетом ОС в одной системе, и другим приоритетам ОС в другой. Некоторые операционные системы имеют меньше чем десять уровней приоритета, и в этом случае несколько приоритетов Java сопоставляется с тем одним и тем же приоритетом ОС. Планировщики операционной системы идут на многое, чтобы обеспечить справедливость планирования и живучесть, сверх того, что требуется спецификацией языка Java. В большинстве приложений Java все потоки приложений имеют одинаковый приоритет, Thread.NORM_PRIORITY . Механизм приоритетов потов является довольно грубым инструментом, и не всегда очевидно, к какому эффекты приведёт изменение приоритетов; повышение приоритета потока может не оказать никаких изменений, или может привести к тому, что один поток будет всегда планироваться в предпочтении к другим, тем самым провоцируя голодание. Как правило, разумной политикой будет сопротивление искушению настроить приоритеты потоков. Как только вы начинаете изменять приоритеты, поведение вашего приложения станет специфичным для платформы, и вы введёте в программу риск возникновения голодания. Вы часто можете определить программу, которая пытается восстановиться после настройки приоритета или других проблем с отзывчивостью, по наличию вызовов Thread.sleep или Thread.yield в странных местах, применяемых для того, чтобы дать больше времени потокам с более низким приоритетом 108 Избегайте соблазна использовать приоритеты потоков, так как они увеличивают зависимость от платформы и могут вызвать проблемы с живучестью. Большинство параллельных приложений могут использовать приоритет по умолчанию для всех потоков. 10.3.2 Плохая отзывчивость Отступая на один шаг от голодания, следует плохая отзывчивость, не являющаяся редкостью в приложениях GUI, использующих фоновые потоки. В главе 9 был разработан фрэймворк, применяемый для разгрузки долговременных задач в фоновые потоки, чтобы не происходило “заморозки” пользовательского интерфейса. Фоновые задачи с интенсивным использованием ЦП могут по- прежнему влиять на скорость отклика, поскольку они могут конкурировать с потоком событий за циклы ЦП. Это один из случаев, когда изменение приоритетов потоков имеет смысл; когда ресурсоемкие фоновые вычисления повлияют на скорость отклика. Если работа, выполняемая другими потоками, действительно является фоновой, снижение их приоритета может сделать задачи переднего плана более отзывчивыми. Плохая отзывчивость также может быть вызвана плохим управлением блокировками. Если поток удерживает блокировку в течение длительного времени (возможно, при итерации большой коллекции и выполнении существенной работы для каждого элемента), другим потоки, которым требуется доступ к той же самой коллекции, возможно, придется ожидать очень долго. 108 Семантика вызовов Thread.yield (и Thread.sleep(0) ) не определена [JLS 17.9]; среда JVM свободна в реализации их как “пустых операций” (no-ops) или рассмотрении их как подсказок планирования. В частности, они не обязаны реализовывать семантику вызова sleep(0) в Unix системах - поместить текущий поток в конец очереди выполнения потоков с таким же приоритетом, уступая другим потокам того же приоритета - хотя некоторые реализации JVM реализуют вызов yield именно таким образом. 10.3.3 Динамическая взаимоблокировка Динамическая взаимоблокировка (livelock) является формой проблемы с живучестью, при которой поток, фактически оставаясь не заблокированным, все же не может добиться прогресса выполнения, потому что продолжает повторять выполнение операции, которая всегда будет терпеть неудачу. Динамическая взаимоблокировка часто возникает в транзакционных приложениях обмена сообщениями, в которых инфраструктура обмена сообщениями откатывает транзакцию, если сообщение не может быть обработано успешно, и помещает сообщение в начало очереди. Если ошибка в обработчике сообщений, приводит к сбою для определенного типа сообщений, каждый раз, когда сообщение будет помещаться в очередь и передаваться неисправному обработчику, будет выполняться откат транзакции. Так как сообщение будет возвращаться в начало очереди, обработчик будет вызываться снова и снова, с тем же результатом. (Такую ситуацию иногда называют проблемой отравленного сообщения (poison message).) Поток обработки сообщений не блокируется, но он также никогда не будет достигать прогресса. Эта форма динамической взаимоблокировки часто берёт своё начало с чрезмерно ретивого кода восстановления ошибок, который ошибочно рассматривает неустранимую ошибку как восстанавливаемую. Динамические взаимоблокировки также могут возникать, когда несколько взаимодействующих потоков изменяют свое состояние в ответ на изменение состояния другими потоками, таким образом, что ни один поток никогда не сможет добиться прогресса. Это похоже на то, что происходит, когда два чрезмерно вежливых человека идут в противоположных направлениях в коридоре: каждый отступает в другую сторону, и теперь они снова на пути друг у друга. Так что они оба отступают снова, и снова, и снова… Решение для приведённого разнообразия динамических взаимоблокировок заключается в том, чтобы ввести некоторую случайную составляющую в механизм повтора. Например, когда две станции в сети ethernet пытаются передать пакет на совместно используемом носителе 109 в одно и то же время, пакеты сталкиваются. Станции обнаруживают коллизию, и каждая из них пытается снова, позже, передать свой пакет. Если каждая из них будет повторять попытку ровно через одну секунду, пакеты будут сталкиваться снова и снова, и ни один пакет никогда не будет отправлен, даже если доступна большая часть пропускной способности. Чтобы избежать этого, мы заставляем каждую станцию ожидать некоторое время, которое включает случайную составляющую. (Протокол ethernet также включает экспоненциальный откат после повторяющихся столкновений, уменьшая как перегрузку, так и риск повторного сбоя с несколькими сталкивающимися пакетами от разных станций.) Повторная попытка, как со случайными задержками, так и с откатами, может быть одинаково эффективной для предотвращения динамической взаимоблокировки в параллельных приложениях. 10.4 Итоги Сбои живучести являются серьезной проблемой, потому что нет никакого более короткого способа восстановиться от них, чем прерывание работы приложения. Наиболее распространенной формой проблем с живучестью является взаимоблокировка, вызванная нарушением порядка захвата блокировок. Предотвращение взаимоблокировки, вызванной нарушением порядка захвата блокировок, начинается с момента проектирования: гарантируйте, что когда 109 Имеется в виду сетевой кабель, wi-fi и т.д. потоки захватывают несколько блокировок, они делают это в согласованном порядке. Лучший способ добиться этого - использовать открытые вызовы во всей программе. Это бы значительно уменьшило количество мест, где одновременно удерживаются несколько блокировок, и сделало бы более очевидным местонахождение таких мест. |