Ouvi dizer que i ++ não é thread-safe, é ++ i thread-safe?

90

Ouvi dizer que i ++ não é uma instrução thread-safe, pois na montagem ela se reduz a armazenar o valor original como uma temporária em algum lugar, incrementando-o e substituindo-o, o que pode ser interrompido por uma troca de contexto.

No entanto, estou pensando sobre ++ i. Pelo que posso dizer, isso se reduziria a uma única instrução de montagem, como 'add r1, r1, 1' e, como é apenas uma instrução, não seria interrompida por uma troca de contexto.

Alguém pode esclarecer? Estou assumindo que uma plataforma x86 está sendo usada.

Samoz
fonte
Só uma pergunta. Que tipo de cenário seria necessário para dois (ou mais) threads acessarem uma variável como essa? Estou perguntando honestamente aqui, não estou criticando. É bem nesta hora, minha cabeça não consegue pensar em nenhum.
OscarRyz
5
Uma variável de classe em uma classe C ++ mantendo uma contagem de objetos?
paxdiablo
1
Bom vídeo sobre o assunto que acabei de assistir hoje porque outro cara me disse: youtube.com/watch?v=mrvAqvtWYb4
Johannes Schaub - litb
1
retagged como C / C ++; Java não está sendo considerado aqui, C # é semelhante, mas não possui essa semântica de memória rigidamente definida.
Tim Williscroft,
1
@Oscar Reyes Digamos que você tenha dois threads, ambos usando a variável i. Se o thread um só aumenta o thread quando está em um determinado ponto e o outro só diminui o thread quando está em outro ponto, você deve se preocupar com a segurança do thread.
samoz

Respostas:

157

Você ouviu errado. Pode muito bem ser que "i++"seja thread-safe para um compilador específico e uma arquitetura de processador específica, mas não é obrigatório nos padrões. Na verdade, como o multi-threading não faz parte dos padrões ISO C ou C ++ (a) , você não pode considerar nada como thread-safe com base no que você acha que será compilado.

É bastante viável que ++ipossa compilar em uma sequência arbitrária, como:

load r0,[i]  ; load memory into reg 0
incr r0      ; increment reg 0
stor [i],r0  ; store reg 0 back to memory

que não seria thread-safe em minha CPU (imaginária) que não tem instruções de incremento de memória. Ou pode ser inteligente e compilá-lo em:

lock         ; disable task switching (interrupts)
load r0,[i]  ; load memory into reg 0
incr r0      ; increment reg 0
stor [i],r0  ; store reg 0 back to memory
unlock       ; enable task switching (interrupts)

onde lockdesabilita e unlockhabilita interrupções. Mas, mesmo assim, isso pode não ser seguro para thread em uma arquitetura que tem mais de uma dessas CPUs compartilhando memória (o lockpode desabilitar interrupções para apenas uma CPU).

A linguagem em si (ou bibliotecas para ela, se não estiver embutida na linguagem) fornecerá construções thread-safe e você deve usá-las ao invés de depender de seu entendimento (ou possivelmente mal-entendido) de qual código de máquina será gerado.

Coisas como Java synchronizede pthread_mutex_lock()(disponível para C / C ++ em alguns sistemas operacionais) são o que você precisa examinar (a) .


(a) Esta pergunta foi feita antes que os padrões C11 e C ++ 11 fossem concluídos. Essas iterações agora introduziram suporte de threading nas especificações da linguagem, incluindo tipos de dados atômicos (embora eles, e os threads em geral, sejam opcionais, pelo menos em C).

paxdiablo
fonte
8
+1 por enfatizar que este não é um problema específico da plataforma, sem mencionar uma resposta clara ...
RBerteig
2
parabéns pelo seu distintivo de prata C :)
Johannes Schaub - litb
Acho que você deve precisar que nenhum sistema operacional moderno autoriza programas em modo de usuário a desligar interrupções, e pthread_mutex_lock () não faz parte de C.
Bastien Léonard
@Bastien, nenhum sistema operacional moderno estaria rodando em uma CPU que não tivesse uma instrução de incremento de memória :-) Mas sua opinião foi tirada sobre C.
paxdiablo
5
@Bastien: Bull. Os processadores RISC geralmente não têm instruções de incremento de memória. O tripplet load / add / stor é como você faz isso, por exemplo, no PowerPC.
derobert
42

