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?
Respostas:
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.
data
não é_Atomic
ouvolatile
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 doislong long
e o gcc usa um únicomovdqa
armazenamento 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_int
també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 quevolatile
isso 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_Atomic
commemory_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 clarovolatile
não possui nenhuma garantia do padrão ISO C vs. código de gravação que é compilado da mesma maneira usando_Atomic
e mo_relaxed.Se você tivesse uma função executada
global_var++;
emint
oulong long
executada 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.h
e o_Atomic
atributo fornecem funcionalidade equivalente ao modelo do C ++ 11std::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], 1
semlock
prefixo 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.
fonte
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 oones
ezeros
em uma única instrução.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
C
ní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.fonte
int
paralong long
e compile para 32 bits. A lição é que você nunca sabe se / quando será quebrado.long long
ainda compila uma instrução para x86-64: 16 bytesmovdqa
. A menos que você desative a otimização, como no seu link Godbolt. (O padrão do GCC é o-O0
modo de depuração, que é cheio de ruídos de armazenamento / recarga e geralmente não é interessante de se olhar.)