Código de exemplo IBM, funções não reentrantes não funcionam no meu sistema

11

Eu estava estudando reentrada em programação. Neste site da IBM (muito bom). Eu fundei um código, copiado abaixo. É o primeiro código que chega ao site.

O código tenta mostrar os problemas que envolvem o acesso compartilhado à variável em um desenvolvimento não linear de um programa de texto (assincronicidade), imprimindo dois valores que mudam constantemente em um "contexto perigoso".

#include <signal.h>
#include <stdio.h>

struct two_int { int a, b; } data;

void signal_handler(int signum){
   printf ("%d, %d\n", data.a, data.b);
   alarm (1);
}

int main (void){
   static struct two_int zeros = { 0, 0 }, ones = { 1, 1 };

   signal (SIGALRM, signal_handler); 
   data = zeros;
   alarm (1);
   while (1){
       data = zeros;
       data = ones;
   }
}

Os problemas apareceram quando tentei executar o código (ou melhor, não apareceu). Eu estava usando o gcc versão 6.3.0 20170516 (Debian 6.3.0-18 + deb9u1) na configuração padrão. A saída incorreta não ocorre. A frequência na obtenção de valores de pares "errados" é 0!

O que está acontecendo afinal? Por que não há problema na reentrada usando variáveis ​​globais estáticas?

