Чому стек викликів має максимальний статичний розмір?


46

Працюючи з декількома мовами програмування, я завжди цікавився, чому стек потоків має попередньо визначений максимальний розмір, а не розширюватись автоматично, як потрібно. 

Для порівняння, деякі дуже поширені структури високого рівня (списки, карти тощо), які зустрічаються в більшості мов програмування, розраховані на необхідність зростати, в той час як нові елементи додаються, обмежуючись розміром лише наявною пам'яттю або обчислювальними обмеженнями ( наприклад 32-бітова адресація).

Я не знаю, хоча про будь-які мови програмування або середовища виконання, де максимальний розмір стека не попередньо обмежений певним параметром або компілятором. Ось чому занадто велика рекурсія дуже швидко призведе до повсюдної помилки / виключення переповнення стека, навіть коли для стека використовується лише мінімальний відсоток пам'яті, доступної для процесу.

Чому так трапляється, що більшість (якщо не всі) середовища виконання задають максимальний ліміт розміру, який може наростати стек під час виконання?


13
Цей вид стека - це безперервний адресний простір, який неможливо безшумно перемістити за кадром. Адресний простір цінний у 32-бітових системах.
CodesInChaos

7
Щоб зменшити появу ідей-башти ідей, таких як рекурсія, що витікає з академічних груп, і викликати такі проблеми в реальному світі, як зменшення читабельності коду та збільшення загальної вартості володіння;)
Бред Томас

6
@BradThomas Саме для цього потрібна оптимізація хвостових викликів.
JAB

3
@JohnWu: Те саме, що зараз, лише трохи пізніше: не вистачає пам’яті.
Йорг W Міттаг

1
У випадку, якщо це не очевидно, одна з причин, коли не вистачає пам’яті, є гіршою, ніж втрачається стек, - це те, що (припустимо, що стоп-сторінка), що вичерпано стек, лише це призводить до збою вашого процесу. Вичерпання пам’яті може призвести до чогось невдачі, хто наступний намагатиметься виділити пам'ять. Потім, знову ж таки, у системі, яка не містить сторінки пастки або інших засобів для виявлення поза-стека, закінчення стека може бути катастрофічним і призведе до не визначеної поведінки. У такій системі вам, швидше, не вистачить пам'яті у вільному зберіганні, і ви просто не можете записати код з необмеженою рекурсією.
Стів Джессоп

Відповіді:


13

Можна записати операційну систему, яка не вимагає, щоб стеки були суміжними в адресному просторі. В основному вам потрібне додаткове возитися в конвенції про виклик, щоб переконатися, що:

  1. якщо в поточному масштабі стеку не вистачає місця для функції, яку ви викликаєте, ви створюєте новий масштаб стека і переміщуєте вказівник стека, щоб вказати на початок його, як частина здійснення дзвінка.

  2. коли ви повертаєтесь з цього дзвінка, ви переносите назад у вихідний розмір стека. Швидше за все, ви збережете створений в (1) для подальшого використання тим самим потоком. В принципі, ви можете звільнити його, але таким чином лежать досить неефективні випадки, коли ви постійно переходите через межу в циклі, і кожен виклик вимагає виділення пам'яті.

  3. setjmpі longjmp, або будь-який інший еквівалент вашої ОС для не локальної передачі керування, перебуває в акті і може правильно повернутись до старої міри стека, якщо потрібно.

Я кажу "конвенція про виклик" - щоб бути конкретним, я думаю, що це, мабуть, найкраще робити в пролозі функцій, а не телефоном, але моя пам'ять про це туманна.

Причина того, що досить багато мов визначають фіксований розмір стека для потоку, полягає в тому, що вони хочуть працювати за допомогою рідного стека, на ОС, які цього не роблять. Як говорять відповіді всіх інших, за умови, що кожен стек повинен бути суміжним в адресному просторі і не може бути переміщений, вам потрібно зарезервувати певний діапазон адрес для використання кожним потоком. Це означає вибір переднього розміру. Навіть якщо ваш адресний простір є масивним, а обраний вами розмір дійсно великий, ви все одно повинні вибрати його, як тільки у вас є дві нитки.

"Ага", ви кажете, "що це за припущенні ОС, які використовують непомітні стеки? Я думаю, що це якась незрозуміла академічна система, яка мені не корисна!". Ну, це ще одне питання, на яке, на щастя, вже задають і відповідають.


36

Ці структури даних зазвичай мають властивості, у яких стек ОС не має:

  • Пов'язані списки не потребують суміжного адресного простору. Таким чином, вони можуть додати частину пам’яті з будь-якого місця, коли вони виростуть.

  • Навіть колекції, які потребують суміжного сховища, як-от вектор C ++, мають перевагу перед стеками ОС: Вони можуть визначити всі покажчики / ітератори недійсними щоразу, коли вони ростуть. З іншого боку, стеку ОС необхідно зберігати покажчики на стек, дійсний, поки функція, до кадру якої належить ціль, не повернеться.

