std :: atomic은 정확히 무엇입니까?


174

나는 그것이 std::atomic<>원자 적 객체 라는 것을 이해 합니다. 그러나 어느 정도까지 원자? 내 이해에 따르면 작업은 원자가 될 수 있습니다. 물체를 원자로 만드는 것은 정확히 무엇을 의미합니까? 예를 들어 다음 코드를 동시에 실행하는 두 개의 스레드가있는 경우 :

a = a + 12;

그렇다면 전체 작업이 add_twelve_to(int)원 자성입니까? 아니면 변수 원자가 변경 operator=()되었습니까?


9
a.fetch_add(12)원자 RMW를 원할 경우 와 같은 것을 사용해야합니다 .
Kerrek SB

그러나 그것은 내가 이해하지 못하는 것입니다. 물체를 원자로 만드는 것의 의미 인터페이스가 있다면 단순히 뮤텍스 또는 모니터를 사용하여 원자로 만들 수 있습니다.

2
@AaryamanSagar는 효율성 문제를 해결합니다. 뮤텍스와 모니터 는 계산 오버 헤드를 전달합니다. std::atomic표준 라이브러리를 사용 하면 원 자성을 달성하는 데 필요한 것을 결정할 수 있습니다.
Drew Dormann

1
@AaryamanSagar : 원자 연산 std::atomic<T>허용 하는 유형입니다 . 그것은 마술처럼 인생을 더 좋게 만들지 않습니다, 당신은 여전히 ​​당신이 그것으로 무엇을하고 싶은지 알아야합니다. 그것은 매우 구체적인 유스 케이스를위한 것이며 원자 연산 (객체에서)의 사용은 일반적으로 매우 미묘하며 로컬이 아닌 관점에서 생각해야합니다. 따라서 이미 알고 있고 원자 연산을 원하는 이유가 아니라면 유형이 많이 사용되지 않을 수 있습니다.
Kerrek SB

답변:


188

std :: atomic <> 의 각 인스턴스화 및 전체 특수화는 정의되지 않은 동작을 발생시키지 않고 다른 스레드가 동시에 작동 할 수있는 유형을 나타냅니다.

원자 유형의 객체는 데이터 경쟁이없는 유일한 C ++ 객체입니다. 즉, 한 스레드가 원자 개체에 쓰는 동안 다른 스레드가 원자 개체에 쓰는 경우 동작이 잘 정의되어 있습니다.

또한 원자 객체에 대한 액세스는 스레드 간 동기화를 설정하고로 지정된 비 원자 메모리 액세스를 주문할 수 std::memory_order있습니다.

std::atomic<>C ++ 이전의 11 번 랩핑 작업 은 GCC의 경우 MSVC 또는 원자 적 부틴 과 (예를 들어) 연동 기능 을 사용하여 수행해야했습니다 .

또한 동기화 및 순서 제약 조건을 지정 std::atomic<>하는 다양한 메모리 순서 를 허용하여 더 많은 제어 기능을 제공합니다 . C ++ 11 원자 및 메모리 모델에 대한 자세한 내용을 보려면 다음 링크가 유용 할 수 있습니다.

일반적인 사용 사례의 경우 오버로드 된 산술 연산자 또는 다른 세트를 사용합니다 .

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

연산자 구문을 사용하면 메모리 순서를 지정할 수 없으므로 std::memory_order_seq_cstC ++ 11의 모든 원자 연산에 대한 기본 순서이므로이 연산은로 수행됩니다. 모든 원자 연산 간의 순차 일관성 (전체 전역 순서)을 보장합니다.

그러나 경우에 따라이 작업이 필요하지 않을 수도 있고 무료로 제공되지 않을 수도 있으므로보다 명시적인 형식을 사용하는 것이 좋습니다.

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

자, 당신의 예 :

a = a + 12;

단일 원자 연산으로 계산하지 않을 것이다 : 그것은 초래한다 a.load()(자체 원자이다)이 값 사이, 덧셈 12a.store()최종 결과 (도 원 참조). 앞서 언급했듯이 std::memory_order_seq_cst여기에서 사용됩니다.

