O que exatamente é std :: atomic?

172

Eu entendo que std::atomic<>é um objeto atômico. Mas atômica até que ponto? Para meu entendimento, uma operação pode ser atômica. O que exatamente se entende por tornar um objeto atômico? Por exemplo, se houver dois threads executando simultaneamente o seguinte código:

a = a + 12;

Então toda a operação (digamos add_twelve_to(int)) é atômica? Ou são feitas alterações na variável atômica (so operator=())?

curiousguy
fonte
9
Você precisa usar algo como a.fetch_add(12)se quiser um RMW atômico.
Kerrek SB 13/08/15
Sim, é isso que eu não entendo. O que se entende por tornar um objeto atômico. Se houvesse uma interface, ela poderia simplesmente ser atômica com um mutex ou um monitor.
2
@AaryamanSagar resolve um problema de eficiência. Mutexes e monitores carregam sobrecarga computacional. O uso std::atomicpermite que a biblioteca padrão decida o que é necessário para atingir a atomicidade.
Tirou Dormann
1
@AaryamanSagar: std::atomic<T>é um tipo que permite operações atômicas. Não magicamente melhora sua vida, você ainda precisa saber o que quer fazer com ela. É para um caso de uso muito específico, e o uso de operações atômicas (no objeto) geralmente é muito sutil e precisa ser pensado de uma perspectiva não local. Portanto, a menos que você já saiba disso e por que deseja operações atômicas, o tipo provavelmente não será muito útil para você.
Krerek SB

Respostas:

188

Cada instanciação e especialização completa de std :: atomic <> representa um tipo no qual diferentes threads podem operar simultaneamente (suas instâncias), sem aumentar o comportamento indefinido:

Objetos de tipos atômicos são os únicos objetos C ++ livres de corridas de dados; isto é, se um thread grava em um objeto atômico enquanto outro thread lê nele, o comportamento é bem definido.

Além disso, o acesso a objetos atômicos pode estabelecer a sincronização entre threads e solicitar acessos à memória não atômica, conforme especificado por std::memory_order.

std::atomic<>envolve operações que, em pré-C ++ 11 vezes, tiveram que ser executadas usando (por exemplo) funções intertravadas com MSVC ou bultins atômicos no caso de GCC.

Além disso, std::atomic<>oferece mais controle, permitindo várias ordens de memória que especificam restrições de sincronização e ordem. Se você quiser ler mais sobre o modelo atômico e de memória C ++ 11, esses links podem ser úteis:

Observe que, para casos de uso típicos, você provavelmente usaria operadores aritméticos sobrecarregados ou outro conjunto deles :

std::atomic<long> value(0);
value++; //This is an atomic op
value += 5; //And so is this

Como a sintaxe do operador não permite que você especifique a ordem da memória, essas operações serão executadas com std::memory_order_seq_cst, pois esta é a ordem padrão para todas as operações atômicas no C ++ 11. Ele garante consistência sequencial (total global de pedidos) entre todas as operações atômicas.

Em alguns casos, no entanto, isso pode não ser necessário (e nada vem de graça), portanto, você pode usar uma forma mais explícita:

std::atomic<long> value {0};
value.fetch_add(1, std::memory_order_relaxed); // Atomic, but there are no synchronization or ordering constraints
value.fetch_add(5, std::memory_order_release); // Atomic, performs 'release' operation

Agora, seu exemplo:

a = a + 12;

não avaliará uma única operação atômica: resultará em a.load()(que é atômico propriamente dito), depois adição entre esse valor 12e a.store()(também atômico) do resultado final. Como observei anteriormente, std::memory_order_seq_cstserá usado aqui.

No entanto, se você escrever a += 12, será uma operação atômica (como observei antes) e é aproximadamente equivalente a a.fetch_add(12, std::memory_order_seq_cst).

Quanto ao seu comentário:

Um regular inttem cargas e reservas atômicas. Qual é o objetivo de envolvê-lo atomic<>?

