суббота, 16 октября 2010 г.

[prog.flame] Пару слов об let-it-crash

Данная заметка является развернутым комментарием к одному моменту в споре об исключениях, завязавшемся в обсуждении моей предыдущей заметки о языках D и Go.

Вкратце напомню, в чем дело. Ув.тов.Left и Gaperton высказывались в том ключе, что раз в Go нет исключений, то и ничего плохого в этом нет. Что можно выделять действия, которые могут завершаться ошибкой (например, делением на ноль или попыткой вычислить квадратный корень из отрицательного числа), в отдельные goroutine. Тогда при возникновении проблемы прибивается только эта goroutine, а запустивший ее код сможет понять, что произошел сбой и что-либо предпримет.

В защиту этой точки зрения приводился подход к разработке ПО на языке Erlang. Там операции распределяются по легковесным Erlang-овским процессам, а сами процессы выстраиваются в деревья, где процесс-родитель информируется о сбоях в дочерних процессах и может либо перезапустить дочерний процесс, либо завершиться самому, либо сделать что-нибудь еще (коротко прочитать об этом можно здесь). При этом сами процессы в Erlang принято разрабатывать в соответствии с принципом let-it-crash. Тут я лучше процитирую ув.тов.Gaperton:

Там рулит подход let-it-crash, при котором ты вообще не пишешь обработки ошибок, и закладываешься только на успешный случай. А когда оно упадет - как бы ни упало - обрабатывается падение процесса, и все.

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

Отличный подход. Понятное дело, что я не мог его не попробовать. Попробовал. Мне очень понравилось. Код получается простой. Кода получается не много. Поведение программ получается намного более предсказуемым. Так что с точки зрения программиста все просто замечательно.

Но в ряде случаев этот подход почему-то не нравится пользователям софта. А когда пользователям не нравится и они настоятельно просят сделать что-то, что мне, как программисту не нравится и кажется странным и абсолютно не нужным, то приходится наступать на горло своей песни. Т.е. забивать на let-it-crash, чтобы воплотить в жизнь принцип let-it-live :/

Ну, например. Есть программулина, которая время от времени лезет в БД за каким-то значением. Поскольку программулина работает долго, а БД лежит на другом сервере, то неизбежно возникают ситуации, когда связь с БД рвется. И обнаруживается это как раз при очередном обращении к БД.

Казалось бы, все просто – мы в БД с select-ом, а нам вместо ответа exception. Ну мы, как приверженцы let-it-crash, описание проблемы в log, а сами зовем abort. После чего нас рестартуют, мы заново подключаемся к БД, выполняем select и продолжаем работать как будто этого сбоя и не было.

Но вот пользователям эта простота не нравится. А у нас, говорят, в логах слишком много таких ошибок появляется, временами. С ними разбираться приходиться. А у нас, говорят, системы мониторинга начинаю кричать, когда какой-то процесс аварийно завершается. А еще у нас, признаются, временами супервизоры “подвисают” и забывают упавшие процессы поднимать на автомате. Поэтому не могли бы вы, программисты вы наши всесильные, сделать так, чтобы при возникновении ошибки “нет связи с сервером БД” при select-е вы не звали abort, а просто пытались заново к БД подключиться? Раза эдак два, три. А еще лучше, чтобы параметры восстановления подключения мы бы сами в конфиге прописывали. И только если все попытки обломились, либо же если на эти попытки было затрачено слишком много времени, то тогда уже abort. Ну очень надо!

Так что let-it-crash – это очень хорошо для программиста, гораздо лучше, чем let-it-live. Только не всегда допустимо. А там где недопустимо, нужны какие-то средства, которые не позволят прозевать неожиданную проблему (вроде забытой проверки кода возврата функции). И которые позволяют проблему вовремя диагностировать и исправить. Исключения, например.

PS. Для C++ных программ, работающих в режиме 24x7, подход let-it-crash, наверное, еще более важен, чем для Erlang-а. Поскольку уронить C++ную программу очень просто. Иногда для этого достаточно просто на новую версию Visual C++ перейти :)

