четверг, 16 декабря 2010 г.

Изгоняющий демонов

С некоторых пор я решил больше писать, что-то совсем забываю про блог. Чтобы не расслабляться - каждую неделю обещал себе выдавать по посту. Пока получается не очень плохо. Когда тебя подстегивают сроки - ты заранее начинаешь собирать идеи для новых постов.

Одна из таких идей у меня касалась ошибок. Не простых, а трудно воспроизводимых. У нас вообще складывается ситуация на данный момент, что очень часто мы ничего не можем сказать, глядя на багу. Хорошо, если воспроизвел на стенде и причина ясна. Часто бывает что она проявляется не всегда, а только при полной луне...

Тема показалась мне не достаточной для полноценного поста. Но сама судьба подбросила одну из таких ошибок, с которой я проковырялся три дня и в конце концов одолел. Есть, что рассказать...

Вообще сам факт наличия таких ошибок, как то трудновоспроизводимых или просто непонятных, характеризует общее состояние проекта. Да, есть над чем работать...

Итак, ситуация следующая: приложение стартует замечательно, но при повышении нагрузки падает, в коре какая-то белиберда.

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

Прошел день...

Удалось выяснить, что падает она при попытке вызова виртуальной функции. Где-то портится память? Память вряд ли может портится в самой виртуальной таблице, скорее всего поехал указатель на виртуальную таблицу. Обложили это все ассертами - не клюет...

В тщетных попытках понять смысл проблемы прошел еще один день...

И вот ведь зараза, вообще перестала обрабатывать соединения. Ошибка происходит в 100% случаев. Это конечно хорошо, только ясности не добавляет. В порыве отчаяния накрутил сильно отладочных опций, как-то -fstack-check, -fbounds-check (Написано что в си не работает, ну до кучи). После чего удалось выяснить, что вызов виртуальной функции всетаки происходит, отладочное сообщение вывелось, а падение происходит где-то дальше.

Но долго искать уже не пришлось. В начале функции стоит буфер на 65 килобайт. Раньше уже приходилось сталкиваться с ограничением на размер стека нити. Помню что оно не велико... Очень невелико... Что-то типа нескольких килобайт...

Определить размер стека по умолчанию - просто.
#include <stdio.h>
#include <pthread.h>

int main(int argc, char **argv)
{
pthread_attr_t thread_attr;
pthread_attr_init(&thread_attr);

size_t stacksize = 0;
pthread_attr_getstacksize(&thread_attr, &stacksize);

printf("Размер стека по умолчанию: %lu\n", stacksize);

return 0;
}
Для Linux (x86-64) он достаточно комфортный - 8 мегабайт... но на FreeBSD (i386) - всего 64 килобайта. Учитывая тот факт, что стек нити располагается в адресном пространстве процесса, выделяясь в куче, выход за его пределы может нарушить работу приложения в самый непонятный момент. И эта проблема может долго оставаться незамеченной. Использование стека в релизной и отладочной версии разное. Расположение блоков памяти в куче тоже может варьироваться, предсказать результат достаточно сложно.

Я в принципе люблю пользоваться стеком, это ограничивает область видимости и избавляет от возни с динамической памятью. Наверное я все таки правильно сделал, что в MDF создал для каждой нити свой, полностью изолированный стек.

А что делать с проектом - где-то видел, что в gcc можно инструментовать каждую функцию. Надо туда вставить функцию, контролирующую размер стека. И погонять...

11 коммент.:

Left комментирует...

1. Стек не выделяется в куче. Это как говорят в Одессе две большие разницы. Т.е. и куча и стек живут в адресном пространстве процесса, но это просто 2 разных способа распределения памяти, не имеющие точек пересечения.

2. Размер адресного пространства для стека можно менять как минимум для потоков которые создаются изнутри процесса, но можно и подтюнить это снаружи (в Windows это значение прошивается в заголовок PE файла и его можно менять через EDITBIN, в никсах должны быть подобные механизмы).

3. Размер адресного пространства для стека делают побольше чтобы не бояться переполнения стека (от которого практически невозможно нормально восстановиться), и наоборот - поменьше, чтобы можно было одновременно запустить побольше потоков (на 32-битных системах это основное ограничения для количества потоков на процесс - к примеру, для стандартных установок Windows получается что-то около 2000 потоков на процесс). Следует также понимать что не вся память из зарезервированного под стек адресного пространства будет выделена сразу - обычно выделяется только первая страница а на следующую за ней ставится специальный флаг который вызывает SEH и обработчик оси, который довыделяет память по мере надобности (т.н. guard page). Обратно, кстати, память уже не отдаётся - если мы один раз выделили 500К на стеке, то эти 500К система обратно не получит до окончания потока. Размер страниц памяти (в т.ч. и guard page) на разных процессорах/осях разный, в среднем где-то 4К. Чтобы можно было выделить большое кол-ва памяти на стеке и не пролететь мимо guard page компиляторы С++ при каждом выделении большОго количества памяти на стеке вставляют специальный код который пытается читать эту память через каждые N килобайт (это гарантирует что guard page обработает рост стека - иначе программа может начать читать-писать эту память с конца в начало и благополучно пролетит мимо guard page).

