В современном программировании асинхронная обработка ошибок является одной из самых сложных задач для разработчиков и проектировщиков API. Особенно это касается тех систем, где важна параллельная обработка или выполнение ресурсов за пределами главного потока выполнения, таких как графические процессоры и высокопроизводительные вычислительные окружения. Сложность заключается не только в том, чтобы зафиксировать и передать информацию об ошибке, но и в обеспечении удобства, безопасности и эффективности работы с этими ошибками для программистов. Тема обработки ошибок в API не нова и во многом формировалась десятилетиями. Исторически многие архитектурные решения основывались на опыте, накопленном при разработке классических языков программирования и системных библиотек.
Например, известно, что функции из стандартной библиотеки языка C, такие как atoi(), из-за отсутствия возможности возвращать валидное значение при ошибках конвертации, породили целый класс проблем с однозначным распознаванием ошибок. Такой пример демонстрирует недостатки, когда ошибка кодируется внутри возвращаемого результата без отдельного механизма ее определения, что приводит к неоднозначности и потенциальным багам. Современные подходы к обработке ошибок делятся на три большие категории: использование исключений, немедленный возврат кода ошибки и механизм get-last-error. Исключения предоставляют механизм, при котором ошибки могут сигнализироваться, будучи выброшенными вверх по стеку вызовов и пойманными в нужных местах через конструкции try-catch. Тем не менее, даже при поддержке исключений в языках программирования, таких как C++ или Python, их применение в низкоуровневых или межъязыковых библиотеках остается спорным.
Это связано с тем, что обработка исключений может запутывать контроль потока программы и увеличивать сложность анализа кода. Добавляется ограничение на переносимость, поскольку не все языки и среда исполнения одинаково поддерживают или рекомендуют использовать исключения. Отказ от исключений ведет к использованию немедленного возврата кода ошибки. При этом функции возвращают специальные типы, кодирующие успешное завершение или конкретную ошибку, а основная полезная информация передается через параметры. Этот подход широко принят в API CUDA, где большинство функций возвращают коды типа cudaError_t или CUresult.
Такой метод позволяет явно проверять успешность операций сразу после их вызова, что способствует более прозрачному и однозначному управлению ошибками. Однако не все так просто. Немедленные коды ошибок необходимо проверять в каждом вызове, иначе ошибки могут остаться незамеченными. На практике разработчики иногда намеренно игнорируют такие коды, считая их второстепенными или нефатальными. Примером служит функция, выделяющая сразу два буфера памяти: если первый буфер выделен успешно, а второй нет, производится очистка первого, но возвращается ошибка второго, при этом ошибки освобождения первого буфера игнорируются.
Такая практика отражает компромисс между тщательностью обработки ошибок и прагматизмом в написании кода. В попытках упростить работу с ошибками возникли механизмы get-last-error, которые фиксируют последний произошедший сбой в глобальной или поточно-локальной переменной. Последующая проверка этой переменной позволяет выявить ошибки не обязательно сразу после вызова, а в более удобном месте программы. Однако этот подход сопряжен с рядом проблем. Например, в многопоточных системах необходимо использовать потоково-локальное хранилище, чтобы не перепутать ошибки нескольких одновременно работающих нитей.
Разработчикам приходится принимать решения, как часто проверять состояния ошибок, чтобы не утратить важную информацию, не снизив при этом производительность из-за большого количества проверок. CUDA иллюстрирует наличие таких сложностей: функции cudaGetLastError() и cudaPeekLastError() предоставляют возможности чтения и очистки последней ошибки. Первая функция читает и сбрасывает ошибку, что означает, что она может быть вызвана один раз для получения информации, вторая же позволяет просто посмотреть на ошибку без ее обнуления. Это вынуждает разработчиков тщательно планировать стратегию обработки ошибок, чтобы избежать ситуации, при которой ошибка была пропущена или «стерта» раньше времени. Особенностью CUDA является сочетание немедленных возвращаемых кодов ошибок с этими же элементами get-last-error.
Это обусловлено асинхронной природой многих вызовов в CUDA, например, запуск ядра GPU происходит асинхронно, и ошибки могут возникать уже после возвращения из функции вызова. Проверка на ошибки требует синхронизации CPU и GPU, что влечет за собой снижение производительности. Поэтому CUDA и использует подход «липких» ошибок, который похож на поведение арифметики с NaN и INF — ошибки накапливаются и выявляются позже, причем в тех местах, где это менее затратное по времени. В практике разработки на CUDA удается создавать корректные программы полностью на основе проверки немедленных кодов ошибок, не используя get-last-error. Это подчеркивает, что при правильной организации обработки ошибок можно избежать многих проблем, связанных с асинхронностью.
Тем не менее возможность получить доступ к последним ошибкам остается полезной как резервный механизм. Перспективы усовершенствования методов заключаются в повышении гранулярности и точности отчета об ошибках, не ухудшая производительность. Предложено использовать механизмы событий CUDA, которые могут не только сигнализировать о достижении определенной точки в выполнении, но и быть оснащены системой проверки ошибок. Такой подход позволит разработчикам более точно локализовать место и время возникновения проблем, уменьшив неясность, присущую текущим get-last-error системам. В итоге, для систем с асинхронной природой, такими как CUDA, важно сбалансировать три критерия: четкость и однозначность передачи ошибок, производительность и удобство для разработчиков.