Сети    

  BSD сокеты ~ 
  Ввод-вывод в Unix ~ 
  Примеры ~ 
  Перехват пакетов ~ 
  Пример сервера ~ 
  Еще примеры ~ 
/Студентам/Сети/Ввод-вывод в Unix

Стратегии ввода-вывода в Unix-подобных ОС

При разработке программного обеспечения для ОС Unix часто возникает задача одновременной работы с несколькими файловыми дескрипторами. В данном тексте рассматриваются различные подходы к решению этой на первый взгляд простой проблемы.

Организация ввода-вывода и управление процессами в Unix,
их взаимосвязь

Everything in Unix is a file

Загляните в каталог /dev/. Вы увидите там несусветное количество каких-то "непонятных" файлов. Это - так называемые специальные файлы или файлы устройств. Среди них Вы найдете жесткие диски (/dev/hda, /dev/hdb, ...), разделы этих жестких дисков (/dev/hda1, /dev/hda2, ...), терминалы (/dev/tty*), параллельные порты, какое-то радио, наконец, замечательный /dev/null, RAM диски и еще массу других всякоразных устройств. Зачем все это? Затем, чтобы сделать ВЕСЬ ввод-вывод унифицированным и тем самым облегчить жизнь программисту, как прикладному, так и системному. Откуда бы мы ни читали и куда бы мы ни писали данные, снаружи ЭТО выглядит как файл. Что значит "снаружи выглядит"? Это значит, что любая операция записи-чтения осуществляется с помощью ОДНИХ И ТЕХ ЖЕ функций, точнее, одних и тех же СИСТЕМНЫХ ВЫЗОВОВ, независимо от конкретного типа устройства, будь то принтер, диск, монитор, сетевой интерфейс и т.д. Вам это ничего не напоминает? Да, это самый настоящий полиморфизм, несмотря на то, что и само ядро Unix, и масса приложений разработаны вовсе не на объектно-ориентированном C++, а на старом добром процедурном C. Как это организовано? В ядре имеется специальная прослойка, кажется, она называется VFS, "расположенная" между самым "внешним" ее слоем (слоя, к которому непосредственно может "прикасаться" прикладной программист ... на то он и прикладной :-), раз прикладывается, то бишь прикасается) и одним из ее более глубоких слоев, а именно, драйверами вот тех вот самых устройств. В задачи этой прослойки входит ведение информации обо всех устройствах (читай - о драйверах этих устройств, до самого устройства операционной системе дела, по большому счету, нет) и ПЕРЕНАПРАВЛЕНИЕ запроса на операцию записи-чтения от прикладной программы к СООТВЕТСТВУЮЩЕМУ драйверу. Делается это элементарно. Имеется список структур, описывающих то или иное устройство, в эти структуры, помимо всего прочего, входит указатель на еще одну структуру, содержащую, в свою очередь, указатели на функции (МЕТОДЫ), выполняющие фактическую работу по записи-чтению. При обращении прикладной программы к файлу, терминалу, сокету и т.д. операционная система определяет, к какому именно устройству обращается программа и, найдя в упомянутом списке нужную запись и в ней нужный метод драйвера, "перепоручает" ему выполнение запроса.

Перечислим некоторые из тех самых ОДНИХ И ТЕХ ЖЕ СИСТЕМНЫХ ВЫЗОВОВ, которые используются прикладной программой для осуществления обмена данными с устройствами. Для открытия устройства используется системный вызов open (), возвращающий так называемый дескриптор файла. На самом деле этот дескриптор вовсе и не дескриптор, а просто целое положительное число, являющееся индексом в таблице файлов, открытых процессом. В дальнейшем этот дескриптор используется для любой операции с файлом или устройством. Следует заметить, что сетевое взаимодействие, видимо, не совсем вписалось в парадигму "everything in unix is a file", поэтому для открытия "файла", представляющего собой какой-то ресурс на удаленном узле, используется другая функция, а именно socket () в сочетании с некоторыми другими. Мы же, однако, великодушно простим разработчикам Unix сию незначительную мелочь, тем более что возвращаемый этой функцией дескриптор вполне такой же, используется как индекс в ту же таблицу, просто в этой табличке пометочка есть, что, дескать, это не просто какой-то там, понимаете ли, ФАЙЛ, а вовсе и СОКЕТ. Последнее слово из уважения к оригиналу следует произносить с ударением на первом слоге. Вот. Далее. После окончания работы с файлом/устройством его полагается закрывать, что делается с помощью вызова close (). Для сокетов существует еще спец-вариант shutdown (). Чтение данных производится посредством вызова read (), запись - write (). Отметим, что существуют операции, несводимые к чтению или записи ДАННЫХ. Для выполнения таких операций предназначены вызовы ioctl (), fcntl (). Помимо перечисленных, существуют, конечно, еще и другие системные вызовы для работы с файлами, но пока мы о них умолчим, ограничившись перечислением наиболее ходовых, так сказать.

