суббота, 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 комментариев:

Quaker комментирует...

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

eao197 комментирует...

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

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

имя комментирует...

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

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

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

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

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

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

имя комментирует...

упс!

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

имя комментирует...

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

int i=0;

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

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

eao197 комментирует...

@имя:

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

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

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

имя комментирует...

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

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

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

eao197 комментирует...

>А чтобы нельзя было "забыть" про ошибку, достаточно возвращать из функции алгебраический тип данных -- чтобы вытащить из него значение, его придется 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. При этом проглатывая исключения и маскируя тем самым ошибки.

eao197 комментирует...

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

имя комментирует...

> case _ // Чтобы компилятор не ругался.

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

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

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

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

_____________________________

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

имя комментирует...

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

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

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

eao197 комментирует...

>> case _ // Чтобы компилятор не ругался.

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


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

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

eao197 комментирует...

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

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

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

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

eao197 комментирует...

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

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

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

имя комментирует...

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

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

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

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

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

eao197 комментирует...

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

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

Rustam комментирует...

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

Rustam комментирует...

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

имя комментирует...

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

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

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

eao197 комментирует...

@имя:

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

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

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

имя комментирует...

может 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)

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

eao197 комментирует...

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

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


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

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

имя комментирует...

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

eao197 комментирует...

>и насчет дятлов -- основным дятлом является Гослинг

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

имя комментирует...

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

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

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

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

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

eao197 комментирует...

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

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

имя комментирует...

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

eao197 комментирует...

>а Эккель тут сильно при чем?

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