Por que a pilha de chamadas tem um tamanho máximo estático?

46

Tendo trabalhado com algumas linguagens de programação, sempre me perguntei por que a pilha de threads tem um tamanho máximo predefinido, em vez de expandir automaticamente, conforme necessário. 

Em comparação, certas estruturas de alto nível muito comuns (listas, mapas etc.) encontradas na maioria das linguagens de programação são projetadas para crescer conforme necessário, à medida que novos elementos são adicionados, sendo limitados em tamanho apenas pela memória disponível ou por limites computacionais ( por exemplo, endereçamento de 32 bits).

Não conheço nenhuma linguagem de programação ou ambiente de tempo de execução em que o tamanho máximo da pilha não seja pré-limitado por alguma opção padrão ou do compilador. É por isso que muita recursão resultará muito rapidamente em um erro / exceção onipresente de estouro de pilha, mesmo quando apenas uma porcentagem mínima da memória disponível para um processo for usada para a pilha.

Por que é que a maioria dos ambientes de tempo de execução (se não todos) define um limite máximo para o tamanho que uma pilha pode aumentar em tempo de execução?

Lynn
fonte
13
Esse tipo de pilha é um espaço de endereço contínuo que não pode ser movido silenciosamente nos bastidores. O espaço de endereço é valioso em sistemas de 32 bits.
CodesInChaos 09/09/16
7
Para reduzir a ocorrência de ideias torre de marfim como recursão vazando da academia e causando problemas no mundo real, como a legibilidade do código reduzida e aumento do custo total de propriedade;)
Brad Thomas
6
@BradThomas É para isso que serve a otimização de chamadas finais.
JAB
3
@ JohnWu: A mesma coisa que faz agora, um pouco mais tarde: fica sem memória.
Jörg W Mittag
1
No caso, não é óbvio, uma razão a falta de memória é pior do que ficar sem pilha, é que (supondo que há uma página armadilha) ficar sem pilha só provoca o seu processo a falhar. Ficar sem memória pode fazer com que tudo falhe, quem quer que tente em seguida fazer uma alocação de memória. Por outro lado, em um sistema sem uma página de interceptação ou outros meios para detectar falta de pilha, ficar sem pilha pode ser catastrófico, levando você a um comportamento indefinido. Nesse sistema, você prefere ficar sem memória de armazenamento livre e simplesmente não pode escrever código com recursão ilimitada.
Steve Jessop

Respostas:

13

É possível escrever um sistema operacional que não exija que as pilhas sejam contíguas no espaço de endereço. Basicamente, você precisa de algumas coisas extras na convenção de chamada, para garantir que:

  1. se não houver espaço suficiente na extensão atual da pilha para a função que você está chamando, crie uma nova extensão de pilha e mova o ponteiro da pilha para apontar para o início dela como parte da realização da chamada.

  2. Quando você retorna dessa chamada, você volta para a extensão original da pilha. Provavelmente você retém o criado em (1) para uso futuro pelo mesmo encadeamento. Em princípio, você pode liberá-lo, mas dessa forma existem casos bastante ineficientes em que você continua pulando para frente e para trás através do limite em um loop, e cada chamada requer alocação de memória.

  3. setjmpe longjmp, ou qualquer equivalente que seu sistema operacional possua para transferência de controle não local, está em ação e pode retornar corretamente à antiga extensão de pilha, quando necessário.

Eu digo "convenção de chamada" - para ser específico, acho que provavelmente é melhor fazer um prólogo de função do que pelo chamador, mas minha memória disso é nebulosa.

O motivo pelo qual algumas linguagens especificam um tamanho fixo de pilha para um encadeamento é que elas desejam trabalhar usando a pilha nativa, em sistemas operacionais que não fazem isso. Como as respostas de todos os outros dizem, sob a suposição de que cada pilha precisa ser contígua no espaço de endereço e não pode ser movida, é necessário reservar um intervalo de endereços específico para uso de cada encadeamento. Isso significa escolher um tamanho antecipadamente. Mesmo que o seu espaço de endereço seja enorme e o tamanho escolhido seja muito grande, você ainda precisará escolhê-lo assim que tiver dois threads.

"Aha", você diz, "o que são esses supostos sistemas operacionais que usam pilhas não contíguas? Aposto que é um sistema acadêmico obscuro que não serve para mim!". Bem, essa é outra pergunta que felizmente já foi feita e respondida.

Steve Jessop
fonte
36

