Книга: Сетевое программирование. От основ до приложений
Назад: Глава 24. Проблемы сетевых приложений, диагностика и отладка
Дальше: Глоссарий

Глава 25. Поиск ошибок и обнаружение места сбоя

Самым эффективным инструментом отладки по-прежнему остается тщательное обдумывание в сочетании с грамотно размещенными операторами print.

Брайан Уилсон Керниган, «Unix for Beginners», 1979

Введение

Сетевые приложения по определению всегда распределенные, поэтому отлаживать их вдвойне сложно.

В этой главе мы разберем поиск ошибки на примере простого UDP-клиента и попробуем сделать следующее:

• выяснить, что проблема находится именно в приложении;

• локализовать проблему и устранить.

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

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

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

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

Некорректный клиент

Устроим переполнение буфера при вводе команды. Пример искусственный, но мы попробуем продемонстрировать приемы отладки на его основе.

Первую часть кода реализуем как обычно:

const size_t command_size = 5;

 

int main(int argc, const char* argv[])

{

    if (argc != 3)

    {

        std::cout << "Usage: " << argv[0] << " <host> <port>" << std::endl;

        return EXIT_FAILURE;

    }

 

    const socket_wrapper::SocketWrapper sock_wrap;

    const socket_wrapper::Socket sock = {AF_INET, SOCK_DGRAM, IPPROTO_UDP};

 

    if (!sock)

    {

       std::cerr << sock_wrap.get_last_error_string() << std::endl;

       return EXIT_FAILURE;

    }

    assert(argv[1]);

    const std::string host_name = { argv[1] };

    const hostent *remote_host{ gethostbyname(host_name.c_str()) };

 

    sockaddr_in server_addr =

    {

        .sin_family = AF_INET,

        .sin_port = htons(std::stoi(argv[2]))

    };

 

    server_addr.sin_addr.s_addr = *reinterpret_cast<const in_addr_t*>(

        remote_host->h_addr);

 

    if (0 == connect(sock,

                     reinterpret_cast<const sockaddr* const>(&server_addr),

                     sizeof(server_addr)))

    {

        // Буфер для чтения пользовательского запроса,

        // начальный размер которого — 5 символов.

        std::string request(command_size, '\0');

 

        std::cout << "Connected to \"" << host_name << "\"..." << std::endl;

Допустим ошибку в цикле отправки данных:

        while (true)

        {

            // !---> Ошибка здесь <---!

            std::cin >> &request[0];

 

            request += "\n";

 

            // Позже мы поставим точку останова в отладчике на эту строку.

            if (send(sock, request.c_str(),

                     static_cast<int>(request.length()), 0) < 0)

            {

                std::cerr << sock_wrap.get_last_error_string() << std::endl;

                return EXIT_FAILURE;

            }

        }

    }

    else // connect() error.

    {

        std::cerr << sock_wrap.get_last_error_string() << std::endl;

        return EXIT_FAILURE;

    }

}

Запустим Netcat и посмотрим, что получилось:

ncat -u -4 -l -p 11111

Подключимся к UDP-порту 11111 с помощью клиента. Затем начнем отправлять сообщения, введенные с клавиатуры:

build/bin/b01-ch25-incorrect-udp-client localhost 11111

Connected to "localhost"...

command1

cmd

cmd2

command12345

command1234567890

На стороне Netcat будет получено следующее:

ncat -u -4 -l -p 11111

comma

cmda

 

cmd2

 

command1

command12

Мы ожидали получить введенную строку, но получили совершенно иное. Значит, либо канал, либо приложение работает некорректно. Обычно, если приложение реализуется «с нуля», всегда стоит ожидать ошибок в реализации. Автоматизированные тесты вместо ручной проверки на эту ситуацию не повлияют.

Как определить местонахождение проблемы

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

Понятно, что приложение в принципе работает. При использовании TCP мы также можем убедиться, что соединение устанавливается. Значит, скорее всего, проблема возникает уже после его установки.

В случае UDP результат connect() неинформативен: к сокету просто привязываются адрес и порт. Однако данные передаются, следовательно, send() работает. Таким образом, вероятно, проблема в данных, которые поступают в send().

Проверка данных

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

На рис. 25.1 хорошо видно, что в канал были отправлены некорректные данные.

Рис. 25.1. Некорректные данные в канале

Главное — увидеть трафик, чтобы понять: ошибка на отправляющей стороне. В современных сетях трафик зачастую шифрованный, но шифрование выполняется не с помощью частных решений, а с помощью библиотек, например OpenSSL. В этом случае необходимо снять шифрование, что, например, умеет делать WireShark и некоторые отладочные прокси.

Сейчас же мы продолжим отладку и рассмотрение дальнейшего процесса.

Отладка

Поставим точку останова на строке, где вызывается send():

-> if (send(sock, request.c_str(), request.length(), 0) < 0)

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

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

Поставим точку останова на ввод данных.

В пошаговом режиме выполним программу и введем строку, которая отправлялась некорректно: например, "command12345".

Проверив буфер ввода, выясним, что его размер — 5 символов и этот размер не изменяется. Следовательно, в данном случае программист некорректно использовал буфер.

В итоге:

• Размер буфера, в который записывается строка, меньше, чем размер строки.

• Размер определяется константой, заданной в начале программы, поэтому его значение видно не сразу.

• Если посмотреть более внимательно, команда, содержащая пробелы, будет отправляться как несколько команд.

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

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

Далее поток данных заканчивается получением буфера вида const char* и передачей его на отправку функции send(). Метод c_str(), возвращающий этот буфер, обрезает строку по заданному размеру, и поэтому, как правило, отправляется урезанная команда.

В C++ достаточно внести следующее исправление:

std::getline(std::cin, request);

Размер объекта std::string будет установлен по размеру введенной строки, и переполнений не будет.

Динамический анализ

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

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

Мы посмотрим, как он используется, на примере инструмента memcheck. Этот инструмент позволяет обнаружить проблемы, перечисленные ниже:

• Доступ к еще не выделенной или уже освобожденной памяти.

• Использование неинициализированных значений.

• Повторное освобождение памяти.

• Наличие утечек памяти.

• Использование перекрывающихся блоков памяти в функциях, которые это не поддерживают, например в memcpy().

Инструмент не выбран явно, и memcheck запустится по умолчанию:

valgrind build/bin/b01-ch25-incorrect-udp-client localhost 11111

==3993862== Memcheck, a memory error detector

==3993862== Copyright (C) 2002-2024, and GNU GPL'd, by Julian Seward et al.

==3993862== Using Valgrind-3.23.0 and LibVEX; rerun with -h for copyright info

==3993862== Command: build/bin/b01-ch25-incorrect-udp-client localhost 11111

==3993862==

Connected to "localhost"...

verybigcommandverybigcommandverybigcommandverybig

commandverybigcommandverybigcommandverybigcommandverybigcommand

==3993862== Invalid read of size 8

==3993862==    at 0x4030E0: socket_wrapper::SocketWrapper::get_last_error_string[abi:cxx11]() const (socket_wrapper.cpp:40)

==3993862==    by 0x40272C: main (main.cpp:61)

==3993862==  Address 0x6d6f636769627972 is not stack'd, malloc'd or (recently) free'd

==3993862==

==3993862==

==3993862== Process terminating with default action of signal 11 (SIGSEGV): dumping core

==3993862==  General Protection Fault

