среда, 2 сентября 2009 г.

[comp.prog] Принцип Fail Fast, Restart Quickly как понимаю его я.

В комментариях к заметке об исключениях я обещал затронуть тему принципа "Fail Fast, Restart Quickly". Вот, нашлось время выполнить это обещание. Неприятно начинать изложение с извинений и отмазок, но я вынужден признать, что не смогу предоставить ссылок на какие-либо документы и статьи, в которых этот принцип был бы описан (тогда как прото по теме Fail Fast информации можно найти довольно много).

У меня в голове этот принцип сформировался после знакомства с платформой HP NonStop (он же Tandem). После штудирования документации я пришел к выводу, что надежность и отказоустойчивость как самих NonStop-ов, так и разработанных для них приложений строится на следующих вещах:

  • тотальное дублирование;
  • тотальная проверка;
  • быстрый отказ;
  • быстрое восстановление.

Все это присутствует в NonStop-ах как на железном, так и на программном уровне. Например, процессорный модуль (CPU Unit, если не ошибаюсь в терминологии) имеет два одинаковых процессора, два одинаковых банка памяти, две шины памяти, два комплекта портов ввода-вывода и т.д. Все это работает параллельно -- оба процессора выполняют одни и те же инструкции, а память содержит одни и те же данные. Дублирование.

CPU unit снабжен специальными контроллерами, которые проверяют совпадение результатов. Т.е. запросил процессор данные из памяти, эти данные считываются из обоих банков памяти и проверяются на совпадение. Если совпадают, то хорошо, данные отдаются процессорам на обработку. Если не совпадают -- все, сбой. Точно так же проверяются данные, которые идут от процессоров в память после обработки. Если совпадают, то пусть идут. Не совпадают -- значит отбой, гасим свет. Тотальная проверка.

После того, как контроллер обнаруживает сбой, он вырубает CPU unit полностью. Быстрый отказ.

О сбойном CPU unit-е становится известно ядру ОС. И ядро предпринимает действия по восстановлению системы. Например, по миграции работавших на этом CPU unit-е приложений на другие CPU unit-ы. Быстрое восстановление.

Аналогичные принципы используются и при построении ПО для NonStop-ов. Программист имеет возможность разрабатывать программы, которые работают в виде пары процессов -- primary- и backup-процессы. Основную работу (например, операции ввода-вывода и пр.) выполняет primary-процесс, а backup-процесс находится в пассивном состоянии. Когда primary-процесс завершает какое-то действие и хочет синхронизировать свое состояние с backup-процессом, то выполняется специальная операция постановки checkpoint-а -- обмен информацией между primary- и backup-процессами. Если primary-процесс после этого упадет или же сбойнет его CPU-unit, то ОС сделает primary-процессом выживший backup-процесс (для чего ОС всегда запускает каждый процесс из пары на разных CPU unit-ах), а сама запустит новый backup-процесс на каком-то другом CPU unit-е.

За контроль за работоспособностью оборудования отвечает программно-аппаратная начинка NonStop-ов. Но для прикладных приложений этого мало. Нужен еще и контроль за корректностью работы самой прикладной программы, а это уже задача программиста. Чем более тщательно он напишет проверки, тем лучше. А одним из удобных способов написания надежного ПО является его максимальное упрощение. В частности, если программа ожидает выполнение каких-то условий и вдруг обнаруживает их нарушение, то она сразу завершает работу. Т.е. акцент делается на:

  • как можно более ранее обнаружение неприятностей, и
  • отсуствие каких-либо сложных действий по их преодолению.

Т.е. тот самый Fail Fast.

Если процесс падает из-за каких-то (предположительно временных) проблем, то прикладная система должна продолжать работать. В идеале, сбой вообще не должен быть заметен внешнему миру. Это значит, что сбойный процесс должен как можно быстрее рестартовать и вернуться к работе. Т.е. выполнить Restart Quickly. На NonStop-ах последнее обеспечивается как раз за счет checkpoint-ов, которыми регулярно должны обмениваться между собой primary- и backup-процессы.