Essas estruturas de dados geralmente têm propriedades que a pilha do SO não possui:

  • As listas vinculadas não requerem espaço de endereço contíguo. Assim, eles podem adicionar um pedaço de memória de onde quiserem quando crescerem.

  • Mesmo coleções que precisam de armazenamento contíguo, como o vetor C ++, têm uma vantagem sobre as pilhas do SO: podem declarar todos os ponteiros / iteradores inválidos sempre que crescerem. Por outro lado, a pilha do SO precisa manter os ponteiros válidos até que a função a qual quadro o destino pertence retorne.

Uma linguagem de programação ou tempo de execução pode optar por implementar suas próprias pilhas que não são contíguas ou móveis para evitar as limitações das pilhas do SO. Golang usa essas pilhas personalizadas para suportar um número muito alto de co-rotinas, originalmente implementadas como memória não contígua e agora via pilhas móveis, graças ao rastreamento de ponteiro (veja o comentário de hobb). Python sem pilha, Lua e Erlang também podem usar pilhas personalizadas, mas não confirmei isso.

Em sistemas de 64 bits, você pode configurar pilhas relativamente grandes com um custo relativamente baixo, pois o espaço de endereço é suficiente e a memória física só é alocada quando você realmente a utiliza.

CodesInChaos
fonte
1
Esta é uma boa resposta e eu sigo o seu significado, mas não é o termo bloco de memória "contíguo" em oposição a "contínuo", já que cada unidade de memória possui seu próprio endereço exclusivo?
DANK
2
O +1 para "uma pilha de chamadas não precisa ser limitada". Geralmente, é implementado dessa maneira por simplicidade e desempenho, mas não precisa ser.
Paul Draper
Você está certo sobre o Go. Na verdade, meu entendimento é que as versões antigas tinham pilhas descontínuas e as novas versões têm pilhas móveis . De qualquer forma, é necessário permitir um grande número de goroutines. A pré-alocação de alguns megabytes por goroutine para a pilha os tornaria muito caros para servir adequadamente a seus propósitos.
hobbs
@ Hobbs: Sim, o Go começou com pilhas crescíveis, no entanto, era difícil torná-las rápidas. Quando o Go ganhou um Garbage Collector preciso, ele apoiou-o para implementar pilhas móveis: quando a pilha é movida, o mapa de tipos exato é usado para atualizar os ponteiros para a pilha anterior.
Matthieu M.
26

Na prática, é difícil (e às vezes impossível) aumentar a pilha. Para entender o porquê, é necessário entender a memória virtual.

Nos Ye Olde Days de aplicativos de thread único e memória contígua, três eram três componentes de um espaço de endereço do processo: o código, o heap e a pilha. Como os três foram organizados depende do sistema operacional, mas geralmente o código veio primeiro, começando na parte inferior da memória, o heap veio a seguir e cresceu, e a pilha começou no topo da memória e cresceu para baixo. Havia também alguma memória reservada para o sistema operacional, mas podemos ignorá-lo. Os programas naqueles dias tinham estouros de pilha um pouco mais dramáticos: a pilha colidia com a pilha e, dependendo de qual fosse a atualização primeiro, você trabalharia com dados incorretos ou retornaria de uma sub-rotina para uma parte arbitrária da memória.

O gerenciamento de memória mudou um pouco esse modelo: da perspectiva do programa, você ainda tinha os três componentes de um mapa de memória de processo, e eles geralmente eram organizados da mesma maneira, mas agora cada um dos componentes era gerenciado como um segmento independente e a MMU sinalizaria o SO se o programa tentou acessar a memória fora de um segmento. Depois de ter memória virtual, não havia necessidade ou desejo de conceder a um programa acesso a todo o espaço de endereço. Portanto, os segmentos receberam limites fixos.

Então, por que não é desejável conceder a um programa acesso ao seu espaço de endereço completo? Porque essa memória constitui uma "carga de confirmação" contra a troca; a qualquer momento, qualquer ou toda a memória de um programa pode ter que ser gravada para ser trocada para liberar espaço para a memória de outro programa. Se todo programa pudesse consumir 2 GB de swap, seria necessário fornecer swap suficiente para todos os seus programas ou aproveitar a chance de dois programas precisarem de mais do que poderiam obter.

Nesse ponto, assumindo espaço de endereço virtual suficiente, você pode estender esses segmentos, se necessário, e o segmento de dados (heap) de fato cresce com o tempo: você começa com um pequeno segmento de dados e quando o alocador de memória solicita mais espaço quando é necessário. Nesse ponto, com uma única pilha, seria fisicamente possível estender o segmento da pilha: o sistema operacional poderia impedir a tentativa de empurrar algo para fora do segmento e adicionar mais memória. Mas isso também não é particularmente desejável.

