Опубликован: 23.10.2005 | Доступ: свободный | Студентов: 4086 / 201 | Оценка: 4.44 / 4.19 | Длительность: 33:04:00
Специальности: Программист
Лекция 12:

Параллельность, распределенность, клиент-сервер и Интернет

Доступ к сепаратным объектам

Сейчас у нас уже достаточно сведений, чтобы предложить подходящие механизмы синхронизации параллельных ОО-систем.

Параллельный доступ к объекту

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

Следует ли разрешить одновременное выполнение нескольких подпрограмм на данном объекте? Основная причина ответить "нет" заключена в желании сохранить способность корректно рассуждать о нашем ПО.

Изучение корректности класса в предыдущей лекции позволяет найти верный подход. Жизненный цикл объекта можно изобразить следующим образом:

Жизненный цикл объекта

Рис. 12.7. Жизненный цикл объекта

На этом рисунке объект извне наблюдается только в состояниях, заключенных в квадратики: сразу после создания (S1), после каждого применения некоторого компонента клиентом (S2 и последующие состояния). Они называются "стабильными моментами" жизни объекта. Как следствие, мы получили формальное правило: чтобы доказать корректность класса, достаточно проверить одно свойство для каждой из процедур создания и одно свойство для каждого экспортируемого компонента (здесь оно несколько упрощено, полная формулировка была дана в лекции 11 курса "Основы объектно-ориентированного программирования"). Если p - это процедура создания, то проверяемое свойство имеет вид:

{Default and prep} Bodyp {postp and INV}

Для экспортируемой подпрограммы r проверяемое свойство имеет вид:

{prer and INV} Bodyr {postr and INV}

Число проверяемых свойств невелико и не требует анализа сложных сценариев во время исполнения. Указанные свойства дают возможность понимания класса, рассматривая его подпрограммы независимо друг от друга, убеждаясь, пусть и неформально, в том, что каждая подпрограмма, начав работу в "правильном" состоянии, завершит ее в нужном заключительном состоянии.

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

Поэтому для обеспечения простоты и корректности разрешим в каждый момент времени выполнять не более одной подпрограммы каждого объекта. Заметим, что существует возможность прервать клиента в случае крайней необходимости или при слишком долгой задержке объекта. Пока это делается насильственным способом - запуском исключения. При этом гарантируется, что неудачливый клиент получит извещение, позволяющее ему предпринять, если потребуется, корректирующие действия. Рассматриваемый далее механизм дуэлей (duels) дает такую возможность.

В конце обсуждения выясним, могут ли какие-либо обстоятельства позволить нам ослабить запрет на одновременный доступ к подпрограммам одного объекта.

Резервирование объекта

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

Идея, привлекательная на первый взгляд (но недостаточная), основана на использовании понятия сепаратного вызова. Рассмотрим вызов x.f (...) для сепаратной сущности x, присоединенной во время выполнения к объекту O2, где вызов выполняется некоторым объектом-клиентом O1. Ясно, что после начала выполнения этого вызова O1 может безопасно перейти к своему следующему делу, не дожидаясь его завершения, но само выполнение этого вызова не может начаться до тех пор, пока O2 не освободится для O1. Отсюда можно заключить, что клиент дождется, когда целевой объект станет свободным, и клиент сможет выполнять над ним свою операцию.

К сожалению, эта простая схема недостаточна, так как она не позволяет клиенту удерживать объект, пока в этом есть необходимость. Предположим, что O2 - это некоторая разделяемая структура данных (например, буфер) и что соответствующий класс предоставляет процедуру remove для удаления одного элемента. Клиенту O1 может потребоваться удалить два соседних элемента, но если просто написать:

Buffer.remove; buffer.remove,

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

Одно решение состоит в добавлении к порождающему классу buffer (или его потомку) процедуры remove_two, удаляющей одновременно два элемента. Но в общем случае это решение нереалистично: нельзя изменять поставщика из-за каждой потребности в синхронизации кода их клиентов. У клиента должна быть возможность удерживать поставленный ему объект так долго, как это требуется.

