Ввод/вывод: разбираем что к чему

16 октября 2010 г.

Preface

В данной статье я бы хотел затронуть важные аспекты программирования приложений для web, которые должны обслуживать множество пользователей одновременно, а значит мы будем разбирать уже всем надоевший асинхронный ввод/вывод, мультиплексирование и т.п.
Преследуются следующие цели:

  1. Систематизировать материал по данному направлению, обсудить некоторые несостыковки в терминологии
  2. Полностью разобрать фундамент, на котором строятся приложения для обслуживания множество клиентов
  3. В последствии обсудить ряд спорных вопросов с профессионалами в этой области
  4. Выработать стратегию для будущего приложения на python которое должно обслуживать множество клиентов
  5. Создать четкую картину в голове (недаром говорят понимаешь — когда можешь объяснить)

Зачем?

Годы программирования на PHP принесли свои плоды — я никогда не задумывался что происходит за кулисами (занимался быдлокодингом). Но когда проекты стали медленно, но верно тормозить — я решил что пора взяться за ум и начал изучать питон (что с успехом делаю и сейчас). Но так как мне решительно надоели всякие слова типа «fork», «socket», «eventloop», «multiprocessing», «epoll» и т.п. я решил копнуть глубже. Что из этого получилось — решать вам. Тем не менее определенная путаница присутствует. Подтверждение этому можно найти в ссылках [6,7]

int main()

В данной статье рассматривается I/O на базе ОС Linux версии 2.6 и старше. Так как материал во многом ориентирован на таких как я (прибор для нагревания воды опционально оборудованный звуковым оповещателем), придется разобрать много азов, поэтому просто пропускайте ненужные вам пункты.

Базовые концепции

Файл — фундаментальная абстракция в Linux. Linux придерживается философии «все есть файл», а значит большая часть взаимодействия реализуется через чтение и запись файлов. Операции с файлом осуществляются с помощью уникального дескриптора — файлового дескриптора или fd. Большая часть системного программирования в Linux состоит в работе с файловыми дескрипторами.
Существуют обычные файлы (regular file) — это то к чему мы привыкли (самый обыкновенный «файл» в привычном понимании) и специальные файлы — это некоторые объекты, которые представляются как файлы. Linux поддерживает 4 разновидности специальных файлов:

  • Файлы блочных устройств
  • Файлы устройств посимвольного ввода-вывода
  • Именованные конвейеры (named pipe или FIFO)
  • Сокеты

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

Модели ввода/вывода

Всего в Unix подобных системах доступно 5 + 1 различных моделей ввода/вывода. «Плюс одна» я объясню немного позже, а пока рассмотрим каждую модель более детально.

Блокирующий I/O (blocking I/O)

По умолчанию весь ввод вывод выполняется в блокирующем стиле. Рассмотрим схематичное изображение процессов происходящих при блокирующем вводе/выводе.

В данном случае процесс делает системный вызов recvfrom. В результате процесс заблокируется (уйдет в сон) до тех пор пока не придут данные и системный вызов не запишет их в буфер приложения.
После этого системный вызов заканчивается (return OK) и мы можем обрабатывать наши данные.
Очевидно, что данный подход имеет очень большой недостаток — пока мы ждем данные (а они могут идти очень долго из-за качества коннекта и т.п.) процесс спит и не отвечает на запросы.

Неблокирующий I/O (nonblocking I/O)

Мы можем установить неблокирующий режим при работе с сокетами, фактически сказав ядру следующее: «Если ввод/вывод, который мы хотим осуществить, невозможен без погружения процесса в блокировку (сон), то верни мне ошибку что не можешь этого сделать без блокировки.» Рассмотрим схематичное изображение процессов происходящих при неблокирующем вводе/выводе.

Первые три раза, которые мы посылаем системный вызов на чтение не возвращают результат, т.к. ядро видит, что данных нет и просто возвращает нам ошибку EWOULDBLOCK.
Последний системный вызов выполнится успешно, т.к. данные готовы для чтения. В результате ядро запишет данные в буфер процесса и они станут доступными для обработки.
На этой основе можно создать цикл, который постоянно вызывает recvfrom (обращается за данными) для сокетов, открытых в неблокирующем режиме. Этот режим называется опросом (поллинг/polling) т.к. приложение все время опрашивает ядро системы на предмет наличия данных. Я принципиально не вижу ограничений чтоб опрашивать несколько сокетов последовательно и соответственно читать из первого, в котором есть данные. Такой подход приводит к большим накладным расходам (overhead) процессорного времени.

Мультиплексирование I/O (multiplexing I/O)

Вообще слово multiplexing переводится как «уплотнение». Мне кажется его удачно можно описать девизом тайм-менеджмента — «учись успевать больше». При мультиплексировании ввода/вывода мы обращаемся к одному из доступных в ОС системному вызову (мультиплексору например select, poll, pselect, dev/poll, epoll (рекомендуемый для Linux), kqueue (BSD)) и на нем блокируемся вместо того, чтобы блокироваться на фактическом I/O вызове. Схематично процесс мультиплексирования представлен на изображенииПриложение блокируется при вызове select’a ожидая когда сокет станет доступным для чтения. Затем ядро возвращает нам статус readable и можно получать данные помощью recvfrom. На первый взгляд — сплошное разочарование. Та же блокировка, ожидание, да и еще 2 системных вызова (select и recvfrom) — высокие накладные расходы. Но в отличии от блокирующего метода, select (и любой другой мультиплексор) позволяет ожидать данные не от одного, а от нескольких файловых дескрипторов. Надо сказать, что это наиболее разумный метод для обслуживания множества клиентов, особенно если ресурсы достаточно ограничены. Почему это так? Потому что мультиплексор снижает время простоя (сна). Попробую объяснить следующим изображением