В целом где-то так, да.
Вообще согласен с тем что ошибки с переполнением стека трудноуловимы. К примеру лично сталкивался с трудноуловимыми глюками при использовании JVM изнутри кастомных EXE файлов - в java.exe прошит начальный размер стека в 256K (против 1M стандартных для Windows)
- получается что код который работает с большим кол-вом потоков отлично работает из java.exe но вылетает без всяких предупреждений из myapp.exe.

Andrey Valyaev комментирует...

1. Я не залезал в недра pthread.. хрен знает откуда он там выделяется... Может быть и в куче...

2. Менять можно... но это уже совсем другая история.

3. Нельзя вообще не бояться. Всегда можно наступить на грабли, если где-то неосторожно буфер прописать... Нельзя быть уверенным что места хватит.

Может быть приложение во FreeBSD и валилось оттого, что наступало на сторожевую страницу, только с наружи это выглядело как обычный segfault... :)

На память: Можно по этой теме предоставлять больше информации где, куда от кого упало... система знает больше, чем говорит... :)

Left комментирует...

> 1. Я не залезал в недра pthread.. хрен знает откуда он там выделяется... Может быть и в куче...

Не может быть. Не выделяется он в куче. Просто потому что это - разные вещи. В случае Windows есть 3 класса функций:
VirtualAlloc
GlobalAlloc
HeapAlloc

Так вот, куча - это то что работает через HeapAlloc. "Под капотом" она выделяет себе понемногу (или помногу, не суть) память через VirtualAlloc, привязывая адресное пространство к физической памяти. Стек в свою очередь тоже под капотом вызывает VirtuallAlloc, но никогда не пользуется HeapAlloc. Причина в том что для стека требуется непрерывность адресного пространства. Куча тут бесполезна поскольку таких гарантий не даёт. Поэтому ни в винде, ни в никсах стек никогда не работает через кучу.

Left комментирует...

> Может быть приложение во FreeBSD и валилось оттого, что наступало на сторожевую страницу, только с наружи это выглядело как обычный segfault... :)

Нет, если наступается на сторожевую страницу - обработчик просто довыделяет память из адресного пространства стека. Проблема начинается тогда когда этого адресного пространства больше нет (стек ВСЕГДА ограничен только одним куском адресного пространства).

Andrey Valyaev комментирует...

Нет, если наступается на сторожевую страницу - обработчик просто довыделяет память из адресного пространства стека.

Я полагал что на сторожевую страницу вообще нельзя наступать. Она типа как пограничный столб. :) Red zone называется во FreeBSD например.

Да, порылся - не в хипе конечно. От основного стека пляшет вниз... Собственно падало скорее всего от попадания в красную зону.

Хм.. тогда не понятно почему работало в релизной версии... буффер был размером с весь стек. может быть FreeBSD не осуществляет последователную набивку страниц до редзоны и проскакивает ее большим буфером? надо потестить...

Left комментирует...

RedZone - это вообще не из той оперы

FreeBSD has introduced kernel heap-smashing detection in 8.0-RELEASE via an implementation
called RedZone. RedZone is oriented more towards debugging the kernel memory allocator rather than detecting and stopping deliberate attacks against it. If enabled (it is disabled by default) RedZone places a static canary value of 16 bytes above and below each buffer allocated on the heap. The canary value consists of the hexadecimal value 0×42 repeated in these 16 bytes.

Andrey Valyaev комментирует...

Это не та Red zone... :)

http://svn.freebsd.org/viewvc/base/head/lib/libthr/thread/thr_stack.c?revision=212536&view=markup

Left комментирует...

http://blogs.gnome.org/markmc/2005/05/11/stack-guard-page/

Qehgt комментирует...

Вообще, задуматься надо было в тот момент, когда 64K массивы на стеке создаются.

Это, как бы, плохая практика.

Left комментирует...

Практика однозначно плохая, да.
Вот только частенько такие выделения памяти не очень хорошо видно глазками. Так что вывод - во всём виноват поганый С++! ;) В современных языках программирования (если кто не в курсе - я про Google Go) стек сегментирован, что позволяет вообще забыть о таких проблемах.

Анонимный комментирует...

1. Стек, конечно, не выделяется в куче. Куча - это вообще ближе к рантайму си, вы не обязаны её использовать и вообще иметь.
2. Я, честно говоря, не знаю как в BSD, а в линуксе выделенная ядром процессу страница вовсе не обязана реально существовать в памяти/свопе. Физически она выделится при первой записи, это называется memory overcommit (отключабельно через /sys/). Поэтому 1) можно делать хоть гигабайтные стеки для тредов (на 64-разрядных системах, ессно) 2) наобещав памяти с три короба и обнаружив, что память и правда нужна вся, ОС оказывается в глупой ситуации. И обращается к out of memory killer.
Учитывая, что гигабайтные стеки - это глупо, а убийство вместо честного malloc()==NULL - некрасиво, бээсдешники реализовали аналогичный механизм иначе и иначе его настроили по умолчанию.