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

LXF108:Дырки в паутине

Материал из Linuxformat
(Различия между версиями)
Перейти к: навигация, поиск
(Новая: == Дырки в паутине == : ''Утечка ресурсов – в первую очередь, памяти – одна из проблем современных сложны...)

Версия 13:03, 24 мая 2009

Содержание

Дырки в паутине

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

Можно смело считать 2007 год переломным моментом в переходе на многоядерные процессоры и активном внедрении многопоточного программирования в повседневную практику. Компании Intel и AMD обозначили производство многоядерных процессоров как основное направление своего развития на ближайшие несколько лет. Покупая современный ноутбук или ПК, вы обязательно увидите шильдик Core2Duo или Athlon X2. В текущем году производители чипов уже делают упор на четырехъядерные процессоры. Очевидно, что производители ПО должны адекватно реагировать на эти изменения, выпуская продукты, задействующие все преимущества новых технологий. А на чем они пишутся?

В языке C++, в отличие, например, от Java, отсутствует встроенная поддержка многопоточного программирования. Иными словами, с помощью «голого» C++ нельзя создавать соответствующие стандарту языка многопоточные приложения. Разумеется, данная функциональность все же доступна в виде библиотек, например, Pthreads (POSIX Threads), особенной популярной в мире Unix. Кроме нее, разработчик может использовать библиотеку Boost (http://www.boost.org) или ThreadWeaver (http://api.kde.org). Коммерческие Unix-системы, например, Sun Solaris, предлагают собственные библиотеки многопоточного программирования.

На данном уроке мы рассмотрим некоторые проблемы, возникающие при написании программ на C++ с использованием библиотеки Pthreads. Наряду со встроенными базовыми типами (int, char, double) в функциях работы с потоками могут использоваться объекты классов. Вот тут-то нас и подстерегают проблемы и неожиданности, которые мы сегодня обсудим. Кроме этого, мы оценим работу многопоточных программ, написанных на C++ и Pthreads, в различных Linux-системах.

И снова классы…

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

Во всех тестах, описываемых в этой статье, будет использоваться класс checker, объявленный в файле checker.hpp. Вот он:

class checker{
 private: string name;
 public:
 explicit checker(string s) ;
 ~checker( );
 void calc(long N);
 void say_hello(void);
};

Класс обладает «говорящими» конструктором и деструктором, метод calc() имитирует длительную по времени расчетную задачу, а функция say_hello() выводит сообщение на консоль.

С вещами на выход!

Выполнение потоковой функции может быть прервано по трем причинам:

  1. В результате «естественного завершения» оператором return;
  2. В результате вызова функции pthread_exit();
  3. В результате аннулирования другим потоком.

Особый интерес для нас будет представлять вызов деструкторов объектов в случаях 2 и 3.

В документе 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() для него должен быть автоматически выполнен деструктор. Однако, как показывает практика, бывают случаи, когда этого не происходит.

Рассмотрим следующую программу: <sorce lang="cpp">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; }</source> Здесь объявлены две потоковых функции: task1() и task2(). В качестве элемента данных в каждой из них используется объект класса checker, выделенный в стеке. Функция task1() завершается принудительно с помощью pthread_exit(), а task2() выходит «есте- ственным образом» через return 0. При этом потоки создаются как открепленные, т.е. при их уничтожении ресурсы, которые они использовали, сразу же возвращаются системе. Рассмотрим результат работы программы в различных дистрибу- тивах Linux. Например, в SLAX 6.0.3 вывод на консоль будет таким:

Start test #1!
Start task_1!
Constructor done! Name:First
Start task_2!
Constructor done! Name:Second
Destructor done! Name:First
Destructor done! Name:Second

А вот что получается в ALT Linux 3.0.4:

Start test #1!
Start task_1!
Constructor done! Name:First
Start task_2!
Constructor done! Name:Second
Destructor done! Name:Second

Видите? Деструктор объекта из потоковой функции task1() вызван не был! Конечно, 3.0.4 – не самая актуальная версия данно- го дистрибутива, но, как мы увидим далее, аналогичные проблемы имеют место и в более современных ОС.

Поел – убери за собой