Создается пул дескрипторов, соответствующих сокетам. Даже если при соединении нам пришел ответ EINPROGRESS это значит, что соединение устанавливается, что нам никак не мешает, т.к. мультиплексор в ходе проверки все равно возьмет тот, который первый освободился.
А теперь внимание! Самое главное!
Ответьте на вопрос: У какого события вероятность больше? У события А, что данные будут готовы у какого-то конкретного сокета или у события Б, что данные будут готовы хотя бы у одного сокета?. Ответ: Б
В случае с мультиплексированием у нас в цикле проверяются ВСЕ сокеты и берется первый который готов. Пока мы с ним работаем, другие также могут подоспеть, тоесть мы снижаем время на простой (первый раз мы может ждем долго, но остальные разы — гораздо меньше).
Если же решать проблему обычным способом (с блокировкой) то нам придется гадать, из какого коннекта прочитать первым вторым и т.п. т.е. мы 100% ошибемся и будем ждать, а хотя могли бы не тратить это время

I/O в тредах/дочерних процессах (One file descr per thread or process)

Говоря в начале что существует 5 + 1 способ, имелся ввиду как раз такой подход, когда используется несколько потоков или процессов, в каждом из которых производится блокирующий I/O. Он похож на мультиплексирование ввода/вывода, но при этом имеет ряд недостатков. Всем известный — потоки в линуксе достаточно дорогие (с т.з. системных команд), так что использование потоков вызывает увеличение накладных расходов. К тому же если рассматривать python в качестве языка программирования, в нем существует GIL и соответственно в каждый момент времени внутри 1 процесса может выполняться только один поток. Другой вариант — создавать дочерние процессы для обработки ввода/вывода в блокирующем стиле. Но тогда надо продумывать взаимодействие между процессами (IPC — interprocess communication), что имеет некоторые сложности. К тому же если суммарное количество ядер не превышает единицы, то такой подход имеет сомнительный выигрыш. Кстати, насколько я знаю Apache работает как раз примерно по такой схеме (MPM prefork или threads) обслуживая клиента либо в треде либо в отдельном процессе.

Ввод вывод управляемый сигналами (signal-driven I/O)

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

Вначале необходимо установить параметры сокета для работы с сигналами и назначить обработчик сигналов (signal handler) с помощью системного вызова sigaction. Результат возвращается мгновенно и приложение, следовательно, не блокируется. Фактически, всю работу на себя берет ядро, т.к. оно отслеживает когда данные будут готовы и посылает нам сигнал SIGIO, который вызывает установленный на него обработчик (функция обратного вызова, callback). Соответственно сам вызов recvfrom можно сделать либо в обработчике сигнала, либо в основном потоке программы. Насколько я могу судить, здесь есть одна проблема — сигнал для каждого процесса такого типа может быть только один. Т.е. за раз мы можем работать только с одним fd. Я не претендую на истину в этом вопросе, его надо уточнить.

Асинхронный ввод/вывод (asyncronous I/O)

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

Делаем системный вызов aio_read и указываем все необходимые параметры. Всю остальную работу делает за нас ядро. Конечно, должен существовать механизм который бы уведомил процесс о том что I/O завершен. И тут потенциально возникает множество проблем. Но об этом в другой раз.
Вообще с данным термином связано очень много проблем, примеры ссылок уже приводились. Часто происходит смешение понятий между асинхронным, неблокирующим и мультиплексированным вводом выводом, видимо потому что само понятие «асинхронный» может трактоваться по-разному. В моем понимании (а я физик по образованию) асинхронный — значит независимый во времени. Тоесть единожды запущенный он живет своей жизнью пока не выполнится, а затем мы просто получаем результат. Я где-то находил строгое определение, но к сожалению не записал

На практике

На практике происходит комбинирование разных моделей исходя из задачи. Поступают следующим образом:

  • Используют по потоку/процессу на каждую операцию с блокировкой — не выгодно
  • Много клиентов в разных тредах/процессах. Каждый тред/процесс использует мультиплексор
  • Много клиентов в разных тредах/процессах. Используется асинхронный ввод/вывод (aio)
  • Просто встраивают сервер в ядро

Более подробно в The C10K problem

Итог

Я надеюсь что теперь хоть немного станет ясно об отличиях в тех вещах, в которых легко запутаться.
Ну и да, мультиплексинг рулит (пока не допилят aio, я думаю).
Изучая справочную литературу я пришел к выводу что в отличии от сокетов, регулярные файлы невозможно перевести в неблокирующий режим. Для них вроде бы доступен aio что рассмотрено тут: Asynchronous I/O on linux or welcome to hell. Следующую свою статью я бы как раз хотел сделать на её основе. Изначально предполагал включить данную публикацию, но потом решил что лучше разбить. Выводы пока не утешительные — в linux’е нет нормально работающей системы aio (как хотелось бы) для работы и с файлами и с сокетами.

Литература и ссылки

  1. Роберт Лав «Linux. Системное программирование»
  2. W. Richard Stevens, Bill Fenner, Andrew M. Rudoff «Unix Network Programming Volume 1, Third edition: The sockets networking API»
  3. Стивенс Р., Раго С. «Unix. Профессиональное программирование, 2-е издание»
  4. Всеми любимая The C10K problem
  5. Asynchronous I/O on linux or welcome to hell
  6. Comparing Two High-Performance I/O Design Patterns
  7. Asynchronous vs non-blocking
  8. Blocking vs. Non-Blocking Sockets
Теги: рубрика Python