Заметим, что функции, о которых мы говорили в предыдущем абзаце, напрямую соответствуют системным вызовам. В libc, стандартной библиотеке из окружения языка C, есть еще масса надстроек над системными вызовами, например, для работы с файлами существуют fopen (), fread (), fwrite (), fclose (), реализующие буферизованный ввод-вывод. Сравните количество файлов во втором разделе руководства (/usr/man/man2 или /usr/share/man/man2, например) по программированию в Unix (системные вызовы) и в третьем разделе (надстройки над системныи вызовами и просто самостоятельные функции, как из libc, так и из других библиотек). Как видим, системных вызовов не так уж и много, около двух сотен, в то время как количество файлов в разделе 3 оставляет нас в совершенно удрученном состоянии. Никакой нормальный человек, даже если он и программист, вряд ли запомнит названия четырех тысяч функций, количество параметров у них, а также их типы и названия (из которых иногда понятен смысл). Ну так никто их и не помнит, на это руководства есть :-).

Процессы, их состояния, переходы между состояниями

Рассмотрим теперь некоторые моменты, связанные с тем, как Unix планирует процессы. Для начала определим процесс как последовательное исполнение программы. Можно побаловаться полутавтологичными-полурекурсивными определениями по типу "процесс - это процесс исполнения программы". Короче говоря, есть ПРОГРАММА, которая лежала себе на диске и никого не трогала, ее запустили, она начала выполняться, вот это вот ее исполнение и есть ПРОЦЕСС. Мы не будем здесь говорить о таких вещах, как КОНТЕКСТ процесса, как операционная система ведет информацию о процессах, как переключаются контексты, дабы не погрязнуть в бесконечной липкой массе деталей. Обрисуем лиш кратко, какие бывают у ПРОЦЕССОВ СОСТОЯНИЯ и в связи с чем они переходят из одного состояния в другое. Первый вопрос, который приходит в голову, это "а сколько, вообще говоря, нужно состояний?" Ну вот вроде как все сходятся во мнении, что таких состояний нужно ПО КРАЙНЕЙ МЕРЕ, три. Что это за состояния? На рисунке 1 приведена диаграмма состояний с некоторыми переходами. Рассмотрим их последовательно. СОСТОЯНИЕ ИСПОЛНЕНИЯ (RUN) - это состояние, когда данный процесс занимает процессор, иными словами, ИСПОЛНЯЕТСЯ в данный момент времени. Максимальное число процессов в состоянии RUN ограничено числом процессоров в системе. СОСТОЯНИЕ ГОТОВНОСТИ (READY) - состояние, в котором для продолжения работы процессу требуется ТОЛЬКО процессор, его время. СОСТОЯНИЕ ОЖИДАНИЯ (SLEEP или WAIT) - состояние, в котором, помимо ПРОЦЕССОРНОГО ВРЕМЕНИ, для продолжения работы процессу нужно ЕЩЕ ЧТО-ТО (пока не будем детально перечислять, это может быть, например, данные в памяти, которые в момент обращения к ним волею судеб оказались уже вовсе не в памяти, а на диске вследствие бурной деятельности подсистемы страничной подкачки). Добавим еще, что процессы в состояниях READY и SLEEP образуют очереди.

Диаграмма состояний процессов в многозадачных средах
Рис 1. Диаграмма состояний процессов в многозадачных средах