Другими словами, нужно нечто в духе механизма критических интервалов. Ранее был введен их синтаксис:

hold a then действия_требующие_исключительного_доступа end

Или в условном варианте:

hold a when a.некоторое_свойство then действия_требующие_исключительного_доступа end

Тем не менее, мы перейдем к более простому способу обозначений, возможно, вначале несколько странному. Наше соглашение состоит в том, что, если a -это непустое сепаратное выражение, то вызов вида:

действия_требующие_исключительного_доступа (a)

автоматически заставляет ожидать до тех пор, пока объект, присоединенный к a, не станет доступным. В инструкции hold нет никакой необходимости - для резервирования сепаратного объекта достаточно указать его в качестве фактического аргумента вызова.

Заметим, что ожидание имеет смысл, только если подпрограмма содержит хоть один вызов x.some_routine с формальным аргументом x, соответствующим a. В противном случае, например, если она выполняет только присваивание вида some_attribute := x, ждать нет никакой необходимости. Это будет уточнено в полной форме правила, которое будет сформулировано далее в этой лекции.

Возможны и другие подходы, в которых авторы предлагают сохранить инструкцию hold. Но передача аргумента в качестве механизма резервирования объекта сохраняет простоту и легкость освоения модели параллелизма. Схема с hold привлекательна для разработчиков, поскольку соответствует девизу ОО-разработки "Инкапсулировать повторения", а главное, объединяет в одной подпрограмме действия, требующие исключающего доступа к объекту. Поскольку этой подпрограмме неизбежно потребуется аргумент, представляющий сам объект, то мы пошли дальше и считаем наличие такого аргумента достаточным для обеспечения резервирования объекта без введения ключевого слова hold.

Это соглашение также означает, что (достаточно парадоксально) большинство сепаратных вызовов не должно ждать. Выполняя тело подпрограммы с сепаратным формальным аргументом a, мы уже зарезервировали соответствующий присоединенный объект, поэтому всякий вызов с целью a может исполняться немедленно. Как уже отмечено, не требуется ждать, когда он завершится. В общем случае для подпрограммы вида:

r (a: separate SOME_TYPE) is
    do
        ...; a.r1 (...); ...
        ...; a.r2 (...); ...
    end

реализация может продолжать выполнение остальных инструкций, не дожидаясь завершения любого из двух вызовов при условии, что она протоколирует вызовы для a так, что они будут выполняться в требуемом порядке. (Нам еще нужно понять, как, если это потребуется, ждать завершения сепаратного вызова; пока же, мы просто запускаем вызовы и никогда не ждем!)

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

Доступ к сепаратным объектам

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

Можно пойти дальше и объявить эту схему единственной для сепаратных вызовов:

Правило сепаратного вызова

Цель сепаратного вызова должна быть формальным аргументом подпрограммы, осуществляющей этот вызов.

Напомним, что вызов a.r (...) является сепаратным, если его цель a - это сепаратная сущность или выражение. Правило запрещает вызов компонентов a, если a не является формальным аргументом вызывающей подпрограммы. Поэтому приходится вводить дополнительные подпрограммы. Например, если attrib -это атрибут, объявленный как сепаратный, то вместо непосредственного вызова attrib.r (...) придется вызывать подпрограмму rf(attrib, ...), где:

rf (x: separate SOME_TYPE; ... Другие аргументы ...) is
        -- Вызов r.
    do
        x.r (...)
    end

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

Следующий пример весьма типичен. Предположим, что имеется разделяемая структура данных - снова буфер - с компонентами remove для удаления элемента и count для запроса числа элементов. Тогда вполне "естественно" написать:

if buffer.count >= 2 then
    buffer.remove; buffer.remove
end

Замысел состоит в удалении двух элементов. Но, как мы уже отмечали, это не всегда работает - по крайней мере, до тех пор, пока не обеспечен безопасный исключающий доступ к buffer. Иначе между моментом, когда проверяется условие для count и моментом, когда выполняется первое удаление remove, любой другой клиент может придти и удалить элемент, так что эта программа аварийно завершится, пытаясь применить remove к пустой структуре.