==3993862==    at 0x4030E0: socket_wrapper::SocketWrapper::get_last_error_string[abi:cxx11]() const (socket_wrapper.cpp:40)

==3993862==    by 0x40272C: main (main.cpp:61)

==3993862==

==3993862== HEAP SUMMARY:

==3993862==     in use at exit: 82,620 bytes in 22 blocks

==3993862==   total heap usage: 36 allocs, 14 frees, 101,360 bytes allocated

==3993862==

==3993862== LEAK SUMMARY:

==3993862==    definitely lost: 8 bytes in 1 blocks

==3993862==    indirectly lost: 0 bytes in 0 blocks

==3993862==      possibly lost: 0 bytes in 0 blocks

==3993862==    still reachable: 82,612 bytes in 21 blocks

==3993862==         suppressed: 0 bytes in 0 blocks

==3993862== Rerun with --leak-check=full to see details of leaked memory

==3993862==

==3993862== For lists of detected and suppressed errors, rerun with: -s

==3993862== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0)

[1]    3993862 segmentation fault (core dumped)  valgrind build/bin/b01-ch25-incorrect-udp-client localhost 11111

То есть в Valgrind передается путь к приложению и параметры приложения. Затем приложение запускается под его управлением.

Для выбора инструмента существует опция --tool:

valgrind --tool=callgrind build/bin/b01-ch25-incorrect-udp-client

localhost 11111

Такая команда запустит инструмент Callgrind, передав ему путь к приложению и параметры приложения.

После запуска мы ввели длинную команду, после чего приложение завершилось с ошибкой. В журнале видно, что приложение упало на строке 61, при выводе ошибки через SocketWrapper::get_last_error_string(). Это, судя по всему, не то место, где возникла проблема.

Выше можно увидеть сообщение Invalid read of size 8, которое говорит, что ошибка произошла при попытке чтения 8 байт памяти. Наличие такого сообщения показывает, что ошибка допущена в приложении и связана либо с некорректным выделением памяти или ее слишком ранним освобождением, либо с доступом за границы выделенной памяти. Знание этого позволяет быстрее найти и локализовать ошибку.

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

Подмена функций и библиотек

Некоторые библиотеки, а также отдельные функции из LibC могут заменяться на отладочные версии — более медленные, но с расширенной диагностикой. Так, например, аллокаторы памяти обычно заменяют на специальные. Пример — . Это отладочный malloc, или Debug Malloc. Он предоставляет возможность отслеживания утечек памяти, обнаружения записи после выделенного блока, ведения статистики и т.д.

Ужесточение проверок

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

Санитайзеры, как и memcheck, позволяют выявлять некорректные выделения памяти, утечки, использование стековых переменных после возврата из функции, переполнение стека и кучи и т.д. Большую часть этих задач выполняет Address Sanitizer. В некоторых компиляторах существуют и другие санитайзеры, например UBSan для выявления неопределенного поведения или TSan для выявления состояний гонки между потоками.

В компиляторах GCC и CLang санитайзер адресов включается опцией -fsanitize=address, а в компиляторе MSVC — опцией /fsanitize=address.

В GCC, кроме того, при включении санитайзера необходимо подключить библиотеку asan и желательно добавить опцию -fno-omit-frame-pointer, чтобы получать нормальные стек-трейсы.

Также при запуске приложения санитайзеры могут принимать опции через переменные, например ASAN_OPTIONS. Так, при включенном санитайзере следующая команда перед запуском приложения выведет справку по опциям GCC санитайзера:

ASAN_OPTIONS=help=1 build/bin/b01-ch25-incorrect-udp-client localhost 11111

Available flags for AddressSanitizer:

     quarantine_size

         — Deprecated, please use quarantine_size_mb. (Current Value: -1)

     quarantine_size_mb

         — Size (in Mb) of quarantine used to detect use-after-free errors.

...

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

В случае, если приложение реализуется на языке C и компилируется GCC версии 4.0 и выше, также имеет смысл определить макрос _FORTIFY_SOURCE, который при включенных оптимизациях компилятора активирует проверку на переполнение буфера в таких функциях, как memcpy(), strcpy(), sprintf(), и прочих.

Безопасное значение макроса — _FORTIFY_SOURCE=1. В этом случае выполняются только те проверки, которые не могут сломать приложение.

Если определено _FORTIFY_SOURCE=2, добавляются более агрессивные проверки.

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

_FORTIFY_SOURCE=3 включает проверки границ буфера на переполнение и прочие, что позволяет обнаружить и нашу ошибку.

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

Выключаются эти предупреждения определением макроса _CRT_SECURE_NO_WARNINGS.

Статический анализ

Большинство современных компиляторов выполняют множество проверок в процессе компиляции. Это и есть статический анализ программы.

Как правило, выполняется проверка соответствия типов, а также использования «опасных» и неоднозначных выражений.

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

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

Существуют как бесплатные утилиты для C++: Сppcheck, cpplint, более «тяжелый» и мощный OCLint, так и платные. Из последних стоит выделить достаточно известный анализатор PVS-Studio, появившийся в 2008 году и отличающийся весьма неплохим качеством анализа. Он поддерживает работу с четырьмя C-подобными языками: C, C++, C# и Java.

Несмотря на то что PVS-Studio является платным решением и распространяется только по модели B2B, для некоммерческих проектов анализатор может быть .

PVS-Studio позволяет находить такие ошибки, как:

• Выход за границы массива и переполнение буферов.

• Разыменование нулевых указателей и разыменование переменных без проверки.

• «Мертвый код» и неиспользуемые переменные.

• Ошибки синхронизации.

• Неопределенное поведение и неинициализированные переменные.

• Ошибки сериализации и десериализации.

• Неправильная работа с WPF на ОС Windows.

• Ошибки работы с типами.

• Ошибки в приоритетах операций.

• Утечки ресурсов.

• Проблемы с безопасностью.

• Опечатки

и другие.

На сайте приведен полный список ошибок.

Анализатор можно использовать как для анализа проектов в CI, так и для интерактивной работы. Кроме того, поддерживается интеграция с популярными IDE: Visual Studio, VSCode, QtCreator, CLion, Rider и IntelliJ IDEA. Инструмент работает на Windows, Linux и MacOS X.

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

В дополнение к анализатору поставляется графический интерфейс Svacer. Анализатор работает на Linux и ОС Windows и поддерживает множество платформ. Кроме C++, он может анализировать код на C#, Go, Java, Kotlin. Также в ближайшей перспективе — анализ Python-кода. С 2015 года этот анализатор использует компания Samsung.

Статические анализаторы есть и для Python. Из них чаще всего используются PyLint, Flake8, ruff, а для проверки типов — MyPy и PyRight.

Улучшение журналирования

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

Например, так:

#ifdef DEBUG

std::cout << request << std::endl;

#endif

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

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

• Журналировать не только в отладочной версии, но и в релизе. Включать и выключать журналирование по мере необходимости.

• Иметь несколько уровней отладки: самый подробный, только предупреждения, только ошибки и др.

• Выводить отладочные данные не только в консоль, но и в разные источники, например в syslog.

• Добавлять к отладочным данным произвольное состояние процесса, например стек вызовов.

Отправлять информацию о сбоях по сети.

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

Несколько советов по журналированию

• В программе должна быть опция, которая позволяет выбрать уровень журналирования без перекомпиляции. Обычно достаточно 3–5 уровней:

TRACE — для очень подробной трассировки, что используется в основном разработчиками;