Você não pode fazer uma declaração geral sobre ++ i ou i ++. Por quê? Considere incrementar um número inteiro de 64 bits em um sistema de 32 bits. A menos que a máquina subjacente tenha uma instrução quad word "carregar, incrementar, armazenar", o incremento desse valor exigirá várias instruções, qualquer uma das quais pode ser interrompida por uma troca de contexto de thread.

Além disso, ++inem sempre é "adicionar um ao valor". Em uma linguagem como C, incrementar um ponteiro, na verdade, adiciona o tamanho da coisa apontada. Ou seja, se ifor um ponteiro para uma estrutura de ++i32 bytes , adiciona 32 bytes. Enquanto quase todas as plataformas têm uma instrução de "valor de incremento no endereço de memória" que é atômica, nem todas têm uma instrução atômica "adicione valor arbitrário ao valor no endereço de memória".

Jim Mischel
fonte
35
Claro, se você não se limitar a inteiros enfadonhos de 32 bits, em uma linguagem como C ++, ++ eu pode realmente ser uma chamada para um serviço da Web que atualiza um valor em um banco de dados.
Eclipse,
16

Ambos são inseguros para thread.

Uma CPU não pode fazer matemática diretamente com a memória. Ele faz isso indiretamente, carregando o valor da memória e fazendo as contas com os registros da CPU.

i ++

register int a1, a2;

a1 = *(&i) ; // One cpu instruction: LOAD from memory location identified by i;
a2 = a1;
a1 += 1; 
*(&i) = a1; 
return a2; // 4 cpu instructions

++ i

register int a1;

a1 = *(&i) ; 
a1 += 1; 
*(&i) = a1; 
return a1; // 3 cpu instructions

Para ambos os casos, há uma condição de corrida que resulta no valor i imprevisível.

Por exemplo, vamos supor que haja dois threads ++ i simultâneos com cada um usando o registro a1, b1 respectivamente. E, com a troca de contexto executada da seguinte forma:

register int a1, b1;

a1 = *(&i);
a1 += 1;
b1 = *(&i);
b1 += 1;
*(&i) = a1;
*(&i) = b1;

Como resultado, i não se torna i + 2, ele se torna i + 1, o que é incorreto.

Para remediar isso, as CPUs modernas fornecem algum tipo de instruções de CPU LOCK, UNLOCK durante o intervalo em que a troca de contexto é desabilitada.

No Win32, use InterlockedIncrement () para fazer i ++ para segurança de thread. É muito mais rápido do que confiar no mutex.

yogman
fonte
6
"Uma CPU não pode fazer matemática diretamente com a memória" - Isso não é preciso. Existem CPU-s, onde você pode fazer matemática "diretamente" nos elementos da memória, sem a necessidade de carregá-la primeiro em um registrador. Por exemplo. MC68000
darklon
1
As instruções LOCK e UNLOCK CPU não têm nada a ver com mudanças de contexto. Eles bloqueiam as linhas de cache.
David Schwartz
11

Se você estiver compartilhando até mesmo um int entre threads em um ambiente com vários núcleos, precisará de barreiras de memória adequadas. Isso pode significar o uso de instruções interligadas (consulte InterlockedIncrement no win32, por exemplo) ou o uso de uma linguagem (ou compilador) que oferece certas garantias de thread-safe. Com o reordenamento de instruções no nível da CPU, caches e outros problemas, a menos que você tenha essas garantias, não presuma que nada compartilhado entre threads é seguro.

Editar: uma coisa que você pode presumir com a maioria das arquiteturas é que, se estiver lidando com palavras únicas alinhadas corretamente, você não terminará com uma única palavra contendo uma combinação de dois valores que foram misturados. Se duas gravações acontecerem uma sobre a outra, uma vencerá e a outra será descartada. Se você for cuidadoso, pode tirar vantagem disso e ver que ++ i ou i ++ são thread-safe na situação de gravador único / leitor múltiplo.