Заметим теперь странную линию, как бы делящую рисунок на две части. Сия линия символически разделяет две вещи, которые носят несколько неудачные названия KERNEL SPACE (пространство ядра) и USER SPACE (пространство пользователя). Речь о том, что процесс в ходе исполнения время от времени обращается к операционной системе (не делать этого он не может, иначе зачем тогда операционная система?). Так вот когда он это делает, то говорят, что он переходит из USER SPACE в KERNEL SPACE, а когда системный вызов заканчивает работу, имеет место обратный переход. Слово SPACE (пространство) здесь употребляется потому, что исполняясь в этих двух ФАЗАХ, процесс имеет несколько различающиеся контексты, именно, он имеет разные стеки, разные права доступа к различным участкам памяти, разные привилегии относительно прямого обращения к аппаратным средствам. С точки зрения процессора, при исполнении системного вызова процесс работает в " кольце защиты 0", то есть ему позволительно делать ВСЕ ЧТО УГОДНО. Однако попадает он туда в результате системного вызова, код которого вкупе с ДРАЙВЕРАМИ устройств делает не ВСЕ, ЧТО УГОДНО, а ТО, ЧТО НУЖНО. То есть процесс имеет привилегии, а программист, который писал ту или иную программу - нет :-).

Перейдем к рассмотрению переходов из состояния в состояние. Цифрой 1 один помечен переход READY->RUN. Такой переход происходит, когда процесс, готовый к продолжению работы (ему нужен только процессор), дождался своей очереди. Обратный переход (помечен цифрой 2) происходит тогда, когда процесс исчерпал отведенный ему квант времени, не перейдя в состояние SLEEP. Естественно, сей переход носит название "вытеснение". Переходы, помеченные цифрами 3 и 4, не являются переходами из состояния в состояние, они обозначают описанные выше смены "пространства", то есть системные вызовы и возвраты из них. Далее. Переход RUN->SLEEP (цифра 5). Такое происходит, когда операция, которую хотел выполнить процесс, не может быть выполнена немедленно. После того, как возможность выполнить операцию появилась, процесс переходит из состояния SLEEP в состояние READY (теперь ему нужно только процессорное время) и имеет шанс через некоторое время сделать то, что он хотел.

Какое отношение к этому имеет ввод-вывод?

Самое что ни на есть прямое. Дело в том, что переходы в состояние SLEEP обычно происходят именно потому, что в момент обращения к системным вызовам, выполняющих чтение или запись данных, данных то (для чтения) может и не быть или же по каким-то не может быть произведена запись. Почему данных может не быть? А потому что "мало ли кто чего хочет". Программа - оболочка (shell), например, страстно желает принять от меня какую-нибудь команду, а я не отвечаю ей никакой взаимностью по причине хронического ненахождения за клавиатурой вследствие ухода на перекур. Или же web-сервер ну просто ждет не дождется, када же к нему ну хоть кто-нибудь обратится ну хотя бы за index.html :-) ... ан нет, никто не ходит, все спят, канал связи упал или вообще машина, на которой этот бедолага сервер работает, к сети не подключена, а локальный, так сказать, пользователь опять же ушел по своим надобностям. С записью несколько тоньше. Почему запись данных не всегда может быть произведена тогда, когда этого хочет процесс? Дело в том, что между буфером этого процесса (в котором он держит данные, подлежащие записи куда-либо) и буфером конечного получателя могут быть и часто имеются промежуточные буфера как аппаратного (в сетевом адаптере, например) свойства, так и программного (области памяти, используемые драйверами для временного хранения данных), которые по каким-то причинам могут быть в данный момент ПОЛНОСТЬЮ ЗАПОЛНЕНЫ. И каково наиболее разумное решение в таких ситуациях? Совершенно верно, go to sleep, бай-бай вообщем, чего зря глазенки таращить. Главное, чтобы кто-нибудь разбудил, когда появятся данные или освободятся буферы. Что касается ввода-вывода, то такими будильниками, как правило, являются обработчики прерываний, входящие в состав драйвера, ответственного за общение с данным устройством.

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