DEBUG — для отладки, поиска типовых неисправностей и ошибок конфигурирования;

INFO — информационные сообщения, которые выводятся при начале либо завершении определенных этапов бизнес-логики;

WARNING — предупреждения о потенциальных ошибках и, возможно, неправильной конфигурации, неожиданных действиях;

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

• Журналы с разным уровнем журналирования лучше записывать в разные сущности. Например, веб-сервер Apache для протоколов доступа использует файл access.log, а для протокола ошибок — error.log. Это не всегда применимо, так как журналы могут сохраняться, например, в базу данных, но и в данном случае лучше их сохранять, например, в отдельные таблицы.

• Вести журналы необходимо на правильном уровне абстракции, то есть на таком, который обладает всей необходимой информацией для формирования записи:

• Бизнес-логика должна вести логи бизнес-процессов.

• Драйверы и адаптеры должны вести логи обращений и вызовов.

• Низкоуровневые примитивы должны вести логи низкоуровневых вызовов.

• Желательно подробно журналировать участки, в которых производится взаимодействие с другими процессами:

• запуск приложений и получение результата;

• выполнение запроса к удаленной системе;

• обращение с запросом от сторонней системы.

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

• Изменение состояния процесса согласно бизнес-логике тоже стоит журналировать. Если клиент выполнил запрос к веб-серверу, это надо записать в журнал. Такая ситуация вполне обычна, и подходит уровень DEBUG либо INFO. При этом записывается следующая информация: данные предыдущего состояния, причина выполнения перехода и данные нового состояния.

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

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

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

Нельзя журналировать:

• Чувствительные данные, такие как пароли. Пользователи могут ошибиться в одном символе либо использовать старый пароль. Такие данные в протоколе — подарок для взломщика.

• Пользовательские данные без проверки. Если их объем очень велик, журналирование сильно замедлит работу системы.

• Персональную информацию, которая может выделить пользователя, если это противоречит соглашениям и законам, таким как или ФЗ «Закон о персональных данных» № 152-ФЗ от 27.07.2006.

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

Для многих языков API журналирования уже содержится в их стандартных библиотеках. Характерным примером является Python. В нем классы журналирования содержатся в модуле logging. Типовой вариант использования данного модуля делится на два этапа.

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

import argparse

import logging

 

if __name__ == '__main__':

    # Добавление опции `--verbose`.

    parser.add_argument('--verbose', '-v', default=False, action='store_true',

                        help='More verbose logging')

    args = parser.parse_args()

    # Инициализация системы журналирования уровнем журналирования.

    logging.basicConfig(format='%(asctime)s %(message)s',

                        datefmt='%d.%m.%Y %H:%M:%S',

                        level='DEBUG' if args.verbose else 'INFO')

Параметры могут быть заданы, например, из опций командной строки.

Второй — непосредственно журналирование:

# Простой вариант.

logging.info('Logging test!')

 

# Вариант с именем модуля, позволяющий легче определить место сбоя.

 

# Переменная уровня модуля, которая инициализируется его именем.

LOGGER = logging.getLogger(__name__)

 

# Запись данных в журнал.

LOGGER.debug('Trying to load "%s"', filename)

Внимание! В аргументах методов логгера нельзя использовать форматирование строк.

Отображение аргументов задается строкой формата, как в C-функции printf(), а сами аргументы перечисляются уже после строки формата. Это сделано для того, чтобы в режимах ниже установленного не выполнялось преобразование сложных объектов, например списков, в строки. Оно занимает много времени и может сильно замедлить работу программы в релизе.

Увы, в стандартной библиотеке шаблонов C++ журналирование не реализовано. Зато есть , из которых стоит отметить следующие:

— известная и зрелая библиотека в составе Boost.

— Google Logging Library.

— header-only-библиотека для журналирования.

— библиотека протоколирования и телеметрии, быстрая и не слишком требовательная к ресурсам.

— маленькая header-only-библиотека, содержащая менее 1000 строк кода.

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

О поиске ошибок

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

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

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

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

• Отсутствие части кодовой базы, когда некоторые компоненты системы доступны только в бинарном виде.

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

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

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

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

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

Некоторые распространенные ошибки

• Перечислим несколько типовых ошибок, которые допускают разработчики сетевых приложений:

• Неправильная обработка пользовательского ввода. Такие ошибки встречаются чаще всего и обычно являются критическими. Могут приводить к переполнению буфера и выполнению произвольного кода.

• Отсутствие проверки кодов возврата функций, особенно recv(), send() и им подобных. Они не всегда будут возвращать данные того размера, который указал программист, даже если используется флаг MSG_WAITALL.

• Отсутствие проверки значения errno на EINTR, когда функции recv(), send() и подобные возвращают –1. Приложение считает, что вызов функции завершился с ошибкой. А ее достаточно всего лишь перезапустить либо настроить автоматический перезапуск по сигналу.

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

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

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

Дескрипторы сокетов в новых процессах остаются открытыми. Это также ведет к утечке и последующему исчерпанию дескрипторов. Чтобы такого не происходило, при открытии сокета в ОС, где это поддерживается, например в Linux, устанавливайте флаг SOCK_CLOEXEC или просто установите F_CLOEXEC на дескриптор.

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

Обработка ошибок при вызове сетевых функций

Поиск ошибок в приложении упрощает грамотная реализация их обработки, а также журналирования, обработки сигналов и прочих средств диагнос­тики.

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

В правильно написанном сетевом приложении:

• Результат выполнения каждой вызванной функции должен быть проверен.

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

Работа с ошибками в C и в POSIX API

Каждая сокетная функция возвращает результат, например:

ssize_t send(int sock_fd, const void *buf, size_t len, int flags);

Тип возвращаемого значения — ssize_t в POSIX или int в ОС Windows.

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

Из предыдущих глав было видно, что при ошибке возвращается –1 или константа INVALID_SOCKET в ОС Windows.

Типы возвращаемых значений

В новых Unix-системах возвращаемое значение обычно имеет тип ssize_t, который объявлен как long, тогда как size_t — как unsigned long. То есть size_t будет включать почти в два раза больше положительных значений, чем ssize_t.

Обычно для разработчика нет особой разницы: даже функции приема данных не вернут блок, размер которого больше, чем может содержать тип long, то есть 2 Гбайт. Если же значение выходит за эти границы, поведение будет зависеть от реализации, но обычно система просто возвращает ошибку EINVAL.

Если это требуется учитывать, получить максимальные значения можно, используя константы:

SIZE_MAX — для типа size_t. Значения типа size_t гарантированно лежат в границах [0, SIZE_MAX].

SSIZE_MAX — для типа ssize_t. Значения ssize_t гарантированно лежат в границах [-1, SSIZE_MAX].

_POSIX_SSIZE_MAX — минимально возможное значение константы SSIZE_MAX на POSIX-системах. Это значение совсем небольшое по сравнению с SIZE_MAX. Обычно 32767.

Две последние константы в POSIX-системах определены в файле limits.h.

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

Каждая ошибка имеет код, по которому ее можно идентифицировать. В POSIX этот код хранится в устанавливаемой функциями переменной errno:

#include <cerrno>

 

int main()

{

    ...

 

    if (connect(sock, reinterpret_cast<const sockaddr* const>(&server_addr),

                sizeof(server_addr)))

    {

        std::cout << "Error code: " << errno << std::endl;

    }

}

