Rust vs Go: gerenciamento de memória

Rust vs Go: gerenciamento de memória

Vamos analisar como duas linguagens de programação populares — Rust e Go — gerenciam a memória.

Quando um programa é iniciado, cria-se um processo com seu próprio espaço de endereçamento e threads que executam nos núcleos do processador. O processador trabalha com memória virtual — uma abstração gerenciada pelo sistema operacional.
Por exemplo, em Go, quando criamos um array:

arr := make([]byte, 100)

o runtime solicita um intervalo de endereços virtuais, mas a memória física não é alocada de imediato, e sim no primeiro acesso aos dados:

first := arr[0]

Ao solicitar o primeiro elemento ocorre um page fault e o sistema operacional aloca uma página física, geralmente de 4kb, associando-a ao intervalo virtual.

Pilha e heap

Em cada processo existe um intervalo de memória comum, acessível a todas as threads, chamado de heap.

Qualquer thread pode trabalhar com essa área de memória, e ela pode se expandir dinamicamente durante a execução do programa.
Ao mesmo tempo, cada thread tem sua própria área de memória, com a qual apenas ela mesma pode trabalhar, chamada de pilha — stack.

Na pilha ficam armazenados:

  • variáveis locais de tipos primitivos

  • argumentos de funções

  • endereços de retorno (o ponto de onde a função foi chamada e para onde a execução deve retornar após seu término)

Todos esses dados existem até o término da função, após o qual a pilha é limpa.

Como o Go decide onde alocar os dados?

Em Go, a decisão sobre a alocação é tomada pelo analisador de escape — uma fase do compilador durante a qual ele decide onde determinados dados serão armazenados.

Por exemplo, para este código:

func add(a, b int) int {
    c := a + b
 
    return c
}

o analisador de escape verá que a variável c vive dentro dos limites da função (return c não retorna o trecho de memória onde c foi alocada, mas copia seu valor para o registrador de retorno), o que significa que ela pode ser colocada na pilha.
Já neste exemplo:

func newUser() *User {
    u := User{Name: "Tom"}
 
    return &u
}

o analisador de escape decidirá que, como o valor é retornado por ponteiro, a área de memória precisa continuar viva após o fim da função e, portanto, alocará u no heap.

Em resumo, em Go o compilador decide onde as variáveis ficarão, analisando o código.

Como o Rust decide onde alocar os dados?

Em Rust, o compilador não faz análise de escape e não decide por conta própria onde armazenar os dados — quem decide é o desenvolvedor. Para colocar um objeto no heap, é preciso usar explicitamente um tipo que opere no heap, como por exemplo o Box:

let x = Box::new(5);

O tipo Box, por definição, garante que o valor T seja armazenado no heap. Depois, quando o desenvolvedor já tomou todas as decisões, o compilador verifica se elas estão corretas por meio da análise de propriedade (ownership) e da análise de tempo de vida (lifetime).

Análise de tempo de vida

Durante essa fase, o compilador verifica se não há referências para um objeto cuja memória já foi liberada.

fn main() {
    let r;
    {
        let x = 5;
        r = &x; // error: x lives shorter than r
    }
    println!("{}", r);
}

Por exemplo, neste código a variável x tem um escopo de visibilidade menor que r, e o compilador retornará um erro: r aponta para uma célula de memória que já terá sido liberada:

Já em Go, esse mesmo exemplo compila sem erros:

func main() {
    var r *int
    {
        x := 5
        r = &x
    }
    fmt.Println(*r)
}

porque a análise de escape entende que x escapa para fora do bloco e decide que x deve viver por mais tempo — logo, o alocamos no heap.

Análise de propriedade

Após a fase de verificação do tempo de vida, vem a fase de análise de propriedade. Em Rust, cada variável é dona (owner) de seus dados. Quando o dono dos dados sai do escopo de visibilidade, o drop() é chamado e a memória é liberada. A propriedade garante que não haja vazamentos nem liberação dupla de memória.

fn main() {
    let s = String::from("hello");
    println!("{}", s);
} // drop() is called here and the memory for s is freed

As regras de propriedade são as seguintes:

  • Um recurso sempre tem um único dono

  • Ao transferir a propriedade, o dono antigo perde o acesso

  • Os dados são removidos quando o dono sai do escopo de visibilidade

Vejamos um exemplo:

fn main() {
    let s1 = String::from("hi");
    let s2 = s1; // ownership is moved
    println!("{}", s1); // error - s1 no longer owns the data
}

Aqui fazemos a transferência do dono e, em seguida, tentamos imprimir na tela os dados do dono anterior — a compilação termina com erro:

O mesmo acontece ao passar o valor para uma função:

fn takes_ownership(s: String) { println("{}", s); }
 
fn main() {
    let s = String::from("hi");
    takes_ownership(s)
    println!("{}", s); // error - s no longer owns the data
}

Erro:

Se você programa em Go, o exemplo acima parece completamente não óbvio. Por que s não é dono dos dados, se dentro de takes_ownership() os dados são apenas impressos na tela?
Porque, ao passar o valor para a função, a propriedade é transferida para o parâmetro s, e quando ela termina, s é destruído. Essa é a garantia de que o mesmo recurso não será liberado duas vezes.

Para que o exemplo acima funcione, precisamos passar o valor por referência:

fn takes_ownership(s: &String) {
    println!("{}", s);
}
 
fn main() {
    let s = String::from("hi");
    takes_ownership(&s)
    println!("{}", s);
}

É assim que o Rust garante a segurança em tempo de compilação.