28 комментариев:

  1. С точки зрения обеспечения корректности мне такой подход не слишком нравится. С другой стороны понятно, что отловить все возможные ошибки - непростая задача.
    p.s. Появились интересные рекомендации по оформлению библиотек на языке Си:
    http://www.opennet.ru/opennews/art.shtml?num=28305
    Там тоже есть пункты про обработку ошибок.

    ОтветитьУдалить
  2. >С другой стороны понятно, что отловить все возможные ошибки - непростая задача.

    Угу. Именно этим и ценен опыт Erlang-а. Там занимаются разработкой надежных систем даже в присутствии программных ошибок.

    ОтветитьУдалить
  3. по-моему, для *этого* исключения не нужны -- они нужны только для того, чтобы не писать код, форвардящий неудачу/панику

    "повторить безуспешные попытки 5 раз, а затем let-it-crash" вполне вписывается в модель let-it-crash

    понятно, что нельзя в сигнатуру функции ввести *все* необходимые детали -- достаточно было бы в сигнатуру функции ввести один параметр Retry& retry; скорее всего, там будет наследник (вероятно абстрактного) класса Retry, который будет определять все детали повторных обращений: логирование, количество повторов, таймаут после 1-го раза, таймаут после 2-го раза, дальше, допустим, если это гуевая (а не серверная) прога -- то функцию, запрашивающую у юзера новый логин-пароль-урл для доступа к БД ...

    ну, а дальше let-it-crash (и возможно возвратить проработавший retry ?)

    да, это выглядит немного непривычно, но ничем не хуже (для данного использования) указания исключений в сигнатуре функции; можно иметь готовые классы Retry3Times например, и синтаксически это намного короче городьбы блоков try/catch

    однако, ПОВТОРЮСЬ, в том случае, когда нам потребуется нелокальная передача управлнеия и код для форвардинга неудачи -- тогда это будет менее удобно, чем исключения

    ОтветитьУдалить
  4. упс!

    все вышенаписанное относится не к нынешнему языку с++, а к "с++ с расширенными возможностями метапрограммирования"; ведь мы в прошлой дискуссии обсуждали разные языки

    ОтветитьУдалить
  5. ну или в D, поскольку там имеются соответсвующий способо передачи параметров

    int i=0;

    Retry3Times(some_function, login, password, ++i);

    и здесь будет i=3, чего в с++ не добиться

    ОтветитьУдалить
  6. @имя:

    >по-моему, для *этого* исключения не нужны -- они нужны только для того, чтобы не писать код, форвардящий неудачу/панику

    Для этого не нужны. Исключения нужны для того, чтобы нельзя было "забыть" про ошибку.

    А по поводу повторов... В Eiffel-е до недавнего времени именно такой подход к исключениям и исповедовался. Там даже специальное ключевое слово есть в языке -- retry.

    ОтветитьУдалить
  7. > Исключения нужны для того, чтобы нельзя было "забыть" про ошибку.

    А чтобы нельзя было "забыть" про ошибку, достаточно возвращать из функции алгебраический тип данных -- чтобы вытащить из него значение, его придется match-ить.

    Так что введение в эрланге исключений -- да, аргумент за них, но полезно бы найти/построить пример, где они действительно нужны.

    ОтветитьУдалить
  8. >А чтобы нельзя было "забыть" про ошибку, достаточно возвращать из функции алгебраический тип данных -- чтобы вытащить из него значение, его придется match-ить.

    Я так не думаю. При использовании АлгТП очень легко придти к коду вида:

    file_read_result = read_N_bytes(file, N);
    match file_read_result {
    case Bytes[K] => // bla-bla-bla
    case _ // Чтобы компилятор не ругался.
    }

    Собственно, такой маразм с исключениями можно увидеть и в Java -- из-за наличия checked exceptions разработчики предпочитают писать пустые catch-и для того, чтобы удовлетворить спецификатор throws. При этом проглатывая исключения и маскируя тем самым ошибки.

    ОтветитьУдалить
  9. Кстати, подумалось еще и вот что по отношению к АлгТП vs исключения. Ведь АлгТП плохо масштабируются, в отличии от исключений. Например, для функции read_N_bytes мы можем ввести базовый тип исключения IOException и некоторое количество наследников. Затем, через N лет, в число наследников будет введен какой-то новый тип RemoteFilesystemFailedException. И ничего страшного. Код, который вызывал read_N_bytes даже перекомпилировать не придется. Тогда как если бы read_N_bytes возвращала какой-нибудь АлгТП, то добавление к нему еще и RemoteFilesystemFailedResult потребовало бы полной перекомпиляции всех использующих read_N_bytes программ.

    ОтветитьУдалить
  10. > case _ // Чтобы компилятор не ругался.

    не понял -- почему ругается компилятор без этого

    он не понимает, что список исчерпывающий? тогда это его тупизм, а не системы с АлгТД

    > то добавление к нему еще и RemoteFilesystemFailedResult потребовало бы полной перекомпиляции всех использующих read_N_bytes программ.

    да, я щас об этом думал -- да, вместо АлгТД вполне возможно использовать "открытый" АлгТД, т.е. обычных наследников класса (но это видимо потребует добавления "case _")

    _____________________________

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

    ОтветитьУдалить
  11. > Собственно, такой маразм с исключениями можно увидеть и в Java -- из-за наличия checked exceptions разработчики предпочитают писать пустые catch-и для того, чтобы удовлетворить спецификатор throws. При этом проглатывая исключения и маскируя тем самым ошибки.

    Если они это делают потому, что считают список исключений исчерпывающим сейчас и *нерасширяемым* в будущем, то пофиксить это можно, объявив класс нерасширяемым в будущем, т.е. АлгТД (например, case-классом в скале).

    Если они считают список исключений расширяемым в будущем, они видимо должны re-throw exception, не? И чтобы сократить синтаксический оверхед, можно было бы сделать такое поведение по умолчанию.

    ОтветитьУдалить
  12. >> case _ // Чтобы компилятор не ругался.

    >не понял -- почему ругается компилятор без этого


    Потому, что насколько я знаю, в ФЯ с паттерн-матчингом в конструкции match должны быть перечислены все возможные варианты. И компилятор ругается, если что-то не обработано. Именно это временами приводят в качестве еще одного преимущества ФЯ над императивными языками.

    Что приводит к тому, что если нам в match-е нужно обработать только один вариант, то остальные мы должны явно проигнорировать через case _ или что-то подобное.

    ОтветитьУдалить
  13. >гуглькод не находит то, что я бы назвал ключевым словом retry... в общем, что оно умеет?

    retry -- это из Eiffel-я. Коротко об особенностях исключений Eiffel здесь: http://eao197.narod.ru/better_language/languages/eiffel/0_overview.html#id25

    Более подробно нужно смотреть где-нибудь на eiffel.com.

    Правда, до меня доходили слухи, что механизм исключений в последних версиях Eiffel-я расширили. Но как я не в курсе.

    ОтветитьУдалить
  14. >Если они это делают потому, что считают список исключений исчерпывающим сейчас и *нерасширяемым* в будущем, то пофиксить это можно, объявив класс нерасширяемым в будущем

    Не, в Java это делают потому, что какие-нибудь дятлы почему-то решают строго зафиксировать в интерфейсе список разрешенных исключений в конструкции throws. Например, напишут: throws FileNotFoundException, SecurityException. А когда другой дятел начинает такой интерфейс имплементить, то у него в реализации может выскочить MimeTypeParseException. И обработать у себя в реализации он его не может. Что в такой ситуации делать? Либо ловить MimeTypeParseException и преобразовывать в FileNotFoundException? Либо подавлять.

    Проще подавлять. Что ламеры и делают.

    ОтветитьУдалить
  15. > Что приводит к тому, что если нам в match-е нужно обработать только один вариант, то остальные мы должны явно проигнорировать через case _ или что-то подобное.

    Cупер!!! Ясно. Я знаю это явление, но не нашел пока ему название.

    То же случается, например, когда делается join по совпадающим именам в двух sql-таблицах, а потом одно поле в одной таблице переименовывается и так *случайно* попадает в join.

    Я считаю, в языке должна быть спец. поддержка против этого. Но она м.б. довольно непривычна.

    А match, хотя бы в некоторых формах, вполне возможно затащить в императивные языки. Сам по себе исчерпывающий перебор вариантов, проверяемый компилятором, не является имхо прерогативой ФЯ. Хотя бы те же enum.

    ОтветитьУдалить
  16. >Сам по себе исчерпывающий перебор вариантов, проверяемый компилятором, не является имхо прерогативой ФЯ.

    Если мне не изменяет склероз, шаги в этом направлении делали в D. Во-первых, если написать switch без default и ни одна ветка не сработает, то во время работы программы будет выброшено исключение. Во-вторых, если в качестве типа выражения в switch используется enum, то во время компиляции проверяется, все ли варианты были обработаны (т.н. final switch в D2).

    ОтветитьУдалить
  17. @имя
    В OCaml есть полиморфные варианты по сути это и есть открытые АТД. Но ошибки при этом все равно принято обрабатывать через исключения.

    ОтветитьУдалить
  18. @имя паттерн матчинг конечно можно ввести в императивные языки, но без алгебраических типов данных, списков и т. п. он как-то малополезен, то есть язык придется существенно перекраивать.

    ОтветитьУдалить
  19. > А когда другой дятел начинает такой интерфейс имплементить, то у него в реализации может выскочить MimeTypeParseException.

    Это интересная проблема, и я пока что не знаю, как ее правильно решать.

    Но мне кажется, тут перебор вариантов все равно сможет, хотя бы частично, работать; допустим, MimeTypeParseException.
    должна возвращаться в виде RealizationDetailException< MimeTypeParseException >, а в любом интерфейсе в списке исключений стоять AnyRealizationDetailException

    ОтветитьУдалить
  20. @имя:

    >а в любом интерфейсе в списке исключений стоять AnyRealizationDetailException

    На самом деле в Java достаточно было бы принять соглашение, что в throws можно писать только Exception. Есть throws Exception -- значит нужно ждать исключений от метода. Если нет, значит метод дает строгие гарантии по исключениям.

    Правда, Java бы это не помогло. Там есть еще и RuntimeException, который плюет на все throws :)

    ОтветитьУдалить
  21. может retry в eiffel чем-то полезна, но там у нее похоже недостатки

    1. обычно логика retry почти не связана с логикой самой функции

    например, гуевая прога может при повторах запросить новый логин-пароль к базе, а серверная -- просто логирует; это никак не связано с внутренностями функции connect_to_db

    поэтому в секцию retry должен передаваться внешний код, допустим анонимная функция

    2. опять же там трудно абстрагироваться, хотя например явно будет полезна функция

    template< class A, class R > retry_3_times(int time1, int time2, int time3, R f(A), A a)

    или допустим template< class A, class R > retry_while( bool while_f(), R f(A), A a)

    (понятно, что реально типов А будет больше)

    ОтветитьУдалить
  22. >1. обычно логика retry почти не связана с логикой самой функции

    например, гуевая прога может при повторах запросить новый логин-пароль к базе, а серверная -- просто логирует; это никак не связано с внутренностями функции connect_to_db


    Я думаю, что retry специально сделали, чтобы такого не было. Т.е. можешь ты внутри функции _исправить_ проблему -- исправляешь ее с помощью retry. Не можешь -- выпускаешь наружу.

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

    ОтветитьУдалить
  23. и насчет дятлов -- основным дятлом является Гослинг (а не рядовые ява-программисты), который не продумал этот момент при создании языка, хотя декларировалось включение в язык Только Офигенно Проверенных Временем Фич, Ведущих К Охрененной Надежности Языка

    ОтветитьУдалить
  24. >и насчет дятлов -- основным дятлом является Гослинг

    Он, Бил Джой (который Ява поддерживал в Sun-е изначально), Брюс Эккель с Фаулером и подобными МакКоннелами. Сначала убедили весь мир, что Java -- это не гуано, а очень вкусно. А потом пошли искать счастье в Python-е и Ruby (это я про Эккеля с Фаулером).

    ОтветитьУдалить
  25. > Т.е. можешь ты внутри функции _исправить_ проблему

    ... то я напишу еще один while

    однако retry тут видимо все же полезен чисто синтаксически:

    1. не надо придумывать имя и объявлять переменную bool all_ok

    2. экономится 1 отступ

    ОтветитьУдалить
  26. >однако retry тут видимо все же полезен чисто синтаксически

    + еще явно специальной синтаксической конструкцией декларируются намерения разработчика. Мол, это исправление исключительной ситуации, а не просто какой-то обычный while.

    ОтветитьУдалить
  27. а Эккель тут сильно при чем? мне казалось, он просто писатель, пишет про все подряд
    :-)

    ОтветитьУдалить
  28. >а Эккель тут сильно при чем?

    Слишком многие по его книжками учили Java :)

    ОтветитьУдалить