Переменная errno определена как макрос. В С++ до стандарта 11 он раскрывается в статическую переменную, а в более новых стандартах — в переменную, которая хранится в локальном хранилище потока, что делает использование errno потокобезопасным: вызов какой-либо функции в одном потоке не изменит данную переменную глобально.

Внимание! До C++11 доступ к данной переменной из разных потоков должен был согласовываться. А с учетом того, что функции ввода-вывода ее изменяют, весь блок — вызов и проверку результата — требовалось выполнять, например, под мьютексом.

Если вызов функции завершен без ошибки, errno будет сброшена в 0.

Коды ошибок содержатся в заголовочном файле errno.h, а для C++ — в cerrno. Посмот­реть их можно на .

В C для печати сообщения об ошибке существует функция perror():

#include <cerror>

 

void perror(const char *s)

Параметр s — строка, которая будет добавлена к сообщению.

Например:

#include <cstdio>

 

int main()

{

    ...

 

    if (0 != connect(sock,

                     reinterpret_cast<const sockaddr* const>(&server_addr),

                     sizeof(server_addr)))

    {

        perror("Simple client error");

    }

}

Если подключение не удастся установить из-за ошибки, будет выведено соответствующее сообщение. Например, в случае отсутствия сервера: Simple client error: Connection refused.

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

Чтобы получить текстовое сообщение, используют функцию:

#include <cstring>

 

char *strerror(int errnum);

Она сама выделяет буфер, который может переиспользовать, и поэтому не является потокобезопасной.

Причина того, что буфер выделяется динамически, а не с возвратом указателей на константные строки, — пользовательские ошибки, например сообщения типа "Unknown error 3542", которые не могут быть записаны в таблице.

Но С++, начиная со стандарта C++11, должен поддерживать функции strer­ror_s() и strerrorlen_s():

#include <cstring>

 

errno_t strerror_s(char *buf, rsize_t bufsz, errno_t errnum);

size_t strerrorlen_s(errno_t errnum);

Они потокобезопасны и могут использоваться следующим образом:

#define __STDC_WANT_LIB_EXT1__ 1

 

#include <cstring>

 

...

 

#if defined(__STDC_LIB_EXT1__)

std::string buffer(std::strerrorlen_s(errno) + 1, '\0');

std::strerror_s(&buffer[0], buffer.size(), errno);

#endif

Эти функции поддерживают далеко не все компиляторы, и приходится использовать потокобезопасный вариант функции strerror() — функцию strerror_r(). Она реентерабельна, то есть ее можно параллельно вызывать из нескольких потоков. Подробнее о таких функциях см. во врезке о _r-функциях в главе 1.

С ней : прототип и поведение могут различаться в зависимости от реализации:

#include <cstring>

 

...

 

// Макрос для проверки варианта функции.

#if (_POSIX_C_SOURCE >= 200112L) && ! _GNU_SOURCE

// POSIX-вариант.

int strerror_r(int errnum, char *strerrbuf, size_t buflen);

#else

// GNU-вариант.

char *strerror_r(int err_num, char *buf, size_t buflen);

#endif

Эта функция записывает строку ошибки в предварительно выделенный буфер. Именно такой вариант получения строки ошибки реализован в классе SocketWrapper для Unix-подобных систем:

std::string get_last_error_string() const override

{

    std::string buffer(err_buffer_size, '\0');

#if (_POSIX_C_SOURCE >= 200112L) && !_GNU_SOURCE

    // POSIX.

    ::strerror_r(get_last_error_code(), &buffer[0], buffer.size());

    return buffer;

#else

    // GNU.

    return ::strerror_r(get_last_error_code(), &buffer[0], buffer.size());

#endif

};

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

Пример, который учитывает особенности обеих реализаций:

#include <cstring>

 

int main()

{

    ...

 

    if (0 != connect(sock,

                     reinterpret_cast<const sockaddr* const>(&server_addr),

                     sizeof(server_addr)))

    {

        std::string buffer(256, '\0');

#if (_POSIX_C_SOURCE >= 200112L) && ! _GNU_SOURCE

        strerror_r(errno, buf.data(), buf.size());

        std::cerr << buf << std::endl;

#else

        std::cerr << strerror_r(errno, buf, sizeof(buf)) << std::endl;

#endif

    }

}

В случае неудачного подключения будет выведена причина ошибки, например: Connection refused.

Сообщения, генерируемые функциями, зависят от локали, и если установить локаль выше вызова нужного API, можно получить сообщение на требуемом языке:

#include <cstring>

 

int main()

{

    ...

    // Установить русскую локаль:

    // — Кодировка utf8 — для Linux.

    // — Кодировки cp1251 либо cp866 для Windows.

    std::setlocale(LC_ALL, "ru_RU.utf8");

    ...

    if (0 != connect(sock,

                     reinterpret_cast<const sockaddr* const>(&server_addr),

                     sizeof(server_addr)))

    {

        std::string buffer(256, '\0');

        std::cerr << strerror_r(errno, buf.data(), buf.size()) << std::endl;

    }

}

На экран будет выведено сообщение: «В соединении отказано».

Функция установки локали тоже меняет errno, и работать с такими функциями неудобно. Но существует функция strerror_l(), в которую можно передать нужную локаль явно:

char *strerror_l(int errnum, locale_t locale);

Кроме того, существуют две специфичные для GNU-систем функции:

#if defined(_GNU_SOURCE)

 

// Возвращает указатель на строку, которая описывает переданный код ошибки.

// Строка не будет переведена с локали C.

const char *strerrordesc_np(int errnum);

 

// Возвращает указатель на мнемоническое имя кода ошибки.

// Например, для кода EPERM, который обычно равен 9972,

// будет возвращена строка "EPERM".

const char *strerrorname_np(int errnum);

 

#endif

В ОС Windows для получения результата есть функция . Это обертка над функцией GetLastError(). Она используется для совместимости вместо GetLastError(), которой в 16-битных версиях Windows не было.

В SocketWrapper данная функция используется так:

// Получить код ошибки WinSock.

int get_last_error_code() const override { return WSAGetLastError(); }

Из кода, возвращенного функцией WSAGetLastError(), можно получить текстовое сообщение, используя функцию :

#include <winbase.h>

 

DWORD FormatMessage(

    DWORD dwFlags,

    LPCVOID lpSource,

    DWORD dwMessageId,

    DWORD dwLanguageId,

    LPTSTR lpBuffer,

    DWORD nSize,

    va_list *arguments

);

Параметры функции FormatMessage():

dwFlags — флаги, управляющие интерпретацией lpSource и форматированием результата:

• FORMAT_MESSAGE_ALLOCATE_BUFFER — выделить буфер для хранения сообщения. Минимальное количество символов для буфера хранится в nSize, максимальный размер — 128 Кбайт.

FORMAT_MESSAGE_ARGUMENT_ARRAY — параметр arguments не структура va_list, а указатель на массив аргументов.

FORMAT_MESSAGE_FROM_HMODULE — указывает, что параметр lpSource — дескриптор модуля, содержащий таблицу сообщений в ресурсах. Исключает FORMAT_MESSAGE_FROM_STRING.

FORMAT_MESSAGE_FROM_STRING — говорит о том, что параметр lpSource — строка. Исключает FORMAT_MESSAGE_FROM_HMODULE.

FORMAT_MESSAGE_FROM_SYSTEM — форматировать системное сообщение.

• FORMAT_MESSAGE_IGNORE_INSERTS — игнорировать аргументы, а подстановочные символы (например, %1) оставлять без изменений.

