Возврат на сайт Назад Оглавление Вперед


6. ПРОБЛЕМА ТУПИКОВ

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

  1. Их требования к объему памяти не будут превышать определенного предела.
  2. Каждый вычислительный процесс завершится при условии, что требуемый процессу объем памяти будет предоставлен в его распоряжение. Завершение вычислительного процесса будет означать, что его требования к памяти уменьшились до нуля.

Предполагаем, что имеющаяся память поделена на "страницы" фиксированного размера, которые с точки зрения программы считаются эквивалентными.

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

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

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

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

 Процесс Максимальное
требование
Настоящий
объем
Дальнейшее
требование
П1

П2
80

60
40
+  
20
40

40
Свободная память = 100 - 60 = 40

(предполагается, что вся память составляет 100 страниц), то увидим, что в этой ситуации еще нет ничего страшного.

Если бы, однако, оба процесса затребовали теперь по следующей странице, и получили бы ее, то сложилась бы такая ситуация:

 Процесс Максимальное
требование
Настоящий
объем
Дальнейшее
требование
П1

П2
80

60
41
+  
21
39

39
Свободная память = 100 - 62 = 38

Эта ситуация опасная, так как оба процесса могут пожелать полностью затребовать свой допустимый объем памяти, не возвращая ни одной страницы. Так, каждый из них может нуждаться в следующих 39 страницах, в то время как их осталось только 38.

Эта ситуация, когда один из процессов может быть продолжен только при условии, что другой будет сперва уничтожен, называется "Смертельное Объятие" ("The Deadly Embrace")10). Проблема, которую нам необходимо решить, заключается в следующем: как избежать опасности тупика, не накладывая слишком больших ограничений.

6.1. Алгоритм банкира

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

  1. Клиент делает заем для совершения сделки, которая будет завершена за конечный промежуток времени.
  2. Клиент должен заранее указать максимальную "потребность" во флоринах для этой сделки.
  3. Пока "заем" не превышает заранее установленную "потребность", клиент может увеличивать или уменьшать свой заем флорин за флорином.
  4. Клиент, который просит увеличить его текущий заем, обязуется принимать без недовольства следующий ответ: "Если бы я дал вам запрашиваемый флорин, вы еще не превысили бы свою установленную потребность, и поэтому вы имеете право на следующий флорин. Однако в настоящий момент мне, по некоторым причинам, неудобно его дать, но я обещаю вам флорин в должное время".
  5. Гарантия для клиента в том, что этот момент действительно наступит, основана на предусмотрительности банкира и на том факте, что остальные клиенты подчиняются тем же условиям, что и он сам: как только клиент получает флорин, он приступает к своей сделке с ненулевой скоростью, т. е. в конечный промежуток времени он или запросит новый флорин, или возвратит флорин, или закончит сделку, что означает возвращение всего займа (флорин за флорином).

Основными вопросами здесь являются:
  а) при каких условиях банкир может заключить контракт с новым клиентом?
  б) при каких условиях банкир может выплатить (следующий) запрашиваемый флорин клиенту, не опасаясь попасть в тупик?

Ответ на вопрос (а) прост: он может принять любого клиента, чья максимальная потребность не превышает капитал банкира.

Для ответа на вопрос (б) введем следующую терминологию.

Банкир имеет в своем распоряжении фиксированный "капитал"; каждый новый клиент устанавливает свою максимальную "потребность" и для любого клиента выполняется условие:

    "потребность[i] < капитал" (для всех i).

Текущая обстановка для каждого клиента характеризуется его "заемом". Каждый заем первоначально равен "0" и будет в любой момент удовлетворять соотношению:

    "0 < заем[i] < потребность[i]" (для всех i).

Отсюда может быть получена полезная величина, представляющая максимальное дальнейшее "требование":

    "требование[i] = потребность[i] - заем[i]" (для всех i).

Наконец, "наличные" банкира составляют:

    "наличные = капитал - сумма всех займов".

Очевидно, что должно выполняться условие:

    "0 < наличные <  капитал".

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

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

Если клиенты пронумерованы от 1 до N, то стандартную программу проверки положения можно составить так:

integer свободные деньги; Boolean безопасно; 
                          Boolean array завершение под сомнением[1 : N];
свободные деньги := наличные;
for i := 1 step 1 until N do завершение под сомнением[i] := true;
L: for i := 1 step 1 until N do
begin if завершение под сомнением[i] and требование[i]
          свободные деньги then
      begin завершение под сомнением[i] := false;
            свободные деньги := свободные деньги + заем[i];
            goto L
      end
end;

if свободные деньги = капитал
     then безопасно := true
     else безопасно := false

Приведенная стандартная программа проверяет любое положение. Улучшение алгоритма банкира было предложено Цваненбургом, который принял во внимание, что подлежат исследованию только те положения, которые возникают, когда при безопасном положении выдается флорин некоторому клиенту [i]. Как только при работе программы выполнится условие "завершение под сомнением[i] := false", можно прямо сделать вывод о безопасности нового положения, так как тогда, очевидно, эта планируемая выплата будет обратима. Это усовершенствование реализовано в программе следующего параграфа.

6.2. Применение алгоритма банкира

Этот пример также связан с флоринами. (Каждому флорину можно поставить в соответствие магнитную ленту; тогда заем флорина будет означать разрешение на использование одной из лент.)

