Защищённые объекты, очереди и барьеры
Защищённый объект — это данные плюс правила безопасного доступа к ним: монитор, встроенный в язык, без единого явного мьютекса в вашем коде.
Защищённый объект (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). - Барьеры входов дают условную синхронизацию декларативно: ограниченный буфер пишется без ручных мьютексов и сигналов.
- Защищённый объект — пассивный страж данных (легче рандеву); рандеву — диалог активных задач для сложных протоколов и поведения.
- Тело защищённой операции обязано быть коротким и неблокирующим — это и обеспечивает предсказуемость для реального времени.