• FORMAT_MESSAGE_MAX_WIDTH_MASK — игнорировать разрывы строк.

• lpSource — источник сообщения.

• dwMessageId — идентификатор сообщения, если сообщение получается не из строки.

• dwLanguageId — идентификатор языка сообщения.

• lpBuffer — выходной буфер. Может быть выделен автоматически.

• nSize — размер выходного буфера, если не задан флаг FORMAT_MESSAGE_ALLOCATE_BUFFER, иначе минимальное количество символов TCHAR.

arguments — аргументы, которые будут подставлены вместо символов %1, %2, %3 и т.д.

Функция вернет количество символов TCHAR в буфере в случае успеха; иначе 0. Подробно она рассмотрена в MSDN, нам же интересна только в комбинации с флагами FORMAT_MESSAGE_ALLOCATE_BUFFER и FORMAT_MESSAGE_FROM_SYSTEM. Первый флаг заставляет функцию выделить буфер под сообщение, второй — сохранить в этот буфер текст системной ошибки.

Рассмотрим, как получение ошибки для Windows реализовано в SocketWrapper:

std::string get_last_error_string() const override

{

    TCHAR *s = nullptr;

 

    // Получить сообщение по коду, буфер выделяется функцией.

    if (!FormatMessage(

            FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM |

            FORMAT_MESSAGE_IGNORE_INSERTS, nullptr, get_last_error_code(),

            MAKELANGID(LANG_ENGLISH, SUBLANG_ENGLISH_US),

            reinterpret_cast<LPTSTR>(&s), 0, nullptr))

    {

        // GetLastError() используется, потому что ошибка вызова

        // FormatMessage() не относится к ошибкам сети.

        throw std::system_error(GetLastError(), std::system_category(),

                                "FormatMessage()");

    }

 

    // Выделенный буфер следует освободить, что будет сделано автоматически.

    const auto on_scope_exit = scope_guard([s](void *) { LocalFree(s); });

    assert(s != nullptr);

 

#if defined(UNICODE)

    // Вариант для широких символов, так как определен макрос UNICODE.

    std::ostringstream res;

 

    // Преобразование в char-буфер.

    while (wchar_t *st = s; st != L'\0')

    {

        res << std::use_facet<std::ctype<wchar_t>>(loc).narrow(*st++, '?');

    }

 

    return res.str();

#else

    // Скопировать буфер в строку при ее создании.

    return std::string(s);

#endif

};

После того как сообщение сформировано и выведено, необходимо освободить буфер, вызвав функцию LocalFree(). Это делает экземпляр scope_guard при выходе из области видимости.

Работа с ошибками функции getaddrinfo()

Для получения кода и текста ошибки при работе с getaddrinfo() используются отдельные функции:

gai_error() — получить код ошибки из результата getaddrinfo_a().

gai_strerror() — получить строку ошибки из кода, возвращенного getaddrinfo() либо gai_error(). Эту функцию мы уже рассматривали.

Посмотрим на определение:

#define _GNU_SOURCE

#include <netdb.h>

 

struct gaicb

{

    const char            *ar_name;

    const char            *ar_service;

    const struct addrinfo *ar_request;

    struct addrinfo       *ar_result;

};

 

int gai_error(gaicb *req);

 

const char *gai_strerror(int errcode);

В функцию gai_strerror() нужно подставить код, возвращенный блокирующим вызовом getaddrinfo(). А функция gai_error() принимает список, который будет возвращен по завершении асинхронного вызова getaddrinfo_a(). Если вызов завершился с ошибкой, функция вернет ее код.

Этот список можно проверять и в процессе выполнения функции getaddrinfo_a(). Функция gai_error() будет возвращать EAI_INPROGRESS, если асинхронный вызов getaddrinfo_a() еще не завершился. Подробнее асинхронные вызовы будут описаны в книге 2.

Работа с ошибками в C++

В C++ при возникновении ошибок принято генерировать исключения. До C++11 для этого можно было использовать исключения класса std::logic_error или std::runtime_error, их наследников, а также отдельных типов.

Начиная со Стандарта C++11, в STL добавлена библиотека для работы с различными системными исключениями. Ее заголовочный файл — system_error. Рекомендуется использовать именно данную библиотеку.

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

• код ошибки — errno;

• код ошибки и пользовательское сообщение;

• код ошибки, категорию ошибки и пользовательское сообщение;

• код ошибки и категорию ошибки.

Категории ошибок — наследники класса std::error_category, который содержит метод name(), возвращающий имя категории. Метод message() возвращает сообщение об ошибке, а также атрибуты с платформенно-зависимыми и независимыми кодами ошибок.

Категории бывают следующими:

std::generic_category — универсальная категория для ошибок. Метод name() возвращает строку generic. Используется для кодов, соответствующих POSIX-ошибкам.

• std::system_category — ошибки, сгенерированные операционной системой. Метод name() возвращает строку system.

• std::future_category — категория ошибок, связанных с фьючерсами и промисами.

std::iostream_category — категория для ошибок потоков ввода-вывода iostream.

Для генерации системных ошибок имеет смысл использовать system_error таким образом:

#include <system_error>

 

...

try

{

    int sock = socket(...);

 

    // Не удалось открыть сокет.

    if (-1 /* INVALID_SOCKET */ == sock)

        throw std::system_error(errno, std::system_category(),

                                "Opening interface");

    ...

}

catch(const std::system_error& e)

{

    std::cerr

        << "Caught system_error with code " << e.code()

        << " meaning " << e.what() << '\n';

}

Например, если сгенерировать исключение с ошибкой доступа:

throw std::system_error(EACCES, std::system_category(), "Opening interface");

Результат вызова метода исключения what() будет следующим: Opening interface: Permission denied.

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

Stacktrace

Отображение стека вызовов — одно из нововведений С++23, облегчающее нахождение ошибок. Изначально он было реализован в Boost и пришел в Стандарт оттуда.

#include <algorithm>

#include <iostream>

#include <stacktrace>

 

int main()

{

    auto trace = std::stacktrace::current();

    auto empty_trace = std::stacktrace{};

 

    // Напечатать стек-трейс.

    std::for_each(trace.begin(), trace.end(), [](const auto& f)

    {

        std::cout << f << '\n';

    });

 

    if (empty_trace.begin() == empty_trace.end())

        std::cout

            << "stacktrace 'empty_trace' is indeed empty."

            << std::endl;

}

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

Обработка сетевых ошибок в Python

В Python большинство методов сокетного модуля генерируют исключения:

os.OSError — его обычно генерируют функции и методы, которые устанавливают значение переменной errno. По завершении C-вызовов проверяется результат функции, исключение OSError генерируется в случае ошибки. Текст ошибки соответствует значению переменной errno.

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

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

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

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

Разрешение имен может вызывать отдельный тип исключений:

socket.gaierror — при ошибках работы getaddrinfo().

socket.herror — это исключение могут генерировать функции gethostbyname_ex() и gethostbyaddr().

Многие библиотеки имеют свои наборы исключений. Обработку этих исключений мы затронем при рассмотрении библиотек в следующих книгах.

В целом обрабатывать сетевые исключения в Python можно следующим образом:

try:

    # Сетевые вызовы.

except socket.error as e:

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

except IOError as e:

    if errno.EPIPE == e.errno:

        continue

    else:

        # Обработка других исключений.

Прочие языки