Digite multiencadeamento. Nesse caso, cada encadeamento possui um segmento de pilha independente, novamente com tamanho fixo. Mas agora os segmentos são dispostos um após o outro no espaço de endereço virtual; portanto, não há como expandir um segmento sem mover outro - o que você não pode fazer porque o programa potencialmente terá ponteiros para a memória que está na pilha. Como alternativa, você pode deixar algum espaço entre os segmentos, mas esse espaço seria desperdiçado em quase todos os casos. Uma abordagem melhor era sobrecarregar o desenvolvedor de aplicativos: se você realmente precisasse de pilhas profundas, poderia especificar isso ao criar o encadeamento.

Hoje, com um espaço de endereço virtual de 64 bits, poderíamos criar pilhas efetivamente infinitas para números efetivamente infinitos de threads. Mas, novamente, isso não é particularmente desejável: em quase todos os casos, uma sobrecarga de pilha indica um erro no seu código. O fornecimento de uma pilha de 1 GB simplesmente adia a descoberta desse bug.

kdgregory
fonte
3
CPUs x86-64 atuais só tem 48 bits de espaço de endereço
CodesInChaos
No entanto, o linux aumenta a pilha dinamicamente: quando um processo tenta acessar a área logo abaixo da pilha alocada atualmente, a interrupção é tratada apenas mapeando uma página adicional da memória da pilha, em vez de falhar o processo.
precisa saber é
2
@ master: true, mas não o que kdgregory quer dizer com "crescer a pilha". Atualmente, há um intervalo de endereços designado para uso como pilha. Você está falando de mapear gradualmente mais memória física para esse intervalo de endereços conforme necessário. O kdgregory está dizendo que é difícil ou impossível aumentar o alcance.
21416 Steve Joplin
x 86 não é a única arquitectura, e 48 bits ainda é eficazmente infinito
kdgregory
1
Aliás, lembro-me de que meus dias trabalhando com o x86 não eram tão divertidos, principalmente devido à necessidade de lidar com a segmentação. Eu preferia muito mais projetos em plataformas MC68k ;-)
kdgregory
4

A pilha com um tamanho máximo fixo não é onipresente.

Também é difícil de acertar: as profundidades da pilha seguem uma Distribuição da Lei de Energia, o que significa que, por menor que seja o tamanho da pilha, ainda haverá uma fração significativa de funções com pilhas ainda menores (portanto, você perde espaço), e não importa o tamanho que você faça, ainda haverá funções com pilhas ainda maiores (para forçar um erro de estouro de pilha para funções sem erro). Em outras palavras: qualquer tamanho que você escolher, sempre será muito pequeno e muito grande ao mesmo tempo.

Você pode corrigir o primeiro problema, permitindo que as pilhas comecem pequenas e cresçam dinamicamente, mas você ainda tem o segundo problema. E se você permite que a pilha cresça dinamicamente de qualquer maneira, por que colocar um limite arbitrário nela?

Existem sistemas em que as pilhas podem crescer dinamicamente e não têm tamanho máximo: Erlang, Go, Smalltalk e Scheme, por exemplo. Existem várias maneiras de implementar algo assim:

  • pilhas móveis: quando a pilha contígua não puder mais crescer porque há mais alguma coisa no caminho, mova-a para outro local na memória, com mais espaço livre
  • pilhas descontínuas: em vez de alocar a pilha inteira em um único espaço de memória contíguo, aloque-a em vários espaços de memória
  • pilhas alocadas na pilha: em vez de ter áreas de memória separadas para pilha e pilha, apenas aloque a pilha na pilha; como você se notou, as estruturas de dados alocadas em heap tendem a não ter problemas para aumentar e diminuir conforme necessário
  • não use pilhas: isso também é uma opção, por exemplo, em vez de controlar o estado da função em uma pilha, faça com que a função passe uma continuação para o chamado

Assim que você tiver construções poderosas de fluxo de controle não local, a ideia de uma única pilha contígua desaparecerá de qualquer maneira: exceções e continuações recuperáveis, por exemplo, "bifurcarão" a pilha, assim você acaba com uma rede de pilhas (por exemplo, implementadas com uma pilha de espaguete). Além disso, sistemas com pilhas modificáveis ​​de primeira classe, como o Smalltalk, exigem pilhas de espaguete ou algo semelhante.

Jörg W Mittag
fonte
1

O sistema operacional deve fornecer um bloco contíguo quando uma pilha é solicitada. A única maneira de fazer isso é se um tamanho máximo for especificado.

Por exemplo, digamos que a memória fique assim durante a solicitação (Xs representa usado, Os não utilizado):

XOOOXOOXOOOOOX

