Страницы

Повышение надёжности по модели проявления ошибок в коде

Сейчас типичная разработка программного кода в большей степени опирается на интуитивное ощущение разработчика, чем на инженерный расчёт. Чутьё, вырабатываемое в опыте, крайне полезно, но требование повышения надёжности требует более осознанного подхода, так как иногда более правильные решения могут быть контринтуитивны. Без выяснения истинных причин ухудшения качества, программисты смогут долго ходить по кругу, вновь и вновь провозглашая очередные спасительные принципы, в немалой степени, основанные на вкусе. Простота достижения алгоритмической полноты обеспечивает бесконечную возможность поиска новых вкусов. Эта же алгоритмическая полнота позволяет обходить любые формалистические требования повышения надёжности, помогая воспроизведению старых ошибок в новых терминах.

Одним из способов повышения осознанности может служить расчёт уровня корректности кода, используя модель вероятности проявления ошибки в коде. Даже неточный расчёт позволит сделать более правильный выбор решений, выгодных для стратегии достижения качественного кода. Но стоит предостеречь и от слишком грубых моделей, которые способны приводить к прямо противоположным результатам.

Условно последовательный код

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

Данные00 → Код0 → Код1 → ... → КодN-1 → Данные0N

Если создать разбиение таким образом, чтобы вероятность ошибочного преобразования одного набора данных в каждом блоке кода одинаково равна p и независима от остальных блоков, то вероятность безошибочной обработки во всём коде и для всего набора данных(K) равна

С = (1 - p)N + K

Чем больше последовательных блоков кода и шире область применяемых данных, тем больше вероятность проявления ошибок, и при большой величине N + K вероятность совокупной безошибочности будет стремиться к 0 даже при малой вероятности ошибки в отдельном блоке кода.

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

Избыточный код

Означает ли это, что любое увеличение кода приводит к ухудшению ошибкоустойчивости? Разумеется, нет, примером чему может быть резервно-избыточный код. В самом простом случае он может быть представлен как параллельное соединение 2-х участков кода.

Код0Данные01
Данные0
Код1Данные11

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

С = (1 - p)2 ≤ 1 - p

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

Но даже без исправления ошибок избыточный код способен понижать вероятность критичных ошибок, так как хотя автоматическое полное устранение ошибки здесь невозможно, но возможно частичное устранение распространения последствий ошибки, понижая уровень критичности её влияния.

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

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

С = (1 - p)(1 - p∙D∙K) + p∙K∙(1 - psm)C
С = (1 - p)(1 - p∙D∙K)/(1 - p∙K∙(1 - psm)) ≥ 1 - p

Где D — это отношениие вероятности ошибок в основном коде к вероятности ошибок в избыточном, 0 ≤ K ≤ 1 — коэфициент, необходимый для учёта покрытия входных данных, а 0 ≤ psm ≤ 1 — вероятность совпадения результата в случае ошибки. В худшем случае вероятность равна 1, но при случайном характере ошибок в нетривиальном коде может стремиться к 0. В последнем случае, а также при полном охвате данных, можно добиться практической безошибочности, но это непросто.

Из формулы должно быть ясно, почему, например, тестирование неспособно добиться безошибочности — тестирование проверяет лишь частные случаи, а для полной безошибочности необходим полный охват. Хотя для полного охвата не обязательно тестировать каждый возможный вход, нахождение набора данных, которые позволяют оценить все качественно отличающихся случаи, предположительно, требует затрат, сопоставимых с формальным доказательством корректности кода.

Многократная избыточность и автокоррекция

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

C = ((1 - p)3 + 3p∙(1 - p)2)(1 - pl)

Где pl - вероятность ошибки дополнительного кода связывания блоков.

Видно, что хотя избыточный параллельный код и повышает среднюю вероятность корректности, он не превращает совокупность ненадёжных блоков кода в надёжный конечный блок. Сверх того, эта схема может даже понизить общую корректность, если сбойность блоков выше 50%. Это вызвано тем, что для таких плохих случаев вероятность сбоя не больше одного из 3-х блоков оказывется даже ниже, чем просто одиночного, что приводит к некоторму количеству ложно-положительных сбоев. То есть слишком сбойные блоки тянут всю систему вниз так же, как менее сбойные тянут вверх. Ситуацию можно поправить, выбрав один из блоков за образец для тех случаев, когда все блоки дают разный результат

Cd = ((1 - p)3 + 3p∙(1 - p)2 + (1 - p)•p2)(1 - pl)

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

Отличие влияния в параллельном и линейном усложнении

Помня про опасность слишком грубого моделирования, следует отличать влияние блоков кода, которые активно преобразуют основные данные и те, что выдают результат отдельно. Например, дополнительные блоки в трансляторе, занимающиеся глубокой оптимизацией генерируемого кода, повышают вероятность появления ошибок в нём.

к0:Исходный код → Разбор(1) → Оптимизация(4) → Генерация(1) → Результирующий код

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

к1:Исходный код→ Разбор(1)→ Генерация(1)→ Результирующий код
↘ Анализ(4) → Диагностика
(Cк1 = (1 - p)2)  ≥  (Cк0 = (1 - p)6)

И это несмотря на то, что для проведения обеих задач может использоваться существенная часть одного и того же кода и они даже могут быть частными случаями одного обобщённого процесса. Важно построить адекватную модель использования и влияния на вероятность ошибки.

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