Еще одно лирическое отступление и перейдем ближе к делу. При выполнении операций чтения и записи, как правило, указывается количество данных, которое процесс желает прочитать или записать. Как поступать коду драйвера, если процесс имеет желание прочитать 1024 байта, данные есть, но их меньше, чем 1024 байта? Так вот, драйвер имеет полное право, зафиксированное в соотвествующих документах, именно, стандартах POSIX, не ждать, когда придут остальные данные, а переписать в USER SPACE столько, сколько есть, сообщив, естественно, что было прочитано столько-то байт. Если помните, функция read () возвращает число байт, реально прочитанных и оно вполне может быть меньше запрошенного третьим параметром сей функции. С записью - тоже самое. Требуется записать 1024 байт. Можем записать только 512. Можем заснуть, а можем записать первые 512 и с чистой совестью (ибо стандарт) вернуться. А дальше проблемы прикладного программиста проверять, все ли записалось и предпринимать шаги для того, чтобы в конце концов записалось бы все, плавно перетекающие в проблемы пользователя, недоумевающего по поводу странностей в поведениях программ.

Итак, мы почти добрались до сути сего незатейливого повествования. Значит, если данных для чтения нет, то разумно перейти в состояние SLEEP. Да, разумнее не бывает, только есть одно жирное НО. Все хорошо, пока мы работаем ТОЛЬКО С ОДНИМ устройством/файлом. Пусть у нас их больше. Например, мы разрабатываем программу-клиент для какой-нибудь службы вроде IRC или ICQ. Такая программа, как минимум, должна будет работать с клавиатурой и с сетью. Таким образом, имеем 2 файловых дескриптора. Из первого (клавиатура) нужно читать, во второй (сеть) - и читать и писать. Предположим, мы обратились к ОС на предмет чтения данных из сети, данных нет, процесс засыпает. И в это время у пользователя появляется настойчивое желание что-нибудь ввести с клавиатуры. А мы НЕ можем сделать read () с клавиатуры, поскольку мы находимся в состоянии SLEEP внутри read () из сети и, пока мы оттуда не вернемся (пока не придут данные), пользователь начисто лишен возможности вводить данные с клавиатуры. Если сначала будем читать данные от пользователя, то же самое, пока он их не введет, мы не сможем читать данные из сети. А пользователь, как известно, существо своевольное, его проблемы программы мало заботят, ему надо, чтобы она работала и все. Еще пример. Пишем, мы, к примеру, какой-нибудь там сервер, выдающий файлы. (HTTP или FTP, скажем). Получили запрос, читаем файл, выдаем его в сеть. А файл длинный-предлинный. И вот пока мы его передаем, приходит еще один или более запросов от других клиентов. А мы их не то, чтобы обслужить не можем, мы даже не можем узнать, что от нас требуется внимание, поскольку находимся внутри write (), выдавая данные в сеть.

Так что же делать?

Подход 1 ("сапоги должон тачать сапожник ... ")

Ну раз ОДИН процесс "нормально" может работать только с одним файловым дескриптором, так давайте сделаем так, чтобы каждым нужным нам дескриптором "заведовал" ОТДЕЛЬНЫЙ процесс или НИТЬ, то есть сделаем нашу программную систему многопроцессной или многонитевой. Рассмотрим (вкратце), например, программу-сервер, использующую такой подход. У такого рода программы число файловых дескрипторов равно числу обслуживаемых в данный промежуток времени клиентов плюс один, нужный для принятия соединения от очередного потенциального клиента (поскольку accept (), служащая для этой цели, также блокируется, если в момент ее вызова желающих соединиться нет). Безусловно, могут быть еще и другие файловые дескрипторы, например, для работы с локальными обычными файлами, устройствами и т.п. В общих чертах алгоритм работы такой программы можно описать следующим образом. После запуска и проведения подготовительной работы программа ожидает соединений от клиентов с помощью функции accept (). После возврата из нее (а это означает, что кто-то с нами соединился) она порождает дочерний ПРОЦЕСС или НИТЬ и поручает ему обслуживание этого нового клиента. Дочерний процесс (нить), закончив обслуживание клиента, завершается. Таким образом, каждый процесс или нить работает ТОЛЬКО С ОДНИМ файловым дескриптором; "главный" процесс(нить) принимает соединения, "подчиненные" - обслуживают клиентов и живописно обрисованная выше трудность не возникает. Зато могут возникнуть другие.