Eclipse
fonte
Realmente errado em ambientes onde o acesso interno (leitura / gravação) é atômico. Existem algoritmos que podem funcionar em tais ambientes, embora a falta de barreiras de memória possa significar que você às vezes está trabalhando com dados obsoletos.
MSalters,
2
Só estou dizendo que a atomicidade não garante a segurança do thread. Se você for inteligente o suficiente para projetar estruturas de dados ou algoritmos sem bloqueio, vá em frente. Mas você ainda precisa saber quais são as garantias que seu compilador lhe dará.
Eclipse,
10

Se você deseja um incremento atômico em C ++, pode usar as bibliotecas C ++ 0x (o std::atomictipo de dados) ou algo como TBB.

Houve uma época em que as diretrizes de codificação GNU diziam que atualizar tipos de dados que cabiam em uma palavra era "geralmente seguro", mas esse conselho é errado para máquinas SMP, errado para algumas arquiteturas e errado ao usar um compilador de otimização.


Para esclarecer o comentário "atualizando o tipo de dados de uma palavra":

É possível que duas CPUs em uma máquina SMP gravem no mesmo local de memória no mesmo ciclo e, em seguida, tentem propagar a alteração para as outras CPUs e o cache. Mesmo se apenas uma palavra de dados estiver sendo gravada, de forma que as gravações levem apenas um ciclo para serem concluídas, elas também acontecem simultaneamente, então você não pode garantir qual gravação será bem-sucedida. Você não obterá dados parcialmente atualizados, mas uma gravação desaparecerá porque não há outra maneira de lidar com este caso.

Compare-and-swap coordena corretamente entre múltiplas CPUs, mas não há razão para acreditar que cada atribuição de variável de tipos de dados de uma palavra usará compare-and-swap.

E embora um compilador de otimização não afete como um carregamento / armazenamento é compilado, ele pode mudar quando o carregamento / armazenamento acontece, causando sérios problemas se você espera que suas leituras e gravações aconteçam na mesma ordem em que aparecem no código-fonte ( o mais famoso sendo o bloqueio com verificação dupla não funciona no vanilla C ++).

NOTA Minha resposta original também disse que a arquitetura Intel de 64 bits foi quebrada ao lidar com dados de 64 bits. Isso não é verdade, então editei a resposta, mas minha edição alegou que os chips PowerPC estavam quebrados. Isso é verdade ao ler valores imediatos (ou seja, constantes) em registradores (consulte as duas seções denominadas "Ponteiros de carregamento" nas listas 2 e 4). Mas há uma instrução para carregar dados da memória em um ciclo ( lmw), então removi essa parte da minha resposta.

Max Lybbert
fonte
Leituras e gravações são atômicas na maioria das CPUs modernas se seus dados estiverem alinhados naturalmente e no tamanho correto, mesmo com SMP e compiladores de otimização. No entanto, há uma série de ressalvas, especialmente com máquinas de 64 bits, então pode ser complicado garantir que seus dados atendam aos requisitos de cada máquina.
Dan Olson,
Obrigado por atualizar. Corrigir, ler e escrever são atômicas, pois você afirma que não podem ser concluídas pela metade, mas seu comentário destaca como abordamos esse fato na prática. O mesmo acontece com as barreiras de memória, elas não afetam a natureza atômica da operação, mas como a abordamos na prática.
Dan Olson
4

Em x86 / Windows em C / C ++, você não deve presumir que é seguro para threads. Você deve usar InterlockedIncrement () e InterlockedDecrement () se precisar de operações atômicas.

i_am_jorf
fonte
4

Se sua linguagem de programação não diz nada sobre threads, mas roda em uma plataforma multithread, como pode qualquer construção de linguagem ser thread-safe?

Como outros apontaram: você precisa proteger qualquer acesso multithread a variáveis ​​por chamadas específicas da plataforma.

Existem bibliotecas por aí que abstraem a especificidade da plataforma, e o próximo padrão C ++ adaptou seu modelo de memória para lidar com threads (e, portanto, pode garantir a segurança das threads).