Se uma solicitação para um tamanho de pilha 6, a resposta do SO responderá não, mesmo que mais de 6 esteja disponível. Se você solicitar uma pilha de tamanho 3, a resposta do SO será uma das áreas de 3 slots vazios (Os) seguidos.

Além disso, percebe-se a dificuldade de permitir crescimento quando o próximo slot contíguo estiver ocupado.

Os outros objetos mencionados (Listas, etc.) não ficam na pilha, eles acabam no monte em áreas não contíguas ou fragmentadas; portanto, quando crescem, apenas pegam espaço, não precisam ser contíguos. gerenciado de maneira diferente.

A maioria dos sistemas define um valor razoável para o tamanho da pilha; você pode substituí-lo quando o encadeamento é construído, se for necessário um tamanho maior.

Jon Raynor
fonte
1

No linux, esse é puramente um limite de recursos que existe para eliminar processos descontrolados antes que eles consumam quantidades prejudiciais do recurso. No meu sistema debian, o seguinte código

#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);
}

produz a saída

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

Observe que o limite rígido está definido como RLIM_INFINITY: O processo pode aumentar seu limite flexível para qualquer valor. No entanto, desde que o programador não tenha motivos para acreditar que o programa realmente precisa de quantidades incomuns de memória da pilha, o processo será interrompido quando exceder um tamanho de pilha de oito mebibytes.

Devido a esse limite, um processo descontrolado (recursão infinita não intencional) é interrompido muito tempo antes de começar a consumir quantidades tão grandes de memória que o sistema é forçado a iniciar a troca. Isso pode fazer a diferença entre um processo com falha e um servidor com falha. No entanto, ele não limita os programas com uma necessidade legítima de uma pilha grande, eles apenas precisam definir o limite flexível para algum valor apropriado.


Tecnicamente, as pilhas crescem dinamicamente: quando o limite flexível é definido como oito mebibytes, isso não significa que essa quantidade de memória já foi mapeada. Isso seria um desperdício, já que a maioria dos programas nunca chega nem perto dos respectivos limites suaves. Em vez disso, o kernel detectará acessos abaixo da pilha e apenas mapeará nas páginas de memória, conforme necessário. Portanto, a única limitação real no tamanho da pilha é a memória disponível em sistemas de 64 bits (a fragmentação do espaço de endereço é bastante teórica com um tamanho de espaço de endereço de 16 zebibytes).

cmaster
fonte
2
Essa é a pilha apenas para o primeiro encadeamento. Novos encadeamentos precisam alocar novas pilhas e são limitados porque serão executados em outros objetos.
Zan Lynx
0

O tamanho máximo da pilha é estático, porque essa é a definição de "máximo" . Qualquer tipo de máximo em qualquer coisa é um número limite fixo e acordado. Se ele se comporta como um alvo em movimento espontâneo, não é o máximo.

De fato, as pilhas nos sistemas operacionais de memória virtual crescem dinamicamente, até o máximo .

Falando nisso, não precisa ser estático. Em vez disso, pode ser configurável, por processo ou por segmento, até.

Se a questão é "por isso que é o tamanho máximo de pilha" (um um artificialmente imposta, geralmente muito menos do que a memória disponível)?

Uma razão é que a maioria dos algoritmos não requer uma quantidade enorme de espaço na pilha. Uma pilha grande é uma indicação de uma possível recursão descontrolada . É bom parar a recursão descontrolada antes de alocar toda a memória disponível. Um problema que parece recursão descontrolada é o uso degenerado da pilha, talvez desencadeado por um caso de teste inesperado. Por exemplo, suponha que um analisador para um operador binário, infix funcione recorrendo no operando direito: analise o primeiro operando, o operador de varredura, analise o restante da expressão. Isto significa que a profundidade da pilha é proporcional ao comprimento da expressão: a op b op c op d .... Um enorme caso de teste deste formulário exigirá uma pilha enorme. Interromper o programa quando atingir um limite razoável de pilha capturará isso.

Outro motivo para um tamanho máximo fixo da pilha é que o espaço virtual para essa pilha pode ser reservado por meio de um tipo especial de mapeamento e, portanto, garantido. Garantido significa que o espaço não será atribuído a outra alocação que a pilha colidirá com ela antes de atingir o limite. O parâmetro de tamanho máximo da pilha é necessário para solicitar esse mapeamento.

Os threads precisam de um tamanho máximo de pilha por um motivo semelhante a isso. Suas pilhas são criadas dinamicamente e não podem ser movidas se colidirem com alguma coisa; o espaço virtual deve ser reservado antecipadamente e é necessário um tamanho para essa alocação.

Kaz
fonte
@ Lynn Não perguntou por que o tamanho máximo era estático, ele perguntou por que era predefinido.
Will Calderwood