Часто бывает, что экземплярам процесса (нитям) требуется обмениваться информацией (например, для сервера это может быть информация о клиентах). Кроме того, нередки ситуации, когда различные нити (процессы) имеют ОБЩИЙ РЕСУРС, доступ к которому должен быть синхронизирован с помощью соотвествующих примитивов. В частности, этим общим ресурсом может быть область памяти, в которой хранится та самая информация о клиентах (при условии, что доступ к ней осуществляется за более чем одну машинную инструкцию, что, как правило, и имеет место). Таким образом, стратегия "каждому процессу(нити) - по одному файловому дескриптору" (точнее - "каждому файловому дескриптору - свой процесс(нить)") влечет за собой необходимость разработки "внутренних", так сказать, протоколов общения, именно, выбор наиболее уместного средства взаимодействия, определение форматов данных и правил этого общения, выделение в коде КРИТИЧЕСКИХ СЕКЦИЙ (что бывает порой нетривиальной задачей), выбор средств синхронизации. Можно также отметить, что описываемый подход несколько расточителен в отношении использования оперативной памяти, а также и процессорного времени (создание процесса - весьма накладная операция)

Подход 2 ("и швец, и жнец и на дуде игрец ... ")

Возникает совершенно законный вопрос, а нельзя ли сделать так, чтобы со всеми имеющимися файловыми дескрипторами работал ОДИН процесс или нить, причем так, чтобы не возникало описанных выше неразрешимых трудностей? Как это ни прискорбно, МОЖНО. Причем можно очень даже по-разному. В чем, собственно, проблема? Проблема в том, чтобы определить, когда тот или иной файловый дескриптор готов к выполнению операции ввода-вывода, то есть отловить момент, когда поступили данные или же когда запись может быть произведена немедленно. Временно опустимся в глубины операционной системы. Как определить, готово ли то или иное устройство к операции чтения (в нем есть данные) или записи (оно может сейчас принять данные)? Два совершенно очевидных способа сделать это - либо постоянно опрашивать устройство на предмет его готовности (естественно, устройство при этом должно иметь какой-то индикатор готовности, как правило, это один или несколько бит в одном из его РЕГИСТРОВ), либо каким-то образом организовать вычислительную систему так, чтобы устройство могло САМО сообщить о своей готовности, а вычислительная система (как совокупность аппаратных и программных средств) могла на этот крик души адекватным образом отреагировать. Первый способ, как всем известно, называется "программный опрос" или "polling" на каком-то иностранном языке, второй же обычно реализуется в виде механизма (аппаратных) ПРЕРЫВАНИЙ. Выбираемся теперь из глубин операционной системы и возвращаемся в родные пенаты прикладного программирования. Способов узнать, когда файловый дескриптор будет готов к чтению/записи, от этого не убавляется и не прибавляется. Их тоже два: постоянный опрос и (программные) ПРЕРЫВАНИЯ (! убедительная просьба не смешивать это понятие с командой int процессоров семейства Intel), которые в Unix носят название СИГНАЛЫ. Однако, разработчики Unix предоставили нам несколько вариаций этих способов, рассмотрением которых мы сейчас и займемся.

\/\/ Опрос дескрипторов

Неблокирующий ввод-вывод (опрос "вручную")

Выше было сказано, что код ядра (драйвера, например) в ситуациях, когда та или иная операция не может быть выполнена немедленно, переводят обращающийся к этому коду процесс в состояние SLEEP. Однако, такое поведение не является, естественно, единственно возможным. Как еще можно поступить? Правильно, можно просто вернуться из системного вызова, сообщив вызывающему процессу о том, что требуемое действие сейчас не может быть выполнено. (как правило, ситуация, когда произошла некоторая ошибка, индицируется тем, что вызывающему процессу возвращается константа -1, а в глобальную переменную errno помещается код ошибки; в описываемой ситуации errno принимает значение EWOULDBLOCK или EAGAIN). Итак, если операция не может быть выполнена в момент обращения к ОС, процесс может