그러나을 쓰면 a += 12원자 연산 (앞서 언급했듯이)과 거의 같습니다 a.fetch_add(12, std::memory_order_seq_cst).

귀하의 의견에 관해서는 :

레귤러 int에는 원자로드 및 저장이 있습니다. 그것을 감싸는 요점은 atomic<>무엇입니까?

귀하의 진술은 상점 및 / 또는로드에 원 자성을 보장하는 아키텍처에만 적용됩니다. 이를 수행하지 않는 아키텍처가 있습니다. 또한 일반적으로 워드 / 워드 정렬 주소에서 작업을 수행해야하므로 원 자성 std::atomic<>이 추가 요구 사항없이 모든 플랫폼에서 원 자성으로 보장됩니다 . 또한 다음과 같은 코드를 작성할 수 있습니다.

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);
}

어설 션 조건은 항상 true이므로 트리거되지 않습니다. 따라서 while루프 종료 후 데이터가 준비되었는지 항상 확인할 수 있습니다 . 그 이유는 다음과 같습니다.

  • store()to 플래그가 sharedData설정되면 수행 됩니다 (우리는 generateData()항상 유용한 것을 반환 한다고 가정합니다 . 특히 결코 반환하지 않는다고 가정합니다 NULL) std::memory_order_release.

memory_order_release

이 메모리 순서의 저장 조작은 해제 조작을 수행합니다 .이 저장 후에 는 현재 스레드에서 읽기 또는 쓰기를 다시 정렬 할 수 없습니다 . 현재 스레드의 모든 쓰기는 동일한 원자 변수를 얻는 다른 스레드에서 볼 수 있습니다

  • sharedDatawhile루프 종료 후에 사용 되므로 after load()from 플래그는 0이 아닌 값을 반환합니다. 순서를 load()사용 std::memory_order_acquire합니다.

std::memory_order_acquire

이 메모리 순서로로드 조작 은 영향을받는 메모리 위치 에서 획득 조작을 수행합니다 .이로드 전에 현재 스레드에서 읽기 또는 쓰기를 다시 정렬 할 수 없습니다 . 동일한 원자 변수를 해제하는 다른 스레드의 모든 쓰기는 현재 스레드에서 볼 수 있습니다 .

이를 통해 동기화를 정확하게 제어 할 수 있으며 코드의 작동 방식 / 작동 방식 / 작동 방식을 명시 적으로 지정할 수 있습니다. 원 자성 자체 만 보장한다면 불가능할 것입니다. 특히 릴리스 소비 순서 와 같은 매우 흥미로운 동기화 모델에 관해서는 .


2
실제로는 ints 와 같은 프리미티브에 대한 원자로드 및 저장이없는 아키텍처가 있습니까?

7
원자성에 관한 것이 아닙니다. 또한 주문, 멀티 코어 시스템의 동작 등에 관한 것 입니다. 이 기사 를 읽으십시오 .
Mateusz Grzejek

4
@AaryamanSagar 내가 실수하지 않으면 x86에서도 읽기와 쓰기는 단어 경계에 정렬 된 경우에만 원자 적입니다.
v.shashenko

@MateuszGrzejek 나는 원자 유형에 대한 참조를 취했습니다. 다음이 객체 할당에서 원자 작동을 보장하는지 친절하게 확인할 수 있습니까
ideone.com/HpSwqo

3
@TimMB 그렇습니다. 일반적으로 실행 순서가 변경 될 수있는 두 가지 상황이 있습니다. (CPU 레지스터, 예측 등의 사용에 따라) 및 (2) CPU는 예를 들어 캐시 동기 점 수를 최소화하기 위해 다른 순서로 명령을 실행할 수 있습니다. std::atomic( std::memory_order)에 제공된 순서 제약 조건 은 발생 가능한 재정렬을 제한하는 목적을 정확하게 제공합니다.
Mateusz Grzejek

20

나는 그것이 std::atomic<>객체를 원자 적으로 만든다는 것을 이해합니다 .

그것은 관점의 문제입니다 ... 임의의 객체에는 적용 할 수 없으며 그 동작이 원자화 될 수 있지만 (대부분의) 정수 유형 및 포인터에 대해 제공된 전문화를 사용할 수 있습니다.

