Семафоры являются удобными и эффективными примитивами, при помощи которых решаются задачи взаимного исключения и синхронизации, поэтому они широко используются во многих ОС. API ОС, обеспечивающий для пользователя работу с семафорами, мы рассмотрим в следующей главе, а здесь остановимся на некоторых возможных расширенных механизмах в системном программном обеспечении (прежде всего - в системах программирования), которые будут использовать семафоры в скрытой от пользователя форме.
Некоторые неудобства использования критических секций могут быть преодолены введением специальных конструкций в язык программирования. Например, если у нас в программе есть разделяемая переменная x, то удобно защитить ее конструкцией типа:
shared int x; . . . section ( x ) { < операторы, работающие с переменной x > }
Конструкция section определяет критическую секцию. Вместо специальных "скобок критической секции" используется заголовок section и обычные операторные скобки. Последнее дает возможность проверять правильность оформления критической секции на синтаксическом уровне - на этапе трансляции программы, а не ее выполнения и предупреждает возможность появления самой распространенной ошибки программистов - непарных скобок. Определение переменной x со специальным описателем shared позволяет выявить (опять на этапе компиляции) все попытки доступа к ней вне критической секции.
Реализация этой конструкции очевидна: переменная x защищается скрытым семафором, над которым производится P-операция при входе в секцию и V-операция - при выходе из нее.
Отчасти такая конструкция позволяет решить и проблему тупиков. Если описать в программе иерархию разделяемых переменных, то можно спокойно разрешить программисту делать вложенные критические секции и проверять правильность вложения на этапе компиляции. Этот метод, однако, не универсален: если в критической секции есть обращение к процедуре, а в последней - другая критическая секция, то правильность такого вложения компилятор проверить не сможет.
Другой путь - запретить вложенные секции, но разрешить в заголовке секции указывать не одну разделяемую переменную, а целый их список. Этот вариант более прост и надежен, но он использует дисциплину залпового выделения ресурсов и, следовательно, более консервативен.
Критические секции, как языковые конструкции, обеспечивают взаимное исключение, но не синхронизацию процессов. Инструментом для синхронизации может быть оператор типа await, операндом которого является логическое выражение. Этим оператором процесс блокируется до тех пор, пока операнд не примет значение "истина". Поскольку ожидание связано с внешним событием, в логическом выражении должна участвовать хотя бы одна разделяемая переменная, следовательно, применение await может быть разрешено только в критической секции.
В качестве примера приведем решение задачи "производители-потребители" для процессов, использующих буфер, организованный в виде стека. Такая организация буфера диктует нам необходимость запретить одновременный доступ к нему любых двух процессов.
1 shared struct { 2 portion buffer [BSIZE]; 3 int stPtr; 4 } stack = { {...}, 0 }; 5 void producer ( void ) { 6 portion work; 7 while (1) { 8 < производство порции в work > 9 section ( stack ) { 10 await ( stack.stRtr < BSIZE ); 11 memcpy ( stack.buffer + stack.stPtr++, &work, sizeof(portion) ); 14 } 15 } 16 } 17 void consumer ( void ) { 18 portion work; 19 while (1) { 20 section ( stack ) { 21 await( stack.stPtr > 0 ); 22 memcpy ( &work, stack.buffer + --stack.stPtr, sizeof(portion) ); 25 } 26 < обработка порции в work> 27 } 28 }
При реализации возможности await мы должны решить проблему исключения. В приведенном выше примере процесс-производитель, заблокированный в строке 10, ждет уменьшения значения указателя стека (если стек полон). Это значение может быть уменьшено процессом-потребителем в строке 23, но эта строка находится в критической секции потребителя, а последний не может войти в свою критическую секцию, так как производитель уже вошел в свою - в строке 9.
Одним из способов разрешения этого противоречия является запрещение употребления await где-либо, кроме самого начала критической секции, возможно, даже включение await-условия в заголовок секции. Исключение в этом случае начинает работать только после выхода из await. Естественно, что сама проверка условия должна выполняться как атомарная операция (защищаться скрытым семафором).
Можно разрешить await где угодно в критической секции, но на время блокировки процесса в await снимать взаимное исключение. Конечно, такой вариант дает программисту более гибкий инструмент, но перекладывает на него ответственность за целостность, так как за время, на которое исключение снимается, разделяемые данные могут быть изменены.
Еще один вопрос, который мы должны решить при реализации await: если процесс заблокировался, то в какие моменты следует проверять условие разблокирования? Вопрос непраздный, так как для вычисления условия, являющегося операндом await, необходимо активизировать контекст заблокированного процесса. Если проделывать это слишком часто, то накладные расходы на переключение неоправданно возрастают. Для варианта, в котором мы жестко привязываем await к началу критической секции, общая переменная, входящая в условие, может быть изменена только другим процессом, и ее новое значение станет доступным при выходе этого другого процесса из его критической секции. Выход другого процесса из критической секции - единственное событие, по которому имеет смысл переключаться в контекст заблокированного процесса и проверять условие в этом варианте. Если же мы разрешаем употребление await где угодно в критической секции, то добавляется еще один тип события - блокировка другого процесса в операторе await его критической секции.