В большинстве языков используются два рассмотренных ранее варианта работы с ошибками:

Возврат кодов ошибок. Так, например, организована часть работы с ошибками в изначальном POSIX API в C или работа с ошибками в адаптации этого API для языка Go.

Исключения. Сокетный API может быть обернут в функции и методы, которые генерируют исключения, как в C++ или Python.

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

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

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

pub enum Result<T, E> {

    Ok(T),

    Err(E),

}

Для функций и методов ввода-вывода, каковыми являются и сетевые функции, возвращается его псевдоним — тип std::io::Result, в котором ошибка специфицирована типом io::Error:

pub type Result<T> = result::Result<T, Error>;

Например, как в функции bind(), которая возвращает объект класса TcpListener в случае успеха:

pub fn bind<A: ToSocketAddrs>(addr: A) -> io::Result<TcpListener>

Особенности сетевых приложений

В различных ОС приложение может получать более подробные данные об ошибке конкретного сообщения или потока.

В главе 8 мы рассматривали несколько опций, которые позволяют это сделать. Начнем с опции SO_ERROR, которая позволяет сразу получить и сбросить ошибку сокета. Возвращаемый код ошибки такой же, как в errno. Данная опция полезна при работе с неблокирующими сокетами через функцию select(), рассматриваемую в книге 2.

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

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

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

В дейтаграммном сокете все сгенерированные ошибки будут помещены в очередь ошибок сокета. Чтобы получить такие ошибки, на уровне IP существуют опции сокетов IP_RECVERR и IPV6_RECVERR. Это флаги, которые включают передачу сообщений из очереди ошибок.

Когда сокетный вызов завершается с ошибкой, вызов recvmsg() с установленным флагом MSG_ERRQUEUE вернет во вспомогательном сообщении типа IP_RECVERR или IP_RECVERR6 структуру sock_extended_err:

// Происхождение неизвестно.

#define SO_EE_ORIGIN_NONE 0

// Ошибка на стороне узла.

#define SO_EE_ORIGIN_LOCAL 1

// Ошибка была получена через сообщение ICMP или ICMP6.

#define SO_EE_ORIGIN_ICMP 2

#define SO_EE_ORIGIN_ICMP6 3

 

struct sock_extended_err

{

    // Номер ошибки в очереди.

    uint32_t ee_errno;

    // Источник происхождения ошибки.

    uint8_t ee_origin;

    // ICMP-тип.

    uint8_t ee_type;

    // ICMP-код.

    uint8_t ee_code;

    // Заполнение для выравнивания.

    uint8_t ee_pad;

    // Дополнительная информация.

    // Для ошибок EMSGSIZE содержит обнаруженный MTU.

    uint32_t ee_info;

    // Другие данные.

    uint32_t ee_data;

    // Далее могут следовать дополнительные данные.

};

Содержимое пакета, который привел к ошибке, передается в виде обычных данных в msg_iovec, а его исходный адрес — в msg_name.

Для локальных ошибок адрес не передается, что видно по значению поля cmsg_len.

Следующий макрос возвращает указатель на адрес сетевого объекта, в котором возникла ошибка:

sockaddr *SO_EE_OFFENDER(sock_extended_err *);

Если адрес неизвестен, sockaddr.sa_family будет равен AF_UNSPEC, а другие поля sockaddr не определены.

Для raw-сокетов опция позволяет передавать все полученные ICMP-ошибки приложению, в иных случаях об ошибках сообщается только в подключенных сокетах.

TCP не имеет очереди ошибок, поэтому сообщение MSG_ERRQUEUE нельзя использовать для сокетов SOCK_STREAM.

IP_RECVERR не приведет к ошибке на TCP-сокетах. Он просто бесполезен. Все ошибки будут возвращаться в errno или в значении опции SO_ERROR.

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

Сигналы

В Unix-подобных ОС общим для многих языков способом уведомления являются сигналы, часть из которых показана на рис. 25.2.

И хотя не все языки поддерживают работу с ними, в большинстве современных языков обработка сигналов реализована. В Rust для этого, например, есть модуль std::io::signal.

Сетевые приложения в плане обработки сигналов не выделяются на фоне остальных.

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

Такие вызовы, как send() или recv(), зачастую реализованы через write() и read(), то есть могут порождать сигналы, например, при обрыве канала. Некоторые из сигналов уже были описаны в книге.

Рис. 25.2. Сигналы в Linux

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

Команды оболочки для работы с сигналами

Существует список из 38 сигналов, часть из которых присутствовала только в UNIX System V, а часть — в старых BSD-системах или в Solaris. Если учитывать Real Time-сигналы — SIGRT*, в некоторых ОС их количество может составлять порядка 64.

Посмотреть список сигналов используемой ОС обычно можно с помощью утилиты kill:

kill -l

HUP INT QUIT ILL TRAP IOT BUS FPE KILL USR1 SEGV USR2 PIPE ALRM TERM STKFLT

CHLD CONT STOP TSTP TTIN TTOU URG XCPU XFSZ VTALRM PROF WINCH POLL PWR SYS

Отправить сигнал также с помощью данной утилиты, передав ей имя сигнала и PID нужного процесса:

kill -URG $$

В Linux и BSD-системах утилиты pkill и killall позволяют отправлять сигналы всем процессам с заданным именем.

В потомках Unix System V, таких как Solaris, IBM AIX и HP-UX HP, команда killall убивает все процессы, которые могут быть уничтожены конкретным пользователем, то есть завершает его работу, а при запуске от root завершает работу системы.

Утилита pkill дает возможность отправить сигнал всем процессам, имя которых содержит заданную строку. Например, команда pkill -9 java принудительно завершит все java-приложения.

pkill является частью пакета procps, который также предоставляет другие утилиты файловой системы /proc, такие как pgrep, ps, top, free, uptime и др. Также pkill имеет удобную опцию -t — отправить сигнал процессам только на определенном терминале.

Утилита killall в Linux предоставляется пакетом psmisc и отличается от pkill тем, что по умолчанию имя процесса в ней должно точно соответствовать заданному. В ней, однако, поддерживается сопоставление имен процессов с регулярными выражениями с помощью опции -r или --regexp.

Также команда killall имеет опции для сопоставления процессов по возрасту -o, --older-than и -y, --younger-than.

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

То есть мы не рассматриваем подробно сигналы SIGBUS или SIGIO. Возникновение первого не имеет отношения к вводу-выводу, а второй не говорит о том, что произошла ошибка.

В целом внимания заслуживают следующие сигналы:

SIGALRM — сигнал таймера, который может быть использован для реализации Heartbeat, о котором рассказано далее.

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

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

• SIGIO или SIGPOLL — данный сигнал приходит, только если он был явно включен. Используется для ввода-вывода, управляемого сигналами. Его приход говорит о возможности прочитать данные из сокета или записать данные в сокет. Приходом этого сигнала управляют уже рассмотренные опции SO_RCVLOWAT и SO_SNDLOWAT. В старых ОС, например Unix System V, он называется SIGPOLL, в новых обычно SIGIO.

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

• SIGURG — требование срочной обработки OOB-данных, поступивших в сокет. Этот сигнал мы рассматривали, когда описывали работу с OOB. По умолчанию он игнорируется.

Обработка сигналов

Каждый процесс обрабатывает только один сигнал каждого типа одновременно. Если до полной обработки первого сигнала придут другие сигналы, они будут отброшены.

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

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

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

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

