Журнал LinuxFormat - перейти на главную

LXF118:threads

Материал из Linuxformat
Перейти к: навигация, поиск
Pthreads Обработаем исключения корректно

Содержание

C++: Исключение утечек

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

Обязательным этапом процесса написания любой мало-мальски сложной программы является создание блоков кода, ответственных за обработку различных ошибок и нештатных ситуаций. В C эта задача обычно решается проверкой кодов возврата функций. В Java или Mono очень распространен механизм исключений, являющийся неотъемлемой частью как самих платформ, так и языков, с ними связанных. Что касается С++, то здесь каждый волен сделать выбор между этими двумя путями самостоятельно. Отметим, что механизм обработки исключений не был встроен в С++ изначально. Однако по инициативе комитета ANSI в язык были добавлены блоки try … catch, и перед разработчиками появились новые возможности: с одной стороны, для повышения надежности программ, с другой – для добавления в них ошибок.

Главное преимущество исключений в том, что их нельзя «проигнорировать» или «забыть». Однако для эффективного использования данного механизма программа должна изначально проектироваться с учетом всех особенностей и нюансов как самих исключений, так и языка С++. Да-да, такая вот дуальность...

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

Экскурс в историю

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

Выполнение потоковой функции может быть прервано по причине «естественного завершения» оператором return или в результате вызова функции pthread_exit(). Что при этом будет происходить с объектами пользовательских классов, являющихся данными потоковой функции и имеющими тип памяти auto? В документе The Open Group Base Specifications Issue 6 IEEE Std 1003.1, 2004 Edition, в разделе, посвященном функции pthread_exit() сказано следующее [здесь и далее – перевод автора]:

«Функция void pthread_exit(void *value_ptr) завершает вызывающий поток и делает значение value_ptr доступным для успешного присоединения к завершающему потоку. Любые обработчики отмены, помещенные в стек, но ещё не извлеченные из него, будут извлечены в порядке, обратном по отношению к порядку помещения в стек, а после – выполнены. Если потоку принадлежат данные, то после выполнения всех обработчиков отмены будут вызваны соответствующие функции деструкторов, при этом порядок их вызова не определен. При завершении потока ресурсы процесса, включая мьютексы и дескрипторы файлов, не освобождаются, и не выполняются никакие восстановительные действия уровня процесса, включая вызовы любых функций atexit( ), какие только могут быть». То есть, если некий объект представляет собой локальную переменную потоковой функции с классом памяти auto, то после вызова функции pthread_exit() для него должен быть автоматически вызван деструктор. Однако, как показывает практика, бывают случаи, когда деструкторы для локальных объектов не вызываются.

Рассмотрим следующую программу:

void* task1(void *X) {
  std::cout<<“ Start task_1!<<std::endl;
  checker P(“First”); P.calc(5);
  pthread_exit(NULL);
  return (0);
}
void* task2(void *X) {
  std::cout<<“ Start task_2!<<std::endl;
  checker Q(“Second”); Q.calc(8);
  return (0);
}
int main(void){
  std::cout<<“ Start test #1!”<<std::endl;
  pthread_t threadA, threadB;
  pthread_create(&threadA, NULL, task1, NULL);
  pthread_detach(threadA);
  pthread_create(&threadB, NULL, task2, NULL);
  pthread_detach(threadB);
  pthread_exit(NULL);
  return (0);
}

Здесь объявлены две потоковые функции: task1() и task2(). В каждой из них в качестве элемента данных используется объект пользовательского класса checker (LXF108). Функция task1() завершается принудительно с помощью pthread_exit(), а task2() выходит «естественным образом». При этом потоки создаются как откреплённые, то есть при их завершении ресурсы, которые они использовали, сразу освобождаются и возвращаются системе.

Корректно работающая программа должна вывести на экран два сообщения ‘Destructor done!’ от объектов First и Second. Зачастую так и происходит, но в [сравнительно старой] системе ALT Linux 3.0.4 (gcc 3.4, glibc 2.3) имеем:

[altlinux@Compact altlinux]$ ./test_1
 Start test #1!
 Start task_1!
 Constructor done! Name:First
 Start task_2!
 Constructor done! Name:Second
 Destructor done! Name:Second
[altlinux@Compact altlinux]$

Мы видим, что деструктор объекта из потоковой функции task1() вызван не был! В качестве одного из решений данной проблемы мы рекомендовали (LXF108) использовать «песочницу», т.е. работать со всеми объектами с типом памяти auto в пределах блока, выделенного в тексте программы фигурными скобками {…}. При выходе из такого блока происходит автоматический вызов деструкторов, после чего можно вызывать pthread_exit(). Второй вариант – использовать «ручное» управление памятью посредством операторов new и delete.

Eсть идея!

В книге Марка Митчелла, Джеффри Оулдема и Алекса Самьюэла [Mark Mitchell, Jeffrey Oldham, Alex Samuel] «Advanced Linux Programming» (http://www.advancedlinuxprogramming.com) предлагается другой способ: «когда объект выходит за пределы своей области видимости, либо по достижению конца блока, либо вследствие возникновения исключительной ситуации, среда выполнения С++ гарантирует вызов деструкторов для тех автоматических переменных, у которых они есть. Это удобный механизм очистки, работающий независимо от того, как осуществляется выход из конкретного программного блока. … Поскольку исключение перехватывается на самом верхнем уровне потоковой функции, все локальные переменные, на ходящиеся в стеке потока, будут удалены правильно». [цитата приводится по русскому изданию книги: Митчелл М., Оулдем Д., Самьюэл А. Программирование для Linux. Профессиональный под ход. Пер. c англ. – М. : Издательский дом «Вильямс», 2002., ныне исчезнувшему из продажи, – прим. ред.]. Авторская реализация метода (см. раздел 4.3.2 «Очистка потоковых данных в C++» в указанной выше книге) выглядит так:

class ThreadExeption{/*Здесь соот ветст вующий код реа лизации класса*/}
void do_some_work( )
{
   if(...) throw ThreadExeption( );
}
void* thread_function(void* )
{
  try {
      do_some_work( );
  }
  catch(ThreadExeption e) {
      e.do_exit( );
  }
}

Метод do_exit() класса ThreadExeption, вызываемый в обработчике исключения, является «оберткой» для функции pthread_exit(). Суть метода в том, что прямой вызов pthread_exit() заменяется на генерацию исключения и его обработку.

Доверяй, но проверяй!

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

class EX_T
{
   public:
      EX_T( ) { cout<<“ EX_T constructor!<<endl; }
      ~EX_T( ){ cout<<“ EX_T destructor!<<endl; }
      void do_exit(void) { pthread_exit(NULL); }
};
void func_throw(void)
{
   checker Z(“In Function object”);
   void *condition;
   Z.calc(4);
   condition = NULL;
   if(condition==NULL) throw EX_T( );
}
void* task1(void *X)
{
   try {
    cout<<“ Start task_1!<<endl;
    checker A(“In try-block object”);
    func_throw( );
  }
  catch(EX_T& e) {
    e.do_exit( );
  }
  return 0;
}
void* task2(void *X)
{
   std::cout<<“ Start task_2!<<std::endl;
   checker Q(“KNOPPIX”);
   Q.calc(8);
   return (0);
}
int main(void)
{
   std::cout<< “ Start test!<<std::endl;
   pthread_t threadA, threadB;
   pthread_create(&threadA, NULL, task1, NULL);
   pthread_detach(threadA);
   pthread_create(&threadB, NULL, task2, NULL);
   pthread_detach(threadB);
   pthread_exit(NULL);
   return (0);
}

Класс checker, расположенный в файле checker.hpp, обладает «говорящими» конструктором и деструктором, метод calc() имитирует длительную по времени расчётную задачу, а say_hello() выводит сообщение на консоль. Объекты класса используются в качестве элементов данных в потоковых функциях.

EX_T – это класс, реализующий сущность «потоковое исключение». Метод do_exit() является обёрткой для функции pthread_exit().

Функция func_throw() имеет в качестве элемента данных объект класса checker с типом памяти auto. В ходе своей работы функция генерирует исключение в виде объекта класса EX_T.

Функция task1() является потоковой. В качестве элемента данных она использует объект класса checker и вызывает функцию func_throw(). Завершение потоковой функции происходит посредством вызова pthread_exit() в обработчике исключения. Функция task2() также является потоковой и использует в качестве элемента данных объект класса checker, однако завершение task2() происходит через вызов оператора return. Функция main() не отличается от таковой в первом примере.

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

Что мы ожидаем увидеть на экране после завершения работы программы? Во-первых, сообщение о запуске основного потока: ‘Start test #1!’, во-вторых, сообщения о работе потоковых функций и конструкторов с деструкторами в них. Разумеется, мы хотим, чтобы количество вызов конструкторов и деструкторов было бы одинаковым.

Вот результат работы программы в Mandriva 2009.0 Free:

[andy@localhost code_cpp]$ ./test_4
Start test!
Start task_1!
Constructor done! Name:In try-block object
Constructor done! Name:In Function object
Start task_2!
Constructor done! Name:KNOPPIX
EX_T constructor!
Destructor done! Name:In Function object
Destructor done! Name:In try-block object
EX_T destructor!
Destructor done! Name:KNOPPIX
[andy@localhost code_cpp]$

Как можно видеть, он полностью соответствует нашим ожиданиям. В task_1() создаются два объекта класса checker (один – в func_throw), а в task_2() – один такой объект. В ходе работы первой потоковой функции генерируется исключение, что приводит к запуску двух деструкторов потоковых объектов и, в конечном счёте, деструктора для объекта-исключения. По завершению второй потоковой функции вызывается деструктор для потокового объекта. Всё так, как и должно быть.

А вот результат работы программы в MPentoo 2006.1 (http://pentoo.ch):

mpentoo home # ./test_4
Start test!
Start task_1!
Constructor done! Name:In try-block object
Constructor done! Name:In Function object
Start task_2!
Constructor done! Name:KNOPPIX
EX_T constructor!
Destructor done! Name:In Function object
Destructor done! Name:In try-block object
Destructor done! Name:KNOPPIX
mpentoo home #

Шило на мыло...

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

Во-первых, почему для проверки были выбраны именно Mandriva 2009.0 Free и MPentoo 2006.1? Ответ прост: Mandriva – это максимально свежий Linux из всех, что у нас были на момент подготовки статьи, а MPentoo – один из двух дистрибутивов, показавших неудовлетворительный результат в статье (LXF108). В числе его проблем было и освобождение памяти после выполнения функции pthread_exit(). Что касается ключевых параметров этих двух дистрибутивов, то они таковы:

  • Mandriva 2009.0 Free: ядро 2.6.27, gcc 4.3.2, glibc 2.8, libstdc++ 5.0.7/6.0.10
  • MPentoo 2006.1: ядро 2.6.16, gcc 3.3.6, glibc 2.3.6, libstdc++ 5.0.7

Во-вторых, давайте корректно сформулируем, в чем состоит обнаруженная нами проблема. Реализованный способ был основан на использовании возможностей, обеспеченных стандартом языка. Авторы книги утверждают, что «исключение перехватывается на самом верхнем уровне потоковой функции, все локальные переменные, находящиеся в стеке потока, будут удалены правильно». На самом деле всё происходит несколько иначе: локальные переменные, находящиеся в стеке потока при использовании данного метода, могут быть удалены, а могут быть и не удалены. Вместо утечки памяти через объекты данных пользовательских классов, мы сталкиваемся с проблемой освобождения памяти после обработки объекта-исключения, который сам находится в адресном пространстве потока. В обоих случаях стек один и тот же! В общем, мы сменяли шило на мыло. Хочется обратить внимание читателя на то, что в различной литературе по языку С++ исключения очень часто трактуются как некий флаг, сигнализирующий о том, что в программе что-то произошло. Это ошибочное и вредное представление! Фраза «исключение есть объект класса» означает, что его информационная и функциональная насыщенность может быть очень и очень велика. Объём данных, который несет в себе объект-исключение, может быть существенно выше того, что хранится в «простых» объектах, и утечка памяти здесь может стоить очень дорого.

В-третьих, надо решить, насколько масштабна данная угроза? Тут следует сделать одно весьма важное замечание. Библиотека Pthreads есть практически в любой UNIX-подобной системе, будь то Linux, FreeBSD или QNX. Вне зависимости от конкретной ОС, которую предстоит использовать, методология везде одинаковая, и программный интерфейс один и тот же. Исследование, результаты которого были опубликованы в (LXF108), показало, что для корректной и надёжной работы функций библиотеки Pthreads с объектами пользовательских классов необходимо, чтобы версии ядра и ключевых системных библиотек операционной системы Linux были не ниже определённых. Поспешу успокоить читателей: проблема освобождения памяти, занимаемой автоматическими переменными, при вызове pthread_exit(), уже решена практически для всех современных дистрибутивов Linux; однако не стоит забывать о безопасности работы в других операционных системах и принципах надёжного программирования, которые от используемой ОС зависеть не должны.

Что касается прочих UNIX’ов, то я не готов назвать конкретные условия, при удовлетворении которых можно рассчитывать на отсутствие проблем в работе (хотя бы потому, что UNIX’ов много, а я один), однако могу привести результат выполнения нашей тестовой программы в среде QNX 6.2.1:

# ./test_4
Start test!
Start task_1!
Constructor done! Name:In try-block object
Constructor done! Name:In Function object
Start task_2!
Constructor done! Name:KNOPPIX
EX_T constructor!
EX_T destructor!
Abort (core dumped)
#

Как можно видеть, здесь программа вообще завершилась аварийно.

В-четвёртых, можно ли решить эту проблему работы с исключениями «малой кровью», используя какой-нибудь хитрый и изящный программистский трюк? Отвечу так: решить проблему освобождения памяти можно, решить проблему исключений – нет. Любители трюков могут вспомнить, что исключение – это не обязательно объект класса в терминах объектно-ориентированного программирования. Стандарт языка вполне допускает генерацию исключения посредством базовых примитивных типов данных. Иными словами, мы можем написать в программе throw 55 или throw “Problem!”. Соответственно, в этом случае блок обработки исключения может быть, например, таким:

catch(int)
{
  pthread_exit(NULL);
}

Помимо того, что это скверный приём программирования сам по себе, нашу проблему он всё равно не решает. Запуск подобного тестового примера в системе QNX 6.2.1 выдаёт всё тот же ‘Abort (core dumped)’. Вот так...

Время собирать камни

Какие выводы можно сделать на основе всего вышеизложенного? Применение механизма обработки исключений с целью принудительного автоматического запуска деструкторов объектов потоковой функции в случае её завершения посредством вызова pthread_exit() с практической точки зрения бесполезно. Если операционная система не имеет проблем с обслуживанием потокового стека, то применение механизма генерации и обработки исключений изначально избыточно, поскольку прямой вызов функции pthread_exit() не провоцирует ошибок освобождения памяти. Если же система не умеет корректно поддерживать потоковый стек, то, как было показано на примерах, механизм исключений тут ничем не поможет.

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

Если же система испытывает проблемы с корректным обслуживанием потокового стека, то единственный надёжный приём по управлению памятью в потоковой функции, который не зависит ни от версии системных библиотек, ни от типа операционной системы – это ее выделение и освобождение посредством вызова операторов new и delete. Фактически, происходит отказ от работы со стеком в пользу работы с кучей. Проблемы в обслуживании потокового стека не позволяют использовать для управления блоками динамической памяти аппарат интеллектуальных указателей (smart pointers), поэтому все действия приходится осуществлять вручную. LXF

Персональные инструменты
купить
подписаться
Яндекс.Метрика