Защищённые объекты, очереди и барьеры

Защищённый объект — это данные плюс правила безопасного доступа к ним: монитор, встроенный в язык, без единого явного мьютекса в вашем коде.

Защищённый объект (protected object) — конструкция Ada, инкапсулирующая разделяемые данные вместе с операциями над ними и гарантирующая взаимное исключение автоматически: одновременная запись невозможна, а условная синхронизация выражается барьерами входов (entry с when).

Рандеву прекрасно подходит для диалога двух задач, но у него есть цена: чтобы получить доступ к общим данным, клиенту приходится «будить» задачу-сервер и ждать встречи — это сравнительно тяжело, особенно когда нужен лишь быстрый защищённый доступ к переменной. Для этого случая Ada 95 ввела защищённые объекты — лёгкий, эффективный механизм разделения данных без отдельной задачи-сервера. По сути это монитор (в смысле Хоара и Бринча Хансена), встроенный прямо в язык: данные спрятаны внутри, а доступ к ним возможен только через объявленные операции, которые язык автоматически сериализует.

Три вида операций защищённого объекта

Защищённый объект, как пакет или задача, имеет спецификацию (видимые операции) и тело (реализация плюс скрытые данные). Его операции бывают трёх видов, и различие между ними — ключ к эффективности:

ВидДоступ к даннымПараллелизм
functionтолько чтениемного читателей одновременно
procedureчтение и записьэксклюзивно (один в момент)
entryчтение и запись + барьерэксклюзивно, с условием входа

Функции защищённого объекта обещают не менять состояние, поэтому язык разрешает нескольким задачам читать одновременно (как «разделяемая блокировка чтения»). Процедуры могут менять состояние, поэтому исполняются эксклюзивно. Точки входа (entry) — это процедуры с барьером: булевым условием, при ложности которого вызывающая задача ставится в очередь и ждёт, пока условие не станет истинным.

--  Защищённый счётчик: безопасный доступ из многих задач
protected Counter is
   procedure Increment;            -- запись: эксклюзивно
   procedure Decrement;
   function  Value return Integer; -- чтение: параллельно
private
   Count : Integer := 0;           -- данные СПРЯТАНЫ внутри объекта
end Counter;

protected body Counter is
   procedure Increment is
   begin
      Count := Count + 1;          -- взаимное исключение гарантировано языком
   end Increment;

   procedure Decrement is
   begin
      Count := Count - 1;
   end Decrement;

   function Value return Integer is
   begin
      return Count;                -- чтение без права записи
   end Value;
end Counter;

--  Использование из любой задачи — БЕЗ единого явного мьютекса:
--  Counter.Increment;
--  X := Counter.Value;

Здесь нигде нет lock/unlock — взаимное исключение обеспечивает сама конструкция. Невозможно «забыть разблокировать»: язык гарантирует, что в любой момент состояние Counter либо читается несколькими функциями, либо изменяется ровно одной процедурой/входом. Это устраняет самый частый класс ошибок ручной синхронизации.

Барьеры входов: условная синхронизация

Самая мощная часть — точки входа с барьерами. Барьер — это булево выражение после when; задача, вызвавшая такой вход, проходит, только если барьер истинен, иначе ждёт в очереди входа. Когда защищённая операция завершается и могла изменить условия, язык перевычисляет барьеры ожидающих входов и пропускает тех, чьё условие стало истинным. Так элегантно решается классическая задача «производитель–потребитель» с ограниченным буфером — без единого условного мьютекса вручную.

--  Ограниченный буфер на защищённом объекте (вместо задачи-сервера)
protected type Bounded_Buffer (Size : Positive) is
   entry Put (X : in Item);
   entry Get (X : out Item);
private
   Data        : Item_Array (1 .. Size);
   Count       : Natural := 0;
   Head, Tail  : Positive := 1;
end Bounded_Buffer;

protected body Bounded_Buffer is
   entry Put (X : in Item) when Count < Size is   -- барьер: пускать, если есть место
   begin
      Data (Tail) := X;
      Tail  := Tail mod Size + 1;
      Count := Count + 1;
   end Put;

   entry Get (X : out Item) when Count > 0 is     -- барьер: пускать, если есть данные
   begin
      X     := Data (Head);
      Head  := Head mod Size + 1;
      Count := Count - 1;
   end Get;
end Bounded_Buffer;

Логика читается прямо: Put впускается «когда есть место» (when Count < Size), Get — «когда есть данные» (when Count > 0). Задача-производитель, пришедшая к полному буферу, аккуратно ждёт в очереди Put; как только потребитель заберёт элемент и Count уменьшится, барьер Put станет истинным, и производитель будет пропущен. Никаких сигналов, переменных условия или ручных пробуждений — всё выражено декларативно.

Защищённый объект против рандеву: что когда

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

Как работает под капотом

Защищённый объект реализуется через эффективную блокировку (нередко без обращения к ядру ОС в неконкурентном случае) и список очередей входов. Вход в процедуру или вход (entry) захватывает объект эксклюзивно; вход в функцию — в режиме разделяемого чтения. Критично, что тело защищённой операции обязано быть коротким и неблокирующим: внутри него запрещены потенциально блокирующие действия (например, delay, рандеву, ввод-вывод), потому что они задержали бы всех ожидающих и сломали бы гарантии времени отклика. Перевычисление барьеров происходит при выходе из защищённой операции: язык проверяет очереди и пропускает задачи, чьи барьеры открылись, по строго определённому протоколу. Именно эта дисциплина — короткие операции, автоматическое исключение, декларативные барьеры — делает защищённые объекты пригодными для жёсткого реального времени, где время удержания блокировки должно быть ограничено и предсказуемо.

Мониторы: история одной правильной идеи

