Как сделать свои программы надежнее?

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

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

Проблема настольно серьезна, что попытки ее решения предпринимаются как на уровне самих языков программирования, так и на уровне компиляторов. К сожалению, достигнутый результат все еще оставляет желать лучшего, и ошибки переполнения продолжают появляться даже в современных приложениях – ярким примером могут служить: Internet Information Service 5.0 (см. Microsoft Security Bulletin MS01-016), Outlook Express 5.5 (см. Microsoft Security Bulletin MS01-012), Netscape Directory Server 4.1x (см. L0PHT A030701-1), Apple QuickTime Player 4.1 (см. SPSadvisory#41), ISC BIND 8 (см. CERT: Advisory CA-2001-02, Lotus Domino 5.0(см. Security Research Team, Security Bulletin 010123.EXP.1.10) -список можно продолжать до бесконечности. А ведь это серьезные продуктысолидных производителей, не скупящихся на тестирование!

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

Причины и последствия ошибок переполнения

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

Мало того, что контроль выхода указателя за границы массивавсецело лежит на плечах разработчика, корректный контроль переполнениявообще невозможен в принципе! Получив указатель на буфер, функция неможет самостоятельно вычислить его размер и вынуждена либо полгать, чтовызывающий код выделил буфер заведомо достаточно размера, либотребовать явного указания длины буфера в дополнительном аргументе (вчастности, по первому сценарию работает gets, а по второму – fgets).

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

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

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

  1. другие переменные и буфера;
  2. служебные данные (например, сохраненные значения регистров и адрес возврата из функции);
  3. исполняемый код;
  4. никем не занятая или несуществующая область памяти.

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

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

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

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

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

Поэтому, представляет интерес поговорить о том, можно ли предотвратить такую угрозу и если да, то как.

Предотвращение возникновения ошибок переполнения

Переход на другой язык

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

Именно такой подход и был использован в Ада, Perl, Java инекоторых других языках. Но сферу его применения ограничиваетпроизводительность – постоянные проверки требуют значительных накладныхрасходов, в то время как отказ от них позволяет транслировать дажесерию операций обращения к массиву в одну инструкцию процессора! Темболее, такие проверки налагают жесткие ограничения на математическиеоперации с указателями (в общем случае требуют запретить их), а это всвою очередь не позволяет реализовывать многие эффективные алгоритмы.

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

Тем более, что ни Ада, ни Perl, ни Java (т. е. языки, неотягощенные проблемами переполнения) принципиально не способны заменитьСи/C++, не говоря уже об ассемблере! Разработчики оказываются зажатыминесовершенством используемого ими языка программирования с однойстороны, и невозможностью перехода на другой язык, – с другой.

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

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

Использование кучи для создания массивов

От использования статических массивов рекомендуется вообщеотказаться (за исключением тех случаев, когда их переполнение заведомоневозможно). Вместо этот следует выделять память из кучи (heap), преобразуя указатель, возвращенный функцией malloc к указателю на соответствующий тип данных (char, int), после чего с ним можно обращаться точно так, как с указателем на обычный массив.

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

В этом случае передавая функции, читающей строку с клавиатуры,указатель на буфер, не придется мучительно соображать: какой именновеличиной следует ограничить его размер, – об этом позаботиться самавызываемая функция, и программисту не придется добавлять еще однуконстанту в свою программу!

Отказ от индикатора завершения

По возможности не используйте какой бы то ни было индикаторзавершения для распознания конца данных (например, символ нуля длязадания конца строки). Во-первых, это приводит к неопределенности вдлине самих данных и количества памяти, необходимой для их размещения,в результате чего возникают ошибки типа buff = malloc(strlen(Str)), которые с первого взгляда не всегда удается обнаружить. (Пояснение для начинающих разработчиков: правильный код должен выглядеть так: buff = malloc(strlen(Str)+1), поскольку, в длину строки, возвращаемой функцией srtlen, не входит завершающий ее ноль).

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

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

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

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

Обработка структурных исключений

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

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

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

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

Несмотря на смелость вопроса, ответ положительный, да – можно! И поможет в этом обработка структурных исключений (SEH).В общих чертах смысл идеи следующий – выделяется некий буфер, с обоихсторон окольцованный несуществующими страницами памяти иустанавливается обработчик исключений, отлавливающий прерывания,вызываемые процессором при попытке доступа к несуществующей странице(вне зависимости от того, был ли запрос на запись или чтение).

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

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

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

Основной недостаток – плохая переносимость исистемнозависимость. Не всякие операционные системы позволяютприкладному коду манипулировать на низком уровне со страницами памяти,а те, что позволяют – реализуют это по-своему. Операционные системысемейства Windows такую возможность к счастью поддерживают, причем надовольно продвинутом уровне.

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

Использование функций, непосредственно работающих с виртуальнойпамятью, воистину позволяет творить настоящие чудеса, на которыепринципиально не способны функции стандартной библиотеки Си/Cи ++.

Единственный их недостаток заключается в непереносимости.Однако, эта проблема может быть решена написанием собственнойреализации функций VirtualAlloc, VirtualProtect инекоторых других, пускай в некоторых случаях на уровне компонентовядра, а обработка структурных исключений изначально заложена в С++.

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

Традиции vs надежность

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

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

Но что лучше – мобильный, но нестабильно работающий инебезопасный код или – плохо переносимое (в худшем случае вообщенепереносимое), зато устойчивое и безопасное приложение? Если отказ отиспользования стандартных библиотек позволит значительно уменьшитьколичество ошибок в приложении и многократно повысить его безопасность,стоит ли этим пренебрегать?

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

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

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

Как с ними борются?

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

Очевидное лобовое решение проблемы заключается всинтаксической проверке выхода за границы массива при каждом обращениик нему. Такие проверки опционально реализованы в некоторых компиляторахСи, например, в компиляторе Compaq C для Tru64 Unix иAlpha Linux. Они не предотвращают возможности переполнения вообще иобеспечивают лишь контроль непосредственных ссылок на элементымассивов, но бессильны предсказать значение указателей.

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

В рамках проекта Synthetix удалось найти несколькопростых и надежных решений, не спасающих от ошибок переполнения, нозатрудняющих их использование злоумышленниками для несанкционированноговторжения в систему. Stack Guard – еще одно расширение ккомпилятору gcc, дополняет пролог и эпилог каждой функции особым кодом,контролирующим целостность адреса возврата. Алгоритм в общих чертахследующий: в стек вместе с адресом возврата заносится, так называемый, Canary Word,расположенный до адреса возврата. Искажение адреса возврата обычносопровождается и искажением Canary Word, что легко проконтролировать.Соль в том, что Canary Word содержит символы \\0, CR, LF, EOF, которыене могут быть обычным путем введены с клавиатуры. А для усиления защитыдобавляется случайная привязка, генерируемая при каждом запускепрограммы.

Компилятор Microsoft Visual C++ так же способенконтролировать сбалансированность стека на выходе из функции: сразупосле входа в функцию он копирует содержимое регистра-указателя вершиныстека в один из регистров общего назначения, а затем сверяет их передвыходом из функции. Недостаток: впустую расходуется один из семирегистров и совсем не проверяется целостность стека, а лишь егосбалансированность.

Bounds Checker, выпущенный фирмой NuMega для операционнойсистемы Microsoft Windows 9x\\NT, довольно неплохо отлавливает ошибкипереполнения, но, поскольку, он выполнен не в виде расширения ккакому-нибудь компилятору, а представляет собой отдельное приложение, ктому же требующее для своей работы исходных текстов подопытнойпрограммы, он может быть использован лишь для отладки, и не пригодендля распространения.

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

Поиск уязвимых программ

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

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

int file(char *buff)
{
char *p;
int a=0;
char proto[10];
p=strchr(&buff[0],:);

if (p)
{
for (;a!=(p-&buff[0]);a++)
proto[a]=buff[a];
proto[a]=0;

if (strcmp(&proto[0],file)) return 0;
else
WinExec(p+3,SW_SHOW);
}
else WinExec(&buff[0],SW_SHOW);
return 1;

}

main(int argc,char **argv)
{
if (argc>1) file(&argv[1][0]);
}

Листинг 1 Пример, демонстрирующий ошибку переполнения буферов

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

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

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

Например, конструкция buff[strlen(str)-1]=0, удаляющаясимвол возврата каретки, стоящий в конце строки, спотыкаться настроках нулевой длины, затирая при этом байт, предшествующий началубуфера.

Не менее опасна ошибка, допущенная в следующем фрагменте:

// 
fgets(&buff[0], MAX_STR_SIZE, stdin);
while(buff[p]!=\\n) p++;
buff[p]=0;
//

Листинг 2 Демонстрация некорректного удаления символа возврата каретки, стоящего в конце строки

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

Так же часты ошибки, возникающие при преобразовании знаковыхтипов переменных в беззнаковые и наоборот. Классический пример такойошибки – атака teardrop, возникающая при сборке TCP пакетоводин из которых находится целиком внутри другого. Отрицательноесмещение конца второго пакета относительно конца первого, будучипреобразованным в беззнаковый тип, становится очень-очень большимположительным числом и выскакивает далеко за пределы отведенного емубуфера. Огромное количество операционных систем, подверженных teardropатаке наглядно демонстрирует каким осторожным следует быть припреобразовании типов переменных, и без особой необходимости такиепреобразования и вовсе не следует проводить!

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

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

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

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

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

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

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

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

  1. а) каждый процесс исполняется в собственном адресном пространстве и полностью (под Windows 9x почти полностью) изолирован от всех остальных;
  2. б) межпроцессорный обмен может быть построен по схеме, гарантирующей синхронность и когерентность данных;
  3. с) каждый процесс можно отлаживать независимо от остальных, рассматривая его как однопоточное приложения.

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

Вместо заключения

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

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

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