[c++] std :: atomic은 정확히 무엇입니까?

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

a = a + 12;

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



답변

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

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

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


답변

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

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

a = a + 12;

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


답변

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에서 테스트되었습니다.


답변