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

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

Материал из Linuxformat
Перейти к: навигация, поиск

Содержание

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

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

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

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

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, выделенный в стеке. Функция 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
#-> START del_ptr_checker!
Destructor done! Name:agent
#-> END del_ptr_checker!
Thread go boom!
End MAIN
START TEST #2!
Enter N:4589
Start test thread!
Constructor done! Name:agent
#-> START del_ptr_checker!
Destructor done! Name:agent
#-> 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. В двух системах, отмеченных ?, наблюдалась неустойчивая работа теста. Она проявлялась в том, что в одном случае результат теста был «правильным», и вывод на консоль полностью соответствовал ожидаемому, а в другом случае наблюдалась проблема с запуском потоковой функции.
Сводная таблица результатов тестирования
Операционная система Номер теста
1 2 3
1 Mandriva 2008 KDE LiveCD

Ядро: 2.6.22 GCC: 4.2.2* Glibc: 2.6.1 Libstdc++: 5.0.7 / 6.0.9

+ ? +
2 Fedora 8 LiveCD

Ядро: 2.6.23 GCC: 4.1.2* Glibc: 2.7 Libstdc++: 6.0.8

+ X +
3 ASP Linux 11 LiveCD

Ядро: 2.6.14 GCC: 4.0.2 Glibc: 2.3.5 Libstdc++: 5.0.7 / 6.0.7

+ X X
4 Knoppix 5.3.1 LiveDVD

Ядро: 2.6.24 GCC: 4.2.3 Glibc: 2.7 Libstdc++: 5.0.7 / 6.0.10

+ + +
5 SLAX 6.0.3 LiveCD

Ядро: 2.6.24 GCC: 4.2.3 Glibc: 2.7 Libstdc++: 6.0.8 / 6.0.9

+ + +
6 Ubuntu 8.0.4 LiveDVD

Ядро: 2.6.24 GCC: 4.2.3 Glibc: 2.7 Libstdc++: 6.0.9

+ + +
7 Ubuntu 7.10 LiveCD

Ядро: 2.6.22 GCC: 4.1.3 Glibc: 2.6.1 Libstdc++: 6.0.9

+  ? X
8 ALT Linux 3.0.4 LiveCD

Ядро: 2.6.12 GCC: 3.4.4* Glibc: 2.3.5 Libstdc++: 5.0.7 / 6.0.3

X X X
9 ALT Linux 4.0.3

Ядро: 2.6.18 GCC: 4.1.1 Glibc: 2.5 Libstdc++: 5.0.7 / 6.0.8

+ X X
10 MPentoo 2006.1 LiveCD

Ядро: 2.6.16 GCC: 3.3.6 Glibc: 2.3.6 Libstdc++: 5.0.7

X X X
11 Puppy Linux (rus_100) LiveCD

Ядро: 2.6.21 GCC: 4.1.2* Glibc: 2.5 Libstdc++: 5.0.6 / 6.0.8

+ X X
12 Gentoo 2008 Beta2 LiveCD

Ядро: 2.6.24 GCC: 4.1.2 Glibc: 2.6.1 Libstdc++: 6.0.8

+ + +
13 OpenSUSE 10.1 LiveDVD

Ядро: 2.6.16 GCC: 4.1.0* Glibc: 2.4 Libstdc++: 5.0.7 / 6.0.8

 ? X X

Обозначения:

X – тест выполнен с ошибками, результат не соответствует ожиданиям
 ? – неожиданный результат выполнения теста, интересен для анализа
+ – тест успешно выполнен, результат адекватен ожиданиями
* – на диске компилятор отсутствует, проверка на бинарной сборке из SLAX

Что в итоге?

Какие же выводы можно сделать на основании результатов, отраженных в таблице? Во-первых, при разработке многопоточных приложений с использованием библиотеки 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 деструктор можно вызвать как обычную функцию.

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