В следующем примере предполагается, что компонент item, не имеющий побочного эффекта, возвращает элемент, удаляемый компонентом remove:

if not buffer.empty then
    value := buffer.item; buffer.remove
end

Без защиты буфера buffer другой клиент может добавить или удалить элемент в промежутке между вызовами item и remove. В один прекрасный день автор этого фрагмента получит доступ к одному элементу, а удалит другой, так что можно, например, (при повторении указанной схемы) получить доступ к одному и тому же элементу дважды! Все это очень плохо.

Сделав buffer аргументом вызывающей подпрограммы, мы устраняем эти проблемы: гарантируется, что buffer будет зарезервирован на все время выполнения вызова подпрограммы.

Конечно, вина за ошибки в рассмотренных примерах лежит на невнимательных разработчиках. Но без правила сепаратного вызова такие ошибки совершаются легко. По-настоящему плохо то, что поведение во время выполнения становится недетерминированным, поскольку оно зависит от относительной скорости клиентов. Из-за этого ошибка будет блуждающей, сейчас в одном месте программы, при следующем запуске - в другом. Еще хуже то, что она, вероятно, будет проявляться редко: во всяком случае (в первом примере) конкурирующий клиент должен оказаться очень удачливым, чтобы протиснуться между проверкой count и первым вызовом remove. Поэтому такую ошибку очень трудно повторить и изолировать.

Такие коварные ошибки ответственны за кошмарную репутацию отладки параллельных систем. Всякое правило, существенно уменьшающее вероятность их появления, оказывает большую помощь разработчикам.

Учитывая правило сепаратного вызова, наши примеры следует записать в виде следующих процедур, использующих сепаратный тип BOUNDED_BUFFER:

remove_two (buffer: BOUNDED_BUFFER) is
        -- Удаляет два самых старых элемента
    do
        if buffer.count >= 2 then
            buffer.remove; buffer.remove
        end
    end
get_and_remove (buffer: BOUNDED_BUFFER) is
        -- Присваивает самый старый элемент value и удаляет его
    do
        if not buffer.empty then
            value := buffer.item; buffer.remove
        end
    end

Эти процедуры могут быть частью некоторого класса приложения; в частности, они могут быть описаны в классе BUFFER_ACCESS ( ДОСТУП_К_БУФЕРУ ), инкапсулирующем операции работы с буфером и служащем родительским классом для различных видов буферов.

Обе эти процедуры взывают о предусловии. Вскоре мы позаботимся о нем.

Ожидание по необходимости

Предположим, что после ожидания необходимых сепаратных аргументов началось выполнение некоторого сепаратного вызова (например buffer.remove ). Мы уже видели, что это не блокирует клиента, который может спокойно продолжать свои вычисления. Но, конечно, клиенту может потребоваться синхронизация с поставщиком, и для продолжения работы ему нужно дождаться завершения вызова.

Может показаться, что для этого нужен специальный механизм (он, действительно, был предложен в некоторых языках параллельного ОО-программирования, например в Hybrid) для воссоединения вычисления родителя с его расточительным вызовом. Но вместо этого можно использовать предложенную Денисом Каромелем (см. раздел "Библиографические заметки" этой лекции) идею ожидания по необходимости. Она состоит в том, чтобы ждать столько, сколько действительно необходимо.

Когда клиенту нужно точно знать, что вызов a.r (...) для сепаратной сущности a, присоединенной к сепаратному объекту O1, завершился? В тот момент, когда нужен доступ к некоторому свойству O1, требуется, чтобы объект был доступен, а все предыдущие его вызовы были завершены. До этого можно делать что-либо с другими объектами, даже запускать новый вызов процедуры a.r (...) на том же сепаратном объекте, поскольку, как мы видели, при разумной реализации можно просто ставить такие вызовы в очередь так, что они будут выполняться в порядке поступления.