Sua declaração é verdadeira apenas para arquiteturas que fornecem essa garantia de atomicidade para lojas e / ou cargas. Existem arquiteturas que não fazem isso. Além disso, geralmente é necessário que as operações sejam executadas no endereço alinhado por palavra / palavra a ser atômico, std::atomic<>algo que é garantido como atômico em todas as plataformas, sem requisitos adicionais. Além disso, permite escrever código como este:

void* sharedData = nullptr;
std::atomic<int> ready_flag = 0;

// Thread 1
void produce()
{
    sharedData = generateData();
    ready_flag.store(1, std::memory_order_release);
}

// Thread 2
void consume()
{
    while (ready_flag.load(std::memory_order_acquire) == 0)
    {
        std::this_thread::yield();
    }

    assert(sharedData != nullptr); // will never trigger
    processData(sharedData);
}

Observe que a condição de asserção sempre será verdadeira (e, portanto, nunca será acionada), para que você sempre tenha certeza de que os dados estão prontos após whilea saída do loop. Isso é porque:

  • store()para o sinalizador é executado depois de sharedDatadefinido (assumimos que generateData()sempre retorna algo útil, em particular, nunca retorna NULL) e usa a std::memory_order_releaseordem:

memory_order_release

Uma operação de armazenamento com essa ordem de memória executa a operação de liberação : nenhuma leitura ou gravação no encadeamento atual pode ser reordenada após este armazenamento. Todas as gravações no encadeamento atual são visíveis em outros encadeamentos que adquirem a mesma variável atômica

  • sharedDataé usado após whilea saída do loop e, portanto, após o load()sinalizador retornará um valor diferente de zero. load()usa std::memory_order_acquireordem:

std::memory_order_acquire

Uma operação de carregamento com esta ordem de memória realiza a operação de aquisição no local de memória afetado: nenhuma leitura ou gravação no encadeamento atual pode ser reordenada antes desse carregamento. Todas as gravações em outros threads que liberam a mesma variável atômica são visíveis no thread atual .

Isso fornece controle preciso sobre a sincronização e permite que você especifique explicitamente como seu código pode / pode ou não / não / se comportará. Isso não seria possível se apenas a garantia fosse a própria atomicidade. Especialmente quando se trata de modelos de sincronização muito interessantes, como a ordem de liberação / consumo .

Mateusz Grzejek
fonte
2
Na verdade, existem arquiteturas que não possuem cargas atômicas e armazenam primitivas como ints?
7
Não se trata apenas de atomicidade. também é sobre pedidos, comportamento em sistemas com vários núcleos, etc. Você pode ler este artigo .
Mateusz Grzejek 13/08/2015
4
@AaryamanSagar Se não me engano, mesmo em x86, as leituras e gravações são SOMENTE atômicas se alinhadas nos limites das palavras.
precisa saber é o seguinte
@MateuszGrzejek Fiz uma referência a um tipo atômico. Você poderia gentilmente verificar se o seguinte ainda garantiria operação atômica em missão objeto ideone.com/HpSwqo
xAditya3393
3
@TimMB Sim, normalmente, você teria (pelo menos) duas situações em que a ordem de execução pode ser alterada: (1) o compilador pode reordenar as instruções (tanto quanto o padrão permitir) para fornecer melhor desempenho do código de saída (com base no uso de registros da CPU, previsões, etc.) e (2) a CPU pode executar instruções em uma ordem diferente para, por exemplo, minimizar o número de pontos de sincronização do cache. As restrições de pedidos previstas em std::atomic( std::memory_order) servem exatamente para a finalidade de limitar as reordenações que são permitidas.
Mateusz Grzejek 01/10/19
20

Eu entendo que std::atomic<>torna um objeto atômico.

Essa é uma questão de perspectiva ... você não pode aplicá-lo a objetos arbitrários e fazer com que suas operações se tornem atômicas, mas as especializações fornecidas para (a maioria) tipos integrais e ponteiros podem ser usadas.

a = a + 12;

