__cxa_atexit
Еще как причем! Эта __cxa_atexit — не просто ценный мех, а еще внутренняя низкоуровневая функция рантайма C++. С ее помощью компилятор тихой сапой регистрирует деструкторы глобальных и статических объектов, чтобы они были корректно вызваны при завершении работы программы или при выгрузке динамической библиотеки.
Есть похожая функция в чистом C - atexit, но она не очень умеет с динамическими объектами работать, да и с деструкторами в общем-то не особо.
Вот, например, пишем такой глобальный объект
struct MyClass {
~MyClass() {}
};
MyClass global_object;
Тогда GCC, никого не спрашивая, нагенерирует неявных вызовов под это дело:
__cxa_atexit((void (*)(void *))&MyClass::~MyClass, &global_object, &__dso_handle);
В so-библиотеке этот деструктор сохраняется во внутренний список с соответствующим маркером __dso_handle.
Если мы вообще закрываем приложение, будет вызвана функция __cxa_finalize(&__dso_handle), а __dso_handle в этом случае нулевая. В таком случае Itanium C++ ABI велит вызвать последовательно все функции из расстрельного списка.
Гораздо веселее выгружать только so-шник. Это вызывает кратковременный dlclose, который в свою очередь дергает __cxa_finalize с __dso_handle, и тут уже она нулю не равна. Вот тогда мы вытаскиваем из списка все деструкторы с таким маркером и начинаем яростно, дико вызывать в порядке живой очереди.
Для сурового embedded я видел советы отключить этот механизм как избыточный, поскольку микроконтроллерам все равно не хочется жонглировать so-либами.
В GCC (и даже в clang) можно явно отключить этот механизм через флаг -fno-use-cxa-atexit.
В этом случае компилятор перестанет использовать __cxa_atexit, а указатели на деструкторы запишет в специальную секцию бинарного файла .fini_array (или .dtors), которые будут последовательно вызваны при выходе из программы самым простым циклом.
Впрочем, я не проверял. Должно работать.
__dso_handle
Таинственная незнакомка неизменно появлялась в Начале. Тенью мелькала она среди невзрачных рабочих и прочих безликих ассистентов. Если специально не искать ее взглядом, то и не заметишь. Впрочем, никому и в голову не приходило искать ее специально. Серые мыши никого не интересуют.
Так думал и я, пока не столкнулся с ней в Конце. После этой встречи я не переставал думать о ней. Меня не обманула ее ложная скромность. Это была кротость омута, таящего в себе мрачную тайну. Кто она, какова ее роль во всем этом? Зачем она приходит в Начале и что делает в Конце? Никто не знал.
Я стал следить за ней, записывать, когда и где она появляется. Долгими бессонными ночами я строил планы, где и как подкараулить ее.
Сегодня наконец-то она попалась в мои силки. Теперь она расскажет нам все!
Я пристально смотрел на нее, а она - на меня. Таинственная глобальная переменная __dso_handle.
Если попробовать вывести ее значение в обычном исполняемом файле
printf("%p", __dso_handle);
Она покажется нам банальной пустышкой: (nil).
Нулевой указатель. Тогда зачем она вообще нужна? Совсем эти яйцеголовые разработчики накурились своих мануалов и айда сувать всякую гадость в свои __cxa_thread_atexit.
Однако я сам плюсовик, разрабатываю уже сто лет, внук председателя колхоза. Просто поверьте, тут не все так однозначно!..
Я специально __dso_handle не объявлял, вы, наверняка, тоже. Тогда кто же это сделал?
Компилятор, конечно! Он же обеспечивает вызов и управление глобальными конструкторами и деструкторами в файлах crtbegin (C Runtime Begin) и там же определяет эту магическую переменную. Для тривиальной сборки исполняемого файла компилятор будет применять файлы crtbegin, где
void *__dso_handle = 0;
Для динамических библиотек будет применен особый файл crtbeginS.o, где уже
void *__dso_handle __attribute__ ((__visibility__ ("hidden"))) = &__dso_handle;
Обязательно нужно пометить символ как hidden, чтоб не засветить снаружи динамической библиотеки. В итоге, даже если мы загрузим 100500 so-объектов, у всех будет разная __dso_handle. Это наводит нас на очевидную мысль, что подобный символ можно использовать как некий маркер бинарного модуля.
Эта же мысль пришла в головы создателям требований Itanium C++ ABI.
В разделе 3.3.6.3 Runtime API в пункте C. Termination ангельским языком писано:
При компоновке любого разделяемого объекта (DSO), содержащего вызов __cxa_atexit, компоновщик должен определить скрытый символ __dso_handle, значением которого является адрес в одном из сегментов объекта. Конкретный адрес не важен, если для разных DSO они различаются.
Казалось бы, причем тут какая-то функция?
__cxa_thread_atexit
Вот если посмотреть пристально на обычную сосиску. Длинная, похожа на палку. А что говорят древние философы?
Палка о двух концах! А это о чем говорит? О том, что концы-то у неё есть, а начала — нет! Значит, по науке, сосиска есть предмет безначальный!
О чем это говорит? Сосиска - это революция. Потому как есть у революции начало, нет у революции конца!
Что же касается потоков, мы можем точно сказать, что они не революция. Конец у них есть, и мы можем легко его обнаружить!
Стоит лишь внимательнее присмотреться к thread_local, ведь такие объекты будут удалять самыми последними.
Обычный объект в потоке - дело скучное:
void thread_worker(int id) {
Signal s{1};
...
Не случится ничего интересного, просто в конце потока вызовется деструктор объекта.
bl Signal::~Signal() [complete object destructor]
Теперь чуть разнообразия в томный вечер:
void thread_worker(int id) {
thread_local Signal tls{2};
...
Жизнь заиграла новыми красками.
Во-первых, пропадет вызов деструктора Signal.
Во-вторых, при создании объекта произойдет следующая удивительная метаморфоза:
movw r2, #:lower16:__dso_handle
movt r2, #:upper16:__dso_handle
mov r1, r3
movw r0, #:lower16:Signal::~Signal() [complete object destructor]
movt r0, #:upper16:Signal::~Signal() [complete object destructor]
bl __cxa_thread_atexit
Что же за непотребство здесь творится?!
Да это просто компилятор регистрирует деструктор thread_local объекта через функцию __cxa_thread_atexit.
Это специальная внутренняя плюсовая функция, в которую можно вписать функцию очистки, и она гарантированно будет вызвана при завершении работы потока.
Для деструкторов работает особая уличная магия компилятора, но почему бы не пошалить и не засунуть туда какой-нибудь свой обработчик? Кто нам запретит?
Например, желаю в самом конце потока удалить кое-какие чувствительные данные, ну и отладочную печать для наглядности тиснуть.
void my_thread_cleanup(void* arg) {
int* id = static_cast<int*>(arg);
std::cout << "[Cleanup] Поток " << *id << " завершился. Чистим память...\n";
delete id;
}
В качестве аргумента получаем указатель. Допустим, мы знаем, что это некий int. В функции приведем его в надлежащий вид и удалим.
Теперь вручную зарегистрируем наш уничтожитель. Для этого нам потребуется несколько магических пассов. Призовем в код таинственную функцию __cxa_thread_atexit и служебную переменную для линкера:
extern "C" int __cxa_thread_atexit(void (*func)(void *), void *obj, void *dso_handle);
extern void* __dso_handle;
void thread_worker(int id) {
thread_local Signal tls{2};
int* thread_data = new int(id);
__cxa_thread_atexit(my_thread_cleanup, thread_data, &__dso_handle);
std::cout << "[Thread] Поток " << id << " выполнил работу.\n";
}
Соответственно, на экране мы увидим
+sig2
[Thread] Поток 1 выполнил работу.
[Cleanup] Поток 1 завершился. Чистим память...
-sig2
Ой, наш чистильщик пришел слишком рано, перед деструктором Signal.
Все дело в порядке вызовов __cxa_thread_atexit: Поскольку объект Signal создается первый, то и деструктор он регистрирует первым. А в конце потока специально обученная функция вызывает все втиснутые в список функции в обратном порядке.
Если вызвать регистратор перед созданием Signal,
__cxa_thread_atexit(my_thread_cleanup, thread_data, &__dso_handle);
thread_local Signal tls{2};
то все будет выглядеть иначе:
+sig2
[Thread] Поток 1 выполнил работу.
-sig2
[Cleanup] Поток 1 завершился. Чистим память...
Теперь порядок, my_thread_cleanup выполнится в самом конце потока. А поток, как известно, не сосиска.
dangling reference
Освежающая легкость летнего утра давно сменилась вязкими предрассветными холодами. Потом, если едкая прохлада и отступала, то день все равно оставался придавлен низкими серыми облаками. Тяжесть эта отдавалась в теле, распадаясь внутри на тоску и туман. Такой же поднимался вечерами в поле от безымянной речки. Плотный и сырой, огромными ватными клочьями выползал на берег.
От этой картины сами собой закрывались глаза, голова чугунела и тянула вниз.
"Возраст наверное, - подумал я, и, лихо по-стариковски крякнув, оторвался от стула, - четверть века прожить - чай, не поле перейти..."
Тут же закружилась голова.
"И вот третьего дня в спину вступило, вчера в ухе стреляло, - мысленно стал перебирать неизлечимые болезни, зевнул, - э-эх, и указатели ломит, деструктор отваливается... а ссылки-то! Ссылки прям висят..."
Да, такое бывает. Многие мужчины старше сорока лет сталкиваются с такой проблемой. Не конец света.
По-научному она называется "проблема синхронизации времени жизни объектов". Чаще всего проявляется у тех, кто любит баловаться потоками.
Тут легче всего поймать рассинхрон, особенно когда есть какие-нибудь thread_local переменные.
void thread_worker(int id) {
Signal s{1};
thread_local Signal tls{2};
}
Класс Signal тут просто для иллюстрации
struct Signal {
int x;
Signal(int x) : x{x} { std::cout << "+sig" << x << "\r\n"; }
~Signal() { std::cout << "-sig" << x << "\r\n"; }
};
Просто посмотрим, кто, где и когда:
+sig1
+sig2
Это вполне себе ожидаемо, создание идет в порядке живой очереди.
А вот уничтожение немного удивляет.
-sig1
-sig2
То есть, тут концепция RAII подводит нас под монастырь. Вы ожидаете... ну хорошо, я ожидаю, что деструктор s будет вызван после уничтожения tls, а выходит наоборот.
Казалось бы, ерунда. Однако, если постараться, то можно поймать серьезные проблемы.
Например, при использовании эксклюзивных ресурсов и отсоединенных потоков, вы же любите так делать?
std::mutex mtx;
bool is_done = false;
struct ExclusiveResource {...} gasoline;
void thread_worker() {
thread_local Vehicle track{gasoline};
...
std::lock_guard<std::mutex> lock{mtx};
is_done = true;
}
Обычный рабочий поток. Допустим, нам очень интересно, когда он завершится, поэтому синхронизируемся глобальным мьютексом.
Еще деталь, - объект track при уничтожении вызывает метод gasoline.unload().
int main() {
std::thread t(thread_worker);
t.detach();
while(true) {
std::lock_guard<std::mutex> lock{mtx};
if (is_done) break;
}
gasoline.spoil(); // Но поток еще работает!
return 0;
}
Теперь выпускаем поток на свободу через detach и ждем его завершения. Выглядит глупо, конечно, но это для примера, я так не пишу на самом деле. Обычно не пишу. А если и пишу, то не я.
Так вот, lock в thread_worker разрушается ближе к концу, но поток, как и Ленин, все еще живой.
Зато в главном потоке мы выходим из цикла и портим из вредности наш эксклюзивный ресурс - gasoline.spoil().
Допустим, объект тотально разрушен, но в рабочем потоке наступает очередь деструктора track, который попытается таки исполнить gasoline.unload() на римских развалинах. Дальше, сами понимаете, случается катастрофа.