Внимание! Кроме сказанного выше, сигналы прерывают поток процесса и выполнение обработчика дороже, чем стандартные системные вызовы, такие как select() и poll(), поэтому использовать сигналы нежелательно.

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

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

Обработка сигналов в POSIX API предполагает использование функций signal() и sigaction() для установки обработчиков. Как уже было показано на примере Rust, для этого применяются различные языковые средства.

В Python это модуль , предоставляющий API, не сильно отличающийся от POSIX:

import signal, os

 

# Обработчик сигналов.

def handler(signum, frame):

    # Имя полученного сигнала.

    signame = signal.Signals(signum).name

    print(f'Signal handler called with signal {signame} ({signum})')

    raise OSError("Couldn't open device!")

 

# Установить обработчик SIGALRM.

signal.signal(signal.SIGALRM, handler)

# Через 5 секунд будет сгенерирован SIGALRM.

signal.alarm(5)

 

# Вызов open() может блокироваться бесконечно.

fd = os.open('/dev/ttyS0', os.O_RDWR)

 

# Отключить уведомления.

signal.alarm(0)

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

В языках оболочки, таких как Bash, обработка сигналов выполняется путем вызова команды trap, которой передается обработчик и номер сигнала или его символическое обозначение:

trap "echo 'Signal'" SIGINT

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

Внимание! Ниже используются базовые функции для обработки сигналов. Например, при работе с потоками лучше использовать функцию pthread_sigmask() для блокирования сигнала, а наличие уже пришедшего сигнала проверять функцией sigpending(). Для процесса же лучше использовать sigprocmask(). Все эти нюансы далее не учитываются.

SIGCHLD

Сигнал SIGCHLD уведомляет родительский процесс о том, что дочерний процесс завершился.

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

Программы в обработчике этого сигнала обычно используют системный вызов wait().

В обработчике сигналов лучше не блокироваться на длительное время, так что желательно использовать любую wait-функцию, в которую можно передать флаг WNOHANG: wait3(), waitpid(), waitid() и т.д. Этот флаг предотвращает блокировку: функции вернут управление, если ни один дочерний процесс не завершился.

Для использования wait-функций необходимо включить заголовочные файлы sys/wait.h и sys/resource.h.

Рассмотрим код обработчика сигнала SIGCHLD:

void proc_exit(int signal)

{

    std::cout

        << "Signal: "

        << signal << " (" << strsignal(signal) << ")"

        << std::endl;

 

    while (true)

    {

        int wstat;

 

        // -1 — ожидать любой дочерний процесс.

        // WNOHANG — немедленно вернуть результат, не ожидая завершения

        // процессов.

        pid_t pid = waitpid(-1, &wstat, WNOHANG);

        switch (pid)

        {

            // При ошибке вернет -1. Ошибкой является и отсутствие дочерних

            // процессов.

            case -1:

                perror("wait()");

                return;

            // Если указан флаг WNOHANG, wait-функция завершится и вернет 0,

            // когда ни один дочерний процесс не изменил состояние.

            case 0:

                std::cout

                    << "No waitable children"

                    << std::endl;

                return;

            // Код возврата потомка.

            default:

                std::cout

                    << "Child returns code: "

                    << wstat

                    << std::endl;

        }

    }

}

Особенностью данного обработчика является то, что wait-функция вызывается в цикле, что позволяет учесть даже процессы, завершившиеся в процессе выполнения обработчика. Ошибки в коде выводятся через perror(), чтобы не загромождать код.

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

Функция main() просто устанавливает обработчик сигнала и порождает новый процесс:

int main ()

{

    if (SIG_ERR == signal(SIGCHLD, &proc_exit))

    {

        perror("signal()");

    }

 

    switch (fork())

    {

        case -1:

            perror("fork()");

            return EXIT_FAILURE;

        case 0:

            std::cout

                << "Child exited..."

                << std::endl;

            return EXIT_SUCCESS;

        default:

            getchar();

    }

}

Посмотрим на результат:

build/bin/b01-ch25-sigchld_handler

Child exited...

Signal: 17 (Child exited)

Child returns code: 0

wait(): No child processes

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

SIGHUP

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

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

Такие программы, как tmux или screen, также перехватывают SIGHUP.

Многие серверы реализуются как демоны, и для них характерны некоторые особенности в обработке данного сигнала:

• Демон работает в фоне или запускает фоновый процесс. Родительский процесс завершает работу, поэтому SIGHUP требуется перехватывать.

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

SIGPIPE

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

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

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

Чтобы избежать генерации данного сигнала, следует использовать опцию MSG_NOSIGNAL при вызове send(), либо проигнорировать его:

#include <csignal>

 

...

 

std::signal(SIGPIPE, SIG_IGN);

В этом случае завершится только функция send(), которая вернет –1, а errno будет установлен в EPIPE. Это говорит о том, что работу с данным сокетом необходимо прекратить.

На практике игнорирование сигналов обычно выполняется следующим образом:

void HttpServer::ignore_sigpipe()

{

    sigset_t mask;

    // Обнулить маску.

    sigemptyset(&mask);

    // Добавить SIGPIPE в маску.

    sigaddset(&mask, SIGPIPE);

 

    // Блокировать сигналы в маске.

    if (pthread_sigmask(SIG_BLOCK, &msk, nullptr) != 0)

        throw std::runtime_error("pthread_sigmask() call failed");

}

Подробнее о SIGPIPE вы можете почитать в man 7 pipe.

Кроме обработки сигнала и поведения, заданного по умолчанию, существует несколько вариантов поведения:

• Игнорирование сигнала.

• Опция SA_RESTART, которую может принимать функция sigaction(). Она позволяет выполнять перезапуск вызовов, таких как send() и recv(), если они были прерваны сигналом.

Почему бы всегда не перезапускать и не игнорировать сигнал?

Ответ достаточно очевиден: приложению может быть необходимо сразу же обрабатывать ситуацию обрыва канала. Потому что в общем случае нормальное приложение должно быть «отзывчивым».

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

То есть приложение должно среагировать, если пользователь собирается выполнить какие-либо действия.

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

Проблемы с дескрипторами

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

Часто такая ситуация возникает при запуске новой программы: сначала вызывается fork(), затем в новом процессе вызывается exec(), который загружает программу в память и запускает ее, то есть заменяет образ текущего процесса.

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

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

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

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

• Через флаг FD_CLOEXEC команды F_SETFD.

• При дублировании файлового дескриптора через команду F_DUPFD_CLOEXEC. Эта команда аналогична F_DUPFD кроме того, что она сразу устанавливает флаг CLOEXEC на продублированный дескриптор.

Все дескрипторы с флагом CLOEXEC будут закрыты при любом вызове exec() атомарно и автоматически.

В Python данный флаг устанавливается так:

import fcntl

 

def set_close_exec(fd):

    """

    Хелпер, добавляющий флаг CLOEXEC к файловому дескриптору.

 

    :param fd: int

    """

    flags = fcntl.fcntl(fd, fcntl.F_GETFD)

    fcntl.fcntl(fd, fcntl.F_SETFD, flags | fcntl.FD_CLOEXEC)

В C и C++ все точно так же. В ОС Windows данная проблема тоже существует, но за неимением fcntl() для ее решения следует использовать функцию SetHandleInformation() с флагом HANDLE_FLAG_INHERIT. Подробнее она была описана в главе 18.

