As pilhas são a única maneira razoável de estruturar programas?

74

A maioria das arquiteturas que eu vi depende de uma pilha de chamadas para salvar / restaurar o contexto antes das chamadas de função. É um paradigma tão comum que as operações push e pop são integradas à maioria dos processadores. Existem sistemas que funcionam sem uma pilha? Em caso afirmativo, como eles funcionam e para que são usados?

ConditionRacer
fonte
5
Dado como se espera que as funções se comportem em linguagens do tipo C (ou seja, você pode aninhar chamadas com a profundidade que desejar e retornar novamente em ordem inversa), não está claro para mim como mais alguém poderia implementar chamadas de função sem que isso fosse incrivelmente ineficiente. Você pode, por exemplo, forçar o programador a usar o estilo de passagem de continuação ou alguma outra forma bizarra de programação, mas ninguém parece realmente trabalhar com o CPS no nível mais baixo por algum motivo.
21716 Kevin
5
O GLSL funciona sem uma pilha (assim como outros idiomas nesse colchete específico). Simplesmente não permite chamadas recursivas.
Leushenko
3
Você também pode procurar nas janelas Register , que são usadas por algumas arquiteturas RISC.
Mark Booth
2
@Kevin: "Os primeiros compiladores do FORTRAN não suportavam recursão nas sub-rotinas. As arquiteturas de computadores anteriores não tinham o conceito de pilha e, quando eles apoiavam diretamente as chamadas de sub-rotina, o local de retorno era frequentemente armazenado em um local fixo adjacente ao código da sub-rotina, o que faz com que não permita que uma sub-rotina seja chamada novamente antes que uma chamada anterior da sub-rotina retorne. Embora não especificado no Fortran 77, muitos compiladores F77 suportaram a recursão como uma opção, enquanto ela se tornou um padrão no Fortran 90. " en.wikipedia.org/wiki/Fortran#FORTRAN_II
Mooing Duck
3
O microcontrolador P8X32A ("Propeller") não tem conceito de pilha na sua linguagem de montagem padrão (PASM). As instruções responsáveis ​​pelo salto também modificam automaticamente a instrução de retorno na RAM para determinar para onde retornar - o que pode ser escolhido arbitrariamente. Curiosamente, a linguagem "Spin" (uma linguagem de alto nível interpretada que é executada no mesmo chip) possui semântica de pilha tradicional.
Wossname

Respostas:

50

Uma alternativa (um tanto) popular a uma pilha de chamadas são as continuações .

A VM Parrot é baseada em continuação, por exemplo. É completamente sem pilha: os dados são mantidos em registros (como Dalvik ou LuaVM, o Parrot é baseado em registros) e o fluxo de controle é representado com continuações (ao contrário do Dalvik ou LuaVM, que possuem uma pilha de chamadas).

Outra estrutura de dados popular, usada normalmente pelas VMs Smalltalk e Lisp é a pilha de espaguete, que é como uma rede de pilhas.

Como @rwong apontou , o estilo de passagem de continuação é uma alternativa a uma pilha de chamadas. Os programas gravados no estilo (ou transformados em) de passagem de continuação nunca retornam; portanto, não há necessidade de uma pilha.

Respondendo à sua pergunta de uma perspectiva diferente: é possível ter uma pilha de chamadas sem ter uma pilha separada, alocando os quadros da pilha no heap. Algumas implementações de Lisp e Scheme fazem isso.

Jörg W Mittag
fonte
4
Depende da sua definição de pilha. Não tenho certeza de que ter uma lista vinculada (ou matriz de ponteiros para ou ...) de quadros de pilha seja tanto "não uma pilha" quanto "uma representação diferente de uma pilha"; e programas em linguagens CPS (na prática) tendem a criar listas efetivamente vinculadas de continuações muito semelhantes às pilhas (se não tiver, você pode conferir o GHC, que coloca o que chama de "continuações" em uma pilha linear para eficiência).
Jonathan Elenco
6
" Programas escritos em (ou transformados em) estilos de passagem de continuação nunca retornam " ... soa ameaçador.
Rob Penridge
5
@ RobPenridge: é um pouco enigmático, eu concordo. CPS significa que, em vez de retornar, as funções tomam como argumento extra outra função a ser chamada quando o trabalho é concluído. Portanto, quando você chama uma função e tem algum outro trabalho que precisa fazer depois de chamá-la, em vez de esperar a função retornar e continuar seu trabalho, você encerra o trabalho restante ("a continuação" ) em uma função e passe essa função como argumento adicional. A função que você chamou chama essa função em vez de retornar, e assim por diante. Nenhuma função retorna, apenas:
Jörg W Mittag
3
… Chama a próxima função. Portanto, você não precisa de uma pilha de chamadas, porque nunca precisa voltar e restaurar o estado de ligação de uma função chamada anteriormente. Em vez de carregar o estado passado para poder voltar a ele, você carrega o estado futuro , se quiser.
Jörg W Mittag
1
@jcast: O recurso que define uma pilha é o IMO, que você pode acessar apenas o elemento superior. Uma lista de continuações, OTOH, daria acesso a todas as continuações e não apenas ao quadro de pilha superior. Se você tiver exceções recuperáveis ​​no estilo Smalltalk, por exemplo, será necessário percorrer a pilha sem movê-la. E ter continuações no idioma enquanto ainda deseja manter a idéia familiar de uma pilha de chamadas leva a pilhas de espaguete, que é basicamente uma árvore de pilhas em que as continuações "bifurcam" a pilha.
Jörg W Mittag
36