1. перейти в состояние SLEEP
2. отстаться в состоянии RUN, НО вернуться в USER SPACE

Если речь идет об операциях ввода-вывода, то первый вариант поведения называется БЛОКИРУЮЩИЙ ВВОД-ВЫВОД (BLOCKING I/O), а второй, соответственно, НЕБЛОКИРУЮЩИЙ ВВОД-ВЫВОД (NON-BLOCKING I/O). Заметим, что ПО УМОЛЧАНИЮ ввод-вывод - БЛОКИРУЮЩИЙ (поскольку такое поведение более разумно). Можно, однако, перевести тот или иной файл (читай - устройство, сокет и т.п.) в режим неблокирующего ввода-вывода. Это делается с помощью функции fcntl (). Переведя все каналы ввода-вывода в такой режим, можно организовать работу с ними в ОДНОМ процессе(нити) следующим образом: обращаться в цикле ко всем дескрипторам; если тот или иной дескриптор готов, операция выполнится, если же нет, то вызов тут же завершится и мы перейдем к следующему дескриптору. Сразу бросается в глаза самый главный недостаток такого способа - именно, процесс большую часть времени будет производить "холостые" обращения к файлам (устройствам, сокетам и т.п.). Ну и что, скажете Вы. Это, мол, проблемы ЭТОЙ программы. Вовсе не так, скажу я, у этой-то программы как раз никаких проблем и нет, она свое дело сделает. Но при этом она поглотит ВСЕ или почти все свободное процессорное время, не давая работать ДРУГИМ программам. Допустим, у вас есть 10 процессов, интенсивно использующих ввод-вывод и 1 процесс вычислительного характера (а такой процесс к операционной системе обращается нечасто и потому в состоянии SLEEP почти не бывает, ему не нужны данные откуда-то извне). Допустим далее, что этот последний выполняет порцию работы (например, обрабатывает один файл с экспериментальными данными или производит один модельный эксперимент) за 1 сутки (при условии, естественно, что он монопольно владеет процессором). Пусть те 10 (среди них могут быть, например, web-сервер, ftp-сервер и пр.) разработаны с использованием неблокирующего ввода-вывода. В субботу вечером Вы запустили Вашу мега-программу по моделированию процессов эволюции в условиях планеты Земля, надеясь к утру понедельника получить ну как минимум млекопитающих, а если повезет, то, может быть, и что-нибудь похожее на homo sapiens. Другие люди, которые, в принципе, могли воспользоваться вашим замечательным сайтом, где вы красочно описали уже полученные черви, улитки и прочую мерзость, но уехали на выходные на дачу. Ваш же web-сервер и прочие процессы, работающие на той же самой машине, где Вы играете в бога, БЕСПРЕСТАННО делают accept (), read () и прочие системные вызовы, которые вместо того, чтобы альтруистически уйти в состояние SLEEP, упрямо возвращаются и насмешливо сообщают "мол, нет никого и ничего, пробуй еще". И, что самое удивительное, он пробует. С тем же результатом. И так без конца. В результате ваша гениальная хромосомомешалка в течение полутора суток, за которые она должна была уже расставить все точки над i и предсказать пути эволюции вида homo sapiens ну как минимум на 10000 лет вперед, получила лишь примерно одну одиннадцатую часть этого времени (всего процессов - 11) и не добралась не только до млекопитающих, но и даже до лягушек. Трудно быть богом, времени ни на что не хватает. Но, к счастию, никто ТАК web-сервера не пишет. И вы тоже не пишИте. :-) Это, собственно все, что можно сказать о неблокирующем вводе-выводе. Never use it. Ну или, по крайней мере, always try to avoid using it. Ведь существует возможность опросить дескрипторы средствами ядра. Итак,

Опрос средствами ядра