Предположим, что клиенты пронумерованы от 1 до N, и что флорины пронумерованы от 1 до M. С каждым клиентом связывается переменная "номер флорина", значение которой после очередной выдачи флорина клиенту определяет номер только что выделенного флорина. В свою очередь с каждым флорином связана переменная "номер клиента", значение которой указывает клиента, которому выдан этот флорин.

Каждый клиент имеет переменную состояния "клиентпер" - переменная клиента; при этом "клиентпер = 1" означает "Я хочу получить заем" (иначе, "клиентпер = 0"). Каждый флорин имеет переменную состояния "флоринпер"; при этом "флоринпер = 1" означает "Я нахожусь среди наличного капитала". С каждым клиентом и каждым флорином связаны двоичные семафоры "клиентсем" и "флоринсем" соответственно, которые будут использоваться обычным образом.

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

Предполагаем, что целочисленные константы "N" и "M" (равная капиталу) и массив целочисленных констант "потребность" описаны и определены во внешних по отношению к данной программе блоках.

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

begin integer array заем, требование, клинтсем, клиентпер, 
              номер флорина, возвращенные флорины[1 : N], 
              флоринсем, флоринпер, номер клиента[1 : М];
      integer взаимискл, наличные, k;
      Boolean procedure попытка выдать флорин клиенту(j);
      value j; integer j;
      begin if клиентпер[j] = 1 then
            begin integer i, свободные деньги;
            Boolean array завершение под сомнением[1 : N];
            свободные деньги := наличные - 1;
            требование[j] := требование[j] - 1; заем[j] := заем[j] + 1;
            for i := 1 step 1 until N do завершение под сомнением[i] := true;
       L0:  for i := 1 step 1 until N do
            begin if завершение под сомнением[i] and
                  требование[i]  свободные деньги then
                  begin if i  j then
                        begin завершение под сомнением[i] := false;
                              свободные деньги := свободные деньги + заем[i];
                              goto L0
                        end
                                 else
                        begin comment Можно реализовать и более сложный способ 
                              выбора свободного флорина, чем предлагаемый здесь;
                              i := 0;
                         L1:  i := i + 1; if флоринпер[i] = 0 then goto L1;
                              номер флорина[j] := i;
                              номер клиента[i] := j;
                              клиентпер[j] = 0;
                              флоринпер[i] := 0;
                              наличные := наличные - 1;
                              попытка выдать флорин клиенту := true;
                              V(клиентсем[i]);
                              V(флоринсем[i]); goto L2
                        end
                  end
            end;
            требование[j] := требование[j] + 1;
            зaeм[j] := зaeм[j] - 1
      end;
      попытка выдать флорин клиенту := false;
  L2: end процедуры;
      взаимискл := 1; наличные := M;
      for k := 1 step 1 until N do
      begin заем[k] := 0; клиентсем[k] := 0; клиентпер[k] := 0;
            требование[k] := потребность[k];
            возвращенные флорины[k] := потребность[k]
      end;
      for k := 1 step 1 until M do
      begin флоринсем[k] := 0; флоринпер[k] := 1 end;
      parbegin
клиент 1: begin. ........... end;
            .
            .
            .
клиент N: begin. ........... end;
флорин 1: begin. ........... end;
            .
            .
            .
флорин M: begin. ........... end;
      parend
end

У клиента "n" запрос нового флорина описывается следующей последовательностью операторов:

    P(возвращенные флорины[n]);
    P(взаимискл);
    клиентпер[n] := 1;
    попытка выдать флорин клиенту(n)
    V(взаимискл);
    P(клиентсем[n]);
после завершения последнего оператора значение переменной "номер флорина [n]" определяет только что выданный флорин; клиент может использовать его, но обязан возвратить его в надлежащее время банкиру.

Структура программы для флорина следующая:

флорин m:
begin integer h;

начало: P(флоринсем[m]);
        comment Теперь "номер клиента [m]" указывает клиента, которому 
        выдан данный флорин. Флорин может обслуживать клиента до тех пор,
        пока не закончит задачу, поставленную перед ним в течение этого 
        заема. Чтобы возвратиться к наличному капиталу, флорин поступает
        следующим образом:
        P(взаимискл);
        требование[номер клиента[m]] := требование[номер клиента [m]] + 1;
        заем[номер клиента[m]] := заем[номер клиента[m]] - 1;
        флоринпер[m] := 1; наличные := наличные + 1;
        V(возвращенные флорины[номер клиента[m]]);
        for h := 1 step 1 until N do
            begin if попытка выдать флорин клиенту(h) then
            goto выход
            end;
выход:  V(взаимискл);
        goto начало
end

Замечание. Грубо говоря, успешный заем флорина может иметь место только при удовлетворении двух условий: флорин должен быть затребован и флорин должен быть доступен. В этой программе механизм общих переменных "клиентпер" и "клиентсем" используется также (клиентом) в случае, когда затребованный флорин немедленно предоставлен; подобно этому, механизм общих переменных "флоринпер" и "флоринсем" используется также (флорином) в случае, если после возвращения его в наличный капитал он может немедленно быть выдан снова ожидающему клиенту. Этот метод программирования был предложен Лайтмансом и Вуурхоеве, и я упоминаю о нем потому, что в случае более сложных правил взаимодействия он приводит к упрощениям, которые оказываются крайне полезными. Основной причиной достижения большей простоты является то, что динамическое движение через топологическую структуру программы больше не проводит различие между фактической задержкой и потенциальной, точно так же как в случае самой P-операции.


Возврат на сайт Назад Оглавление Вперед