Antigamente, os processadores não tinham instruções de pilha e as linguagens de programação não suportavam recursão. Com o tempo, mais e mais idiomas optam por oferecer suporte à recursão e o conjunto de hardware seguido com recursos de alocação de quadros de pilha. Esse suporte variou bastante ao longo dos anos com diferentes processadores. Alguns processadores adotaram registros de quadro de pilha e / ou ponteiro de pilha; algumas instruções adotadas que realizariam a alocação de quadros de pilha em uma única instrução.

À medida que os processadores avançam com caches de nível único e multinível, uma vantagem crítica da pilha é a da localização do cache. A parte superior da pilha está quase sempre no cache. Sempre que você pode fazer algo que tenha uma grande taxa de acertos no cache, você está no caminho certo com os processadores modernos. O cache aplicado à pilha significa que variáveis ​​locais, parâmetros, etc. estão quase sempre no cache e desfrutam do mais alto nível de desempenho.

Em suma, o uso da pilha evoluiu tanto em hardware quanto em software. Existem outros modelos (por exemplo, a computação no fluxo de dados foi tentada por um período prolongado); no entanto, a localização da pilha faz com que ela funcione muito bem. Além disso, o código processual é exatamente o que os processadores desejam para o desempenho: uma instrução informando o que fazer após o outro. Quando as instruções estão fora de ordem linear, o processador diminui tremendamente, pelo menos até o momento, uma vez que não descobrimos como fazer o acesso aleatório tão rápido quanto o acesso seqüencial. (Aliás, existem problemas semelhantes em cada nível de memória, do cache, a memória principal, o disco ...)

Entre o desempenho demonstrado das instruções de acesso seqüencial e o comportamento benéfico de armazenamento em cache da pilha de chamadas, temos, pelo menos no momento, um modelo de desempenho vencedor.

(Podemos lançar mutabilidade das estruturas de dados nos trabalhos também ...)

Isso não significa que outros modelos de programação não funcionem, especialmente quando eles podem ser traduzidos para as instruções seqüenciais e o modelo de pilha de chamadas do hardware atual. Mas há uma vantagem distinta para os modelos que suportam onde o hardware está. No entanto, as coisas nem sempre permanecem as mesmas, portanto, podemos ver mudanças no futuro, à medida que diferentes tecnologias de memória e transistor permitem mais paralelismo. É sempre uma brincadeira entre linguagens de programação e recursos de hardware, então, vamos ver!

Erik Eidt
fonte
9
De fato, as GPUs ainda não têm pilhas. Você está proibido de repetir no GLSL / SPIR-V / OpenCL (não tenho certeza sobre o HLSL, mas provavelmente não vejo razão para que seja diferente). A maneira como eles realmente lidam com "pilhas de chamadas de função" é usando um número absurdamente grande de registros.
LinearZoetrope
@ Jsor: Isso é, em grande parte, detalhes de implementação, como pode ser visto na arquitetura SPARC. Como as GPUs, o SPARC possui um enorme conjunto de registros, mas é único, pois possui uma janela deslizante que, ao redor, derrama os registros muito antigos para uma pilha na RAM. Portanto, é realmente um híbrido entre os dois modelos. E o SPARC não especificou exatamente quantos registros físicos havia, quão grande era a janela de registro, para que diferentes implementações pudessem estar em qualquer lugar na escala de "grande quantidade de registros" a "apenas o suficiente para uma janela, em cada chamada de função derramar diretamente para empilhar "
MSalters
Um lado negativo do modelo de pilha de chamadas é que a matriz e / ou o excesso de endereço deve ser observado com muito cuidado, pois programas auto-modificáveis ​​como exploração são possíveis se bits arbitrários do heap forem executáveis.
BenPen
14