Daniel Bandeira
fonte
11
Verifique se toda a otimização do compilador está desabilitada e tente novamente
roaima 16/01
Eu supunha isso ... mas quais opções eu mudaria? Eu não faço ideia. :-(
Daniel Bandeira
5
Parece uma questão de programação (estouro de pilha). Dose não parece bem colocada aqui. (Desculpe, eu havia menos sub-sites; é tão
complicado
11
O código reentrante mais simples é imutável.
ctrl-alt-delor 16/01
No primeiro momento, acho que a pergunta estaria relacionada ao ambiente gcc e Linux. Evoluindo, por exemplo, o agendamento do SO (executando mais texto do programa após o sinal de interrupção antes de chamar a rotina do manipulador), por exemplo.
Daniel Bandeira

Respostas:

12

Isso não é realmente reentrância ; você não está executando uma função duas vezes no mesmo thread (ou em threads diferentes). Você pode obtê-lo por recursão ou passando o endereço da função atual como um argumento de retorno de chamada de função para outra função. (E não seria inseguro porque seria síncrono).

Este é apenas um simples UB (Undefined Behavior) entre um manipulador de sinal e o thread principal: somente isso sig_atomic_té garantido . Outros podem funcionar, como no seu caso, onde um objeto de 8 bytes pode ser carregado ou armazenado com uma instrução em x86-64, e o compilador escolhe esse asm. (Como mostra a resposta da @ icarus).

Veja Programação MCU - a otimização do C ++ O2 é interrompida durante o loop - um manipulador de interrupção em um microcontrolador de núcleo único é basicamente a mesma coisa que um manipulador de sinal em um único programa encadeado. Nesse caso, o resultado do UB é que uma carga foi içada de um loop.

Seu caso de teste de rasgo realmente acontecendo por causa da UB de corrida de dados provavelmente foi desenvolvido / testado no modo de 32 bits ou com um compilador mais antigo que carregou os membros da estrutura separadamente.

No seu caso, o compilador pode otimizar os armazenamentos a partir do loop infinito, porque nenhum programa livre de UB poderia observá-los. datanão é _Atomicouvolatile e não há outros efeitos colaterais no loop. Portanto, não há como qualquer leitor sincronizar com este escritor. Isso de fato acontece se você compilar com a otimização ativada ( Godbolt mostra um loop vazio na parte inferior do main). Também mudei a estrutura para dois long longe o gcc usa um único movdqaarmazenamento de 16 bytes antes do loop. (Isso não é garantido atômico, mas é na prática em quase todas as CPUs, supondo que esteja alinhado ou na Intel simplesmente não ultrapasse o limite da linha de cache. Por que a atribuição de número inteiro em uma variável atômica naturalmente alinhada no x86? )

Portanto, compilar com a otimização ativada também interromperia o teste e mostraria sempre o mesmo valor. C não é uma linguagem assembly portátil.

volatile struct two_inttambém forçaria o compilador a não otimizá-lo, mas não forçaria o carregamento / armazenamento de toda a estrutura atomicamente. (Também não impediria que isso acontecesse.) Observe que volatileisso não evita o UB de corrida de dados, mas, na prática, é suficiente para a comunicação entre threads e foi como as pessoas construíram átomos enrolados à mão (junto com asm inline) antes de C11 / C ++ 11, para arquiteturas normais de CPU. Eles são cache-coerente, volatileé na prática, principalmente semelhante ao _Atomiccommemory_order_relaxed por puro-carga e puro-store, se usado para tipos de estreitar o suficiente para que o compilador irá utilizar uma única instrução para que você não obter lacrimejamento. E clarovolatilenão possui nenhuma garantia do padrão ISO C vs. código de gravação que é compilado da mesma maneira usando _Atomice mo_relaxed.


Se você tivesse uma função executada global_var++;em intou long longexecutada a partir de main e assincronamente a partir de um manipulador de sinal, essa seria uma maneira de usar a reentrada para criar UB de corrida de dados.

Dependendo de como foi compilado (para um destino de memória incluído ou adicionado, ou para separar carga / inc / armazenamento), seria atômico ou não em relação aos manipuladores de sinal no mesmo encadeamento. Consulte Num ++ pode ser atômico para 'int num'? para saber mais sobre atomicidade em x86 e em C ++. (O C11 stdatomic.he o _Atomicatributo fornecem funcionalidade equivalente ao modelo do C ++ 11 std::atomic<T>)

Uma interrupção ou outra exceção não pode acontecer no meio de uma instrução, portanto, um add de destino de memória é wrt atômico. contexto alterna em uma CPU de núcleo único. Somente um gravador de DMA (coerente em cache) poderia "avançar" em um incremento de um add [mem], 1sem lockprefixo em uma CPU de núcleo único. Não há outros núcleos nos quais outro thread possa estar sendo executado.

Portanto, é semelhante ao caso dos sinais: um manipulador de sinal é executado em vez da execução normal do encadeamento que manipula o sinal, portanto, não pode ser manipulado no meio de uma instrução.

Peter Cordes
fonte
2
Fui impelido a aceitar a sua como a melhor resposta, apesar de a resposta do Icaru ser suficiente para mim. Os conceitos claros que você nos contou me fornecem um balde de tópicos para estudar durante todo esse dia (e mais). De fato, dificilmente tenho o que você escreve nos dois primeiros parágrafos à primeira vista. Obrigado! Se você publica artigos na internet sobre computadores e programação, dê-nos o link!
Daniel Bandeira
17

Olhando para o explorador do compilador godbolt (depois de adicionar o que falta #include <unistd.h>), percebe-se que, para quase qualquer compilador x86_64, o código gerado usa movimentos QWORD para carregar o onese zerosem uma única instrução.

        mov     rax, QWORD PTR main::ones[rip]
        mov     QWORD PTR data[rip], rax

O site da IBM diz o On most machines, it takes several instructions to store a new value in data, and the value is stored one word at a time.que pode ter sido verdadeiro para cpus típicos em 2005, mas como o código mostra, não é verdade agora. Alterar a estrutura para ter dois longos em vez de duas polegadas mostraria o problema.

Eu escrevi anteriormente que isso era "atômico", o que era preguiçoso. O programa está sendo executado apenas em uma única CPU. Cada instrução será concluída do ponto de vista desta CPU (assumindo que não há mais nada alterando a memória, como dma).

Portanto, no Cnível não está definido que o compilador escolha uma única instrução para escrever a estrutura e, portanto, a corrupção mencionada no artigo da IBM pode ocorrer. Os compiladores modernos direcionados aos cpus atuais usam uma única instrução. Uma única instrução é boa o suficiente para evitar a corrupção de um único programa encadeado.

Icaro
fonte
3
Tente alterar o tipo de dados de intpara long longe compile para 32 bits. A lição é que você nunca sabe se / quando será quebrado.
ctrl-alt-delor 16/01
2
isso significa, na minha máquina, a atribuição desses dois valores é uma operação atômica? (considerando a compilação da arquitetura x86_64)
Daniel Bandeira
11
long longainda compila uma instrução para x86-64: 16 bytes movdqa. A menos que você desative a otimização, como no seu link Godbolt. (O padrão do GCC é o -O0modo de depuração, que é cheio de ruídos de armazenamento / recarga e geralmente não é interessante de se olhar.)
Peter Cordes
Alterei o tipo para "muito longo" depois de ler todos os comentários. O resultado foi interessante: os resultados esperados foram alcançados e, configurando alguns contadores, foi capaz de melhorar outras concepções de como a taxa de dados incompatíveis é influenciada pelo restante do código. Obrigado por toda a ajuda!
Daniel Bandeira