В чем беда только что описанного способа? В том, что за один системный вызов типа read (), recv (), accept () и т.п. мы узнаем о состоянии только ОДНОГО дескриптора. Вот если бы существовал вызов, который бы позволял проверить сразу несколько дескрипторов на предмет готовности к операции ввода-вывода, тогда мы бы могли применить его и потом выполнить эти операции над теми дескрипторами, которые готовы. Спешу Вас обрадовать. Такой вызов существует, да еще в нескольких ипостасях. Прошу любить и жаловать, select () и poll (). Сразу отмечу, что оба этих вызова - БЛОКИРУЮЩИЕ, но нас это нисколько не смутит, поскольку они возвращаются, как только ЛЮБОЙ из опрашиваемых ими дескрипторов (хотя бы один) становится готовым к немедленному выполнению той или иной операции. А именно это нам и нужно. Собственно, все. Дальше - man select, man poll ... :-).

Асинхронное уведомление

Вы когда нибудь писали программы под Windows или под DOS с использованием Turbo Vision? Если да, то Вам должна быть знакома парадигма под названием "event driven program". Перевести это на русский язык можно как "программа, управляемая событиями" или дословно "программа, движимая событиями", "программа, приводимая в действие событиями". В чем суть? Традиционный способ написания программ состоит в том, что если ей (программе) нужно выполнить какое-то действие (я не имею ввиду вычисления), то она обращается к операционной системе. А можно ведь поступить по-другому. "Эт как эт???" - спросите Вы, - "операционная система, что ли, к Вам обращаться будет???" Да, именно так. В программе можно иметь часть, которая будет называться "обработчик события". Один такой обработчик может обрабатывать несколько событий. Дальше нужно операционной системе сказать, что вот ЭТО ВОТ есть обработчик того или иного события. И все. Когда соотвествующее событие возникает, операционная система САМА ВЫЗОВЕТ этот Ваш обработчик и передаст ему информацию о событии. Недаром в терминологии Windows такого рода подпрограммы называются callback functions, то есть функции обратного вызова. Слово "обратный" как раз и подчеркивает тот факт, что не прикладная программа обращается к коду ядра, а ядро обращается к коду программы. Понятно, что самому-то ядру обращаться к какому-то там сомнительного происхождения коду не очень то и нужно, это этому коду нужно, чтобы он получил управление при возникновении тех или иных условий.

А нет ли чего подобного в старушке Unix? Естественно, есть. Только называется по-другому. А именно, механизм, вполне аналогичный по сути функциям обратного вызова, здесь называется СИГНАЛЫ. Даже многие из тех, кто привык орудовать мышкой, наверняка знают, что _почти_ любую программу консольного внешнего вида можно прервать, нажав на клавиатуре комбинацию ^c (по крайней мере, в Unix это так). Вопрос. Кто или что эту программу прерывает? Ответ. Да никто. Она это делает добровольно. Поясняю. Когда вы нажимаете указанную комбинацию, операционная система ПОСЫЛАЕТ соответствующему процессу некоторый СИГНАЛ, именно, SIGINT, то есть как бы дает процессу указание бросить все дела и завершиться. А тут уж дело процесса, КАК отреагировать на подобного рода посягательства на его личную жизнь. Можно покорно исполнить, можно заранее сказать операционной системе, чтобы вообще не смела такое учинять, а можно еще предусмотреть свою собственную нестандартную реакцию на тот или иной сигнал. Так а почему все-таки многие программы по ^c завершаются? Да просто потому, что обработчик сигнала SIGINT ПО УМОЛЧАНИЮ делает что? Правильно, сворачивает все дела и завершает процесс. Если это нежелательно, то тогда нужно просто переопределить обработчик этого сигнала. Заметим, что переопределение обработчика возможно не для любого сигнала. Например, нельзя установить собственный обработчик сигнала SIGKILL. KILL - это смертельно :-). Отметим, что термин "ПОСЛАТЬ СИГНАЛ" (равно как и "ПОСЛАТЬ СООБЩЕНИЕ" в Windows) несколько неудачен и сильно замутняет суть дела. На самом деле никто ничего и никого никуда НЕ "ПОСЫЛАЕТ", просто тем или иным способом УПРАВЛЕНИЕ ПЕРЕДАЕТСЯ обработчику того или иного события/сигнала/сообщения.

Стандартный механизм сигналов Unix