TL; DR

  • Pilha de chamadas como um mecanismo de chamada de função:
    1. Geralmente é simulado por hardware, mas não é fundamental para a construção de hardware
    2. É fundamental para a programação imperativa
    3. Não é fundamental para a programação funcional
  • A pilha como uma abstração de "último a entrar, primeiro a sair" (LIFO) é fundamental para a ciência da computação, algoritmos e até mesmo alguns domínios não técnicos.
  • Alguns exemplos de organização de programas que não usam pilhas de chamadas:
    • Estilo de passagem de continuação (CPS)
    • Máquina de estado - um loop gigante, com tudo embutido. (Supostamente inspirado na arquitetura de firmware da Saab Gripen e atribuído a uma comunicação de Henry Spencer e reproduzida por John Carmack.) (Nota 1)
    • Arquitetura de fluxo de dados - uma rede de atores conectados por filas (FIFO). As filas às vezes são chamadas de canais.

O restante desta resposta é uma coleção aleatória de pensamentos e histórias e, portanto, um pouco desorganizada.


A pilha que você descreveu (como um mecanismo de chamada de função) é específica para a programação imperativa.

Abaixo da programação imperativa, você encontrará o código da máquina. O código da máquina pode emular a pilha de chamadas executando uma pequena sequência de instruções.

Abaixo do código da máquina, você encontrará o hardware responsável pela execução do software. Embora o microprocessador moderno seja complexo demais para ser descrito aqui, pode-se imaginar que exista um design muito simples, lento, mas capaz de executar o mesmo código de máquina. Um design tão simples utilizará os elementos básicos da lógica digital:

  1. Lógica combinacional, isto é, uma conexão de portas lógicas (e, ou, não, ...) Observe que "lógica combinacional" exclui feedbacks.
  2. Memória, isto é, flip-flops, travas, registradores, SRAM, DRAM, etc.
  3. Uma máquina de estado que consiste em alguma lógica combinatória e em alguma memória, apenas o suficiente para poder implementar um "controlador" que gerencia o restante do hardware.

As discussões a seguir continham muitos exemplos de formas alternativas de estruturar programas imperativos.

A estrutura de such as program terá a seguinte aparência:

void main(void)
{
    do
    {
        // validate inputs for task 1
        // execute task 1, inlined, 
        // must complete in a deterministically short amount of time
        // and limited to a statically allocated amount of memory
        // ...
        // validate inputs for task 2
        // execute task 2, inlined
        // ...
        // validate inputs for task N
        // execute task N, inlined
    }
    while (true);
    // if this line is reached, tell the programmers to prepare
    // themselves to appear before an accident investigation board.
    return 0; 
}

Esse estilo seria apropriado para microcontroladores, ou seja, para aqueles que veem o software como um companheiro para as funções do hardware.


rwong
fonte
@ Peteris: pilhas são estruturas de dados LIFO.
Christopher Creutzig
1
Interessante. Eu teria pensado o contrário. Por exemplo, FORTRAN é uma linguagem de programação imperativa e as versões anteriores não usavam uma pilha de chamadas. No entanto, a recursão é fundamental para a programação funcional, e não acho que seja possível implementar no caso geral sem usar uma pilha.
TED
@TED ​​- em uma implementação de linguagem funcional, há uma estrutura de dados de pilha (ou tipicamente uma árvore) representando cálculos pendentes, mas você não necessariamente a executa com instruções usando os modos de endereçamento orientado a pilha da máquina ou mesmo as instruções de chamada / retorno (de maneira aninhada / recursiva - talvez apenas como parte de um loop de máquina de estado).
Davidbak
@davidbak - IIRC, um algoritmo recursivo praticamente precisa ser recursivo para ser capaz de se livrar da pilha. Provavelmente existem outros casos em que você pode otimizá-lo, mas no caso geral , você precisa ter uma pilha . De fato, me disseram que havia uma prova matemática disso flutuando em algum lugar. Esta resposta afirma que é o teorema de Church-Turing (acho que com base no fato de que as máquinas de Turing usar uma pilha?)
TED
1
@TED ​​- Eu concordo com você. Acredito que a falta de comunicação aqui é que li o post do OP falando sobre arquitetura de sistema que significava para mim arquitetura de máquina . Eu acho que outras pessoas que responderam aqui têm o mesmo entendimento. Portanto, aqueles de nós que entenderam que esse é o contexto responderam respondendo que você não precisa de uma pilha no nível do modo de instrução / endereçamento da máquina. Mas vejo que a pergunta também pode ser interpretada como significando, meramente, um sistema de linguagem em geral precisa de uma pilha de chamadas. Essa resposta também é não, mas por diferentes razões.
Davidbak
11