Очень часто при завершении потока бывает необходимо выпол- нить некоторые заключительные операции: освободить память, закрыть файлы, снять блокировки с разделяемых переменных и т.п. Желательно, чтобы эти действия выполнялись единообразно, как для стандартного завершения потоковой функции операто- ром return, так и при аннулировании другим потоком. Библиотека Pthreads предоставляет для этого возможность, называемую «сте- ком очистительно-восстановительных операций». Как она рабо- тает? С каждым потоком, имеющимся в программе, связывается стек очистительно-восстановительных операций, который содер- жит указатели на функции, вызываемые во время аннулирования (завершения) потока. Для работы с данным стеком используются две функции (или макроса): pthread_cleanup_push() Принимает в качестве параметров указа- тель на помещаемую в стек функцию и передаваемый ей аргумент; pthread_cleanup_pop() Принимает в качестве параметра цело- численное значение и извлекает завершающую функцию с верши- ны стека. Если аргумент отличен от нуля, завершающая функция выполняется. Давайте рассмотрим еще один пример. Здесь мы определяем потоковую функцию, которая в зависимости от значения параметра, заданного пользователем, завершается либо «обычным образом», либо вызовом pthread_exit(): void* task1(void *X){ std::cout<<" Start test thread!"<<std::endl; checker *Z = new checker("agent"); pthread_cleanup_push(del_ptr_checker, Z); int *counter = static_cast<int*>(X); for(int i=0; i<(*counter); ++i) { if(i==1000) pthread_exit(NULL); } pthread_cleanup_pop(1); std::cout<<" Thread go boom!"<<std::endl; return 0; } Обратите внимание, что в качестве элемента данных теперь используется экземпляр класса checker, расположенный в дина- мической памяти. Для ее освобождения была написана функ- ция del_ptr_checker(), указатель на которую помещается в стек очистительно-восстановительных операций. Мы вызываем деструк- тор объекта при помощи pthread_cleanup_pop(1) – кажется, это должно гарантировать выполнение очистительных действий вне зависимости от способа завершения потока. Функция del_ptr_checker() сама по себе довольно проста: void del_ptr_checker(void *X){ checker *del = static_cast<checker*>(X); std::cout<<" #-> START del_ptr_checker!"<<std::endl; delete del; del = 0; std::cout<<" #-> END del_ptr_checker!"<<std::endl; } Мы приводим переданный указатель к типу checker * и вызы- ваем оператор delete для освобождения памяти. del_ptr_checker() определена в файле helper.hpp. Функция main() для нашего примера выглядит так: int main(void) { std::cout<<" START TEST #2!"<<std::endl; int N=0; std::cout<<" Enter N:"; std::cin>>N; pthread_t threadA; pthread_create(&threadA, NULL, task1, &N); pthread_detach(threadA); std::cout<<" End MAIN"<<std::endl; return 0; }

Приведу результаты выполнения двух тестов в SLAX 6.0.3: START TEST #2! Enter N:222 Start test thread! Constructor done! Name:agent

  1. -> START del_ptr_checker!

Destructor done! Name:agent

  1. -> END del_ptr_checker!

Thread go boom! End MAIN START TEST #2! Enter N:4589 Start test thread! Constructor done! Name:agent

  1. -> START del_ptr_checker!

Destructor done! Name:agent

  1. -> END del_ptr_checker!

End MAIN Как мы видим, функция del_ptr_checker() вызывается независи- мо от значения параметра N, задаваемого пользователем, и дина- мическая память, занимаемая объектом класса checker, всегда кор- ректно освобождается. А вот что происходит в OpenSUSE 10.1: linux@linux:~/super> ./test_2 Enter N:654 End MAIN linux@linux:~/super> ./test_2 Enter N:8888 End MAIN linux@linux:~/super> Любопытно, но судя по выводу на консоль, поток, использующий динамическую переменную класса checker, даже не создается, не гово- ря уже о вызове деструктора для объекта. Очень интересный результат! Кстати, аналогичное поведение наблюдается и в Mpentoo Linux 2006.1.

Я тебя породил...

Бывают ситуации, когда одному потоку нужно завершить другой: это может делаться при организации управления программой или для экономии ограниченных ресурсов. В библиотеке Pthreads для этих целей предназначена функция pthread_cancel(). В каче- стве параметра она принимает идентификатор потоковой функ- ции, которую надо завершить, и возвращает 0 в случае успешно- го выполнения. В уже упоминавшемся документе The Open Group Base Specifications Issue 6 IEEE Std 1003.1, 2004 Edition в разделе, описывающем функцию pthread_cancel(), говорится: «Функция pthread_cancel() создает запрос на отмену потока. Когда он будет реализован, зависит от текущего состояния потока и его типа. При отмене потока должны быть вызваны обработчики, которые выпол- нят связанные с отменой подготовительные действия. По заверше- нию последнего обработчика должны быть вызваны деструкторы данных, используемых потоком».

Посмотрим, что же действительно происходит в данной ситуа- ции, с помощью следующей простой программы:

void *task1(void *X){
 std::cout<<" Start thread!"<<std::endl;
 checker Q("spy");
 checker *Z = new checker("agent");
 pthread_cleanup_push(del_ptr_checker, Z);
 pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, NULL);
 std::cout<<" @-> Hello, Threads!"<<std::endl;
 for(int i=0; i<5; ++i)
 {
  Z->say_hello( );
  sleep(1);
 }
 pthread_testcancel();
 pthread_cleanup_pop(1);
 std::cout<<" @-> Logical End of THREAD function"<<std::endl;
}
int main(int argc, char * argv[]){
 cout<<" Start test #3!"<<std::endl;
 int ret;
 pthread_t thread;
 pthread_create(&thread, NULL, task1, NULL);
 ret = pthread_cancel(thread);
 if(ret==0)
 {
  std::cout<<" $$$ Thread CANCEL OK!"<<std::endl;
 }
 pthread_join(thread, NULL);
 std::cout<<" $$$ The thread go boom!"<<std::endl;
 return 0;
}