Итак, что должен сделать процесс, чтобы получать уведомления о том, что тот или иной дескриптор готов к операции ввода-вывода (читай - чтобы обработчик сигнала вызывался в нужное время)? Во-первых, нужно иметь этот сам обработчик, это просто некая функция с определенными параметрами, имя, естественно, она может иметь любое. Во-вторых, нужно с помощью функции fcntl () установить для нужного дескриптора опцию O_ASYNC (команда F_SETFL). Затем с помощью этой же функции (команда F_SETOWN) нужно указать, какой процесс или группа процессов будут получать сигнал SIGIO и SIGURG (именно эти сигналы посылаются, когда дескриптор становится готов к чтению-записи или возникает исключительная ситуация). Наконец, нужно с помощью системного вызова signal () установить обработчик для этих сигналов, по крайней мере, для SIGIO. После этих действий процесс может перейти в бесконечный цикл, в котором лучше всего делать что? Правильно, спать, а иначе мы опять же почем зря поглотим все процессорное время. Функция sleep () возвращается, когда приходит какой-нибудь сигнал или истекло заказанное время сна. А возвращает она количество времени, которое осталось, простите, доспать (0, если проспали заказанное время). Это можно использовать для определения того, что пришел сигнал и произвести операцию ввода-вывода. Конечно, эту операцию можно делать и непосредственно в обработчике.

Для ожидания сигнала вместо вызова sleep () можно использовать вызов pause (), который возвращается только тогда, когда процессу был послан сигнал и, кроме того, обработчик сигнала закончил работу и вернулся, при этом всегда возвращается -1, а errno принимает значение EINTR (обработчик может завершить процесс, поэтому он не всегда возвращается).

Отметим, что обработчику сигнала передается один-единственный параметр - это, собственно, номер сигнала. Очень информативно %-). Это делается на случай, если у нас один обработчик на несколько сигналов. Это не очень хорошо, если мы работаем с несколькими дескрипторами. В обработчике нужно различать, какой именно дескриптор готов к чтению-записи. Это можно сделать с помощью описанных выше способов - использовать опрос дескрипторов, вручную или с помощью select () или poll (). Еще один тонкий момент. Вы можете не знать, сколько данных готово к чтению. Поэтому, обрабатывая сигнал SIGIO, вы будете читать данные какими-то небольшими порциями. Когда они таки закончатся, а вы вновь попытаетесь читать, то процесс заблокируется внутри обработчика сигнала, а хорошего в этом мало. Поэтому заодно с опцией O_ASYNC совсем нелишним будет указать O_NONBLOCK, то есть перевести дескрипторы в неблокирующий режим. Мы ведь не собираемся беспрерывно опрашивать их (до тех пор, пока не придет сигнал, а раз он пришел, то, значит, какой-то дескриптор готов, но другие могут быть не готовы, поэтому они все должны быть в неблокирующем режиме), так что проблемы нерационального использования процессорного времени здесь нет, как при использовании неблокирующего ввода-вывода в чистом виде, без использования механизма сигналов.

Для справки отметим, что для определения количества данных, которое доступно для чтения, можно использовать вызов ioctl ():

	int nbytes;
	fd = open(fname, fflags);
	ioctl(fd, FIONREAD, &nbytes);

Кроме того, вызов ioctl () можно использовать вместо fcntl () для перевода канала ввода-вывода в неблокирующий режим, для указания процесса-получателя сигнала SIGIO, а также для перевода в режим ввода-вывода, инициируемого сигналами.

Сигналы по стандарту POSIX

От некоторых недостатков предыдущего способа свободен механизм сигналов, реализованный в соотвествии со стандартом POSIX. Например, помимо номера сигнала, обработчику передается много другой необходимой ему информации, а также имеются развитые средства блокировки и ожидания сигналов. Что касается действий, которые должен совершить процесс, то они почти такие же, как при использовании традиционного механизма сигналов в Unix, только обработчик устанавливается не функцией signal (), а функцией sigaction ().

Дата последней модификации: 2012-11-03


/Студентам/Сети/Ввод-вывод в Unix

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

Valid HTML 4.0 Strict Valid CSS!