При участии Тима Перлса, Джошуа Блоха, Джозева Боубира, Дэвида Холмса и Дага Ли Параллельное программирование в java на
Скачать 4.97 Mb.
|
notifyAll(); } // BLOCKS-UNTIL: not-empty public synchronized V take() throws InterruptedException { while (isEmpty()) wait(); V v = doTake(); notifyAll(); return v; } } Листинг 14.6 Ограниченный буфер с использованием очередей условий Класс BoundedBuffer , наконец-то, достаточно хорош в использовании - он прост в использовании и разумно управляет зависимостью от состояния. 151 Производственная версия должна также включать синхронизированные версии методов put и take , чтобы блокировка операций могла прерываться по таймауту, если они не смогут завершиться в течение выделенного бюджета времени. Синхронизированная версия метода Object.wait упрощает реализацию. 14.2 Использование очередей условий Очереди условий упрощают создание эффективных и отзывчивых классов, зависящих от состояния, но их всё ещё довольно просто использовать некорректно; существует множество правил относительно их корректного использования, которые не применяются компилятором или платформой. (Это одна из причин для того, чтобы строить ваши классы, в тех случаях, когда это возможно, на основе классов, подобных LinkedBlockingQueue , CountDownLatch , Semaphore , и FutureTask ; если вам это удастся, это значительно упростит дело.) 14.2.1 Предикат условия Ключом к правильному использованию очередей условий является определение предикатов условий (condition predicates), которые может ожидать объект. Это предикат условия приводит к путанице вокруг методов wait и notify , потому что у него нет создания экземпляра в API, и ни спецификация языка, ни реализация JVM не гарантирует его правильного использования. Фактически, о нём напрямую не упоминается ни в спецификации языка, ни в Javadoc. Но без него, ожидание по условию работать не будет. Предикат условия представляет собой предусловие, которое в первую очередь делает операцию зависимой от состояния. В ограниченном буфере метод take может продолжить выполнение только в том случае, если буфер не пуст; в противном случае он должен ожидать. Для метода take предикатом условия является утверждение “буфер не пуст”, которое метод take должен проверить перед продолжением выполнения. Точно так же предикатом условия для метода put будет “буфер не полон”. Предикаты условий представляют собой выражения, построенные из переменных состояния класса; проверка экземпляра класса BaseBoundedBuffer на соответствие утверждению “буфер не пуст” выполняется 151 Класс ConditionBoundedBuffer из раздела 14.3 еще лучше: он более эффективен, потому что может использовать одно уведомление вместо вызова метода notifyAll путём сравнения значения поля count со значением ноль, а проверка утверждения “буфер не полон” выполняется путём сравнения значения поля count с размером буфера. Документируйте предикаты условий, связанные с очередью условий, и ожидающими на них операциями. Условие ожидания включает в себя важное трехстороннее отношение: блокировку, метод wait и предикат условия. Предикат условия включает в себя переменные состояния, а переменные состояния защищены блокировкой, поэтому перед проверкой предиката условия, мы должны удерживать эту блокировку. Объект блокировки и объект очереди условий (объект, на котором вызываются методы wait и notify ) должны быть одним и тем же объектом. В классе BoundedBuffer состояние буфера защищено блокировкой буфера, а объект буфера используется в качестве очереди условий. Метод take захватывает блокировку буфера, а затем проверяет предикат условия (буфер не пуст). Если буфер действительно не пуст, он удаляет первый элемент, в отношении которого может это выполнить, потому что он по-прежнему удерживает блокировку, защищающую состояние буфера. Если предикат условия не выполняется (буфер пуст), метод take должен ожидать, пока другой поток не поместит объект в буфер. Это делается с помощью вызова метода wait на внутренней очереди условий буфера, что требует удержания блокировки на объекте очереди условий. Так как в продуманном дизайне это было реализовано, метод take уже удерживает ту блокировку, которая ему нужна для проверки предиката условия (и если бы предикат условия выполнялся, то метод изменил бы состояние буфера в той же атомарной операции). Метод wait освобождает блокировку, блокирует текущий поток и ожидает истечения указанного времени ожидания, прерывания потока или пробуждения потока поступившим уведомлением. После того, как поток просыпается, метод wait , перед возвратом управления, повторно захватывает блокировку. Поток просыпается из метода wait без получения особого приоритета в повторном захвате блокировки; он конкурирует за блокировку, подобно любому другому потоку, пытаясь войти в блок synchronized Каждый вызов метода wait неявно связан с определенным предикатом условия. При вызове метода wait относительно определенного предиката условия, вызывающий объект должен уже удерживать блокировку, связанную с очередью условий, и эта блокировка также должна защищать переменные состояния, из которых состоит предикат условия. 14.2.2 Раннее пробуждение Как будто бы трехстороннее отношение между блокировкой, предикатом условия и очередью условий не было достаточно сложным, так ещё и возврат управления из метода wait не обязательно означает, что предикат условия, ожидаемый потоком, возвращает true Единственная внутренняя очередь условий может использоваться более чем одним предикатом условия. Когда ваш поток пробуждается из-за того, что кто-то вызвал метод notifyAll , это не означает, что предикат условия, которого вы ожидали, теперь истинен. (Это похоже на то, как если бы ваш тостер и кофеварка имели один и тот же звонок; когда он звонит, вам все равно нужно посмотреть, какое устройство подало сигнал.) 152 Кроме того, методу wait разрешается даже “ложно” возвращать управление - не в ответ на вызов метода notify каким-либо потоком. 153 Когда управление повторно входит в код, вызвавший метод wait , метод повторно захватывает блокировку, связанную с очередью условий. Теперь предикат условия истинен? Возможно. Это могло быть истиной на момент уведомления потока вызовом метода notifyAll , но могло бы опять стать ложью, когда вы повторно захватите блокировку. Другие потоки могли захватить блокировку и изменить состояние объекта, в промежутке между моментом пробуждения потока и моментом повторного захвата блокировки. Или, может быть, условие вообще не было истинным с момента, как вы вызвали метод wait Вы не знаете, почему другой поток вызвал методы notify или notifyAll ; возможно, это произошло потому, что другой предикат условия, связанный с той же очередью условий, стал истинным. Наличие множество предикатов условий для каждой очереди состояния довольно распространённое явление - класс BoundedBuffer использует одну и ту же очередь условий в таких предикатах, как “не полон”, так и “не пуст”. 154 В связи со всеми этими причинами, когда вы просыпаетесь из метода wait , вы должны вновь проверить предикат условия и вернуться к ожиданию (или упасть со сбоем), если условие ещё не истинно. Так как вы можете несколько раз просыпаться без выполнения предиката условия (значение предиката не равно true ), вы должны всегда вызывать метод wait в цикле, проверяя предикат условия в каждой итерации. В листинге 14.7 приведена каноническая форма для ожидания по условию. void stateDependentMethod() throws InterruptedException { // condition predicate must be guarded by lock synchronized(lock) { while (!conditionPredicate()) lock.wait(); // object is now in desired state } } Листинг 14.7 Каноническая форма методов, зависимых от состояния При использовании ожидания по условию ( Object.wait или Condition.await ): • Всегда иметь предикат условия - некоторая проверка состояния объекта, которая должен выполняться перед продолжением; • Всегда проверяйте предикат условия перед вызовом метода wait и после возврата из метода wait ; 152 Эта ситуация, на самом деле, прекрасно описывает кухню Тима; так много устройств подает звуковой сигнал, что, когда вы слышите его, вы должны проверить тостер, микроволновую печь, кофе-машину и несколько других устройств, чтобы определить причину подачи сигнала. 153 Если и дальше развивать аналогию с завтраком, это похоже на тостер со свободным соединением, которое заставляет звонок включаться, когда тост готов, но также иногда и в том случае, когда он ещё не готов. 154 Фактически возможна ситуация, когда потоки могут ожидать выполнения предикатов “не полон” и “не пуст” одновременно! Это может произойти, когда несколько производителей / потребителей достигнут пределов емкости буфера. • Убедитесь, что переменные состояния, составляющие предикат условия, защищены блокировкой, связанной с очередью условий; • Удерживайте блокировку, связанную с очередью условий, при вызове методов wait , notify или notifyAll ; и • Не освобождайте блокировку после проверки предиката условия, но до выполнения действий с ним. 14.2.3 Пропущенные сигналы В главе 10 обсуждались проблемы живучести, такие как взаимоблокировка и динамическая блокировка. Другой формой проблем с живучестью являются пропущенные сигналы (missed signals). Пропущенный сигнал возникает тогда, когда поток должен дождаться определенного условия, которое уже истинно, но не может проверить предикат условия перед началом ожидания. Таким образом, поток ожидает уведомления о событии, которое уже произошло. Это все равно, что начать готовить тост, выйти за газетой, пока вы находитесь снаружи, сработает звонок, а затем сесть за кухонный стол в ожидании тоста. Вы можете ожидать длительное время - потенциально бесконечно. 155 В отличие от мармелада для тостов, уведомление не является “липким” (sticky) - если поток A уведомляет очередь условий, а поток B впоследствии ожидает в этой же очереди условий, поток B просыпается не сразу - для пробуждения потока B требуется другое уведомление. Пропущенные сигналы являются результатом ошибок кодирования, подобных тем, предупреждение о которых было приведено в списке выше, например, провал при попытке выполнения проверки предиката условия, перед вызовом метода wait . Если вы структурируете свое состояние ожидания, как показано в листинге 14.7, у вас не будет проблем с пропущенными сигналами. 14.2.4 Уведомление До сих пор мы описывали только половину того, что происходит в ожидании по условию: ожидание. Другая половина - уведомление. В ограниченном буфере метод take блокируется, если вызывается в тот момент, когда буфер пуст. Для того чтобы разблокировать метод take , когда буфер становится непустым, мы должны гарантировать, что в каждой ветке выполнения кода, в которой буфер может стать непустым, выполняется уведомление. В классе BoundedBuffer есть только одно такое место – после метода put . Поэтому метод put , после успешного добавления объекта в буфер, вызывает метод notifyAll . Подобным образом, метод take вызывает метод notifyAll после удаления элемента, чтобы указать, что буфер больше не полон, в случае, если какие-либо потоки ожидают выполнения условия “буфер не полон”. Всякий раз, ожидая выполнения условия, убедитесь, что кто-то выполнит уведомление, когда предикат условия станет истинным. Существует два метода уведомления в API очереди условий - notify и notifyAll . Для вызова любого из них необходимо удерживать блокировку, связанную с объектом очереди условий. Вызов метода notify приводит к тому, что 155 Чтобы выйти из этого ожидания, кто-то другой должен был бы сделать тост, но это только ухудшит ситуацию; когда раздастся звонок, у вас возникнут разногласия по поводу владения тостом. JVM выбирает один поток, ожидающий пробуждения на очереди условий; вызов notifyAll пробуждает все потоки, ожидающие на очереди условий. Поскольку при вызове notify или notifyAll необходимо удерживать блокировку объекта очереди условий, а ожидающие потоки не могут вернуться из режима ожидания без повторного захвата блокировки, уведомляющий поток должен быстро освободить блокировку, чтобы гарантировать, что ожидающие потоки будут разблокированы как можно скорее. Класс BoundedBuffer представляет собой хорошую иллюстрацию того, почему следует отдавать предпочтение методу notifyAll , вместо метода notify , в большинстве случаев. Очередь условий используется для двух различных предикатов условий: “не полон” и “не пуст”. Предположим, поток A ожидает в очереди условий предиката P A , а поток B ожидает в той же очереди условий предиката P B . Теперь предположим, что условие предиката P B становится истинным, а поток C выполняет только одно уведомление: среда JVM разбудит один поток по своему выбору. Если будет выбран поток A, он проснется, увидит, что предикат P A еще не истинен, и вернется к ожиданию. Между тем, поток B, который смог бы продолжить выполнение, не просыпается. Это не совсем пропущенный сигнал - это скорее “захваченный сигнал” (hijacked signal), но проблема одна и та же: поток ожидает сигнала, который уже произошел (или должен был произойти). Единственный вызов метода notify может использоваться вместо вызова метода notifyAll только в том случае, когда выполняются оба следующих условия: Единообразное ожидание. Только один предикат условия связан с очередью условий, и каждый из поток выполняет одну и ту же логику при возврате управления из метода wait; и Один вход, один выход. Уведомление на условной переменной позволяет продолжить выполнение не более чем одного потока. Класс BoundedBuffer встречается с требованием “один вход, один выход”, но не встречается с требованием единообразного ожидания, потому что ожидающие потоки, могут ожидать как условия “не полон”, так и условия “не пуст”. Защелка “стартового затвора”, подобная той, что используется в классе TestHarness из раздела 5.5.1 , в которой одно событие освобождает набор потоков, не соответствует требованию "один вход, один выход", поскольку открытие начального затвора позволяет продолжить выполнение множества потоков. Большинство классов не отвечают этим требованиям, поэтому использование метода notifyAll вместо одиночного метода notify будет являться мудрым решением. Хотя это может быть и неэффективно, при использовании метода notifyAll вместо метода notify , гораздо проще обеспечить правильное поведение классов. Это "устоявшееся мнение" (prevailing wisdom) заставляет некоторых людей чувствовать себя некомфортно, и не зря. Использование метода notifyAll , когда только один поток может добиться прогресса, неэффективно - иногда немного, иногда очень весомо. Если десять потоков ожидают на очереди условий, вызов notifyAll приводит к тому, что каждый из них просыпается и конкурирует за блокировку; затем большинство из них, или все, снова переходят в спящий режим. Это означает, что выполняется множество переключений контекста и множество конкурирующих захватов блокировки для каждого события, которое позволяет (возможно) одному потоку продолжить выполнение. (В худшем случае использование метода notifyAll приводит к O(n 2 ) пробуждениям, где n пробуждений будет достаточно.) Это еще одна ситуация, когда проблемы производительности поддерживаются одним подходом, а проблемы безопасности - другим. Уведомление, выполняемое методами put и take класса BoundedBuffer , является консервативным: уведомление выполняется каждый раз, когда объект помещается в буфер или удаляется из него. Это можно оптимизировать, наблюдая, что поток может быть освобожден из ожидания, только если буфер переходит от пустого к не пустому или от полного к не полному, и уведомляя, только если методы put или take оказывают влияние на эти переходы состояния. Это называется уведомлением по условию (conditional notification). В то время как уведомление по условию может улучшить производительность, его сложно использовать правильно (оно также усложняет реализацию подклассов) и поэтому должно использоваться аккуратно. В листинге 14.8 приведён пример использования уведомления по условию в методе BoundedBuffer put public synchronized void put(V v) throws InterruptedException { while (isFull()) wait(); boolean wasEmpty = isEmpty(); doPut(v); if (wasEmpty) notifyAll(); } Листинг 14.8 Использование уведомления по условию в методе BoundedBuffer.put Одиночное уведомление и условное уведомление являются оптимизациями. Как всегда, при использовании этих оптимизаций, следуйте принципу “Сначала сделайте это правильно, а затем сделайте это быстро - если это еще не достаточно быстро”; легко ввести странные сбои живучести, из-за неправильного применения уведомлений. 14.2.5 Пример: класс затвора Защёлка “стартового затвора” в классе TestHarness , приведённом в разделе 5.5.1 , была построена с начальным значением счётчика равным единице, создав, таким образом, бинарную защелку (binary latch): одну с двумя состояниями, начальным состоянием и конечным состоянием. Защелка препятствует потокам в преодоления стартового затвора до тех пор, пока она не будет раскрыта, и в этот момент все потоки смогут пройти. Несмотря на то, что механизм защёлки часто является именно тем, что необходимо, иногда является недостатком, что затвор, построенный таким образом, не может быть повторно закрыт после открытия. Достаточно просто разработать повторно закрываемый класс ThreadGate , используя ожидание по условию, как показано в листинге 14.9. Класс ThreadGate позволяет затвору открываться и закрываться, предоставляя метод await , который блокируется, пока затвор открыт. Метод open использует метод notifyAll , так как семантика этого класса не позволяет выполнить проверку "один вход, один выход" для единственного уведомления. @ThreadSafe public class ThreadGate { // CONDITION-PREDICATE: opened-since(n) (isOpen || generation>n) @GuardedBy("this") private boolean isOpen; @GuardedBy("this") private int generation; public synchronized void close() { isOpen = false; } public synchronized void open() { ++generation; isOpen = true; notifyAll(); } // BLOCKS-UNTIL: opened-since(generation on entry) public synchronized void await() throws InterruptedException { int arrivalGeneration = generation; while (!isOpen && arrivalGeneration == generation) wait(); } } |