Разветвление

Можно продолжить рассмотрение ветвящегося кода, для которого характерен неравномерный вклад в вероятность корректности разных блоков кода

↗ Код2,0→ Данные3,0
↗ Код1,0→ Код2,1→ Данные3,1
Данные0→ Код0
↘ Код1,1→ Код2,2→ Данные3,2
↘ Код2,3→ Данные3,3

Совокупная вероятность коррекности такого кода выше, чем если бы все 7 блоков были включены последовательно

C = (1 - p)3 ≥ (1 - p)7

Периферийный слой, несмотря на несоизмеримо больший объём кода при прочих равных имеет то же влияние на корректность, что и центральный. Следуя принципу Парето, можно перераспределить усилия от периферийного к центральному коду таким образом, чтобы в условиях дефицита времени добиться более высокой корректности в целом, пусть и снизив среднее качество блока, но увеличив качество более значимых. И что бы себе не думали идеалисты, требующие достижения максимальной корректности для всех участков, ресурс разработчика почти всегда дефицитен, иначе все бы писали ПО с формальным доказательством корректности.

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

Отсюда видны истоки одной ошибки ряда разработчиков. Создавая периферийный код на языках с высокой гибкостью, но плохой ошибкоустойчивостью, они, наслаждаясь гибкостью, не получают моментальных и жёстких последствий в виде плохо работающей системы, потому что их код — это пусть и массовый, но лишь тонкий и частный слой в полном программном комплексе. Неоправданно экстраполируя такой опыт, разработчики получают соблазн к созданию на основе тех же языков крупных систем с уходом в глубину, что закономерно приводит к неудовлетворительным результатам. После этого, как правило, следует фаза попыток починки языка, что почти никогда не может быть выполнено удовлетворительно.

Влияние организации разработки

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

C = 1 - pc∙p ≥ 1 - (pc∙p + pr)

Где pc — вероятность предотвращения ошибки, а pr — вероятность внесения ошибки при переписывании.

Рецепт написания упомянутых рекомендаций по написанию кода тоже должен включать себя построение модели возникновения ошибок. К сожалению, для полноты картины может понадобиться учитывать психологию человека, разнообразие приобретённого опыта и даже возможность неосознанного саботажа, что делает задачу написания всеобще полезных рекомендаций делом довольно трудным. Более полезным может быть выделение целевой аудитории, способной разделять предложенные подходы.

Пример рекомендации для С — по умолчанию используйте для вычисления целых тип со знаком.

  1. Из-за близости нижней границы у беззнаковых повышается вероятность переполнения даже для выражений с малыми величинами
  2. Невозможность простой диагностики переполнения беззнаковых, которые по стандарту просто вычисляются по модулю, в то время как переполнение чисел со знаком — неопределённое поведение, что позволяет компилятору воплотить его как диагностику ошибки.
  3. И так далее, вроде ошибочных неявных и явных приведений при вычислениях, совмещающие знаковые и беззнаковые

Итоги

Можно сделать некоторые выводы для повышения надёжности:

  1. Уменьшать наслоения последовательного кода при прочих равных
    1. Повышать высокоуровневость языка, так как высокий уровень позволяет сократить количество кода. Транслятор языка — это тоже слой, надёжность которого не равна 1 и необходиммо также учитывать, какой ценой даётся повышение уровня.
    2. Избегать оптимизаций, когда в них нет необходимости, как ручных, так и компиляторных.
    3. Избегать чрезмерной архитектуризации, решающей несуществующие проблемы. Это приводит к избыточному наслонению тогда, когда это ещё не нужно. Иногда нужно просто написать код.
    4. Обобщённо, избегать любой сложности, обусловленной возможностью, а не необходимостью.
  2. Правильно повышать параллельную избыточность кода.
  3. Сужать область данных. Избегать ложных обобщений. Ошибочные обобщения способны не только вывести код за область адекватной применимости, но и провоцируют размножение кода, необходимого для поддержки расширенной области данных, которая из-за ложности обобщения окажется незадействованной.
  4. Уменьшать уровень ошибочности каждого блока кода
    1. Выбирать языки с повышенной ошибкоустойчивостью при создании системообразующего кода, кода с длительной поддержкой, широкоприменимого кода
    2. Использовать все доступные средства статического и динамического анализа, особенно тех, что обходятся максимально дёшево. Обычно это встроенные средства транслятора.
    3. С самого начала использовать рекомендации, нацеленные на понижение риска возникновения ошибок и, наоборот, избегать их формалистического внедрения на поздних стадиях разработки кода
    4. Учиться создавать код с максимально достижимым уровнем корректности с самого начала, а не расчитывать только на отладку
    5. Выбирать и создавать интерфейсы, сужающие степень свободы появления ошибок
  5. Необходимо строить уточнённые модели, учитывая конкретизацию. Нельзя применять рекомендации якобы на все случаи жизни за границей их применимости. Нужно учитывать разницу в блоках кода, полученных разными путями, способе использования, изменения в дорабатываемом коде и другие особенности. Никакое перечисление ответов для частных случаев не сможет ответить на то количество вопросов, на которое сможет ответить умение моделировать.

Комментариев нет:

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