Não, não necessariamente.

Leia a coleta de lixo de papel antigo de Appel pode ser mais rápida que a Alocação de pilha . Ele usa o estilo de passagem de continuação e mostra uma implementação sem pilha.

Observe também que arquiteturas antigas de computador (por exemplo, IBM / 360 ) não tinham nenhum registro de pilha de hardware. Mas o SO e o compilador reservaram um registro para o ponteiro da pilha por convenção (relacionada às convenções de chamada ) para que eles pudessem ter uma pilha de chamadas de software .

Em princípio, todo um compilador e otimizador de programa C poderia detectar o caso (algo comum em sistemas embarcados) em que o gráfico de chamada é estaticamente conhecido e sem nenhuma recursão (ou ponteiros de função). Nesse sistema, cada função poderia manter seu endereço de retorno em um local estático fixo (e foi assim que o Fortran77 trabalhou nos computadores da época de 1970).

Atualmente, os processadores também têm pilhas de chamadas (e instruções de chamada e devolução da máquina) cientes dos caches da CPU .

Basile Starynkevitch
fonte
1
Certamente o FORTRAN parou de usar locais de retorno estáticos quando o FORTRAN-66 saiu e exigiu suporte para SUBROUTINEe FUNCTION. Você está correto para versões anteriores (FORTRAN-IV e possivelmente WATFIV).
TMN
E COBOL, é claro. E um ponto excelente sobre o IBM / 360 - ele foi bastante utilizado, apesar dos modos de endereçamento de pilha de hardware ausentes. (R14, eu creio que foi?) E tinha compiladores para linguagens baseadas em pilha, por exemplo, PL / I, Ada, Algol, C.
davidbak
Na verdade, estudei o 360 na faculdade e achei desconcertante a princípio.
JDługosz
1
@ JDługosz A melhor maneira para os estudantes modernos de arquitetura de computadores considerarem o 360 é como uma máquina RISC muito simples ... embora com mais de um formato de instrução ... e algumas anomalias como TRe TRT.
Davidbak
Que tal "zero e adicionar empacotado" para mover um registro? Mas "ramificar e vincular" ao invés de empilhar para o endereço de retorno retornou.
JDługosz
10

Você tem boas respostas até agora; deixe-me dar um exemplo impraticável, mas altamente educacional, de como você pode criar um idioma sem a noção de pilhas ou "fluxo de controle". Aqui está um programa que determina fatoriais:

function f(i) => if i == 0 then 1 else i * f(i - 1)
let x = f(3)

Colocamos este programa em uma string e avaliamos o programa por substituição textual. Então, quando estamos avaliando f(3), fazemos uma pesquisa e substituímos por 3 por i, assim:

function f(i) => if i == 0 then 1 else i * f(i - 1)
let x = if 3 == 0 then 1 else 3 * f(3 - 1)

Ótimo. Agora, realizamos outra substituição textual: vemos que a condição do "se" é falsa e substituímos outra string, produzindo o programa:

function f(i) => if i == 0 then 1 else i * f(i - 1)
let x = 3 * f(3 - 1)

Agora, substituímos outra string em todas as subexpressões que envolvem constantes:

function f(i) => if i == 0 then 1 else i * f(i - 1)
let x = 3 * f(2)

E você vê como isso vai; Não vou insistir mais nisso. Poderíamos continuar fazendo uma série de substituições de cordas até chegarmos ao fim let x = 6e terminarmos.

Usamos a pilha tradicionalmente para variáveis ​​locais e informações de continuação; lembre-se, uma pilha não diz de onde você veio, mas para onde você está indo a seguir com esse valor de retorno em mãos.

No modelo de programação de substituição de cadeia, não há "variáveis ​​locais" na pilha; os parâmetros formais são substituídos por seus valores quando a função é aplicada ao seu argumento, em vez de colocados em uma tabela de pesquisa na pilha. E não há "ir a algum lugar a seguir" porque a avaliação do programa está simplesmente aplicando regras simples para que a substituição de cadeias produza um programa diferente, mas equivalente.

Agora, é claro, realmente fazer substituições de strings provavelmente não é o caminho a percorrer. Porém, linguagens de programação que suportam "raciocínio equacional" (como Haskell) estão logicamente usando essa técnica.