E por que o Rust simplesmente não move o objeto da pilha para o heap por conta própria, como o Go faz?

Já discutimos como acontece a coleta de lixo em Rust — pelo controle do desenvolvedor e pelos erros do compilador. E como isso acontece em Go?

Aqui entra em cena o coletor de lixo — garbage collector. O coletor de lixo é parte do runtime da linguagem, executando em paralelo com o nosso programa. O GC remove todos os objetos para os quais foi alocada memória, mas para os quais ninguém aponta.
Graças a isso, não nos preocupamos com onde o objeto será armazenado, mas devemos sempre lembrar que o GC roda em paralelo com o nosso programa e gasta recursos do processador para isso. O Rust, por sua vez, gasta recursos apenas na execução do código, garantindo já na fase de compilação que todas as alocações serão colocadas nos lugares certos e liberadas no momento certo.

Vamos ver um exemplo de quando e de quantos recursos de processador ambas as linguagens gastam.

Abaixo está um código em Go que executa 1000000 alocações e guarda parte delas em um buffer comum:

package main
 
import (
    "fmt"
    "net/http"
    _ "net/http/pprof"
    "time"
)
 
var keep [][]byte // keep some buffers so there is "live" garbage
 
func main() {
    // pprof endpoints on :6060
    go func() {
        fmt.Println("pprof on http://localhost:6060/debug/pprof/")
        _ = http.ListenAndServe("localhost:6060", nil)
    }()
 
    // light CPU load + lots of allocations
    go func() {
        for i := 0; i < 1_000_000; i++ {
            buf := make([]byte, 1024) // 1KB on the heap
 
            if i%1000 == 0 { // keep some of them "alive"
                keep = append(keep, buf)
            }
            if i%100_000 == 0 {
                fmt.Println("iter", i)
                time.Sleep(1000 * time.Millisecond)
            }
        }
        fmt.Println("done, kept:", len(keep))
    }()
    	
	select{}
}

O Go permite ver quais decisões a análise de escape tomou; para isso, executamos go build com a flag -m:

Agora vamos ver quanto do tempo total de processador o GC consumiu. Em uma aba, executamos o programa com GODEBUG=gctrace=1:

GODEBUG=gctrace=1 go run gc_visualizer/main.go

No terminal ao lado — pprof:

go tool pprof -seconds 20 -http=:8080

E vemos que todas as amostras de CPU consumiram 510ms:

Aqui está o mesmo perfil, mas filtrado pelos métodos usados pelo GC:

O trabalho do GC consumiu quase metade do tempo de processador

Agora vejamos o código em Rust, que executa uma lógica parecida:

use std::{thread, time::Duration};
use pprof::ProfilerGuardBuilder;
use pprof::protos::Message; // for write_to_writer()
 
fn main() {
    let guard = ProfilerGuardBuilder::default()
        .frequency(100)
        .build()
        .unwrap();
 
    let mut keep: Vec<Vec<u8>> = Vec::new();
    for i in 0..1_000_000u32 {
        let buf = vec![0u8; 1024];
        if i % 1000 == 0 { keep.push(buf); }
        if i % 100_000 == 0 { println!("iter {i}"); thread::sleep(Duration::from_millis(1000)); }
    }
    println!("done, kept: {}", keep.len());
 
    if let Ok(report) = guard.report().build() {
        let mut f = std::fs::File::create("cpu.pb").unwrap();
        report.pprof().unwrap().write_to_writer(&mut f).unwrap();
    }
}

cargo.toml:

[profile.release]
debug = true
 
[dependencies]
pprof = { version = "0.15", features = ["flamegraph", "protobuf-codec"] }

Executamos:

RUSTFLAGS="-C force-frame-pointers=yes" cargo run --release

Abrimos com o pprof:

go tool pprof -http=:0 ./cpu.pb

E vemos que o perfil do Rust mostra 110ms de CPU:

Nada de runtime.gc*, ao contrário do Go. Os objetos são liberados imediatamente ao saírem do escopo de visibilidade.
Em Rust não há coletor de lixo em segundo plano, por isso não veremos ciclos de mark/sweep nem chamadas de gcBgMarkWorker. Todo o gerenciamento de memória se resume a alocações/desalocações explícitas e à chamada de drop().

Agora vamos olhar para essas mesmas amostras, mas com o filtro pelos métodos que trabalham com memória:

As alocações consumiram, no total, 90% do tempo de processador. Mas o tempo total de execução é quase 5 vezes menor que o tempo de execução de um programa análogo em Go.

Vale entender que comparar esse tipo de código de forma direta não é totalmente correto. O tempo de execução na CPU pode depender de muitos fatores, e o próprio código também funciona de maneira diferente. Mas a ideia geral é que você precisa conhecer o funcionamento do GC em Go e lembrar que, no caso de muitas alocações no heap, o tempo de trabalho do GC e a carga sobre o processador serão significativamente maiores.

Conclusão

O Go economiza o seu tempo agora, o Rust — o tempo do processador depois. Mas lembre-se de que a melhor linguagem é aquela que economiza o recurso mais caro em um projeto específico, e para cada projeto esse recurso será diferente.

Não se esqueça de se inscrever — basta deixar seu email lá embaixo e você receberá notificações sobre novos artigos.

Também mantenho um canal no telegram com um resumo de materiais sobre Go:

https://t.me/the_dev_signal

E um canal autoral no telegram, onde escrevo diversas anotações, ferramentas e compartilho experiências:

https://t.me/junsenior