Мова програмування або час виконання може вирішити реалізувати власні стеки, які не є суміжними або рухомими, щоб уникнути обмежень стеків ОС. Golang використовує такі власні стеки для підтримки дуже великої кількості спільних процедур, спочатку реалізованих як безперервна пам'ять, а тепер за допомогою рухомих стеків завдяки відстеженню покажчиків (див. Коментар Хобба). Нестабільний пітон, Lua та Erlang також можуть використовувати власні стеки, але я цього не підтвердив.

У 64-бітних системах ви можете налаштувати відносно великі стеки з відносно низькою вартістю, оскільки простору адреси достатньо, а фізична пам'ять виділяється лише тоді, коли ви її фактично використовуєте.


1
Це хороша відповідь, і я слідую за вашим значенням, але хіба не термін "суміжний" блок пам'яті на відміну від "безперервного", оскільки кожен блок пам'яті має свою унікальну адресу?
DanK

2
+1 для "стека викликів не має бути обмежено" Це часто реалізується таким чином для простоти та продуктивності, але це не повинно бути.
Пол Дрейпер

Ви абсолютно праві щодо Go. Власне, я розумію, що старі версії мали суперечливі стеки, а нові версії - рухомі стеки. Так чи інакше, необхідно дозволити велику кількість городин. Попереднє виділення декількох мегабайт на одну програму для стека зробить їх занадто дорогими, щоб правильно виконувати їх призначення.
котушки

@hobbs: Так, Go почав із розмножуваних стеків, однак важко було зробити їх швидкими. Коли Go отримав точний збирач сміття, він створив резервну копію на ньому, щоб реалізувати рухомі стеки: коли стек рухається, точна карта типу використовується для оновлення покажчиків до попереднього стека.
Маттьє М.

26

На практиці виростити стек важко (а іноді і неможливо). Щоб зрозуміти, чому потрібно певне розуміння віртуальної пам'яті.

У Єдніх днях однопотокових програм та суміжної пам'яті три були три компоненти адресного простору процесу: код, купа та стек. Те, як ці три були розкладені, залежало від ОС, але, як правило, перший прийшов код, починаючи з нижньої частини пам’яті, купа йшла поруч і зростала вгору, а стек починався у верхній частині пам’яті і зростав вниз. Була також якась пам'ять, зарезервована для операційної системи, але ми можемо це ігнорувати. Програми в ті часи мали дещо драматичніші переповнення стека: стек врізався в купу, і залежно від того, який оновлювався спочатку, ви або працювали б з поганими даними, або поверталися з підпрограми в якусь довільну частину пам'яті.

Управління пам’яттю дещо змінило цю модель: з точки зору програми у вас все ще були три компоненти карти процесної пам’яті, і вони, як правило, були організовані однаково, але тепер кожен з компонентів управлявся як незалежний сегмент, і MMU буде сигналізувати про ОС, якщо програма намагалася отримати доступ до пам'яті поза сегментом. Після того, як у вас була віртуальна пам'ять, не було потреби та бажання надавати програмі доступ до всього її адресного простору. Тож сегментам були призначені фіксовані межі.

То чому б не бажано надавати програмі доступ до повного адресного простору? Тому що ця пам’ять є "фіксацією" проти свопу; в будь-який час, можливо, доведеться записати будь-яку або всю пам'ять для однієї програми для заміни, щоб звільнити місце для пам'яті іншої програми. Якщо кожна програма потенційно може споживати 2 Гб свопу, то або вам доведеться забезпечити достатню кількість підкачки для всіх своїх програм, або ризикнути, що на дві програми знадобиться більше, ніж вони можуть отримати.

У цей момент, припускаючи достатній віртуальний адресний простір, ви можете розширити ці сегменти, якщо це потрібно, і сегмент даних (купа) насправді зростає з часом: ви починаєте з невеликого сегмента даних, і коли розподільник пам'яті вимагає більше місця, коли це потрібно. На цьому етапі за допомогою одного стека було б фізично можливо розширити сегмент стека: ОС могла захопити спробу висунути щось поза сегментом і додати більше пам’яті. Але і це не особливо бажано.

