Eliminando o Coletor de Lixo: O Caminho RAII

Publicados: 2022-03-11

No início, havia C. Em C, existem três tipos de alocação de memória: estática, automática e dinâmica. Variáveis ​​estáticas são as constantes embutidas no arquivo de origem e, como têm tamanhos conhecidos e nunca mudam, não são tão interessantes. A alocação automática pode ser pensada como alocação de pilha - o espaço é alocado quando um bloco léxico é inserido e liberado quando esse bloco é encerrado. Sua característica mais importante está diretamente relacionada a isso. Até o C99, as variáveis ​​alocadas automaticamente precisavam ter seus tamanhos conhecidos em tempo de compilação. Isso significa que qualquer string, lista, mapa e qualquer estrutura derivada deles tinha que viver no heap, na memória dinâmica.

Eliminando o Coletor de Lixo: O Caminho RAII

A memória dinâmica foi explicitamente alocada e liberada pelo programador usando quatro operações fundamentais: malloc, realloc, calloc e free. Os dois primeiros não executam qualquer inicialização, a memória pode conter cruft. Todos eles, exceto o gratuito, podem falhar. Nesse caso, eles retornam um ponteiro nulo, cujo acesso é um comportamento indefinido; na melhor das hipóteses, seu programa explode. Na pior das hipóteses, seu programa parece funcionar por um tempo, processando dados inúteis antes de explodir.

Fazer as coisas dessa maneira é meio doloroso porque você, o programador, é o único responsável por manter um monte de invariantes que fazem com que seu programa exploda quando violado. Deve haver uma chamada malloc antes que a variável seja acessada. Você deve verificar se malloc retornou com sucesso antes de usar sua variável. Deve existir exatamente uma chamada gratuita por malloc no caminho de execução. Se zero, vazamentos de memória. Se mais de um, seu programa explode. Pode não haver tentativas de acesso à variável após ela ser liberada. Vamos ver um exemplo de como isso realmente se parece:

 int main() { char *str = (char *) malloc(7); strcpy(str, "toptal"); printf("char array = \"%s\" @ %u\n", str, str); str = (char *) realloc(str, 11); strcat(str, ".com"); printf("char array = \"%s\" @ %u\n", str, str); free(str); return(0); }
 $ make runc gcc -oc cc ./c char * (null terminated): toptal @ 66576 char * (null terminated): toptal.com @ 66576

Esse código, por mais simples que seja, já contém um antipadrão e uma decisão questionável. Na vida real, você nunca deve escrever contagens de bytes como literais, mas sim usar a função sizeof. Da mesma forma, alocamos o array char * exatamente ao tamanho da string que precisamos duas vezes (uma a mais que o comprimento da string, para contabilizar a terminação nula), o que é uma operação bastante cara. Um programa mais sofisticado pode construir um buffer de string maior, permitindo que o tamanho da string cresça.

A invenção do RAII: uma nova esperança

Todo aquele gerenciamento manual era desagradável, para dizer o mínimo. Em meados dos anos 80, Bjarne Stroustrup inventou um novo paradigma para sua nova linguagem, C++. Ele chamou de Resource Acquisition Is Initialization, e os insights fundamentais foram os seguintes: objetos podem ser especificados para ter construtores e destruidores que são chamados automaticamente em momentos apropriados pelo compilador, isso fornece uma maneira muito mais conveniente de gerenciar a memória de um determinado objeto requer, e a técnica também é útil para recursos que não são memória.

Isso significa que o exemplo acima, em C++, é muito mais limpo:

 int main() { std::string str = std::string ("toptal"); std::cout << "string object: " << str << " @ " << &str << "\n"; str += ".com"; std::cout << "string object: " << str << " @ " << &str << "\n"; return(0); }
 $ g++ -o ex_1 ex_1.cpp && ./ex_1 string object: toptal @ 0x5fcaf0 string object: toptal.com @ 0x5fcaf0