В качестве элемента данных потоковая функция task1() использует два объекта класса checker: один расположен в динамической памяти, второй имеет класс auto. Для уничтожения первого экземпляра мы опять используем стек очистительно-восстановительных операций. Вызов pthread_setcancelstate() разрешает аннулирование нашего пото- ка другим. Функция pthread_testcancel() проверяет наличие необрабо- танных запросов на уничтожение. Если они есть, процесс аннулирова- ния активизируется в точке вызова pthread_testcancel(). Обратите вни- мание, что в потоковой функции не используются разделяемые пере- менные и не происходит вызова системных функций (кроме вывода сообщений на консоль), что обеспечивает ее безопасное прекращение.

Результат выполнения тестовой программы в SLAX 6.0.3 таков:

Start test #3!
Start thread!
Constructor done! Name:spy
Constructor done! Name:agent
@-> Hello, Threads!
Hello from cheker!
#-> START del_ptr_checker!
Destructor done! Name:agent
#-> END del_ptr_checker!
Destructor done! Name:spy
$$$ Thread CANCEL OK!
$$$ The thread go boom!

А вот вывод в системе Mpentoo 2006.1:

Start test #3!
$$$ Thread CANCEL OK!
Start thread!
$$$ The thread go boom!

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

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

  • Тест № 1. При запуске программы в OpenSUSE 10.1 наблюдается порядок вызова деструкторов, отличный от всех других систем, получивших +. Однако все деструкторы вызываются, и утечки памяти не происходит.
  • Тест № 2. В двух системах, отмеченных ?, наблюдалась неустойчивая работа теста. Она проявлялась в том, что в одном случае результат теста был «правильным», и вывод на консоль полностью соответствовал ожидаемому, а в другом случае наблюдалась проблема с запуском потоковой функции.

Что в итоге?

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

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

В-третьих, анализ характеристик дистрибутивов, получивших + по всем тестам, обнаруживает, что версия ядра (в этих тестовых образцах) не ниже 2.6.24 (это обязательное условие), библио- тека glibc – не ниже 2.6.1 (лучше 2.7), библиотека libstdc++ – не ниже 6.0.8 (лучше 6.0.9), версия компилятора GCC – не ниже 4.1.2. Соответственно, дистрибутивы, получившие за тест -, имеют другие версии библиотек. Например, в ALT Linux 4.0.3 используется библи- отека glibc версии 2.5. Кстати, очень интересно сравнить результаты Gentoo 2008 Beta 2 и Fedora Core 8. Исход их «спора» решила вер- сия ядра. У Gentoo она выше, хотя у Fedora библиотека glibc новее. Это говорит о том, что своевременное обновление ядра и ключевых системных библиотек позволяет повысить надежность работы опе- рационной системы.

Читателя наверняка интересует вопрос: а что будет, если про- грамму, скомпилированную в «правильной» системе, например, Knoppix 5.3.1, попробовать запустить в «неправильной», скажем, Knoppix 3.2 RE? Здесь возможны два варианта:

  1. Программа не запустится из-за отсутствия необходимых библиотек и выдаст сообщение следующего содержания:
    knoppix@ttyp0[knoppix]$ ./etalon
    ./etalon: error while loading shared libraries: libstdc++.so.6: cannot open shared object file: No such file or directory
    knoppix@ttyp0[knoppix]$
  2. Однако, даже если «правильная» программа запустится в «неправильной» среде, вести себя она будет «неправильно».

И что делать?

Что можно посоветовать, чтобы свести к минимуму издержки, связанные с особенностями взаимодействия библиотеки Pthreads с объектами классов?

Что касается теста №1, то тут может помочь метафора «песочницы»: прием, при котором вся работа с объектами классов, имеющих тип памяти auto (не динамические, а «обычные» переменные), ведется в пределах блока, выделенного в тексте программы фигурными скобками {…}. При выходе из блока происходит автоматический вызов деструкторов, после чего можно «запускать» pthread_exit().

Однако возникает вопрос: что делать, если pthread_exit() вызывается в результате выполнения некоторого условия при работе программы, как, например, в тесте № 2, или происходит аннулирование потока, как в тесте № 3? Здесь относительно универсальным, хотя и достаточно трудозатратным будет такой выход, как «ручное» управление памятью посредством операторов new и delete. Да, это трудно и хлопотно, однако на текущий момент это, наверное, единственный эффективный выход из ситуации. Кстати, для объектов с классом памяти auto деструктор можно вызвать как обычную функцию.

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