Введіть багаторізкові нитки. У цьому випадку кожна нитка має незалежний сегмент стека, знову фіксованого розміру. Але тепер сегменти викладені один за одним у віртуальний адресний простір, тому немає можливості розширити один сегмент без переміщення іншого - чого ви не можете зробити, оскільки програма потенційно матиме вказівники на пам'ять, що живе в стеку. Ви також можете залишити деякий простір між сегментами, але цей простір буде витрачений майже на всі випадки. Кращим підходом було покласти тягар на розробника програми: якщо вам справді потрібні глибокі стеки, ви можете вказати це під час створення потоку.

Сьогодні, маючи 64-розрядний віртуальний адресний простір, ми могли б створити ефективно нескінченні стеки для ефективно нескінченного числа потоків. Але знову ж таки, це не особливо бажано: майже у всіх випадках стек із перекладом вказує на помилку з вашим кодом. Надання вам стеку 1 Гб просто відкладає виявлення цієї помилки.


3
Поточні процесори x86-64 мають лише 48 біт адресного простору
CodesInChaos

AFAIK, Linux робить рости стек динамічно: Коли процес намагається отримати доступ до потрібної області нижче виділеного в даний момент стека, переривання обробляються тільки відображення додаткової сторінки пам'яті стеки, а НЕ segfaulting процесу.
cmaster

2
@cmaster: правда, але не те, що kdgregory означає "вирощування стека". В даний час діапазон адрес призначений для використання як стек. Ви говорите про поступове відображення більшої кількості фізичної пам’яті в той діапазон адрес у міру необхідності. kdgregory говорить, що збільшити діапазон важко або неможливо.
Стів Джессоп

x86 - не єдина архітектура, і 48 біт досі ефективно нескінченно
kdgregory

1
До речі, я пам’ятаю свої дні роботи з x86 не надто весело, в першу чергу через необхідність займатися сегментацією. Я дуже віддав перевагу проектам на платформах MC68k ;-)
kdgregory

4

Стек, що має фіксований максимальний розмір, не є всюдисущим.

Важко також виправитись: глибина стеки дотримується розподілу закону про енергетику, а це означає, що незалежно від того, наскільки малий ви будете робити розмір стека, все одно буде значна частина функцій з ще меншими стеками (значить, ви витрачаєте місце), і незалежно від того, наскільки великою ви їх зробите, все одно будуть функціонувати ще більші стеки (тому ви змушуєте помилку переповнення стека для функцій, які не мають помилок). Іншими словами: який би розмір ви не вибрали, він завжди буде одночасно і занадто малим, і занадто великим.

Ви можете виправити першу проблему, дозволивши стекам починати малі й динамічно зростати, але тоді у вас все ще є друга проблема. І якщо ви дозволяєте стеку динамічно зростати, то навіщо ставити на ньому довільну межу?

Є системи, де стеки можуть динамічно зростати і не мати максимального розміру: наприклад, Erlang, Go, Smalltalk і Scheme. Існує маса способів реалізувати щось подібне:

  • рухомі стеки: коли суміжний стек вже не може зростати, оскільки на шляху є щось інше, перемістіть його в інше місце в пам’яті з більшим вільним простором
  • суперечливі стеки: замість того, щоб виділяти весь стек в одному суміжному просторі пам'яті, виділіть його в декількох просторах пам'яті
  • купи, виділені з купи: замість того, щоб мати окремі області пам'яті для стека та купи, просто виділіть стек на купу; як ви самі помітили, структури даних, виділених купуми, як правило, не мають проблем з ростом і скороченням у міру необхідності
  • взагалі не використовуйте стеки: це також варіант, наприклад, замість того, щоб відстежувати стан функції в стеку, передати функцію передачу продовження для виклику

Як тільки у вас є потужні нелокальні конструкції управління потоком, ідея про єдиний суміжний стек все одно виходить з вікна: відновлені винятки та продовження, наприклад, "розщеплюють" стек, так що ви фактично опиняєтесь в мережі штабелів (наприклад, реалізовано зі спагетті). Крім того, системи з першокласними модифікованими стеками, такі як Smalltalk, в значній мірі вимагають стег спагетті або щось подібне.


1

Операційна система повинна надати суміжний блок, коли запитується стек. Єдиний спосіб зробити це - якщо вказано максимальний розмір.

Наприклад, припустимо, що пам’ять виглядає так під час запиту (Xs представляють використані, Os не використовуються):

XOOOXOOXOOOOOX

Якщо запит розміром стека становить 6, відповідь ОС відповість «ні», навіть якщо доступно більше 6. Якщо запит на стек розміром 3, відповідь ОС буде однією з областей 3 порожніх слотів (Os) підряд.

Крім того, можна побачити труднощі дозволити зростання, коли наступний суміжний проріз буде зайнятий.

Інші об'єкти, про які згадується (Списки тощо), не йдуть на стек, вони опиняються на купі в непоміжних або фрагментарних областях, тому коли вони ростуть, просто захоплюють простір, вони не потребують суміжних, як вони є керували інакше.