std::atomic<>não (usa expressões de modelo para) simplifica isso para uma única operação atômica; em vez disso, o operator T() const volatile noexceptmembro faz um atômico load()de a, então doze são adicionados e operator=(T t) noexceptfaz a store(t).

Tony Delroy
fonte
Era isso que eu queria perguntar. Um int regular possui cargas e lojas atômicas. Qual é o ponto de envolvê-lo com atômica <>
8
@AaryamanSagar Simplesmente modificar um normal intnão garante portabilidade que a alteração seja visível em outros threads, nem a leitura garante que você veja as alterações de outros threads, e algumas coisas como my_int += 3não são garantidas que sejam feitas atomicamente, a menos que você use std::atomic<>- elas podem envolver uma busca e, em seguida, adicione e, em seguida, sequência de armazenamento, em que algum outro encadeamento que tente atualizar o mesmo valor possa aparecer após a busca e antes da loja e prejudicar a atualização do encadeamento.
Tony Delroy
" Simplesmente modificar um int normal não garante que a alteração seja visível de outros threads " É pior do que isso: qualquer tentativa de medir essa visibilidade resultaria em UB.
precisa
8

std::atomic existe porque muitos ISAs têm suporte direto de hardware para ele

O que o padrão C ++ diz sobre std::atomicfoi analisado em outras respostas.

Então agora vamos ver o que std::atomiccompila para obter um tipo diferente de insight.

O principal argumento desse experimento é que as CPUs modernas têm suporte direto para operações inteiras atômicas, por exemplo, o prefixo LOCK em x86, e std::atomicexistem basicamente como uma interface portátil para essas instruções : O que a instrução "lock" significa na montagem do x86? No aarch64, o LDADD seria usado.

Esse suporte permite alternativas mais rápidas a métodos mais gerais, como std::mutex, o que pode tornar mais complexas as seções multi-instruções atômicas, ao custo de ser mais lento do que std::atomicporque std::mutexfaz futexchamadas de sistema no Linux, que é muito mais lento que as instruções da terra do usuário emitidas por std::atomic, veja também: std :: mutex cria uma cerca?

Vamos considerar o seguinte programa multiencadeado que incrementa uma variável global em vários encadeamentos, com diferentes mecanismos de sincronização, dependendo de qual definição do pré-processador é usada.

main.cpp

#include <atomic>
#include <iostream>
#include <thread>
#include <vector>

size_t niters;

#if STD_ATOMIC
std::atomic_ulong global(0);
#else
uint64_t global = 0;
#endif

void threadMain() {
    for (size_t i = 0; i < niters; ++i) {
#if LOCK
        __asm__ __volatile__ (
            "lock incq %0;"
            : "+m" (global),
              "+g" (i) // to prevent loop unrolling
            :
            :
        );
#else
        __asm__ __volatile__ (
            ""
            : "+g" (i) // to prevent he loop from being optimized to a single add
            : "g" (global)
            :
        );
        global++;
#endif
    }
}

int main(int argc, char **argv) {
    size_t nthreads;
    if (argc > 1) {
        nthreads = std::stoull(argv[1], NULL, 0);
    } else {
        nthreads = 2;
    }
    if (argc > 2) {
        niters = std::stoull(argv[2], NULL, 0);
    } else {
        niters = 10;
    }
    std::vector<std::thread> threads(nthreads);
    for (size_t i = 0; i < nthreads; ++i)
        threads[i] = std::thread(threadMain);
    for (size_t i = 0; i < nthreads; ++i)
        threads[i].join();
    uint64_t expect = nthreads * niters;
    std::cout << "expect " << expect << std::endl;
    std::cout << "global " << global << std::endl;
}

GitHub upstream .

Compilar, executar e desmontar:

comon="-ggdb3 -O3 -std=c++11 -Wall -Wextra -pedantic main.cpp -pthread"
g++ -o main_fail.out                    $common
g++ -o main_std_atomic.out -DSTD_ATOMIC $common
g++ -o main_lock.out       -DLOCK       $common

./main_fail.out       4 100000
./main_std_atomic.out 4 100000
./main_lock.out       4 100000