xtofl
fonte
4

Mesmo se for reduzido a uma única instrução de montagem, incrementando o valor diretamente na memória, ainda não é seguro para threads.

Ao incrementar um valor na memória, o hardware realiza uma operação de "leitura-modificação-gravação": ele lê o valor da memória, o incrementa e o grava de volta na memória. O hardware x86 não tem como incrementar diretamente na memória; a RAM (e os caches) só podem ler e armazenar valores, não modificá-los.

Agora suponha que você tenha dois núcleos separados, em soquetes separados ou compartilhando um único soquete (com ou sem um cache compartilhado). O primeiro processador lê o valor e, antes que possa escrever de volta o valor atualizado, o segundo processador o lê. Depois que os dois processadores escreverem o valor de volta, ele terá sido incrementado apenas uma vez, não duas vezes.

Existe uma maneira de evitar esse problema; Os processadores x86 (e a maioria dos processadores multi-core que você encontrará) são capazes de detectar esse tipo de conflito no hardware e sequenciá-lo, de forma que toda a sequência de leitura-modificação-gravação pareça atômica. No entanto, como isso é muito caro, só é feito quando solicitado pelo código, em x86 geralmente por meio do LOCKprefixo. Outras arquiteturas podem fazer isso de outras maneiras, com resultados semelhantes; por exemplo, load-linked / store-condicional e atômico compare-and-swap (processadores x86 recentes também têm este último).

Observe que usar volatilenão ajuda aqui; ele apenas informa ao compilador que a variável pode ter sido modificada externamente e a leitura dessa variável não deve ser armazenada em cache em um registro ou otimizada. Não faz com que o compilador use primitivas atômicas.

A melhor maneira é usar primitivas atômicas (se seu compilador ou bibliotecas as tiverem) ou fazer o incremento diretamente na montagem (usando as instruções atômicas corretas).

CesarB
fonte
2

Nunca presuma que um incremento será compilado para uma operação atômica. Use InterlockedIncrement ou quaisquer funções semelhantes existentes em sua plataforma de destino.

Edit: Acabei de pesquisar esta questão específica e o incremento no X86 é atômico em sistemas de processador único, mas não em sistemas de multiprocessador. Usar o prefixo de bloqueio pode torná-lo atômico, mas é muito mais portátil apenas para usar InterlockedIncrement.

Dan Olson
fonte
1
InterlockedIncrement () é uma função do Windows; todas as minhas máquinas Linux e máquinas OS X modernas são baseadas em x64, então dizer que InterlockedIncrement () é 'muito mais portátil' do que o código x86 é bastante espúrio.
Pete Kirkham,
É muito mais portátil no mesmo sentido que C é muito mais portátil do que montagem. O objetivo aqui é isolar-se da dependência de um conjunto gerado específico para um processador específico. Se outros sistemas operacionais são sua preocupação, InterlockedIncrement é facilmente empacotado.
Dan Olson
2

De acordo com esta lição de montagem em x86, você pode adicionar atomicamente um registro a um local de memória , portanto, potencialmente seu código pode executar atomicamente '++ i' ou 'i ++'. Mas como disse em outro post, o C ansi não aplica atomicidade à operação '++', então você não pode ter certeza do que seu compilador irá gerar.

Selso Liberado
fonte
1

O padrão C ++ de 1998 não tem nada a dizer sobre threads, embora o próximo padrão (lançado neste ano ou no próximo) sim. Portanto, você não pode dizer nada inteligente sobre thread-safety das operações sem se referir à implementação. Não é apenas o processador que está sendo usado, mas a combinação do compilador, do sistema operacional e do modelo de thread.

Na ausência de documentação em contrário, eu não presumiria que qualquer ação seja segura para thread, particularmente com processadores multi-core (ou sistemas multi-processador). Eu também não confiaria nos testes, já que os problemas de sincronização de thread provavelmente surgirão apenas por acidente.

Nada é seguro para thread, a menos que você tenha uma documentação que diga que é para o sistema específico que você está usando.

David Thornley
fonte
1

Jogue i no armazenamento local do thread; não é atômico, mas então não importa.