В некоторых многопоточных приложениях использование отдельной операции fcntl() для установки флага может привести к гонке: один поток открывает файловый дескриптор и пытается установить флаг, а другой уже выполняет fork() и exec().

При использовании сокетов гонка также может происходить между получением нового сокета от accept() и последующей установкой флага CLOEXEC.

Чтобы избежать гонки, в Linux в параметр типа при вызове функции socket() необходимо добавить флаг SOCK_CLOEXEC:

int sock = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0);

Для сокетов, возвращаемых из accept(), предусмотрена функция accept4(), описанная в главе 5. Она предоставляет возможность установить новому сокету переданные флаги, в том числе SOCK_CLOEXEC. Установка переданных флагов будет сделана атомарно, и состояния гонки не возникнет.

К сожалению, описанные опция и функция существуют только в Linux.

В функции open() и подобных также можно установить флаг O_CLOEXEC с целью избежания гонки, но уже для любого дескриптора. В WinAPI для этой цели есть плохо документированный флаг O_NOINHERIT, _O_NOINHERIT или _FNOINHERIT. В Python этот флаг определен, как os.O_NOINHERIT.

Повышение стабильности работы

Перечислим мероприятия, которые помогут улучшить стабильность программы, не допустить новых ошибок и выявить старые:

Автоматическое тестирование, в частности юнит-тесты на каждую функцию и метод. Оно отнимает много времени, но оправдывает себя. Тесты позволяют соблюдать «контракт» и особенно помогают, когда вносятся изменения, а тесты работают как регрессионные.

• Статические проверки компилятором. Не передавайте в метод параметры через map<string, string>, как делают некоторые. Используйте строгую типизацию, константные спецификаторы и проверки на уровне компиляции, чтобы избежать многих ошибок. Этот же пункт включает широкое использование .

• Динамический анализ кода. Производится выполнение программы, по результатам которого анализатор выдает отчет.

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

Оценка метрик кода. Чем проще код, тем проще его понять и заметить ошибку. Такие программы, как , и подобные, могут считать метрики. Например, цикломатическую сложность по Маккейбу: чем она выше, тем сложнее понимать, что делает программа. Если сложность кода выше 10, понимание очень затруднено.

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

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

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

• Мониторинг и телеметрия работающей системы.

• Налаженный процесс CI/CD, в котором применяются все описанные выше методы.

Запуск анализа на ранних этапах, или так называемый сдвиг влево по времени. Анализаторы, такие как cpplint, clang-tidy, pylint, запускаются еще на машине разработчика автоматически перед коммитом. Это не исключает проверок на сборочном конвейере, но позволяет ускорить разработку.

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

Принципиальные требования для сетевых и «обычных» приложений одинаковы:

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

• Наличие методики написания кода, обязательной для соблюдения разработчиками.

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

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

Heartbeat

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

Если приложения работают поверх какого-либо протокола, организующего соединение, например TCP, обрыв канала может быть обнаружен не сразу:

• Если приложение ожидает данные, канал может отсутствовать несколько минут, и это не будет замечено.

• Если приложение отправляет данные, обрыв канала будет обнаружен при отправке, в результате чего приложение получит сигнал SIGPIPE.

В некоторых случаях диагностика канала должна быть постоянной. Это обес­печивает техника Heartbeat — периодическая отправка проверочных сообщений по каналу.

Heartbeat можно совместить с техникой сторожевого, или watchdog, таймера.

Пришедшее сообщение должно сбрасывать таймер.

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

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

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

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

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

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

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

• Сигнализировать о сбое и завершить работу.

• Перейти на резервный канал.

• В новом соединении — уменьшить размер порций данных, которое оно передает, зная, что надежность сети снижена.

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

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

Литература по отладке

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

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

• Книга David J. Agans (2002) содержит описание большей части методов отладки, а также дает понимание того, что это за процесс и как он выполняется.

• Книга Robert Charles Metzger (2003) показывает разные подходы к отладке.

• Некоторые способы отладки также описаны в книге Diomidis Spinellis (2016).

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

Кому-то будет проще смотреть видеоролики, множество которых доступно на YouTube. Достаточно поискать их по запросу «Debugging» или «Debugging techniques».

Резюме

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

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

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

В поиске ошибок приложения часто помогает ужесточение проверок компилятора, санитайзеры, статистический анализ — это автоматические проверки и выполняются они быстрее «поиска вручную».

Иногда требуется не исправлять «ошибки» приложения, а обратиться к сетевому администратору и начать поиск ошибок в сети.

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

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

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

Часто используемые функции, такие как send() или recv(), могут генерировать сигналы в различных случаях, например при обрыве связи. И если этот сигнал не обработать, приложение завершится.

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

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

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

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

Вопросы и задания

1. Как определить, что ошибка находится именно в клиентском приложении?

2. Какие шаги следует предпринять для локализации проблемы в сетевом приложении?

3. С чего нужно начинать отладку, если выяснилось, что отправляемое сообщение некорректно?

4. Какие инструменты и средства помогают локализовать ошибки в сетевых приложениях помимо отладки?

5. Как называется один из самых распространенных инструментов динамического анализа в Linux и других Unix-подобных системах? Каковы его возможности?

6. Какие вы знаете инструменты динамического анализа в ОС Windows?

7. Для чего иногда требуется подменять функции, вызываемые из библиотек? Какие функции обычно подменяются?

8. Какие проверки и опции лучше включить при компиляции приложения?

9. Для чего нужны статические анализаторы? Почему их задачи не выполняет компилятор?

10. На каком уровне журналирования желательно записывать подключение к серверу? А неожиданный разрыв связи?

11. Какое правило должно соблюдаться при передаче аргументов вызову логгера?

12. Какие существуют библиотеки журналирования?

13. Какие распространенные ошибки допускают разработчики сетевых приложений?

14. Почему важно проверять результат каждой операции в сетевом приложении и что следует делать при возникновении ошибок?

15. Почему нельзя читать строку ошибки из буфера, переданного GNU-версии функции strerror_r()? Почему в POSIX-версии, наоборот, требуется работать только с буфером?

16. Почему в ОС Windows для получения кода сетевых ошибок нежелательно использовать функцию GetLastError()? Какую функцию лучше использовать вместо нее?

17. Как получить строку ошибки для блокирующего вызова getaddrinfo()? А для неблокирующего?

18. Какой класс исключений лучше использовать в C++ для извещения об ошибках сетевых функций?

19. Исключения какого класса обычно генерируют сетевые функции в Python и методы класса socket.socket?

20. Исключения какого класса генерируют в Python функции разрешения имен?

21. За что отвечает опция сокета SO_ERROR? На каких сокетах она будет работать?

22. Какие сигналы нужно обрабатывать в сетевых приложениях и зачем?

23. Что случится, если не закрывать дескрипторы в новых процессах?

24. В каких операционных системах поддерживается флаг SOCK_CLOEXEC и что он делает? Какая замена существует для него в ОС Windows?

25. Что может улучшить стабильность работы приложения?

26. Что такое heartbeat и когда он полезен?

27. Где можно найти сведения по отладке?

28. Какие еще проблемы и ошибки вы заметили в примере некорректного UDP клиента? Исправьте клиент так, чтобы код был корректным. Добавьте обработку сигналов.

29. Перепишите traceroute, разработанный в предыдущей главе, на использование UDP-зондов. Используйте опцию IP_RECVERR для получения ошибок ICMP.


Назад: Глава 24. Проблемы сетевых приложений, диагностика и отладка
Дальше: Глоссарий