Sem gerenciamento manual de memória à vista! O objeto string é construído, tem um método sobrecarregado chamado e é automaticamente destruído quando a função é encerrada. Infelizmente, essa mesma simplicidade pode levar a outras complicações. Vejamos um exemplo com algum detalhe:

 vector<string> read_lines_from_file(string &file_name) { vector<string> lines; string line; ifstream file_handle (file_name.c_str()); while (file_handle.good() && !file_handle.eof()) { getline(file_handle, line); lines.push_back(line); } file_handle.close(); return lines; } int main(int argc, char* argv[]) { // get file name from the first argument string file_name (argv[1]); int count = read_lines_from_file(file_name).size(); cout << "File " << file_name << " contains " << count << " lines."; return 0; }
 $ make cpp && ./c++ makefile g++ -o c++ c++.cpp File makefile contains 38 lines.

Isso tudo parece bastante simples. As linhas vetoriais são preenchidas, retornadas e chamadas. No entanto, sendo programadores eficientes que se preocupam com o desempenho, algo sobre isso nos incomoda: na instrução return, o vetor é copiado para um novo vetor devido à semântica do valor em jogo, pouco antes de sua destruição.

Isso não é mais estritamente verdadeiro no C++ moderno. O C++11 introduziu a noção de semântica de movimento, na qual a origem é deixada em um estado válido (para que ainda possa ser destruído adequadamente), mas não especificado. As chamadas de retorno são um caso muito fácil para o compilador otimizar para mover a semântica, pois sabe que a origem será destruída pouco antes de qualquer acesso adicional. No entanto, o objetivo do exemplo é demonstrar por que as pessoas inventaram um monte de linguagens coletadas de lixo no final dos anos 80 e início dos anos 90, e naquela época a semântica do movimento C++ não estava disponível.

Para grandes dados, isso pode ficar caro. Vamos otimizar isso e apenas retornar um ponteiro. Existem algumas alterações de sintaxe, mas, caso contrário, é o mesmo código:

Na verdade, vetor é um identificador de valor: uma estrutura relativamente pequena contendo ponteiros para itens no heap. Estritamente falando, não é um problema simplesmente retornar o vetor. O exemplo funcionaria melhor se fosse uma matriz grande sendo retornada. Como tentar ler um arquivo em um array pré-alocado não faria sentido, usamos o vetor. Apenas finja que é uma estrutura de dados impraticavelmente grande, por favor.

 vector<string> * read_lines_from_file(string &file_name) { vector<string> * lines; string line; ifstream file_handle (file_name.c_str()); while (file_handle.good() && !file_handle.eof()) { getline(file_handle, line); lines->push_back(line); } file_handle.close(); return lines; }
 $ make cpp && ./c++ makefile g++ -o c++ c++.cpp Segmentation fault (core dumped)

Ai! Agora que as linhas são um ponteiro, podemos ver que as variáveis ​​automáticas estão funcionando como anunciado: o vetor é destruído à medida que seu escopo sai, deixando o ponteiro apontando para um local avançado na pilha. Uma falha de segmentação é simplesmente uma tentativa de acesso ilegal à memória e, portanto, deveríamos ter esperado isso. Ainda assim, queremos recuperar as linhas do arquivo de nossa função de alguma forma, e o natural é simplesmente mover nossa variável para fora da pilha e para o heap. Isso é feito com a nova palavra-chave. Podemos simplesmente editar uma linha do nosso arquivo, onde definimos as linhas:

 vector<string> * lines = new vector<string>;
 $ make cpp && ./c++ makefile g++ -o c++ c++.cpp File makefile contains 38 lines.

Infelizmente, embora isso pareça funcionar perfeitamente, ainda tem uma falha: vaza memória. Em C++, os ponteiros para o heap devem ser excluídos manualmente depois que não forem mais necessários; caso contrário, essa memória ficará indisponível quando o último ponteiro sair do escopo e não será recuperada até que o sistema operacional a gerencie quando o processo terminar. O C++ moderno idiomático usaria um unique_ptr aqui, que implementa o comportamento desejado. Ele exclui o objeto apontado quando o ponteiro fica fora do escopo. No entanto, esse comportamento não fazia parte da linguagem até o C++11.

Neste exemplo, isso pode ser facilmente corrigido:

 vector<string> * read_lines_from_file(string &file_name) { vector<string> * lines = new vector<string>; string line; ifstream file_handle (file_name.c_str()); while (file_handle.good() && !file_handle.eof()) { getline(file_handle, line); lines->push_back(line); } file_handle.close(); return lines; } int main(int argc, char* argv[]) { // get file name from the first argument string file_name (argv[1]); vector<string> * file_lines = read_lines_from_file(file_name); int count = file_lines->size(); delete file_lines; cout << "File " << file_name << " contains " << count << " lines."; return 0; }

Infelizmente, à medida que os programas se expandem além da escala de brinquedos, rapidamente se torna mais difícil raciocinar sobre onde e quando exatamente um ponteiro deve ser excluído. Quando uma função retorna um ponteiro, você o possui agora? Você deve excluí-lo quando terminar de usá-lo ou ele pertence a alguma estrutura de dados que será liberada de uma só vez mais tarde? Errar de uma maneira e vazamentos de memória, errar de outra e você corrompeu a estrutura de dados em questão e provavelmente outras, pois eles tentam desreferenciar ponteiros que agora não são mais válidos.

Relacionado: Depuração de vazamentos de memória em aplicativos Node.js

“No Coletor de Lixo, aviador!”

Os coletores de lixo não são uma tecnologia nova. Eles foram inventados em 1959 por John McCarthy para Lisp. Com Smalltalk-80 em 1980, a coleta de lixo começou a se popularizar. No entanto, a década de 1990 representou o verdadeiro florescimento da técnica: entre 1990 e 2000, um grande número de linguagens foram lançadas, todas usando coleta de lixo de um tipo ou de outro: Haskell, Python, Lua, Java, JavaScript, Ruby, OCaml , e C# estão entre os mais conhecidos.

O que é coleta de lixo? Em suma, é um conjunto de técnicas utilizadas para automatizar o gerenciamento manual de memória. Geralmente está disponível como uma biblioteca para linguagens com gerenciamento manual de memória, como C e C++, mas é muito mais usado em linguagens que o exigem. A grande vantagem é que o programador simplesmente não precisa pensar em memória; é tudo abstraído. Por exemplo, o equivalente em Python ao nosso código de leitura de arquivos acima é simplesmente este:

 def read_lines_from_file(file_name): lines = [] with open(file_name) as fp: for line in fp: lines.append(line) return lines if __name__ == '__main__': import sys file_name = sys.argv[1] count = len(read_lines_from_file(file_name)) print("File {} contains {} lines.".format(file_name, count))
 $ python3 python3.py makefile File makefile contains 38 lines.

A matriz de linhas surge quando atribuída pela primeira vez e é retornada sem copiar para o escopo de chamada. Ele é limpo pelo Garbage Collector algum tempo depois de sair desse escopo, pois o tempo é indeterminado. Uma observação interessante é que em Python, RAII para recursos que não são de memória não é idiomático. É permitido - poderíamos simplesmente escrever fp = open(file_name) em vez de usar um bloco with e deixar o GC limpar depois. Mas o padrão recomendado é usar um gerenciador de contexto quando possível para que possam ser liberados em tempos determinísticos.

Por mais agradável que seja abstrair o gerenciamento de memória, há um custo. Na coleta de lixo de contagem de referência, todas as saídas de escopo e atribuição de variável ganham um pequeno custo para atualizar as referências. Em sistemas de marcação e varredura, em intervalos imprevisíveis, toda a execução do programa é interrompida enquanto o GC limpa a memória. Isso geralmente é chamado de evento de parar o mundo. Implementações como Python, que usam os dois sistemas, sofrem as duas penalidades. Esses problemas reduzem a adequação de linguagens coletadas por lixo para casos em que o desempenho é crítico ou são necessários aplicativos em tempo real. Pode-se ver a penalidade de desempenho em ação mesmo nesses programas de brinquedos:

 $ make cpp && time ./c++ makefile g++ -o c++ c++.cpp File makefile contains 38 lines. real 0m0.016s user 0m0.000s sys 0m0.015s $ time python3 python3.py makefile File makefile contains 38 lines. real 0m0.041s user 0m0.015s sys 0m0.015s

A versão Python leva quase três vezes mais tempo real que a versão C++. Embora nem toda essa diferença possa ser atribuída à coleta de lixo, ela ainda é considerável.

Propriedade: RAII Awakens

É o fim, então? Todas as linguagens de programação devem escolher entre desempenho e facilidade de programação? Não! A pesquisa em linguagem de programação continua e estamos começando a ver as primeiras implementações da próxima geração de paradigmas de linguagem. De particular interesse é a linguagem chamada Rust, que promete ergonomia semelhante a Python e velocidade semelhante a C enquanto faz ponteiros pendentes, ponteiros nulos e coisas impossíveis - eles não serão compilados. Como pode fazer essas alegações?

A tecnologia principal que permite essas declarações impressionantes é chamada de verificador de empréstimo, um verificador estático que é executado na compilação, rejeitando o código que pode causar esses problemas. No entanto, antes de nos aprofundarmos muito nas implicações, precisaremos falar sobre os pré-requisitos.

Propriedade

Lembre-se em nossa discussão sobre ponteiros em C++, tocamos na noção de propriedade, que em termos gerais significa “quem é responsável por excluir essa variável”. A ferrugem formaliza e fortalece esse conceito. Cada vinculação de variável tem a propriedade do recurso que ela vincula, e o verificador de empréstimo garante que haja exatamente uma vinculação que tenha a propriedade geral do recurso. Ou seja, o seguinte trecho do Rust Book, não será compilado:

 let v = vec![1, 2, 3]; let v2 = v; println!("v[0] is: {}", v[0]);
 error: use of moved value: `v` println!("v[0] is: {}", v[0]); ^

As atribuições em Rust têm semântica de movimento por padrão - elas transferem a propriedade. É possível dar semântica de cópia a um tipo, e isso já é feito para primitivas numéricas, mas é incomum. Por isso, a partir da terceira linha de código, v2 possui o vetor em questão e não pode mais ser acessado como v. Por que isso é útil? Quando cada recurso tem exatamente um proprietário, ele também tem um único momento em que sai do escopo, o que pode ser determinado em tempo de compilação. Isso significa, por sua vez, que o Rust pode cumprir a promessa do RAII, inicializando e destruindo recursos deterministicamente com base em seu escopo, sem nunca usar um coletor de lixo ou exigir que o programador libere qualquer coisa manualmente.

Compare isso com a coleta de lixo de contagem de referência. Em uma implementação RC, todos os ponteiros têm pelo menos duas informações: o objeto apontado e o número de referências a esse objeto. O objeto é destruído quando essa contagem atinge 0. Isso duplica o requisito de memória do ponteiro e adiciona um pequeno custo ao seu uso, pois a contagem é automaticamente incrementada, decrementada e verificada. O sistema de propriedade do Rust oferece a mesma garantia, que os objetos são destruídos automaticamente quando ficam sem referências, mas sem nenhum custo de tempo de execução. A propriedade de cada objeto é analisada e as chamadas de destruição inseridas em tempo de compilação.

Empréstimo

Se a semântica de movimento fosse a única maneira de passar dados, os tipos de retorno de função ficariam muito complicados, muito rápidos. Se você quisesse escrever uma função que usasse dois vetores para produzir um inteiro, que não destruísse os vetores depois, você teria que incluí-los no valor de retorno. Embora isso seja tecnicamente possível, é terrível usar:

 fn foo(v1: Vec<i32>, v2: Vec<i32>) -> (Vec<i32>, Vec<i32>, i32) { // do stuff with v1 and v2 // hand back ownership, and the result of our function (v1, v2, 42) } let v1 = vec![1, 2, 3]; let v2 = vec![1, 2, 3]; let (v1, v2, answer) = foo(v1, v2);

Em vez disso, Rust tem o conceito de empréstimo. Você pode escrever a mesma função assim, e ela emprestará a referência aos vetores, devolvendo-a ao proprietário quando a função terminar:

 fn foo(v1: &Vec<i32>, v2: &Vec<i32>) -> i32 { // do stuff 42 } let v1 = vec![1, 2, 3]; let v2 = vec![1, 2, 3]; let answer = foo(&v1, &v2);

v1 e v2 retornam sua propriedade ao escopo original após o retorno de fn foo, saindo do escopo e sendo destruídos automaticamente quando o escopo que o contém é encerrado.

Vale a pena mencionar aqui que existem restrições ao empréstimo, impostas pelo verificador de empréstimos em tempo de compilação, que o Rust Book coloca de forma muito sucinta:

Qualquer empréstimo deve durar um escopo não maior que o do proprietário. Segundo, você pode ter um ou outro desses dois tipos de empréstimos, mas não os dois ao mesmo tempo:

uma ou mais referências (&T) a um recurso

exatamente uma referência mutável (&mut T)

Isso é digno de nota porque constitui um aspecto crítico da proteção do Rust contra corridas de dados. Ao impedir vários acessos mutáveis ​​a um determinado recurso em tempo de compilação, ele garante que o código não possa ser escrito em que o resultado seja indeterminado porque depende de qual thread chegou primeiro ao recurso. Isso evita problemas como invalidação do iterador e uso após a liberação.

O verificador de empréstimos em termos práticos

Agora que conhecemos alguns dos recursos do Rust, vamos ver como implementamos o mesmo contador de linha de arquivo que vimos antes:

 fn read_lines_from_file(file_name: &str) -> io::Result<Vec<String>> { // variables in Rust are immutable by default. The mut keyword allows them to be mutated. let mut lines = Vec::new(); let mut buffer = String::new(); if let Ok(mut fp) = OpenOptions::new().read(true).open(file_name) { // We enter this block only if the file was successfully opened. // This is one way to unwrap the Result<T, E> type Rust uses instead of exceptions. // fp.read_to_string can return an Err. The try! macro passes such errors // upwards through the call stack, or continues otherwise. try!(fp.read_to_string(&mut buffer)); lines = buffer.split("\n").map(|s| s.to_string()).collect(); } Ok(lines) } fn main() { // Get file name from the first argument. // Note that args().nth() produces an Option<T>. To get at the actual argument, we use // the .expect() function, which panics with the given message if nth() returned None, // indicating that there weren't at least that many arguments. Contrast with C++, which // segfaults when there aren't enough arguments, or Python, which raises an IndexError. // In Rust, error cases *must* be accounted for. let file_name = env::args().nth(1).expect("This program requires at least one argument!"); if let Ok(file_lines) = read_lines_from_file(&file_name) { println!("File {} contains {} lines.", file_name, file_lines.len()); } else { // read_lines_from_file returned an error println!("Could not read file {}", file_name); } }

Além dos itens já comentados no código-fonte, vale a pena percorrer e traçar os tempos de vida das diversas variáveis. file_name e file_lines duram até o final de main(); seus destruidores são chamados nesse momento sem custo extra, usando o mesmo mecanismo das variáveis ​​automáticas de C++. Ao chamar read_lines_from_file , file_name é emprestado imutavelmente a essa função por sua duração. Dentro de read_lines_from_file , o buffer age da mesma maneira, destruído quando fica fora do escopo. lines , por outro lado, persiste e é retornada com sucesso para main . Por quê?

A primeira coisa a notar é que, como Rust é uma linguagem baseada em expressão, a chamada de retorno pode não parecer uma a princípio. Se a última linha de uma função omitir o ponto e vírgula à direita, essa expressão será o valor de retorno. A segunda coisa é que os valores de retorno recebem tratamento especial. Presume-se que eles querem viver pelo menos tanto quanto o chamador da função. A nota final é que, devido à semântica de movimentação envolvida, não há necessidade de cópia para transmutar Ok(lines) em Ok(file_lines) , o compilador simplesmente faz a variável apontar para o bit apropriado de memória.

“Somente no final você percebe o verdadeiro poder do RAII.”

O gerenciamento manual de memória é um pesadelo que os programadores vêm inventando maneiras de evitar desde a invenção do compilador. RAII era um padrão promissor, mas prejudicado em C++ porque simplesmente não funcionava para objetos alocados em heap sem algumas soluções alternativas estranhas. Conseqüentemente, houve uma explosão de linguagens coletadas de lixo nos anos 90, projetadas para tornar a vida mais agradável para o programador mesmo em detrimento do desempenho.

No entanto, essa não é a última palavra em design de linguagem. Usando noções novas e fortes de propriedade e empréstimo, Rust consegue mesclar a base de escopo dos padrões RAII com a segurança de memória da coleta de lixo; tudo sem nunca exigir que um coletor de lixo pare o mundo, enquanto faz garantias de segurança não vistas em nenhum outro idioma. Este é o futuro da programação de sistemas. Afinal, “errar é humano, mas os compiladores nunca esquecem”.


Leitura adicional no Blog da Toptal Engineering:

  • Tutorial WebAssembly/Rust: Processamento de áudio perfeito
  • Caçando vazamentos de memória em Java