Eric Lippert
fonte
3
Retina é um exemplo de uma linguagem de programação baseada em Regex que usa operações de string para computação.
Andrew Piliser
2
@AndrewPiliser Projetado e implementado por esse cara legal .
gato
3

Desde a publicação de Parnas em 1972 de Sobre os critérios a serem usados ​​na decomposição de sistemas em módulos , foi razoavelmente aceito que a informação oculta no software é uma coisa boa. Isso se segue a um longo debate ao longo dos anos 60 sobre decomposição estrutural e programação modular.

Modularidade

Um resultado necessário das relações de caixa preta entre os módulos implementados por diferentes grupos em qualquer sistema multiencadeado requer um mecanismo para permitir a reentrada e um meio para rastrear o gráfico de chamadas dinâmico do sistema. O fluxo controlado de execução deve passar para dentro e para fora de vários módulos.

Escopo dinâmico

Assim que o escopo lexical for insuficiente para rastrear o comportamento dinâmico, será necessária alguma contabilidade de tempo de execução para rastrear a diferença.

Como qualquer thread (por definição) possui apenas um ponteiro de instrução atual, uma pilha LIFO é apropriada para rastrear cada chamada.

Exceções

Portanto, embora o modelo de continuação não mantenha uma estrutura de dados explicitamente para a pilha, ainda há a chamada aninhada de módulos que deve ser mantida em algum lugar!

Até mesmo linguagens declarativas mantêm o histórico de avaliação ou, por outro lado, achatam o plano de execução por razões de desempenho e mantêm o progresso de alguma outra maneira.

A estrutura de loop infinito identificada por rwong é comum em aplicativos de alta confiabilidade com agendamento estático que desaprova muitas estruturas de programação comuns, mas exige que o aplicativo inteiro seja considerado uma caixa branca sem ocultar informações significativas.

Vários loops intermináveis ​​simultâneos não exigem nenhuma estrutura para armazenar endereços de retorno, pois eles não chamam funções, tornando a questão discutível. Se eles se comunicam usando variáveis ​​compartilhadas, elas podem degenerar facilmente em análogos de endereço de retorno herdados no estilo Fortran.

Pekka
fonte
1
Você se pinta em um canto assumindo " qualquer sistema multiencadeado". Máquinas de estado finito acopladas podem ter vários encadeamentos em sua implementação, mas não exigem uma pilha LIFO. Não há limitação nos FSMs de que você retorna a qualquer estado anterior, sem falar na ordem LIFO. Portanto, esse é um sistema multithread real para o qual não se aplica. E se você se restringir a uma definição de multiencadeamento como "pilhas de chamadas de funções independentes paralelas", terá uma definição circular.
precisa saber é o seguinte
Eu não leio a pergunta dessa maneira. O OP está familiarizado com as chamadas de função, mas pergunta sobre outros sistemas.
MSalters
@MSalters Atualizado para incorporar loops intermináveis ​​simultâneos. O modelo é válido, mas limita a escalabilidade. Eu sugiro que mesmo máquinas de estado moderado incorporem chamadas de função para permitir a reutilização de código.
Pekka
2

Todos os mainframes antigos (IBM System / 360) não tinham noção de pilha. No 260, por exemplo, os parâmetros foram construídos em um local fixo na memória e, quando uma sub-rotina foi chamada, ela foi chamada R1apontando para o bloco de parâmetros e R14contendo o endereço de retorno. A rotina chamada, se quisesse chamar outra sub-rotina, teria que armazenar R14em um local conhecido antes de fazer a chamada.

Isso é muito mais confiável que uma pilha, porque tudo pode ser armazenado em locais fixos de memória estabelecidos em tempo de compilação e pode ser 100% garantido que os processos nunca ficarão sem pilha. Não existe nenhum dos "Alocar 1 MB e cruzar os dedos" que temos que fazer hoje em dia.

Chamadas de sub-rotina recursivas foram permitidas no PL / I, especificando a palavra-chave RECURSIVE. Eles queriam dizer que a memória usada pela sub-rotina foi alocada dinamicamente e não estaticamente. Mas ligações recursivas eram tão raras quanto agora.

A operação sem pilha também facilita muito o multiencadeamento massivo, e é por isso que geralmente são feitas tentativas para tornar os idiomas modernos sem obstáculos. Não há nenhuma razão, por exemplo, para que um compilador C ++ não possa ser modificado pelo back-end para usar a memória alocada dinamicamente em vez de pilhas.

Martin Kochanski
fonte