Більшість систем встановлюють розумне значення для розміру стека, ви можете його замінити, коли нитка побудована, якщо потрібен більший розмір.


1

Для Linux це суто обмеження ресурсів, яке існує для вбивства утікаючих процесів, перш ніж вони споживають шкідливі кількості ресурсу. У моїй системі debian наступний код

#include <sys/resource.h>
#include <stdio.h>

int main() {
    struct rlimit limits;
    getrlimit(RLIMIT_STACK, &limits);
    printf("   soft limit = 0x%016lx\n", limits.rlim_cur);
    printf("   hard limit = 0x%016lx\n", limits.rlim_max);
    printf("RLIM_INFINITY = 0x%016lx\n", RLIM_INFINITY);
}

виробляє вихід

   soft limit = 0x0000000000800000
   hard limit = 0xffffffffffffffff
RLIM_INFINITY = 0xffffffffffffffff

Зауважте, що жорсткий ліміт встановлено так RLIM_INFINITY: Процес дозволений підняти м'який ліміт до будь-якої суми. Однак, поки у програміста немає підстав вважати, що програмі дійсно потрібні незвичні обсяги пам'яті стека, процес буде знищений, коли він перевищить розмір стека у вісім мебібайт.

Завдяки цій межі забійний процес (ненавмисна нескінченна рекурсія) вбивається задовго до того, як він почне споживати настільки велику кількість пам’яті, що система змушена починати обмінюватися. Це може змінити різницю між збоєм процесу та сервером, що вийшов з ладу. Однак це не обмежує програми з законною потребою у великому стеку, їм просто потрібно встановити м'який ліміт на якесь відповідне значення.


У технічному плані стеки зростають динамічно: коли встановлено обмежене обмеження у вісім мебібайт, це не означає, що цей об'єм пам'яті фактично ще не відображений. Це було б досить марно, оскільки більшість програм ніколи не дістаються до відповідних обмежених обмежень. Швидше, ядро ​​буде виявляти доступ під стеком і просто відображати на сторінках пам'яті за потребою. Таким чином, єдиним реальним обмеженням розміру стека є доступна пам'ять у 64-бітових системах (фрагментація адресного простору є доволі теоретичною з розміром адресного простору в 16 zebibyte).


2
Це стек тільки для першого потоку. Нові потоки повинні виділяти нові стеки і обмежені, оскільки вони будуть стикатися з іншими об'єктами.
Zan Lynx

0

Максимальний розмір стека є статичним , тому що це визначення «максимум» . Будь-який максимум у чомусь є фіксованою, узгодженою лімітуючою цифрою. Якщо вона поводиться як спонтанно рухома ціль, це не максимум.

Стеки в операційних системах віртуальної пам'яті насправді зростають динамічно, до максимуму .

Якщо говорити про це, воно не повинно бути статичним. Швидше, це може бути налаштоване, на основі кожного процесу або навіть за потоком.

Якщо питання «чому це є максимальний розмір стеки» (штучно ввів один, як правило , набагато менше , ніж доступна пам'ять)?

Однією з причин є те, що для більшості алгоритмів не потрібно величезна кількість місця у стеку. Великий стек - це вказівка ​​на можливу втечу рекурсії . Добре припинити втечу рекурсії, перш ніж вона виділить всю наявну пам'ять. Проблема, схожа на утікаючу рекурсію, - це вироджене використання стека, можливо, викликане несподіваним тестовим випадком. Наприклад, припустимо, що аналізатор бінарного оператора інфіксації працює, повторюючи правий операнд: перший операнд аналізує, оператор сканування, решта аналізу виражає. Це означає , що глибина стека пропорційна довжині вираження: a op b op c op d .... Величезний тестовий випадок такої форми зажадає величезного стека. Відмова від програми, коли вона досягне розумного ліміту стеків, спричинить це.

Інша причина фіксованого максимального розміру стека полягає в тому, що віртуальний простір для цього стека можна зарезервувати за допомогою спеціального виду картування і таким чином гарантувати. Гарантоване означає, що простір не буде надано іншому виділенню, яке стек потім зіткнеться з ним, перш ніж вдатися до межі. Максимальний параметр розміру стека необхідний для того, щоб запросити це відображення.

Нитки потребують максимального розміру стека з аналогічної причини. Їх стеки створюються динамічно, і їх неможливо перемістити, якщо вони стикаються з чимось; віртуальний простір повинен бути зарезервований заздалегідь, і для цього виділення потрібний розмір.


@Lynn Не запитував, чому максимальний розмір статичний, він запитав, чому він визначений заздалегідь.
Буде Калдервуд
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.