Lógica de Separação e Raciocínio sobre o Heap
Objetivos de Aprendizagem
- Explicar por que a lógica de Hoare escala mal para programas com ponteiros que têm alias, e como o raciocínio local com um footprint resolve isso.
- Ler e escrever asserções de heap:
emp, o points-to e a conjunção separadora com o seu significado de disjunção. - Enunciar os pequenos axiomas das operações de heap e a regra de frame, e usá-los para verificar um programa curto com ponteiros.
- Definir um predicado indutivo para listas encadeadas, e explicar como a bi-abdução do Infer e a propriedade do Rust se relacionam com a lógica de separação.
Contexto e Motivação
ai-theory/formal-methods desenvolveu a lógica de Hoare: triplas , regras para atribuição, sequência, condicionais e laços com invariantes. Essa lógica funciona bem para programas cujas variáveis são independentes. Ela se quebra em programas com ponteiros: se x e y podem apontar para a mesma célula, então {x.f = 0} y.f := 1 {x.f = 0} não é válida, e toda prova sobre x precisa ser reconferida contra toda escrita através de qualquer outro ponteiro, com condições explícitas de não-aliasing para cada par. As condições crescem quadraticamente com o número de ponteiros e afogam a prova.
John Reynolds, Peter O'Hearn, Samin Ishtiaq e Hongseok Yang desenvolveram a lógica de separação por volta de 2000 a 2002, uma extensão da lógica de Hoare cujas asserções descrevem pedaços do heap e dizem quais pedaços são disjuntos. Ela é a base do analisador Infer da Meta, dos verificadores VeriFast e Viper, do framework Iris em que a prova do RustBelt foi feita e de código de sistemas verificado em assistentes de prova. O artigo de O'Hearn na CACM e o artigo de Reynolds no LICS 2002 são as introduções padrão.
Teoria Central
Estados e asserções de heap
Um estado de programa é um store (variáveis para valores) junto com um heap, um mapa parcial finito de endereços para valores. As asserções da lógica de separação descrevem o heap:
- : o heap é vazio.
- : o heap contém exatamente uma célula, no endereço , guardando . A notação significa "algum valor".
- (conjunção separadora): o heap pode ser dividido em duas partes com endereços disjuntos, uma satisfazendo e a outra . Formalmente, sse com e disjuntos, e .
- (a varinha mágica): estender o heap com qualquer parte disjunta que satisfaça produz um heap que satisfaz . É usada em algumas provas e ignorada aqui.
- Fatos puros como ou , que não dependem do heap.
A propriedade-chave: implica , porque uma célula não pode ser dividida em duas partes disjuntas. A disjunção está embutida no conectivo, em vez de ser uma condição lateral.
Triplas e o footprint
Uma tripla da lógica de separação é lida de forma que evita falhas: se vale antes de rodar, então não acessa nenhum endereço fora do heap descrito por (não falha) e vale depois. portanto descreve o footprint de : exatamente a memória de que ele precisa. As regras para atribuições, sequência, condicionais e laços são as da lógica de Hoare.
Pequenos axiomas e a regra de frame
Os comandos de heap têm especificações que mencionam apenas a célula que tocam:
e uma leitura tem . A regra que torna essas especificações pequenas utilizáveis é a regra de frame:
Se funciona corretamente no heap pequeno , funciona em qualquer heap maior e deixa o resto intocado. Isso é raciocínio local: provar uma especificação sobre o footprint uma vez e reutilizá-la em todo contexto. O aliasing é tratado pela lógica, não à mão.
Predicados indutivos para estruturas de dados
Formas como listas e árvores são definidas por predicados recursivos. Um segmento de lista de até :
e uma lista completa é . O no caso recursivo diz que a célula da cabeça e o resto da lista ocupam memória disjunta, então a lista não tem ciclo nem compartilhamento. Invariantes de laço para código que processa listas são escritos com esses predicados.
Concorrência e ferramentas
Para duas threads trabalhando em memória disjunta, a lógica de separação concorrente (O'Hearn, Brookes, 2004) dá uma regra simples: de e derivar . A ausência de data races decorre da disjunção. A propriedade de uma célula também pode ser transferida para ou de um lock.
As ferramentas se constroem sobre isso de maneiras diferentes. O Infer (Meta) introduziu a bi-abdução em escala: dado o corpo de um procedimento, ele infere uma pré-condição (o anti-frame, a memória de que o procedimento precisa e que o chamador deve fornecer) e uma pós-condição, mais o frame que fica intocado. Cada procedimento ganha um resumo que é reutilizado nos seus pontos de chamada, o que torna a análise composicional e rápida o bastante para rodar sobre mudanças de código. O seu checker atual, o Pulse, mantém o mesmo projeto composicional e acha dereferências nulas, uso após liberação e vazamentos em C, C++, Objective-C e Java. VeriFast e Viper são verificadores em que o usuário anota o código com especificações da lógica de separação, e a ferramenta Prusti usa o Viper para Rust. O Iris embute a lógica de separação concorrente de ordem superior no assistente de prova Coq.
Relação com tipos de propriedade
Um points-to diz que a thread corrente é dona da célula em , e diz que duas coisas possuídas são disjuntas. Essa é a lógica por trás dos moves de dono único do Rust: entregar um valor é abrir mão da sua asserção. As permissões fracionárias (Boyland) a refinam: é acesso compartilhado somente de leitura, e só a propriedade total permite escrita, que é a regra "vários leitores ou um escritor" do conceito anterior sobre o borrow checker expressa numa lógica.
Exemplos Resolvidos
Exemplo 1: por que a regra de frame elimina condições laterais de aliasing
Problema: Mostrar que [x] := 1 deixa uma segunda célula y inalterada, sem uma condição lateral x ≠ y escrita à mão.
Solução: O pequeno axioma dá . Aplique a regra de frame com (nenhuma variável é modificada pelo comando, então a condição lateral vale): . A pré-condição já implica , porque exige células disjuntas. A pós-condição diz que y ainda guarda 9. Se em vez disso o chamador passar x e y que podem ser a mesma célula, a pré-condição simplesmente não é satisfeita por aquele heap, e a verificação da chamada falha.
Exemplo 2: liberando uma lista encadeada
Problema: Verificar que o laço libera todo nó e deixa o heap vazio.
text// {list(x)} while (x != nil) { t := x.next; // lê o ponteiro next do nó em x dispose(x); // libera o nó x := t; } // {emp}
Solução: Tome o invariante . Na entrada ele vale. Dentro do laço , então desdobrar o segundo ramo da disjunção dá . A leitura dá a o ponteiro seguinte, dispose consome a célula e deixa pela regra de frame, e a atribuição x := t restabelece . Na saída , então se desdobra em . A prova também exclui um double free: depois de dispose(x) a asserção daquela célula some, então um segundo dispose dela não teria footprint sobre o qual rodar e falharia na verificação, e uma lista com ciclo nem satisfaria para começar.
Exemplo 3: bi-abdução em um método
Problema: O que uma análise no estilo do Infer infere para void setVal(Node n) { n.next.val = 1; }?
Solução: Executar n.next.val = 1 a partir de uma suposição vazia falha: precisa ler o campo next de n e escrever o campo val do nó para o qual ele aponta. A abdução registra esses recursos faltantes como a pré-condição inferida: para algum m. A pós-condição é . Esse resumo é guardado. Num ponto de chamada em que se sabe que o argumento tem next == null, o chamador não consegue fornecer porque não existe nó m, então a análise reporta uma possível dereferência de ponteiro nulo ali, sem reanalisar setVal. Qualquer parte do heap do chamador não mencionada no resumo é o frame, preservado pela regra de frame.
Equívocos Comuns e Armadilhas
- " é só para variáveis diferentes." diz que os dois valem para o mesmo heap. diz que valem para partes disjuntas do heap, o que também dá não-aliasing.
- "A regra de frame é sempre aplicável." Ela exige a condição lateral de que o comando não modifica nenhuma variável livre no frame . O conteúdo do heap é protegido por , mas as variáveis não.
- "Lógica de separação é uma ferramenta só para C." A lógica se aplica a qualquer linguagem com estado mutável compartilhado: Java e Objective-C no Infer, Rust no RustBelt e no Prusti, e estruturas de dados concorrentes no Iris.
- "O Rust torna a lógica de separação desnecessária." O borrow checker do Rust é uma instância fixa e automática das suas ideias (propriedade, disjunção). A lógica de separação é o cenário geral em que essa instância, e o código
unsafepor baixo dela, é provada correta.
Resumo
A lógica de Hoare precisa de condições laterais de não-aliasing para cada par de ponteiros, o que não escala. A lógica de separação acrescenta asserções de heap, o points-to e a conjunção separadora para partes disjuntas, com triplas que evitam falhas e descrevem o footprint de um comando. Pequenos axiomas para comandos de heap mais a regra de frame dão raciocínio local: prove uma especificação sobre o footprint e estenda-a a qualquer contexto. Predicados indutivos como descrevem estruturas de dados, a lógica de separação concorrente dá uma regra para threads em memória disjunta, e a bi-abdução no Infer infere footprints automaticamente. Tipos de propriedade são uma forma restrita e automática da mesma ideia. O próximo conceito passa de provar para testar, em que as entradas do programa são geradas em vez de raciocinadas: o fuzzing guiado por cobertura.