Disclaimer. Сразу хочу предупредить. Писать пары процессов для NonStop-ов мне не довелось, т.к. повезло. На NonStop-ах было две программные оболочки. Одна, называемая Guardian, является родной. И все специфические NonStop-овские вещи, включая пары процессов, доступны только под ней. Но программировать под Guardian -- это то еще занятие. Поскольку там своя специфическая файловая система, свой диалект языка C, свой собственный API, свой собственный текстовый редактор, свой собственный shell и т.д. Вторая оболочка, OSS -- Open System Services, это обычный POSIX. Нормальная UNIX-овая файловая система, нормальные C/C++, нормальный POSIX API. Но зато никаких NonStop-овских вкусностей. Я как раз портировал приложение под OSS.

Итак, принцип Fail Fast, Restart Quickly заключается в том, чтобы программа прекращала свое выполнение сразу же после обнаружения неожиданных проблем (не пытаясь их преодолеть) и при этом обеспечивала быстрый автоматический рестарт с возвратом в то состояние, которое максимально близко предшествовало сбою.

Ну например, пусть мы пишем SMTP сервер. Получает он от клиента пару писем, фиксирует их в БД, отсылает клиенту OK, определяет, куда нужно эти письма переслать, выполняет логирование своего намерения... И тут оказывается, что запись в лог не удалась. Не важно почему -- переполнился ли диск, достиг ли файл лога максимального размера, отмонтировали раздел с логами или же он вообще полетел. Не важно. Свое намерение мы не выполнили, значит нужно тушить свет. Т.е. наш SMTP сервер должен завершить свою работу (Fail Fast). Для этого достаточно операций в самом SMTP сервере.

А вот для обеспечения части Restart Quickly нам потребуется дополнительный процесс-монитор (называемый еще watch dog-ом или supervisor-ом). Наш SMTP сервер будет стартовать из-под монитора. И когда монитор обнаружит падение SMTP сервера, он его рестартует. Но это еще не будет полной поддержкой Restart Quickly. Для ее полной поддержки нужно, чтобы SMTP сервер после рестарта вернулся к продолжению операции отсылки тех самых двух писем, на которых все завершилось. Что достигается, например, расстановками каких-либо контрольных точек в БД.

Естественно, принцип Fail Fast извествен очень давно и под разными марками. Так, на мой взгляд, Design By Contract в Eiffel (а так же в D) -- это чистой воды Fail Fast. Причем не только за счет пред- и постусловий. Но еще и за счет инвариантов классов (специальных проверок, которые автоматически запускаются после завершения любого публичного метода класса) и инвариантов циклов. Только вот в Eiffel нет готовой фазы Restart Quickly.

Зато практически все это есть в Erlang. Только там это называется Let It Fail. (Насколько я знаю, в Erlang не принято выполнять проверки пред- и постусловий. Но зато механизм супервизоров и рестартов сбойных процессов там используется на всю катушку).

Отдельной темой в разговоре о принципе Fail Fast является его практическая применимость. Лично мне кажется, что Fail Fast (даже без Restart Quickly) должен использоваться как можно больше. Поскольку он защищает нас от нас же самих. Например, недавно мы запустили суточный тестовый прогон важной программы. Чтобы через сутки обнаружить, что файлы отчетов не были сформированны из-за ошибки в имени каталога в конфигурации. Недоработки в программе -- не проконтролировали результат вызова open, затем писали отчеты в неоткрытый файл без проверки кодов возврата. В результате обнаружили проблему только спустя много часов после ее возникновения. А могли бы сразу.

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

В качестве иллюстрации очень удачного, на мой взгляд, деления приведу программку eMule. У нее есть хитрая особенность -- если по каким-то причинам она не может установить соединение с сервером из файла server.met (список eDonkey-серверов), то имя этого сервера из server.met удаляется. Соответственно, если провайдер на несколько часов "обрубает" канал, то eMule может удалить server.met все имена. Дабы восприпятствовать этому я просто поставил на файл server.met атрибут read-only. Теперь eMule просто пишет, что не может обновить этот файл и все. Ни тебе падений (вполне разумное игнорирование Fail Fast-а, поскольку в многозадачном окружении любой процесс может заблокировать на время файл и это нормально), ни тебе попыток снять read-only.