gdb -batch -ex "disassemble threadMain" main_fail.out
gdb -batch -ex "disassemble threadMain" main_std_atomic.out
gdb -batch -ex "disassemble threadMain" main_lock.out

Saída de condição de corrida "incorreta" extremamente provável para main_fail.out:

expect 400000
global 100000

e saída "certa" determinística dos outros:

expect 400000
global 400000

Desmontagem de main_fail.out:

   0x0000000000002780 <+0>:     endbr64 
   0x0000000000002784 <+4>:     mov    0x29b5(%rip),%rcx        # 0x5140 <niters>
   0x000000000000278b <+11>:    test   %rcx,%rcx
   0x000000000000278e <+14>:    je     0x27b4 <threadMain()+52>
   0x0000000000002790 <+16>:    mov    0x29a1(%rip),%rdx        # 0x5138 <global>
   0x0000000000002797 <+23>:    xor    %eax,%eax
   0x0000000000002799 <+25>:    nopl   0x0(%rax)
   0x00000000000027a0 <+32>:    add    $0x1,%rax
   0x00000000000027a4 <+36>:    add    $0x1,%rdx
   0x00000000000027a8 <+40>:    cmp    %rcx,%rax
   0x00000000000027ab <+43>:    jb     0x27a0 <threadMain()+32>
   0x00000000000027ad <+45>:    mov    %rdx,0x2984(%rip)        # 0x5138 <global>
   0x00000000000027b4 <+52>:    retq

Desmontagem de main_std_atomic.out:

   0x0000000000002780 <+0>:     endbr64 
   0x0000000000002784 <+4>:     cmpq   $0x0,0x29b4(%rip)        # 0x5140 <niters>
   0x000000000000278c <+12>:    je     0x27a6 <threadMain()+38>
   0x000000000000278e <+14>:    xor    %eax,%eax
   0x0000000000002790 <+16>:    lock addq $0x1,0x299f(%rip)        # 0x5138 <global>
   0x0000000000002799 <+25>:    add    $0x1,%rax
   0x000000000000279d <+29>:    cmp    %rax,0x299c(%rip)        # 0x5140 <niters>
   0x00000000000027a4 <+36>:    ja     0x2790 <threadMain()+16>
   0x00000000000027a6 <+38>:    retq   

Desmontagem de main_lock.out:

Dump of assembler code for function threadMain():
   0x0000000000002780 <+0>:     endbr64 
   0x0000000000002784 <+4>:     cmpq   $0x0,0x29b4(%rip)        # 0x5140 <niters>
   0x000000000000278c <+12>:    je     0x27a5 <threadMain()+37>
   0x000000000000278e <+14>:    xor    %eax,%eax
   0x0000000000002790 <+16>:    lock incq 0x29a0(%rip)        # 0x5138 <global>
   0x0000000000002798 <+24>:    add    $0x1,%rax
   0x000000000000279c <+28>:    cmp    %rax,0x299d(%rip)        # 0x5140 <niters>
   0x00000000000027a3 <+35>:    ja     0x2790 <threadMain()+16>
   0x00000000000027a5 <+37>:    retq

Conclusões:

  • a versão não atômica salva o global em um registro e incrementa o registro.

    Portanto, no final, muito provavelmente quatro gravações retornam ao global com o mesmo valor "errado" de 100000.

  • std::atomiccompila para lock addq. O prefixo LOCK faz a seguinte incbusca, modificação e atualização da memória atomicamente.

  • nosso prefixo LOCK explícito do assembly embutido é compilado quase da mesma maneira que std::atomic, exceto que o nosso incé usado em vez de add. Não sei por que o GCC escolheu add, considerando que nosso INC gerou uma decodificação 1 byte menor.

O ARMv8 poderia usar LDAXR + STLXR ou LDADD em CPUs mais recentes: Como inicio threads em C simples?

Testado no Ubuntu 19.10 AMD64, GCC 9.2.1, Lenovo ThinkPad P51.

Ciro Santilli adicionou uma nova foto
fonte