Защищённые объекты — это реализация концепции монитора, и у этой концепции почтенная история, помогающая понять, почему она так хороша. Монитор предложили Пер Бринч Хансен и Тони Хоар в начале 1970-х как ответ на хаос низкоуровневой синхронизации семафорами. Идея проста и глубока: объединить разделяемые данные и операции над ними в одну конструкцию, которая автоматически гарантирует, что в каждый момент данные изменяет не более одного потока. Программисту не нужно вручную захватывать и освобождать блокировку — взаимное исключение встроено в саму конструкцию, и забыть его невозможно.

Ada взяла классический монитор и усовершенствовала его в важном аспекте — условной синхронизации. В исходных мониторах ожидание условий выражалось «переменными условия» с операциями сигнализации, которые легко было использовать неверно (забыть сигнал, перепутать порядок). Защищённые объекты Ada заменяют это барьерами входов — булевыми условиями после when, которые язык вычисляет и перевычисляет сам. Программист декларативно говорит «этот вход открыт, когда буфер не пуст», а механика пробуждения ожидающих задач полностью на стороне языка. Это устраняет целый класс ошибок, типичных для ручной работы с переменными условия, и делает код синхронизации одновременно короче и надёжнее.

Дисциплина коротких операций и реальное время

Ключевое ограничение, делающее защищённые объекты пригодными для жёсткого реального времени, — требование, чтобы тело защищённой операции было коротким и неблокирующим. Внутри него запрещены потенциально блокирующие действия: delay, рандеву, ввод-вывод. Причина в том, что пока одна задача исполняет защищённую операцию, все остальные, желающие обратиться к объекту, ждут; если бы внутри можно было заблокироваться надолго, время ожидания стало бы непредсказуемым, и весь анализ планируемости рухнул бы. Дисциплина «зашёл, быстро сделал, вышел» гарантирует ограниченное сверху время удержания, а вместе с протоколом потолка приоритета (о нём — в уроке про Ravenscar) даёт вычислимую верхнюю границу на блокировку любой задачи. Именно эта предсказуемость, а не только удобство, делает защищённые объекты основным средством разделения данных в сертифицируемых системах реального времени на Ada.

Паттерн «производитель-потребитель» как лакмус

Классическая задача «производитель-потребитель» — отличный лакмус выразительности средств параллелизма, и защищённые объекты проходят его блестяще. Суть задачи: один или несколько производителей кладут данные в ограниченный буфер, один или несколько потребителей их забирают; нужно, чтобы производитель ждал, когда буфер полон, потребитель — когда пуст, и чтобы доступ к буферу был безопасен. В языках с ручной синхронизацией это требует мьютекса плюс двух переменных условия, аккуратной последовательности захватов и сигналов, где легко ошибиться: забыть сигнал — и задача повиснет навсегда, перепутать порядок — и возникнет гонка.

Защищённый объект выражает то же самое декларативно и без единого ручного сигнала. Барьер entry Put when Count < Size прямо говорит «впускать производителя, только когда есть место», барьер entry Get when Count > 0 — «впускать потребителя, только когда есть данные». Всё. Язык сам ставит задачи в очередь при закрытом барьере и пробуждает их, перевычисляя барьеры при изменении состояния. Нет переменных условия, нет явных сигналов, нет шанса забыть пробуждение. Решение читается как спецификация задачи, а не как кропотливая механика синхронизации. Этот контраст — лучшая иллюстрация того, почему встроенные в язык высокоуровневые механизмы конкурентности надёжнее библиотечных примитивов: они убирают не отдельные ошибки, а саму возможность целых классов ошибок, перенося рутину синхронизации с программиста на язык.

Частые ошибки

  • Менять состояние в функции защищённого объекта. Функции — только для чтения; запись в них запрещена, ведь они исполняются параллельно несколькими читателями. Для изменения используйте процедуру или вход.
  • Выполнять блокирующие действия внутри защищённой операции. delay, ввод-вывод, рандеву внутри тела защищённого объекта недопустимы — они подвешивают всех ожидающих; держите тело коротким.
  • Брать защищённый объект там, где нужно активное поведение. У него нет своего потока; если сервер должен сам что-то периодически делать, нужна задача, а не защищённый объект.
  • Писать сложные барьеры с побочными эффектами. Барьер — чистое булево условие над состоянием объекта; не закладывайте в него вычисления с эффектами.

Итоги

  • Защищённый объект — встроенный в язык монитор: данные спрятаны, доступ только через операции, взаимное исключение автоматическое.
  • Три вида операций: function (параллельное чтение), procedure (эксклюзивная запись), entry (запись с барьером when).
  • Барьеры входов дают условную синхронизацию декларативно: ограниченный буфер пишется без ручных мьютексов и сигналов.
  • Защищённый объект — пассивный страж данных (легче рандеву); рандеву — диалог активных задач для сложных протоколов и поведения.
  • Тело защищённой операции обязано быть коротким и неблокирующим — это и обеспечивает предсказуемость для реального времени.
Проверьте себя
1. Почему несколько задач могут одновременно выполнять function защищённого объекта, но procedure — только по одной?
AЭто произвольное ограничение без причины
BФункция обещает только читать состояние (параллельное чтение безопасно), а процедура может менять его, поэтому требует эксклюзивного доступа
CФункции в Ada всегда быстрее процедур
DПроцедуры защищённого объекта вообще не имеют доступа к данным
2. Как защищённый объект реализует ожидание 'пускать Put, только если в буфере есть место'?
AЧерез активное ожидание в цикле
BЧерез барьер входа: entry Put (...) when Count < Size — задача ждёт в очереди, пока условие не станет истинным
CЧерез ручной мьютекс с lock/unlock
DЧерез оператор delay внутри тела