Напомним, что компоненты подразделяются на команды (процедуры), выполняющие преобразование целевого объекта, и на запросы (функции и атрибуты), возвращающие информацию о нем. Завершения вызовов команд ждать не нужно, а завершения запросов - вполне возможно.

Рассмотрим, например, сепаратный стек s и последовательные вызовы:

s.put (x1); ...Другие инструкции...; s.put (x2); ... Другие инструкции ...; value := s.item

(которые в соответствии с правилом сепаратного вызова должны входить в некоторую подпрограмму с формальным аргументом s ). Если предположить, что ни одна из "Других инструкций" не использует s, то единственной инструкцией, требующей ожидания, является последняя; ей необходима информация о стеке - его верхнее значение (которое в данном случае должно равняться x2 ).

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

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

Мультипускатель

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

launch (a: ARRAY [separate X]) is
        -- Запустить для каждого элемента a
    require
        -- Все элементы a непусты
    local
        i: INTEGER
    do
        from i := a.lower until i > a.upper loop
            launch_one (a @ i); i := i + 1
        end
    end
launch_one (p: separate X) is
        -- Запустить для p
    require
        p /= Void
    do
        p.live
    end

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

Читатели, знакомые с моделированием дискретных событий, основанным на сопрограммах, изучаемым в одной из следующих лекций, легко распознают схему, близкую к используемой, когда оператор языка Simula detach возвращает управление после запуска процесса моделирования.

Оптимизация

(В этом разделе рассматриваются достаточно тонкие темы, и при первом чтении его можно опустить).

Для завершения обсуждения ожидания по необходимости аккуратно определим, когда клиенту следует ожидать завершения сепаратного вызова.

Мы уже видели, что причиной ожидания могут быть только вызовы запросов. Но можно пойти дальше и выяснить, чем является результат запроса - развернутым типом или ссылкой. Если это развернутый тип, например INTEGER или какой-нибудь другой базисный тип, то выбора нет - нам нужно его значение, поэтому вычисление клиента должно ждать, пока запрос не вернет результат. Но в случае ссылочного типа разумная реализация сможет продолжить работу в то время, пока будет вычисляться сепаратный объект-результат запроса; в частности, если эта реализация использует "заместителей" сепаратных объектов, объект-заместитель может быть создан немедленно, так что ссылка на него доступна даже, если этот заместитель пока не ссылается на требуемый сепаратный объект.

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

Ожидание по необходимости

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

Чтобы учесть только что рассмотренную возможную оптимизацию, измените "вызов запроса" на "вызов запроса, возвращающего развернутый тип".

Устранение блокировок (тупиков)

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

Обычный способ передачи несепаратных ссылок состоит в использовании схемы визитной карточки: используется сепаратный вызов вида x.f (a), где x - сепаратная сущность, а a - нет; иначе говоря, a - это ссылка на локальный объект клиента, возможно, на сам Current. На стороне поставщика f имеет вид:

f (u: separate SOME_TYPE) is
    do
       local_reference := u
    end

где local_reference типа separate SOME_TYPE является атрибутом объемлющего класса поставщика. Далее поставщик может использовать local_reference в подпрограммах, отличных от f, для выполнения операций над объектами на стороне клиента с помощью вызовов вида local_reference.some_routine (...).

Эта схема корректна. Предположим, однако, что f делает еще что-то, например, включает для некоторого g вызов вида u.g (...). Это с большой вероятностью приведет к тупику: клиент (обработчик объекта, присоединенного к u и a ) занят выполнением f или, быть может, ожиданием по необходимости выполнения другого вызова, резервирующего тот же объект.

Следующее правило позволяет избежать таких ситуаций:

Принцип визитной карточки

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

Пока это только методологическое руководящее указание, хотя было бы желательно ввести соответствующее формальное правило (в упражнениях У12.4 и У12.13 эта идея рассматривается глубже). Дополнительные комментарии о блокировках появятся еще при общем обсуждении.