a = a + 12;

std::atomic<>(템플릿 표현식을 사용하여) 이것을 단일 원자 연산으로 단순화 operator T() const volatile noexcept하지 않고 대신 멤버가의 원자 load()를 수행 a한 다음 12가 추가되고를 operator=(T t) noexcept수행합니다 store(t).


그것이 내가 묻고 싶었던 것이었다. 일반 int에는 원자 적로드와 저장이 있습니다. atomic <>로 감싸는 요점

8
@AaryamanSagar 단순히 노멀을 수정한다고 int해서 변경 사항이 다른 쓰레드에서 보이도록 보장 할 수 없으며, 읽지 않아도 다른 쓰레드의 변경 사항을 볼 수 없으며, my_int += 3사용하지 않는 한 원자 적으로 수행되지 std::atomic<>않을 수도 있습니다. 페치, 추가, 저장 순서, 동일한 값을 업데이트하려고하는 다른 스레드가 페치 후 및 저장 전에 올 수 있으며 스레드 업데이트를 방해 할 수 있습니다.
Tony Delroy

" 단순히 일반 int를 수정한다고해서 다른 스레드에서 변경 사항을 볼 수 있다고 보장 할 수는 없습니다 ."
curiousguy 2018 년

8

std::atomic 많은 ISA가 직접 하드웨어를 지원하기 때문에 존재합니다

C ++ 표준에 대한 내용 std::atomic은 다른 답변에서 분석되었습니다.

이제 std::atomic다른 종류의 통찰력을 얻기 위해 컴파일되는 내용을 살펴 보겠습니다 .

이 실험에서 가장 중요한 점은 최신 CPU가 원자 정수 연산 (예 : x86의 LOCK 접두사)을 직접 지원하고 std::atomic기본적으로 이러한 명령어에 대한 이식 가능한 인터페이스로 존재 한다는 것입니다. x86 어셈블리에서 "잠금"명령의 의미는 무엇입니까? aarch64에서는 LDADD 가 사용됩니다.

이 지원은 다음과 같은 일반적인 방법에 빠른 대안을 수 있습니다 std::mutex보다 느린되는 비용으로, 더 복잡한 다중 명령 섹션 원자 만들 수 std::atomic있기 때문에 std::mutex이 만드는 futex느린 방출 유저 랜드 지침보다 훨씬 리눅스에서 시스템 호출을 std::atomic, 또한 참조 : 비 음주자 :: 울타리를 만들 뮤텍스 수 std?

사용되는 전 처리기 정의에 따라 서로 다른 동기화 메커니즘을 사용하여 여러 스레드에서 전역 변수를 증가시키는 다음 다중 스레드 프로그램을 고려해 봅시다.

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의 상류 .

컴파일, 실행 및 분해 :

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

매우 가능성이 "잘못된"경쟁 조건 출력 main_fail.out:

expect 400000
global 100000

그리고 다른 사람들의 결정적인 "올바른"결과 :

expect 400000
global 400000

분해 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

분해 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   

분해 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

결론 :

  • 비 원자 버전은 전역을 레지스터에 저장하고 레지스터를 증가시킵니다.

    따라서 결국에는 "잘못된"값이 같은 전역으로 다시 네 번의 쓰기가 발생할 가능성이 큽니다 100000.

  • std::atomic로 컴파일합니다 lock addq. LOCK 접두사는 inc메모리를 원자 적으로 가져 오기, 수정 및 업데이트합니다.

  • 명시 적 인라인 어셈블리 LOCK 접두어 는 대신에를 사용 std::atomic한다는 점을 제외하고는 거의 동일한 내용으로 컴파일됩니다 . INC가 1 바이트 더 작은 디코딩을 생성 한 것을 고려할 때 GCC가 왜을 선택했는지 잘 모르겠습니다 .incaddadd

ARMv8은 최신 CPU에서 LDAXR + STLXR 또는 LDADD를 사용할 수 있습니다. 일반 C에서 스레드를 시작하려면 어떻게해야합니까?

Ubuntu 19.10 AMD64, GCC 9.2.1, Lenovo ThinkPad P51에서 테스트되었습니다.

당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.