A pilha é sua amiga… use-a!

Tenho a mania de usar o heap quando tenho que alocar recursos e, fatalmente, preciso gerenciar seu uso, chamando free() no hora certa. No entanto, às vezes, precisamos alocar só um bufferzinho e dealocá-lo tão logo a função saia do escopo… Não gosto muito de códigos que declaram buffers de tamanho fixo como variáveis locais… Me dá “gastura”! Fica parecendo que vou consumir a pilha somente na chamada daquela função!

Todo processo tem uma thread principal e uma pilha associada a essa thread. A pilha da thread principal é gerenciada pelo sistema operacional. Adota-se um tamanho máximo e o OS aloca um tamanho mínimo ao iniciar o processo. A medida que vai precisando usar mais espaço na pilha ela cresce (para baixo).

Sem olhar para o código fonte do sistema operacional não temos como saber o tamanho mínimo da pilha. Pelo menos não conheço um jeito eficaz para isso. Assumo, no caso do Linux, que a pilha inicial tenha o mesmo tamanho definido em PTHREAD_STACK_MIN, definido em limits.h: Ou seja, normalmente, 16 KiB, com uma guarda de 4 KiB (uma página). Saber o tamanho máximo é fácil. Em KiB ele pode ser obtido por:

$ ulimit -s
8192

Em sistemas baseados em Debian o tamanho máximo da pilha é de 8 MiB, como pode ser visto acima. No RHEL (Red Hat Enterprise Linux) isso chega a 10 MiB. Note que esse tamanho máximo é sempre múltiplo do tamanho de uma página (4 KiB). Assim, uma única página do total usado pela pilha é usada como “guarda” para que o sistema saiba que houve um “Stack Overflow“… No caso da thread principal, sempre que há uma falta de página (se atingirmos a página de guarda, que provavelmente deve ser marcada como read only), o sistema operacional aloca novas páginas… No caso de threads secundárias ele faz o mesmo… mas podemos limitar o tamanho da pilha, nesse caso.

Nos atributos de uma thread podemos ajustar o tamanho da pilha usando a função pthread_attr_setstacksize(). E PTHREAD_STACK_MIN é o tamanho mínimo permitido… Podemos nos livrar da página de guarda também, via pthread_attr_setguardsize(), ajustando o tamanho para zero. Isso não é uma boa idéia! Por default, suas threads segundárias alocarão pilha com tamanho máximo correspondente à thread principal. Isso significa que, se tivermos 10 threads em execução (a principal e 9 outras), podemos gastar 80 MiB de memória somente com pilhas!!

Quanto aos buffers locais. A linguagem C permite declarações assim, dentro de uma função:

void f(void)
{
  char buffer[1024];
  ... faz algo com o buffer aqui ...
}

Isso significa que o apontador de pilha (RSP ou ESP) assumirá o topo da mesma cerca de 1024 bytes “para baixo”, reservando este espaço para uso da função. Quanto mais espaço usado, mais o stack pointer terá que ser decrementado. Mas, basta ultrapassarmos o espaço máximo possível da pilha e temos ou um “Segmentation Fault” ou um “Stack Overflow“. Eis um teste:

#include <stdio.h>
#include <time.h>

//#define MAX_BUFFER_SIZE (12*1024*1024)
#define MAX_BUFFER_SIZE 32
  
__attribute__((noinline))
void f(void)
{
  char buffer[MAX_BUFFER_SIZE]; // buffer de 12 MiB na pilha.
  time_t t = time(NULL);
  struct tm *tm = gmtime(&t);

  strftime(buffer, MAX_BUFFER_SIZE, "%Y-%m-%d %H:%M:%S", tm); 
  printf("UTC Today's date: %s\n", buffer);  
}

void main(void)
{
  f();
}

Com apenas 32 bytes de buffer, alocado na pilha, a rotina imprime a data e hora atual na timezone GMT (ou UTC), mas mude o tamanho do buffer para 12 MiB (que falhará, inclusive, no RHEL) e BANG! Eis o que um buffer deste tamanho cria, em assembly:

f:
  ; Aloca 12 MiB na pilha.
  sub  rsp,12582936

  xor  edi,edi
  call time           ; RAX = time(NULL);

  ; O "Segmentation fault" ocorrerá aqui!
  lea  rdi,[rsp+8]    ; RDI = &t;
  mov  [rdi],rax      ; t = RAX.

  ; gtime: RDI=&t, retorna RAX=&tm.
  call gmtime

  lea  rdi,[rsp+16]   ; RDI = &buffer[0]
  mov  rcx,rax
  mov  rdx,strftime_fmt
  mov  rsi,12582936

  ; strftime: RDI=&buffer[0], RSI=size, 
  ;           RDX=strftime_fmt, RCX=&tm
  call strftime

  lea  rsi,[rsp+16]   ; RSI = &buffer[0]
  mov  rdi,printf_fmt
  xor  eax,eax        / EAX = stdout

  ; printf: RAX=stdout, RDI=printf_fmt, 
  ;         RSI=&buffer[0]
  call printf

  ; Dealoca 12 MiB da pilha.
  add  rsp,12582936
  ret

Essa deslocamenteo de RSP é importante porque a função chamará outras (time()strftime()printf()) e cada uma delas empilhará o endereço de retorno, bem como usará espaço da pilha para suas variáveis locais…

Aliás, um aviso: Se você declarar arrays com esses com tamnhos não múltiplos de 8 (no caso do modo x86_64) ou 4 (no modo i386), o compilador automaticamente alinhará RSP (ou ESP). Assim, para não gastar muito espaço à toa, é uma boa idéia declarar seus arrays como tendo tamanhos múltiplos de 4 ou 8, dependendo do modo de operação.

Alocação dinâmica da pilha:

Mas, será que podemos alocar espaço na pilha somente se houver demanda para o uso desse espaço adicional? Sim! Ao invés de usar funções como malloc()free(), podemos usar alloca(). O que essa função faz é exatamente o que o compilador fez automaticamente: Ela incrementa RSP e, quando a função termina, faz com que RSP volte para a sua posição original…. Ela funciona da mesma maneira que malloc(), com uma diferença: Não há motivos para testar pelo retorno NULL em caso de falhas. Ou temos espaço na pilha ou não temos e se não tivermos, ao preencher o buffer e estourarmos a pilha, teremos um segmentation fault ou stack overflow, como citei antes. Assim, alloca() devolve um ponteiro que, em teoria, é sempre válido.

Não há como dealocar o espaço alocado por alloca() sem sair da função… Não use free() com esse ponteiro!

E quanto aos “sub-escopos”?

Você deve estar pensando: Mas e se eu definir um bloco, entre chaves, dentro de outro, não estou definindo um “sub-escopo”? Afinal, códigos como este são perfeitamente válidos:

void f(int x)
{
  int y = x;

  if (x > 10)
  {
    int y = x + 2;
    printf("%d\n", y);
  }

  printf("%d\n", y);
}

O y de fora será inicializado com o valor de x, mas o y de dentro só existe neste sub-escopo e não afetará o y de fora. Se declararmos buffers com o mesmo nome em ambos os escopos isso significa que o segundo buffer, do escopo mais interno, só deslocará RSP, alocando espaço na pilha, se o escopo for executado e, quando sair, retornará RSP ao normal?

Sim! Mas, nem sempre! É mais provável que, ao entrar na função, RSP seja ajustado para o taamanho total de todas as variáveis locais usadas no escopo mais externo somado ao tamanho de todas as variáveis dos escopos mais internos. Isso evita ter que ficar mexendo com o apontador de pilha o tempo todo… Afinal, a pilha é grande!

Da mesma forma, ao usar alloca(), provavelmente o espaço total reservado por todas as chamadas a essa função só será liberado no final da rotina…

Ahhhh… e não fique entusiasmado: Isso acontece, inclusive, em C++. Objetos alocados na pilha em escopos mais internos são “construídos” em runtime, mas o espaço reservado para eles, na pilha, provavelmente foi calculado desde o início… Note que o polimorfismo só é possível quando se usa o free-store (o “heap”), porque é uma maneira de fazer um ponteiro de objeto base apontar para um descendente. Mas, um objeto estaticamente alocado na pilha não sofre polimorfismo nunca (um ponteiro para um pode sofrer!) e seu tamanho pode ser determinado de ante mão…

Conclusão:

Seja chato com relação ao uso da pilha. Afinal, suas funções, as funções da libc e de outras bibliotecas usarão a pilha de seu processo e você nunca sabe quando um desconhecido pode surrupiar suas coisas. Mas não é necessário ter “medo” de usar a pilha, desde que com moderação… Nada de criar arrays enormes!

Anúncios