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?
Respostas:
É 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:
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.
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.
setjmp
elongjmp
, 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.
fonte
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.
fonte
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.
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.
fonte
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:
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.
fonte
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.
fonte
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
produz a saída
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).
fonte
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.
fonte