fonte
1

AFAIK, de acordo com o padrão C ++, a leitura / gravação em um inté atômica.

No entanto, tudo o que isso faz é se livrar do comportamento indefinido que está associado a uma corrida de dados.

Mas ainda haverá uma corrida de dados se os dois threads tentarem incrementar i.

Imagine o seguinte cenário:

Deixe i = 0inicialmente:

O thread A lê o valor da memória e o armazena em seu próprio cache. O segmento A incrementa o valor em 1.

O thread B lê o valor da memória e o armazena em seu próprio cache. A linha B aumenta o valor em 1.

Se tudo isso fosse um único tópico, você obteria i = 2 na memória.

Mas com ambos os threads, cada thread grava suas alterações e, portanto, o Thread A grava de i = 1volta na memória e o Thread B escrevei = 1 na memória.

Está bem definido, não há destruição parcial ou construção ou qualquer tipo de arrancamento de um objeto, mas ainda é uma corrida de dados.

Para incrementar atomicamente, ivocê pode usar:

std::atomic<int>::fetch_add(1, std::memory_order_relaxed)

A ordenação relaxada pode ser usada porque não nos importamos onde essa operação ocorre, tudo o que importa é que a operação de incremento seja atômica.

Moshe Rabaev
fonte
0

Você diz "é apenas uma instrução, seria ininterrupta por uma troca de contexto." - isso é muito bom para uma única CPU, mas e uma CPU dual core? Então você pode realmente ter dois threads acessando a mesma variável ao mesmo tempo, sem nenhuma troca de contexto.

Sem saber o idioma, a resposta é testá-lo até o fim.

Chris
fonte
4
Você não descobre se algo é seguro para thread testando-o - os problemas de threading podem ser uma em um milhão de ocorrências. Você procura na sua documentação. Se não for threadsafe garantido por sua documentação, não é.
Eclipse,
2
Concorde com @Josh aqui. Algo só é seguro para thread se puder ser comprovado matematicamente por meio de uma análise do código subjacente. Nenhuma quantidade de testes pode começar a se aproximar disso.
Rex M,
Foi uma ótima resposta até a última frase.
Rob K,
0

Eu acho que se a expressão "i ++" é a única em uma instrução, é equivalente a "++ i", o compilador é inteligente o suficiente para não manter um valor temporal, etc. Então, se você pode usá-los alternadamente (caso contrário, você ganhou pergunte qual usar), não importa o que você usar, pois eles são quase os mesmos (exceto pela estética).

De qualquer forma, mesmo que o operador de incremento seja atômico, isso não garante que o resto do cálculo será consistente se você não usar os bloqueios corretos.

Se você quiser experimentar por si mesmo, escreva um programa em que N threads incrementem simultaneamente uma variável compartilhada M vezes cada ... se o valor for menor que N * M, algum incremento foi sobrescrito. Experimente com pré-incremento e pós-incremento e diga-nos ;-)

Fortran
fonte
0

Para um contador, eu recomendo usar o idioma compare e swap, que não é bloqueado e é seguro para thread.

Aqui está em Java:

public class IntCompareAndSwap {
    private int value = 0;

    public synchronized int get(){return value;}

    public synchronized int compareAndSwap(int p_expectedValue, int p_newValue){
        int oldValue = value;

        if (oldValue == p_expectedValue)
            value = p_newValue;

        return oldValue;
    }
}

public class IntCASCounter {

    public IntCASCounter(){
        m_value = new IntCompareAndSwap();
    }

    private IntCompareAndSwap m_value;

    public int getValue(){return m_value.get();}

    public void increment(){
        int temp;
        do {
            temp = m_value.get();
        } while (temp != m_value.compareAndSwap(temp, temp + 1));

    }

    public void decrement(){
        int temp;
        do {
            temp = m_value.get();
        } while (temp > 0 && temp != m_value.compareAndSwap(temp, temp - 1));

    }
}
AtariPete
fonte
Parece semelhante a uma função test_and_set.
samoz
1
Você escreveu "sem bloqueio", mas "sincronizado" não significa bloqueio?
Corey Trager