В качестве иллюстрации неудачного использования Fail Fast: пусть некий сервер получает от клиента пакет команд (индивидуальные команды офомляются в пакет для уменьшения трафика), выполняет их и отсылает клиенту пакет ответов (опять же для экономии трафика). Если при выполнении i-й команды из пакета сервер вдруг обнаруживает, что он не может ее выполнить, то он может отказаться выполнять и все остальные команды из пакета. Якобы руководствуясь принципами Fail Fast. Хотя ошибка может быть вызвана не проблемами сервера, а неправильными параметрами данной команды. И остальные команды могли бы быть успешно выполнены.

К сожалению, каких-то готовых критериев, которые бы подсказали разработчику, где нужно следовать Fail Fast-у, а где лучше его проигнорировать, у меня нет. Тут нужен опыт, вскус, чувство меры, здравый смысл и, наверное, некоторая степень параноидальности в купе с педантичностью. В общем, все то, что со временем приобретает хороший разработчик.

Но вот что касается более конкретных технических деталей, то здесь ситуация получше. Итак, на мой взгляд, для использования принципов Fail Fast необходимо:

  • отказаться от использования языков программирования, в которых нет поддержки исключений;
  • отказаться от использования библиотек, которые не используют исключений (если только речь не идет о суровых real-time или embedded условиях, где исключений нет вообще) (касается D и C++);
  • если уж приходится использовать "старые" библиотеки, которые используют коды возврата или глобальные флаги типа errno, то следует либо проверять коды завершения всех вызовов, либо создавать обертки для используемых функций, которые бы генерировали исключения (касается D и C++);
  • сразу информировать о своих проблемах с помощью исключений;
  • не "проглатывать" исключения. Т.е. блоки catch должны ловить только те исключения, которые вы в данный момент способы обработать или преобразовать в новые исключения. По сути, блок catch должен либо исправлять ситуацию, либо выбрасывать исключение;
  • если это возможно, использовать Design By Contract (пред-, постусловия, инварианты). Как частность -- проверка аргументов в методах/функциях, которые являются внешним итерфейсов ваших модулей/библиотек;
  • писать простой код.

Естественно, это только мой взгляд на вещи и вы не обязаны его разделять. В конце-концов, нормальный C-шный код с assert-ами и abort-ами может быть гораздо надежней переусложненного C++/Java/C# варианта. Но, имхо, на языках с исключениями результат достигается все-таки проще, быстрее и дешевле. Хотя в том же Google в C++ запрещено применять исключения, но Google-разработчикам удается писать отказоустойчивый софт.

Отмечу еще, что по-моему, в языках с поддержкой исключений применять подход Fail Fast, Restart Quickly можно не только на уровне всего приложения, но и на уровне его отдельных модулей. Особенно в безопасных языках, где среда выполнения следит за отсутствием ошибок вроде выхода за пределы массива и перезаписи чужой памяти. Приложение может состоять из нескольких слоев. Сбой на одном из слоев будет приводить к завершению работы на этом слое и передаче описания проблемы (исключения) на более высокий уровень. Где имеется возможность рестартовать сбойный слой полностью. Например, пусть упомянутый выше SMTP сервер состоит из одной главной нити и нескольких рабочих. Рабочие нити выполняют взаимодействие с клиентами и отсылку их почты. Когда рабочий поток обнаруживает сбой (невозможность записать строку в лог-файл), он завершает свою работу и отсылает уведомление главной нити. Та реагирует на это уведомление и стартует новый рабочий поток. Таким образом прицип Fail Fast, Restart Quickly может использоваться на "микроуровне".

Вот, вкратце и все, что я хотел рассказать про Fail Fast и Restart Quickly. Но скажу еще одну вещь, имхо, важную -- это на словах все так просто и гладко. И в тех же рестартах есть очень простая возможность войти в бесконечный цикл постоянных рестартов... Но это уже совсем другая история.

В заключение несколько ссылочек по теме:
Сборник мнений и цитат по поводу Fail Fast, Offensive Programming, Defensive Programming на www.c2.com.
Краткое описание принципов построения и работы NonStop-ов: A NonStop Kernel.
Небольшая и попсовая статья о приципе Fail Fast в контексте C#: Fail Fast.

Отправить комментарий