Потоки и память

Думаю, эта запись будет интересна только программистам, а все остальные могут пропустить и не читать её.

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

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

Для начала изучите следущий код и попробуйте ответить на простой вопрос:

Что произойдет, если запустить эту программу?

Потоки и памятьЧтобы упростить понимание напишу небольшое описание: этот код в цикле пытается создать и запустить 100 тыс потоков.

Потоки просто выделяют 100 байт памяти и засыпают.

Программа выдает assert, пишет на экран число созданых потоков и выходит, если вдруг CreateThread не создал поток.

Программа была скомпилирована, как Console application с помощью Visual Studio (допускаю, что с другими компиляторами результаты могут разниться).

Итак, повторю вопрос: что произойдет, если запустить эту программу?

DWORD __stdcall MyThread(LPVOID lpThreadParameter)
{
char* p = new char[100];
Sleep(10000000);
return 0;
}

int _tmain(int argc, _TCHAR* argv[])
{
for(int i = 1; i < 100000; ++i)
{
HANDLE hThread = CreateThread(0, 0, MyThread, 0, 0, 0);
if (!hThread)
{
printf(«Maximum threads: %d\n», i);
assert(hThread);
break;
}
CloseHandle(hThread);
}
return 0;
}

Так вот, для 32 битной Windows правильный ответ — программа всегда выходит с ошибкой, создав всего 2028 потоков!

При этом при запуске debug сборки выдается очень странное сообщение об ошибке в виде Message box-a — про ошибочную релокацию системной dll user32.dll. Это происходит при вызове assert, а почему это происходит — вы поймете позже.

При этом Task Manager не дает никаких подсказок о том, что не так — программа занимает 20 Мб памяти и Virtual Memory Size всего 30 Мб. То есть, получается, что 1 поток отъедает всего 16 Кб памяти…

Почему же создано всего 2028 потоков?

Подсказку дает Process Explorer — он умеет показывать правильный Virtual Size, а не обрезаный, который показывает Task Manager. И Process Explorer показывает, что Virtual Size больше 2 Гб. А это уже значит, что программа израсходовала всю память и больше ничего сделать не может. Даже выделить совсем немного памяти, чтобы загрузить системные dll, чтобы показать assert.

Получается, что каждый поток на самом деле занимает около 1 Мб в памяти.

И это верно для программ, скомпилированных в Visual Studio. По умолчанию линкер там задает размер стэка для потоков в 1Мб. Изменить это можно через ключ /STACK в линкере.

Соответственно, когда процесс создает новый поток, он сразу выделяет (резервирует) этому потоку 1 Мб памяти, т.к. стэк должен уметь расти и не может быть реалокирован или перемещен.

Так что помните, что каждый поток по умолчанию отъест у вас 1 Мб памяти, потому что поток должен иметь стэк, а стэк — это просто непрерывный кусок памяти, который нужно зарезервировать заранее.

А можно ли это изменить?

Можно, конечно.

Можно изменить значение по умолчанию в линкере, используя ключ /STACK, а можно использовать второй параметр (dwStackSize) при создании потока в CreateThread. Изменять дефолтный размер стэка в параметрах линкера не очень хорошо, так как этот параметр влияет на все потоки, создаваемые в процессе. Лучше задавать размер стэка (dwStackSize) при создании потока.

Итак, давайте поменяем CreateThread(0, 0, MyThread, 0, 0, 0) на CreateThread(0, 100000, MyThread, 0, 0, 0). Как вы думаете, сколько потоков будет создано теперь?

Ответ, как ни странно — опять 2028!

Но эта ошибка уже от невнимательности чтения MSDN и кривости WinAPI. Там в описании флагов написано, что если флаги не указаны (предпоследний параметр == 0), то второй параметр используется не для размера стэка, а для размера commit size стэка. Думаю, уже не одна тысяча программистов поймалась на эту ошибку в названии параметра dwStackSize, который при определенных условиях может быть совсем не StackSize, а StackCommitSize.

Так что правильно надо писать: CreateThread(0, 100000, MyThread, 0, STACK_SIZE_PARAM_IS_A_RESERVATION, 0).

Запускаем. Результат — 15690 потоков. Опять странный результат. Стэк получается не 100КБ, а около 128КБ. Давайте попробуем так: CreateThread(0, 128*1024, MyThread, 0, STACK_SIZE_PARAM_IS_A_RESERVATION, 0).

Запускаем. Результат абсолютно тот же — 15690. Как и написано в MSDN, система увеличивает размер до размера ближайшей страницы. Но размер страницы 4К. Откуда же берется 64К? Оказывается, стэк резервируется функцией VirtualAlloc, а у нее гранулярность — 64K, так что хотя в MSDN и написано “страница”, а на самом деле должно быть “гранулярность VirtualAlloc”.

Проверим минимальный стэк и удалим new из функции потока для чистоты эксперимента: CreateThread(0, 1024, MyThread, 0, STACK_SIZE_PARAM_IS_A_RESERVATION, 0).

Запускаем. Результат — 30157 потоков. Это и есть максимум потоков для моей системы, т.к. каждый из потоков резервирует 1 страницу в 64 К в памяти, а ограничение памяти — 2 Гб. Можно увеличить до 3-х, тогда получится создать около 45000 потоков.

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

Потом ещё как-нибудь напишу про то, как потоки влияют на скорость работы программы в разных ситуациях. А на сегодня хватит — и так много букв.

Надеюсь эта информация кому-нибудь пригодится.

Оставить комментарий