num ++가 'int num'의 원자가 될 수 있습니까?


153

일반적 속 int num, num++(나 ++num), 판독 - 수정 - 기록 동작으로, 인 원자 없다 . 그러나 종종 GCC 와 같은 컴파일러 가 다음 코드를 생성하는 것을 종종 볼 수 있습니다 ( here 시도 ).

여기에 이미지 설명을 입력하십시오

num++하나의 명령에 해당하는 5 번 라인 이이 경우에 num++ 원자 라고 결론 지을 수 있습니까?

그렇다면, 그렇게 생성 된 num++것은 데이터 경쟁의 위험없이 동시 (멀티 스레드) 시나리오에서 사용될 수 있다는 것을 의미 합니까 (예를 들어 데이터 를 만들지 않아도 std::atomic<int>되므로 관련 비용을 부과 할 필요가 없습니다) 어쨌든 원자)?

최신 정보

이 질문은 증분 원자 적인지 여부 가 아닙니다 (그렇지 않으며 그것이 질문의 시작 선이 아닙니다). 특정 시나리오에 있을 있는지 , 즉 어떤 경우에는 접두사 의 오버 헤드를 피하기 위해 하나의 명령 특성을 이용할 수 있는지 여부 입니다. 그리고 단일 프로세서 머신에 대한 섹션에서 언급 된 답변 과이 답변 뿐만 아니라 주석 및 다른 사람들의 대화가 설명 할 수 있지만 (C 또는 C ++은 아니지만) 가능합니다.lock


65
누가 add원자 라고 했어요 ?
Slava

6
원자의 특징 중 하나는 실제 작업의 원자성에 관계없이 최적화 과정에서 특정 종류의 재정렬을 방지하는 것입니다.
jaggedSpire

19
또한 이것이 플랫폼에서 원자 적 인 경우 다른 pltaform에 있다고 보장하지는 않습니다. 플랫폼을 독립적으로 사용하고을 사용하여 의도를 표현하십시오 std::atomic<int>.
NathanOliver

8
해당 add명령을 실행하는 동안 다른 코어는이 코어의 캐시에서 해당 메모리 주소를 훔쳐 수정할 수 있습니다. x86 CPU 에서 작업 기간 동안 주소를 캐시에 고정해야하는 경우 add명령에 lock접두사가 필요합니다.
David Schwartz

21
것이 수 있는 작업이 될 일을 "원자." 당신이해야 할 일은 운이 좋으며 그것이 원자가 아니라는 것을 드러내는 것을 실행하지 않는 것입니다. 원자는 보증 으로서 만 가치가 있습니다. 당신이 어셈블리 코드를보고있는 점을 감안, 문제는 특정 아키텍처는 당신에게 보장을 제공하기 위해 발생 여부 컴파일러는 그 자신이 선택한 어셈블리 레벨 구현 있다는 보증을 제공하는지 여부.
Cort Ammon

답변:


197

이것은 하나의 컴파일러가 일부 대상 시스템에서 원하는 것을 수행하는 코드를 생성하더라도 C ++이 정의되지 않은 동작을 일으키는 데이터 레이스로 정의하는 것입니다. std::atomic신뢰할 수있는 결과 를 위해 사용해야 하지만 memory_order_relaxed재정렬에 신경 쓰지 않으면 사용할 수 있습니다 . 를 사용하는 예제 코드 및 asm 출력에 대해서는 아래를 참조하십시오 fetch_add.


그러나 먼저 질문의 어셈블리 언어 부분은 다음과 같습니다.

num ++는 하나의 명령어 ( add dword [num], 1)이므로이 경우 num ++이 원자 적이라고 결론 내릴 수 있습니까?

메모리 저장소 명령 (순수 저장소 제외)은 여러 내부 단계에서 발생하는 읽기-수정-쓰기 작업입니다 . 아키텍처 레지스터는 수정되지 않지만 CPU는 ALU를 통해 데이터를 전송하는 동안 내부적으로 데이터를 보유해야합니다 . 실제 레지스터 파일은 가장 간단한 CPU조차도 데이터 저장 장치의 일부에 불과하며 래치는 한 스테이지의 출력을 다른 스테이지의 입력 등으로 유지합니다.

다른 CPU의 메모리 작업은로드와 저장 사이에서 전체적으로 볼 수 있습니다. 즉 add dword [num], 1, 루프에서 실행 되는 두 개의 스레드 는 서로의 저장소를 밟습니다. ( 좋은 다이어그램은 @Margaret의 답변 을 참조하십시오 ). 두 스레드 각각에서 40k 씩 증가한 후 카운터는 실제 멀티 코어 x86 하드웨어에서 ~ 60k (80k 아님) 만 증가했을 수 있습니다.


불가분의 의미로 그리스어 단어에서 "원자"는 관찰자가 조작을 별도의 단계로 수 없음을 의미 합니다. 모든 비트에 대해 동시에 물리적 / 전기적으로 동시에 발생하는 것은로드 또는 저장에서이를 달성하는 한 가지 방법 일 뿐이지 만 ALU 작업에서는 불가능합니다. x86의 Atomicity에 대한 답변에서 순수한로드와 순수한 상점에 ​​대해 더 자세히 설명 했지만이 답변은 읽기 수정 쓰기에 중점을 둡니다.

lock프리픽스는 시스템의 모든 가능한 관찰자에 대한 전체 동작 원자 있도록 많은 읽기 - 수정 - 쓰기 (메모리 목적지)의 지시에 적용될 수있다 (다른 코어와 DMA 장치하지 오실로스코프는 CPU 핀에 매여). 그것이 존재하는 이유입니다. ( 이 Q & A 도 참조하십시오 ).

원자도 마찬가지 lock add dword [num], 1 입니다 . 이 명령어를 실행하는 CPU 코어는로드가 캐시에서 데이터를 읽을 때부터 저장소가 결과를 다시 캐시에 커밋 할 때까지 캐시 라인을 개인 L1 캐시에서 수정 된 상태로 고정합니다. 이를 통해 MESI 캐시 일관성 프로토콜 (또는 멀티 코어 AMD / MESIF가 사용하는 MOESI / MESIF 버전의 규칙)에 따라 시스템의 다른 캐시에로드 할 때마다 캐시 라인의 사본이 저장되는 것을 방지합니다. 각각 Intel CPU). 따라서 다른 코어의 작업은 이전이 아닌 이전 또는 이후에 발생하는 것으로 보입니다.

lock접두어가 없으면 다른 코어가 캐시 라인의 소유권을 가져 와서로드 후, 스토어 전에이를 수정할 수 있으므로로드와 스토어 사이에 다른 스토어가 전체적으로 표시 될 수 있습니다. 다른 몇 가지 대답은이 문제를 lock해결하고 동일한 캐시 라인의 사본이 충돌 하지 않는다고 주장합니다 . 코 히어 런트 캐시가있는 시스템에서는 이런 일이 발생할 수 없습니다.

( locked 명령이 두 캐시 라인에 걸쳐있는 메모리에서 작동 하는 경우 , 객체의 두 부분에 대한 변경 사항이 모든 관찰자에게 전파 될 때 원자 적으로 유지되도록 관찰하는 데 더 많은 작업이 필요하므로 관찰자가 눈물을 볼 수 없습니다. 데이터가 메모리에 도달 할 때까지 전체 메모리 버스를 잠 가야합니다. 원자 변수를 잘못 정렬하지 마십시오!)

있습니다 lock접두사는 (같은 전체 메모리 장벽에 명령을집니다 MFENCE 모든 런타임 재정렬 따라서 순차적 일관성을 제공 중지). Jeff Preshing의 우수한 블로그 게시물을 참조하십시오 . 그의 다른 게시물도 모두 우수하며 x86 및 기타 하드웨어 세부 정보에서 C ++ 규칙에 이르기까지 잠금없는 프로그래밍 에 대한 많은 좋은 점을 명확하게 설명합니다 .


단일 프로세서 시스템 또는 단일 스레드 프로세스 에서 단일 RMW 명령어는 실제로 접두사 없는 원자 lock입니다. 다른 코드가 공유 변수에 액세스하는 유일한 방법은 CPU가 컨텍스트 전환을 수행하는 것입니다. 이는 명령 도중에 발생할 수 없습니다. 따라서 일반 dec dword [num]스레드는 단일 스레드 프로그램과 해당 신호 처리기 또는 단일 코어 시스템에서 실행되는 다중 스레드 프로그램에서 동기화 할 수 있습니다. 보기 다른 질문에 대한 내 대답 하반기 , 그리고 좀 더 자세하게 설명 그 아래 주석을.


C ++로 돌아 가기 :

num++컴파일러에게 단일 읽기-수정-쓰기 구현으로 컴파일해야한다고 알려주지 않고 사용 하는 것은 가짜입니다 .

;; Valid compiler output for num++
mov   eax, [num]
inc   eax
mov   [num], eax

num나중에 값을 사용하면 컴파일러가 증가 후에도 레지스터에 그대로 유지됩니다. 따라서 num++자체 컴파일 방법을 확인하더라도 주변 코드를 변경하면 영향을 줄 수 있습니다.

값이 나중에 필요하지 않은 경우 ( inc dword [num]바람직하고, 현대의 x86 CPU가 세 개의 별도의 지침을 사용하여 효율적으로 적어도 메모리 대상 RMW 명령을 실행 재미있는 사실 :. gcc -O3 -m32 -mtune=i586실제로이 방출됩니다 , (펜티엄) P5의 슈퍼 스칼라 파이프 라인 didn를하기 때문에 P6 이상의 마이크로 아키텍처에서와 같이 복잡한 명령어를 여러 개의 간단한 마이크로 연산으로 디코딩하지 않습니다. 자세한 정보는 Agner Fog의 명령어 표 / 마이크로 아키텍처 안내서 를 참조하십시오. 많은 유용한 링크에 대한 태그 위키 (PDF로 무료로 제공되는 Intel의 x86 ISA 설명서 포함).


대상 메모리 모델 (x86)을 C ++ 메모리 모델과 혼동하지 마십시오

컴파일 시간 재정렬 이 허용 됩니다. std :: atomic으로 얻는 것의 다른 부분은 컴파일 타임 재정렬을 제어하여num++다른 작업 후에 만 ​​전 세계적으로 볼 수있도록합니다.

전형적인 예 : 다른 스레드가 볼 수 있도록 일부 데이터를 버퍼에 저장 한 다음 플래그를 설정합니다. x86은 무료로로드 / 릴리스 저장소를 가져 오지만 여전히 컴파일러를 사용하여 순서를 바꾸지 않도록 지시해야 flag.store(1, std::memory_order_release);합니다.

이 코드가 다른 스레드와 동기화 될 것으로 예상 할 수 있습니다.

// flag is just a plain int global, not std::atomic<int>.
flag--;       // This isn't a real lock, but pretend it's somehow meaningful.
modify_a_data_structure(&foo);    // doesn't look at flag, and the compilers knows this.  (Assume it can see the function def).  Otherwise the usual don't-break-single-threaded-code rules come into play!
flag++;

그러나 그렇지 않습니다. 컴파일러는 flag++함수 호출을 가로 질러 자유롭게 이동할 수 있습니다 (함수가 함수를 인라인하거나 보지 않는 것을 알고있는 경우 flag). 그런 다음 flag조차도 아니기 때문에 수정 사항을 완전히 최적화 할 수 있습니다 volatile. (그리고 더, C는 ++ volatile표준을위한 유용한 대체 :: 원자. 표준 : 컴파일러는 메모리에 그 값을 가질 수 있도록 않는 원자의 정보는 다음의 제품에 비동기 적으로 유사한 수정할 수 있습니다하지 않습니다 volatile,하지만 그것보다 훨씬 더있다. 또한, volatile std::atomic<int> foo한다가 아니라 std::atomic<int> foo@Richard Hodges와 논의한 바와 동일 )

정의되지 않은 동작으로 비 원자 변수에 대한 데이터 레이스 정의는 컴파일러가 여전히 루프에서로드를로드 및 싱크하고 여러 스레드가 참조 할 수있는 메모리에 대한 다른 많은 최적화를 허용하는 것입니다. ( UB가 컴파일러 최적화를 활성화하는 방법에 대한 자세한 내용은 이 LLVM 블로그 를 참조하십시오 .)


앞에서 언급했듯이 x86 lock접두사 는 전체 메모리 장벽이므로 num.fetch_add(1, std::memory_order_relaxed);x86에서 동일한 num++기본값을 사용하면 (기본값은 순차 일관성) ARM과 같은 다른 아키텍처에서는 훨씬 더 효율적일 수 있습니다. x86에서도 완화는 더 많은 컴파일 타임 재정렬을 허용합니다.

이것이 std::atomic전역 변수 에서 작동하는 몇 가지 함수에 대해 GCC가 x86에서 실제로 수행하는 작업 입니다.

Godbolt 컴파일러 탐색기 에서 멋진 형식의 소스 + 어셈블리 언어 코드를 참조하십시오 . ARM, MIPS 및 PowerPC를 포함한 다른 대상 아키텍처를 선택하여 해당 대상의 원자에서 어떤 종류의 어셈블리 언어 코드를 얻을 수 있는지 확인할 수 있습니다.

#include <atomic>
std::atomic<int> num;
void inc_relaxed() {
  num.fetch_add(1, std::memory_order_relaxed);
}

int load_num() { return num; }            // Even seq_cst loads are free on x86
void store_num(int val){ num = val; }
void store_num_release(int val){
  num.store(val, std::memory_order_release);
}
// Can the compiler collapse multiple atomic operations into one? No, it can't.

# g++ 6.2 -O3, targeting x86-64 System V calling convention. (First argument in edi/rdi)
inc_relaxed():
    lock add        DWORD PTR num[rip], 1      #### Even relaxed RMWs need a lock. There's no way to request just a single-instruction RMW with no lock, for synchronizing between a program and signal handler for example. :/ There is atomic_signal_fence for ordering, but nothing for RMW.
    ret
inc_seq_cst():
    lock add        DWORD PTR num[rip], 1
    ret
load_num():
    mov     eax, DWORD PTR num[rip]
    ret
store_num(int):
    mov     DWORD PTR num[rip], edi
    mfence                          ##### seq_cst stores need an mfence
    ret
store_num_release(int):
    mov     DWORD PTR num[rip], edi
    ret                             ##### Release and weaker doesn't.
store_num_relaxed(int):
    mov     DWORD PTR num[rip], edi
    ret

순차 일관성 저장 후에 MFENCE (완전한 장벽)가 필요한지 확인하십시오. x86은 일반적으로 강력하게 정렬되지만 StoreLoad 재정렬은 허용됩니다. 파이프 라인 된 비 순차적 CPU에서 좋은 성능을 위해서는 저장소 버퍼가 필요합니다. 법에 적발 된 Jeff Preshing의 메모리 재정렬 은 실제 하드웨어에서 재정렬이 일어나는 것을 보여주는 실제 코드와 함께 MFENCE를 사용 하지 않은 결과 를 보여줍니다.


Re : std :: atomic num++; num-=2;연산을 하나의 num--;명령 으로 병합 하는 컴파일러 에 대한 @Richard Hodges의 답변에 대한 의견 토론 :

동일한 주제에 대한 별도의 Q & A : 컴파일러가 중복 std :: atomic 쓰기를 병합하지 않는 이유는 무엇입니까? 내 답변은 아래에 쓴 내용을 많이 정리합니다.

현재 컴파일러는 실제로 이것을하지 않지만 허용되지 않기 때문에 아닙니다. C ++ WG21 / P0062R1 : 컴파일러는 언제 원자를 최적화해야합니까? 많은 프로그래머들이 컴파일러가 "놀라운"최적화를하지 않을 것이라는 기대와 프로그래머가 제어 할 수있는 표준을 설명합니다. N4455 는이를 포함하여 최적화 할 수있는 많은 예를 설명합니다. 인라인 및 상수 전파는 원래 소스에 명백한 중복 원자 연산이 없었더라도 ( fetch_or(0)단지 load()의미를 획득하고 해제 하는) 것으로 전환 할 수있는 것과 같은 것을 도입 할 수 있다고 지적합니다.

컴파일러가 그것을하지 않는 진짜 이유는 (아직) : (1) 컴파일러가 (잘못 실수하지 않고) 안전하게 할 수있는 복잡한 코드를 작성한 사람은 없으며, (2) 잠재적 으로 최소한원칙을 위반하는 것입니다. 놀람 . 잠금이없는 코드는 처음부터 올바르게 작성하기에 충분하지 않습니다. 따라서 원자력 무기를 사용하는 데 부담이되지 마십시오. 싸지 않고 많이 최적화하지도 않습니다. std::shared_ptr<T>비록 원자 버전이 아니기 때문에 ( 여기서 대답 중 하나가shared_ptr_unsynchronized<T> gcc 를 정의하는 쉬운 방법을 제공 하지만) 중복 원자 연산을 피하는 것이 항상 쉬운 것은 아닙니다 .


num++; num-=2;마치 마치 컴파일로 돌아 가기 num--: is가 아닌 한 컴파일러 이 작업을 수행 할 있습니다. 재정렬이 가능한 경우, as-if 규칙을 사용하면 컴파일러는 컴파일 타임에 항상 그런 식으로 발생 하도록 결정할 수 있습니다. 관찰자가 중간 값 ( 결과)을 볼 수 있다고 보장하는 것은 없습니다 .numvolatile std::atomic<int>num++

즉, 이러한 작업 사이에 아무것도 보이지 않는 순서가 소스의 순서 요구 사항과 호환되는 경우 (대상 아키텍처가 아닌 추상 기계의 C ++ 규칙에 따라) 컴파일러 lock dec dword [num]lock inc dword [num]/ 대신 단일을 방출 할 수 있습니다 lock sub dword [num], 2.

num++; num--는 다른 스레드와 동기화 관계가 여전히 있기 때문에 사라질 수 없으며, num이 스레드에서 다른 작업의 순서를 변경할 수없는 획득로드 및 릴리스 저장소입니다. x86의 경우 lock add dword [num], 0(즉 num += 0) 대신 MFENCE로 컴파일 할 수 있습니다 .

PR0062 에서 논의한 바와 같이 , 컴파일 타임에 인접하지 않은 아톰 ops 를보다 적극적으로 병합하는 것은 좋지 않을 수 있습니다 (예 : 진행 카운터는 매번 반복하는 대신 마지막에 한 번만 업데이트 됨). shared_ptr컴파일러가 shared_ptr임시의 전체 수명 동안 다른 객체가 존재 함을 증명할 수있는 경우 a의 사본 이 생성 및 소멸 될 때 원자 inc / ref의 카운트가 계산됩니다 .)

num++; num--하나의 스레드가 즉시 잠금을 해제하고 다시 잠금을 설정하면 병합 조차도 잠금 구현의 공정성을 손상시킬 수 있습니다. 실제로 asm에서 릴리스되지 않으면 하드웨어 중재 메커니즘조차도 다른 스레드가 그 시점에서 잠금을 잡을 수있는 기회를주지 않습니다.


현재 gcc6.2 및 clang3.9를 사용하면 가장 명확하게 최적화 가능한 경우 lock에도 별도의 작업을 수행 할 수 있습니다 memory_order_relaxed. ( Godbolt 컴파일러 탐색기 이므로 최신 버전이 다른지 확인할 수 있습니다.)

void multiple_ops_relaxed(std::atomic<unsigned int>& num) {
  num.fetch_add( 1, std::memory_order_relaxed);
  num.fetch_add(-1, std::memory_order_relaxed);
  num.fetch_add( 6, std::memory_order_relaxed);
  num.fetch_add(-5, std::memory_order_relaxed);
  //num.fetch_add(-1, std::memory_order_relaxed);
}

multiple_ops_relaxed(std::atomic<unsigned int>&):
    lock add        DWORD PTR [rdi], 1
    lock sub        DWORD PTR [rdi], 1
    lock add        DWORD PTR [rdi], 6
    lock sub        DWORD PTR [rdi], 5
    ret

1
"[별도의 명령을 사용하여] 더 효율적으로 사용되었지만 최신 x86 CPU는 다시 한번 RMW 작업을 최소한 효율적으로 처리합니다."- 업데이트 된 값이 나중에 같은 기능에서 사용될 경우 여전히 더 효율적입니다 그리고 컴파일러가 그것을 저장할 수있는 무료 레지스터가 있습니다 (그리고 변수는 물론 휘발성으로 표시되지 않습니다). 이것은 컴파일러가 연산을 위해 단일 명령을 생성하는지 아니면 여러 명령을 생성하는지의 여부는 문제의 단일 라인이 아니라 함수의 나머지 코드에 의존 할 가능성 이 높다는 것을 의미합니다 .
Periata Breatta

@PeriataBreatta : 예, 좋은 지적입니다. asm에서는 mov eax, 1 xadd [num], eax(잠금 접두어 없음) post-increment 구현에 사용할 수 num++있지만 컴파일러는 그렇게하지 않습니다.
Peter Cordes

3
@DavidC. Rankin : 편집하고 싶은 내용이 있으면 자유롭게 느끼십시오. 그래도 CW를 만들고 싶지 않습니다. 여전히 내 작품 (그리고 엉망 : P)입니다. 나는 나의 Ultimate [frisbee] 게임 후에 정리할 것이다 :)
Peter Cordes

1
커뮤니티 위키가 아닌 경우 해당 태그 위키의 링크 일 수 있습니다. (x86과 원자 태그 둘 다?). SO에 대한 일반적인 검색을 통해 희망적인 수익을 얻는 것보다 추가 연결 가치가 있습니다. Wiki linkage)
David C. Rankin

1
항상 그렇듯이-좋은 대답입니다! 일관성과 원자
성의

39

... 그리고 이제 최적화를 활성화합시다 :

f():
        rep ret

좋아요, 기회를 드리겠습니다 :

void f(int& num)
{
  num = 0;
  num++;
  --num;
  num += 6;
  num -=5;
  --num;
}

결과:

f(int&):
        mov     DWORD PTR [rdi], 0
        ret

다른 관찰 스레드 (캐시 동기화 지연을 무시하더라도)는 개별 변경 사항을 관찰 할 기회가 없습니다.

다음과 비교하십시오 :

#include <atomic>

void f(std::atomic<int>& num)
{
  num = 0;
  num++;
  --num;
  num += 6;
  num -=5;
  --num;
}

결과는 다음과 같습니다.

f(std::atomic<int>&):
        mov     DWORD PTR [rdi], 0
        mfence
        lock add        DWORD PTR [rdi], 1
        lock sub        DWORD PTR [rdi], 1
        lock add        DWORD PTR [rdi], 6
        lock sub        DWORD PTR [rdi], 5
        lock sub        DWORD PTR [rdi], 1
        ret

이제 각 수정 사항은 다음과 같습니다.

  1. 다른 스레드에서 관찰 가능
  2. 다른 스레드에서 발생하는 유사한 수정을 존중합니다.

원자 성은 단지 명령어 수준이 아니라 프로세서에서 캐시를 통해 메모리로 돌아가는 전체 파이프 라인을 포함합니다.

추가 정보

의 업데이트 최적화 효과에 대해 std::atomic.

C ++ 표준에는 'as as'규칙이 있으며,이를 통해 컴파일러는 코드를 다시 정렬 할 수 있으며, 결과가 단순히 실행 된 것처럼 결과 와 동일한 관찰 가능한 효과 (부수 효과 포함)를 갖는 경우 코드를 다시 작성할 수 있습니다. 암호.

as-if 규칙은 보수적이며 특히 원자를 포함합니다.

치다:

void incdec(int& num) {
    ++num;
    --num;
}

스레드 간 시퀀싱에 영향을 미치는 뮤텍스 잠금, 원자 또는 기타 구성이 없기 때문에 컴파일러는이 기능을 NOP로 자유롭게 다시 작성할 수 있다고 주장합니다.

void incdec(int&) {
    // nada
}

이는 C ++ 메모리 모델에서 다른 스레드가 증가 결과를 관찰 할 가능성이 없기 때문입니다. 경우는 물론 다른 것 num이었다 volatile(힘의 영향 하드웨어 동작). 그러나이 경우이 기능은이 메모리를 수정하는 유일한 기능입니다 (그렇지 않으면 프로그램이 잘못 구성됨).

그러나 이것은 다른 볼 게임입니다.

void incdec(std::atomic<int>& num) {
    ++num;
    --num;
}

num원자입니다. 변경 사항 은보고있는 다른 스레드에서 확인할 수 있어야합니다 . 이러한 스레드 자체의 변경 (예 : 증분과 감소 사이의 값을 100으로 설정)은 num의 최종 값에 매우 광범위한 영향을 미칩니다.

데모는 다음과 같습니다.

#include <thread>
#include <atomic>

int main()
{
    for (int iter = 0 ; iter < 20 ; ++iter)
    {
        std::atomic<int> num = { 0 };
        std::thread t1([&] {
            for (int i = 0 ; i < 10000000 ; ++i)
            {
                ++num;
                --num;
            }
        });
        std::thread t2([&] {
            for (int i = 0 ; i < 10000000 ; ++i)
            {
                num = 100;
            }
        });
        
        t2.join();
        t1.join();
        std::cout << num << std::endl;
    }
}

샘플 출력 :

99
99
99
99
99
100
99
99
100
100
100
100
99
99
100
99
99
100
100
99

5
이것은 그 설명에 실패 add dword [rdi], 1입니다 하지 합니다 (없이 원자 lock접두사). 로드는 원자 적이며 저장소는 원자 적이지만 다른 스레드가로드와 저장소 간의 데이터 수정을 막는 것은 없습니다. 따라서 저장소는 다른 스레드에서 수정 한 단계를 수행 할 수 있습니다. jfdube.wordpress.com/2011/11/30/understanding-atomic-operations를 참조하십시오 . 또한 Jeff Preshing의 잠금없는 기사는 매우 훌륭 하며 해당 기사에서 기본적인 RMW 문제를 언급합니다.
Peter Cordes

3
실제로 여기서 진행되고있는 것은 gcc에서 아무도이 최적화를 구현하지 않았다는 것입니다. 왜냐하면 거의 쓸모없고 아마도 도움이되는 것보다 더 위험하기 때문입니다. (최소한의 놀라움의 원칙. 누군가 때때로 일시적인 상태가 가시적 일 것으로 예상하고 통계적 확률에 문제 있거나 수정을 방해하기 위해 하드웨어 감시 점을 사용하고있을 것입니다.) 잠금없는 코드는 신중하게 제작되어야합니다. 최적화 할 것이 없습니다. 코드를보고 생각을 의미하지 않을 수 있음을 코더에게 알리기 위해 경고를 인쇄하고 경고를 인쇄하는 것이 유용 할 수 있습니다!
Peter Cordes

2
그것은 아마도 컴파일러가 이것을 구현하지 않는 이유 일 것입니다 (최소한 놀라움의 원리 등). 실제 하드웨어에서 실제로 가능하다는 것을 관찰하십시오. 그러나 C ++ 메모리 순서 규칙은 한 스레드의로드가 C ++ 추상 시스템의 다른 스레드의 연산과 "균등하게"혼합된다는 보장에 대해 아무 말도하지 않습니다. 나는 여전히 합법적이지만 프로그래머 적대적이라고 생각합니다.
Peter Cordes

2
생각 실험 : 협력적인 멀티 태스킹 시스템에서 C ++ 구현을 고려하십시오. 교착 상태를 피하기 위해 필요한 위치에 항복점을 삽입하여 std :: thread를 구현하지만 모든 명령 사이에있는 것은 아닙니다. C ++ 표준의 무언가 num++와 사이에 항복점이 필요하다고 주장 할 것입니다 num--. 표준에서 요구하는 섹션을 찾으면 이것을 해결합니다. 나는 관찰자가 틀린 재정렬을 볼 수있는 사람이 없어야한다고 확신합니다. 수율이 필요하지 않습니다. 그래서 나는 그것이 단지 구현 품질 문제라고 생각합니다.
Peter Cordes

5
최종성을 위해, 나는 std 토론 메일 링리스트에 물었다. 이 질문은 Peter와 동의 한 것으로 보이는 두 가지 논문을 작성 했으며 wg21.link/p0062wg21.link/n4455 와 같은 최적화에 대한 우려를 해결 했습니다. 앤디에게 감사를 표했습니다.
Richard Hodges

38

많은 합병증이 없으면 같은 add DWORD PTR [rbp-4], 1CISC 스타일 의 명령 입니다.

메모리에서 피연산자를로드하고, 증가시키고, 피연산자를 다시 메모리에 저장하는 세 가지 작업을 수행합니다.
이러한 작업 동안 CPU는 버스를 두 번 획득하고 해제합니다. 다른 에이전트 간에도 버스를 획득 할 수 있으며 이는 원 자성을 위반합니다.

AGENT 1          AGENT 2

load X              
inc C
                 load X
                 inc C
                 store X
store X

X는 한 번만 증가합니다.


7
@LeoHeinsaar이를 위해서는 각 메모리 칩에는 자체 산술 논리 장치 (ALU)가 필요합니다. 실제로 각 메모리 칩 프로세서 여야합니다.
Richard Hodges

6
@LeoHeinsaar : 메모리 대상 명령어는 읽기-수정-쓰기 작업입니다. 아키텍처 레지스터는 수정되지 않지만 CPU는 데이터를 ALU를 통해 보내는 동안 내부적으로 데이터를 보유해야합니다. 실제 레지스터 파일은 가장 간단한 CPU 내부의 데이터 저장 장치 중 일부일 뿐이며, 한 단계의 출력을 다른 단계의 입력 등으로 유지하는 래치가 있습니다.
Peter Cordes

@PeterCordes 귀하의 의견은 내가 찾던 정답입니다. 마가렛의 대답은 저와 같은 일이 내부로 진행되어야한다고 의심하게 만들었습니다.
Leo Heinsaar

그 의견을 질문의 C ++ 부분을 다루는 것을 포함하여 완전한 답변으로 바꿨습니다.
Peter Cordes

1
@PeterCordes 매우 상세하고 모든 점에서 감사합니다. 그것은 분명히 데이터 경쟁이었고 C ++ 표준에 의해 정의되지 않은 동작이었습니다. 생성 된 코드가 게시 한 것이 원자적일 수 있다고 가정 할 수 있는지 궁금합니다. 적어도 인텔 개발자를 확인했습니다. 매뉴얼은 매우 명확하게 정의 원 자성을 에 대한 메모리 작업을 나는 가정으로, 그리고 명령 불가분성 : "잠금 작업이 다른 모든 메모리 작업 및 모든 외부에서 볼 수있는 이벤트에 대한 원자이다."
Leo Heinsaar

11

추가 명령은 원 자성 이 아닙니다 . 메모리를 참조하며 두 개의 프로세서 코어는 해당 메모리의 로컬 캐시가 다를 수 있습니다.

추가 명령의 원자 변형 인 IIRC를 잠금 xadd 라고합니다.


3
lock xaddC ++ std :: atomic을 구현 fetch_add하여 이전 값을 반환합니다. 필요하지 않으면 컴파일러는 lock접두사 와 함께 일반적인 메모리 대상 명령어를 사용 합니다. lock add또는 lock inc.
Peter Cordes

1
add [mem], 1캐시가없는 SMP 컴퓨터에서 여전히 원자 적이 지 않을 것입니다. 다른 답변에 대한 의견을 참조하십시오.
Peter Cordes

원자가 아닌 방법에 대한 자세한 내용은 내 대답을 참조하십시오. 또한 이 관련 질문에 대한 나의 대답의 끝 .
Peter Cordes

10

num ++에 해당하는 5 행이 하나의 명령이므로이 경우 num ++가 원자 적이라고 결론 내릴 수 있습니까?

"역 엔지니어링"생성 어셈블리를 기반으로 결론을 내리는 것은 위험합니다. 예를 들어 최적화가 비활성화 된 상태에서 코드를 컴파일 한 것 같습니다. 그렇지 않으면 컴파일러가 해당 변수를 버리거나 호출하지 않고 직접 변수를로드했을 것 operator++입니다. 생성 된 어셈블리는 최적화 플래그, 대상 CPU 등에 따라 크게 변경 될 수 있으므로 결론은 모래를 기준으로합니다.

또한 하나의 어셈블리 명령이 작업이 원자 적이라는 것을 의미한다는 생각도 잘못되었습니다. 이는 addx86 아키텍처에서도 다중 CPU 시스템에서는 원 자성이 아닙니다.


9

컴파일러가 항상 원자 연산으로 이것을 방출하더라도 num다른 스레드에서 동시에 액세스 하면 C ++ 11 및 C ++ 14 표준에 따라 데이터 레이스가 구성되며 프로그램에는 정의되지 않은 동작이 있습니다.

그러나 그것보다 더 나쁩니다. 먼저, 언급 된 바와 같이, 변수를 증가시킬 때 컴파일러에 의해 생성 된 명령은 최적화 레벨에 의존 할 수있다. 둘째, 컴파일러는 원자가 아닌 경우 다른 메모리 액세스를 재정렬 할 수 있습니다.++numnum

int main()
{
  std::unique_ptr<std::vector<int>> vec;
  int ready = 0;
  std::thread t{[&]
    {
       while (!ready);
       // use "vec" here
    });
  vec.reset(new std::vector<int>());
  ++ready;
  t.join();
}

우리 ++ready가 "원자" 라고 낙관적으로 가정 하고 컴파일러가 필요에 따라 검사 루프를 생성 한다고 가정하더라도 ( 유의 한 바와 같이 UB이므로 컴파일러는 자유롭게 제거하고 무한 루프로 대체 할 수 있습니다.) 컴파일러는 여전히 포인터 할당을 이동 시키거나 vector증분 연산 후 점으로 의 초기화를 악화 시켜 새 스레드에서 혼란을 야기 할 수 있습니다. 실제로 최적화 컴파일러가 ready변수와 검사 루프를 완전히 제거한 경우 언어 규칙에 따라 관찰 가능한 동작에 영향을 미치지 않으므로 (개인의 희망과는 달리) 전혀 놀라지 않을 것 입니다.

실제로 작년의 회의 C ++ 컨퍼런스에서, 두 명의 컴파일러 개발자 로부터 약간의 성능 향상이 있더라도 언어 규칙이 허용하는 한 순진한 멀티 스레드 프로그램을 오작동하게 만드는 최적화를 매우 기쁘게 구현한다고 들었습니다. 올바르게 작성된 프로그램에서.

마지막으로, 심지어 경우에 당신이 휴대 신경 쓰지 않았고, 컴파일러 마술 좋았어요, 당신이 사용하고있는 CPU는 매우 가능성이 슈퍼 스칼라 CISC 형이며, 마이크로 작전, 재주문 및 / 또는 추론을 실행에 지침을 무너 뜨리는 것, LOCK초당 작업을 최대화하기 위해 접두사 또는 메모리 펜스 와 같은 (인텔에서) 프리미티브를 동기화함으로써 만 제한됩니다 .

간단히 이야기하자면 스레드 안전 프로그래밍의 자연스러운 책임은 다음과 같습니다.

  1. 언어 규칙 (특히 언어 표준 메모리 모델)에 따라 올바르게 정의 된 동작을 갖는 코드를 작성해야합니다.
  2. 컴파일러의 임무는 대상 아키텍처의 메모리 모델에서 동일하게 정의 된 (관찰 가능한) 동작을 갖는 머신 코드를 생성하는 것입니다.
  3. 관찰 된 동작이 자체 아키텍처의 메모리 모델과 호환되도록 CPU는이 코드를 실행해야합니다.

자신의 방식으로 원한다면 어떤 경우에는 효과가있을 수 있지만 보증이 무효화되며 원치 않는 결과에 대한 책임은 전적으로 귀하에게 있음을 이해하십시오 . :-)

추신 : 올바르게 작성된 예 :

int main()
{
  std::unique_ptr<std::vector<int>> vec;
  std::atomic<int> ready{0}; // NOTE the use of the std::atomic template
  std::thread t{[&]
    {
       while (!ready);
       // use "vec" here
    });
  vec.reset(new std::vector<int>());
  ++ready;
  t.join();
}

다음과 같은 이유로 안전합니다.

  1. ready언어 규칙에 따라 확인을 최적화 할 수 없습니다.
  2. ++ready 전에-발생 본다 체크 ready하지 0으로하고, 다른 작업은 이러한 작업을 주변에 다시 정렬 할 수 없습니다. 이는 ++ready검사가 순차적으로 일관 되기 때문입니다. 이는 C ++ 메모리 모델에 설명 된 다른 용어이며이 특정 순서 변경을 금지합니다. 따라서 컴파일러는 명령어의 순서를 변경해서는 안되며, CPU vec에 증분 후 쓰기를 연기해서는 안된다고 CPU에 알려야합니다 ready. 순차적으로 일관성 은 언어 표준의 원자에 관한 가장 강력한 보증입니다. 보다 적은 (그리고 이론적으로 더 저렴한) 보증은 예를 들어std::atomic<T>그러나 이들은 전문가 전용이며 컴파일러 개발자는 거의 사용하지 않기 때문에 컴파일러에 의해 많이 최적화되지 않을 수 있습니다.

1
컴파일러가의 모든 사용을 볼 ready수 없다면 아마도 while (!ready);더 비슷한 것으로 컴파일 될 것 if(!ready) { while(true); }입니다. 공감 : std :: atomic의 핵심 부분은 의미를 변경하여 언제든지 비동기 수정을 가정하는 것입니다. 일반적으로 UB가되는 것은 컴파일러가로드를 끌어 올리고 루프에서 저장소를 싱크 할 수있게하는 것입니다.
Peter Cordes

9

단일 코어 x86 시스템에서 add명령은 일반적으로 CPU 1의 다른 코드와 관련하여 원 자성입니다 . 인터럽트는 단일 명령어를 중간으로 나눌 수 없습니다.

단일 코어 내에서 순서대로 한 번에 하나씩 실행되는 명령어의 환상을 유지하려면 비 순차적 실행이 필요하므로 동일한 CPU에서 실행되는 모든 명령어는 추가 전 또는 후에 완전히 발생합니다.

최신 x86 시스템은 멀티 코어이므로 단일 프로세서 특수 사례는 적용되지 않습니다.

소형 임베디드 PC를 대상으로하고 코드를 다른 것으로 옮길 계획이없는 경우 "추가"명령의 원자 적 특성을 이용할 수 있습니다. 다른 한편으로, 운영이 본질적으로 원자적인 플랫폼은 점점 더 부족 해지고 있습니다.

C ++로 작성하는 경우에는 도움이되지 않습니다. 컴파일러 에는 접두사 없이num++ 메모리 대상 add 또는 xadd로 컴파일 할 필요가 없습니다 . 레지스터 및 저장소에 로드하도록 선택할 수 있습니다 별도의 명령으로 결과를 증가 시키며 결과를 사용하면 그렇게 할 것입니다.)locknum


각주 1 : lock접두사는 I / O 장치가 CPU와 동시에 작동하기 때문에 원본 8086에도 존재했습니다. 단일 코어 시스템의 드라이버 lock add는 장치 메모리를 수정하거나 DMA 액세스와 관련하여 장치 메모리의 값을 원자 적으로 증가 시켜야 합니다.


일반적으로 원자 적이지는 않습니다. 다른 스레드가 동시에 동일한 변수를 업데이트 할 수 있으며 하나의 업데이트 만 대신합니다.
fuz

1
멀티 코어 시스템을 고려하십시오. 물론 하나의 핵심 내에서 명령은 원자 적이지만 전체 시스템과 관련하여 원자 적이지는 않습니다.
fuz September

1
@FUZxxl : 제 대답의 네 번째와 다섯 번째 단어는 무엇입니까?
supercat

1
@supercat 요즘은 단일 코어의 드문 경우 만 고려하고 OP에 잘못된 보안 감각을 부여하기 때문에 귀하의 대답은 매우 오도됩니다. 그렇기 때문에 멀티 코어 사례를 고려한 의견도 있습니다.
fuz

1
@ FUZxxl : 이것이 일반적인 현대 멀티 코어 CPU에 대해 이야기하고 있지 않다는 것을 눈치 채지 못한 독자들에게 혼란을 없애기 위해 편집했습니다. (또한 supercat이 확실하지 않은 일부 항목에 대해 더 구체적으로 설명하십시오). BTW, read-modify-write가 원자 "무료"인 플랫폼에 대한 마지막 문장을 제외 하고는이 답변의 모든 것이 이미 준비되어 있습니다.
Peter Cordes

7

x86 컴퓨터에 하나의 CPU가 있었던 시절, 단일 명령을 사용하면 인터럽트가 읽기 / 수정 / 쓰기를 분할하지 않으며 메모리가 DMA 버퍼로도 사용되지 않으면 사실상 원자 적이었습니다. C ++은 표준에서 스레드를 언급하지 않았 으므로이 문제는 해결되지 않았습니다).

고객 데스크탑에 듀얼 프로세서 (예 : 듀얼 소켓 Pentium Pro)가 거의없는 경우, 단일 코어 시스템에서 LOCK 접두사를 피하고 성능을 향상시키기 위해이 프로세서를 효과적으로 사용했습니다.

오늘날에는 모두 동일한 CPU 선호도로 설정된 여러 스레드에 대해서만 도움이되므로 걱정되는 스레드는 시간 조각이 만료되고 다른 스레드를 동일한 CPU (코어)에서 실행해야만 작동합니다. 현실적이지 않습니다.

최신 x86 / x64 프로세서를 사용하면 단일 명령이 여러 개의 마이크로 연산으로 구분 되며 메모리 읽기 및 쓰기가 버퍼링됩니다. 다른 CPU에서 실행 그래서 다른 스레드는 비 원자로서이 표시되지 않습니다하지만 메모리에서 무엇을 읽고 그것을 다른 스레드가 시간에 그 시점에 읽고있는 것을 전제로 무엇에 관한 일관성없는 결과가 나타날 수 있습니다 추가 할 필요가 메모리 울타리를 제정신을 복원을 행동.


1
그들은 너무 인터럽트는 여전히 분할하지 RMW 작업을 수행 여전히 시그널 핸들러와 함께 단일 스레드를 동기화하는 동일한 스레드에서 실행됩니다. 물론 이것은 asm이 단일 명령을 사용하고 별도의로드 / 수정 / 저장이 아닌 경우에만 작동합니다. C ++ 11 은이 하드웨어 기능을 노출 할 수는 있지만 실제로는 아닙니다 (아마도 단일 프로세서 커널에서만 신호 처리기가있는 사용자 공간이 아닌 인터럽트 처리기와 동기화하는 것이 유용했기 때문에). 또한 아키텍처에는 읽기-수정-쓰기 메모리 대상 명령이 없습니다. 그래도 x86이 아닌 곳에서 완화 된 원자 RMW처럼 컴파일 할 수 있습니다
Peter Cordes

내가 기억 하겠지만, Lock 프리픽스를 사용하는 것은 슈퍼 스케일러가 등장 할 때까지 터무니없이 비싸지 않았다. 따라서 프로그램에서 필요하지 않더라도 486에서 중요한 코드가 느려지는 것으로 알릴 이유가 없었습니다.
JDługosz

맞아 미안해! 나는 실제로주의 깊게 읽지 않았습니다. 나는 Uops 로의 디코딩에 관한 빨간 청어와 함께 단락의 시작을 보았고 실제로 당신이 말한 것을보기 위해 독서를 끝내지 않았습니다. re : 486 : 초기 SMP가 일종의 Compaq 386이라는 것을 읽었지만 메모리 순서 의미론은 x86 ISA가 현재 말하는 것과 같지 않았습니다. 현재 x86 매뉴얼에는 SMP 486이 언급되어있을 수도 있습니다. 그러나 PPro / Athlon XP 일까지는 HPC (Beowulf 클러스터)에서도 일반적으로 사용되지 않았습니다.
Peter Cordes

1
@PeterCordes Ok. 물론 DMA / 장치 관찰자도 없다고 가정하면 주석 영역에 포함되지 않았습니다. 훌륭한 추가를위한 JDługosz에게 감사의 말을 전한다. 정말 토론을 완료했습니다.
Leo Heinsaar

3
@Leo : 언급되지 않은 한 가지 핵심 사항 : 비 순차적 CPU는 내부적으로 물건을 재정렬하지만, 가장 중요한 규칙은 단일 코어의 경우 한 번에 하나씩 실행되는 명령의 환상을 순서대로 유지한다는 것입니다. 여기에는 컨텍스트 전환을 트리거하는 인터럽트가 포함됩니다. 값은 순서대로 메모리에 전기적으로 저장 될 수 있지만 모든 것이 실행되는 단일 코어는 환영을 유지하기 위해 모든 자체 순서를 추적합니다. 이것이 a = 1; b = a;방금 저장 한 1을 올바르게로드하는 것과 동일한 메모리에 대한 메모리 장벽이 필요없는 이유 입니다.
Peter Cordes

4

아니요. https://www.youtube.com/watch?v=31g0YE61PLQ ( "사무실"의 "아니오"장면에 대한 링크 일뿐)

이것이 프로그램에 가능한 출력 일 것이라는 데 동의하십니까?

샘플 출력 :

100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100

그렇다면 컴파일러는 컴파일러가 원하는 방식으로 프로그램 의 유일한 출력을 자유롭게 만들 수 있습니다. 즉, 100을 나타내는 main ()입니다.

이것이 "있는 그대로"규칙입니다.

그리고 관계없이 출력, 당신은 스레드 동기화의 같은 방법을 생각할 수 - 스레드 A가하는 경우 num++; num--;와 스레드 B 읽기 num를 반복하고 가능한 유효한 인터리빙은 스레드 B 사이 읽고되지 않습니다 num++num--. 인터리빙이 유효하기 때문에 컴파일러는 인터리빙을 유일 하게 가능한 인터리빙 으로 만들 수 있습니다. 그리고 incr / decr을 완전히 제거하십시오.

여기에 흥미로운 의미가 있습니다.

while (working())
    progress++;  // atomic, global

(즉, 다른 스레드가에 따라 진행률 표시 줄 UI를 업데이트한다고 상상해보십시오 progress)

컴파일러가 이것을 다음으로 바꿀 수 있습니까?

int local = 0;
while (working())
    local++;

progress += local;

아마 그것은 유효합니다. 그러나 아마도 프로그래머가 바라는 것은 아닐 것입니다 :-(

위원회는 여전히이 일을하고 있습니다. 컴파일러는 원자를 많이 최적화하지 않기 때문에 현재 "작동"합니다. 그러나 그것은 변화하고 있습니다.

그리고 경우에도 progress휘발성이고, 이것은 여전히 유효 할 것입니다 :

int local = 0;
while (working())
    local++;

while (local--)
    progress++;

:-/


이 답변은 Richard와 제가 숙고하고있는 부수적 인 질문에만 답하는 것 같습니다. 우리는 결국 그것을 해결했습니다. 그렇습니다. C ++ 표준 volatile 다른 규칙을 위반하지 않을 때 원자가 아닌 객체 에 대한 작업 병합을 허용 합니다. 두 가지 표준 토론 문서 에서 동일한 진행 상황 예제를 사용하는 두 가지 표준 토론 문서 ( Richard 's comment 링크)를 정확하게 설명합니다 . 따라서 C ++에서이를 방지하는 방법을 표준화 할 때까지 구현 품질 문제입니다.
Peter Cordes

네, "아니오"는 사실 모든 추론에 대한 답입니다. 질문이 단지 "일부 컴파일러 / 구현에서 num ++가 원 자성이 될 수있다"라면 대답은 확실합니다. 예를 들어, 컴파일러는 lock모든 작업 에 추가 하기로 결정할 수 있습니다 . 또는 순서를 바꾸지 않은 (즉 "좋은 일") 모든 것이 원자 적이 지 않은 컴파일러 + 단일 프로세서 조합. 하지만 요점이 뭐야? 당신은 그것에 의존 할 수 없습니다. 당신이 알고있는 시스템이 아니라면. (아마도 atomic <int>는 그 시스템에 추가 ops를 추가하지 않는 것이 좋습니다. 따라서 표준 코드를 작성해야합니다 ...)
tony

1
그것은 And just remove the incr/decr entirely.옳지 않다는 점 에 유의하십시오 . 에 대한 획득 및 릴리스 작업입니다 num. x86에서 num++;num--MFENCE로 컴파일 할 수는 있지만 아무것도 아닙니다. (컴파일러의 전체 프로그램 분석이 그 수의 수정과 동기화되는 것이 없으며, 그 이전의 일부 상점이 그 이후로드 이후까지 지연되는지 여부는 중요하지 않음을 제외하고는) 예를 들어 잠금 해제 및 다시 바로 사용 가능한 유스 케이스로, 하나의 큰 섹션이 아닌 두 개의 중요한 섹션 (mo_relaxed를 사용하는 경우도 있음)이 여전히 있습니다.
Peter Cordes

@PeterCordes 아 네, 동의했습니다.
tony

2

네,하지만...

원자는 당신이 말하려는 것이 아닙니다. 아마도 잘못된 것을 묻고있을 것입니다.

증분은 확실히 원자 적 입니다. 스토리지가 잘못 정렬되어 있지 않으면 (컴파일러에 정렬 된 상태가 아니므로) 단일 캐시 라인 내에 정렬되어야합니다. 캐싱하지 않는 특별한 스트리밍 명령이 부족하므로 각 쓰기마다 캐시를 ​​통과합니다. 완전한 캐시 라인은 원자 적으로 읽고 쓰지만 결코 다르지 않습니다.
캐시 라인보다 작은 데이터는 물론 주변 캐시 라인이 있기 때문에 원자 적으로 작성됩니다.

스레드 안전합니까?

이것은 다른 질문이며, "아니오!"라고 대답해야하는 두 가지 이유가 있습니다 . .

첫째, 다른 코어가 L1에 해당 캐시 라인의 사본을 가지고있을 가능성이 있으며 (L2 이상은 일반적으로 공유되지만 L1은 보통 코어 당입니다!) 그 값을 동시에 수정합니다. 물론 그것은 원자 적으로도 발생하지만 이제는 두 개의 "올바른"(올바르게, 원자 적으로, 수정 된) 값이 있습니다.
CPU는 물론 어떻게 든 정렬 할 것입니다. 그러나 결과가 예상과 다를 수 있습니다.

둘째, 보장하기 전에 메모리 순서가 있거나 다르게 표시됩니다. 원자 지침에 대한 가장 중요한 것은 순전히 그들이이지 않는다 원자 . 주문하는 중이 야

메모리 측면에서 발생하는 모든 것이 "이전에 발생 했음"을 보증하는 확실하고 명확한 순서로 실현된다는 보장을 시행 할 수 있습니다. 이 순서는 "완화"(필수 : 없음) 또는 필요에 따라 엄격 할 수 있습니다.

예를 들어, 일부 데이터 블록 (예 : 일부 계산 결과)에 대한 포인터를 설정 한 다음 "data is ready"플래그 를 원자 적으로 해제 할 수 있습니다. 이제이 플래그를 얻는 사람 포인터가 유효하다고 생각하게됩니다. 실제로, 그것은 항상 유효한 포인터 일 것입니다. 원자 연산 이전에 포인터 쓰기가 발생했기 때문입니다.


2
로드와 저장소는 각각 개별 원자이지만 전체 읽기-수정-쓰기 작업은 전체적으로 원 자성 이 아닙니다 . 캐시는 일관성이 있으므로 동일한 행의 충돌 사본을 보유 할 수 없습니다 ( en.wikipedia.org/wiki/MESI_protocol ). 다른 코어는 읽기 전용 사본을 가질 수 없지만이 코어는 수정 된 상태입니다. 원자가 아닌 이유는 RMW를 수행하는 코어가로드와 저장소 간의 캐시 라인 소유권을 잃을 수 있다는 것입니다.
Peter Cordes

2
또한, 전체 캐시 라인이 항상 원자 적으로 전송되는 것은 아닙니다. 참조 이 답변 그들이하더라도, 실험적으로 멀티 소켓 옵테론은 하이퍼 트랜스 포트와 8B 덩어리에서 캐시 라인을 전송하여 16B SSE 저장 비 원자를 만드는 것을 증명 것, 입니다 (같은 종류의 단일 소켓 CPU에 대한 원자 부하 때문에 / 저장소 하드웨어에는 L1 캐시에 대한 16B 경로가 있습니다. x86은 별도의 부하에 대한 원 자성을 보장하거나 최대 8B를 저장합니다.
Peter Cordes

컴파일러에 정렬을 유지한다고해서 메모리가 4 바이트 단위로 정렬되는 것은 아닙니다. 컴파일러는 정렬 경계를 변경하기위한 옵션 또는 pragma를 가질 수 있습니다. 예를 들어 네트워크 스트림에서 압축 된 데이터를 조작하는 데 유용합니다.
Dmitry Rubanovich

2
소피스트 리, 그 밖의 것은 없습니다. 예제에 표시된 것처럼 구조체의 일부가 아닌 자동 저장 장치가있는 정수는 절대적 으로 정확하게 정렬됩니다. 다른 것을 주장하는 것은 멍청한 일입니다. 캐시 라인과 모든 POD는 전 세계의 비순환 아키텍처에서 PoT (2의 거듭 제곱) 크기와 정렬을 갖습니다. 수학에 따르면 올바르게 정렬 된 PoT는 동일한 크기 이상의 다른 PoT 중 정확히 하나 이상에 적합합니다. 그러므로 나의 진술은 정확하다.
데이먼

1
질문에 주어진 예제 인 @Damon은 구조체를 언급하지 않지만 정수가 구조체의 일부가 아닌 상황으로 질문을 좁히지는 않습니다. POD는 PoT 크기를 가질 수 있으며 PoT와 정렬되지 않을 수 있습니다. 구문 예제는 stackoverflow.com/a/11772340/1219722에 대한이 답변을 살펴보십시오 . 따라서 이러한 방식으로 선언 된 POD는 실제 코드에서 네트워킹 코드에 상당히 사용되기 때문에 "정교성"이 아닙니다.
Dmitry Rubanovich

2

특정 CPU 아키텍처에서 최적화가 비활성화 된 단일 컴파일러의 출력 (gcc가 빠른 & 더러운 예에서 최적화 ++add때 컴파일되지 않기 때문에 )이 방식으로 증가하는 것이 원자 적이라고 암시하는 것은 이것이 표준 준수를 의미하지는 않습니다 ( 당신은 액세스하려고 할 때 정의되지 않은 동작이 발생할 것입니다 때문에, 스레드에서), 그리고 어쨌든 잘못 입니다 하지 86의 원자.numadd

lockx86에서 원자 ( 명령 접두사 사용)는 상대적으로 무겁지 만 ( 이 관련 답변 참조 ) 여전히 뮤텍스보다 작습니다.이 유스 케이스에는 적합하지 않습니다.

로 컴파일 할 때 clang ++ 3.8에서 다음 결과를 가져옵니다 -Os.

참조로 "정규"방식으로 int를 늘리는 방법 :

void inc(int& x)
{
    ++x;
}

이것은 다음으로 컴파일됩니다.

inc(int&):
    incl    (%rdi)
    retq

원자 적 방법으로 참조로 전달 된 int 늘리기 :

#include <atomic>

void inc(std::atomic<int>& x)
{
    ++x;
}

일반적인 방법보다 훨씬 복잡하지 않은이 예제 lockincl명령에 접두사를 추가하기 만합니다. 그러나 앞에서 언급 한 것처럼 저렴 하지는 않습니다 . 조립이 짧아 보인다고해서 빠르지는 않습니다.

inc(std::atomic<int>&):
    lock            incl    (%rdi)
    retq

-2

컴파일러가 증분에 단일 명령 만 사용하고 시스템이 단일 스레드 인 경우 코드가 안전합니다. ^^


-3

x86 이외의 컴퓨터에서 동일한 코드를 컴파일하면 매우 다른 어셈블리 결과를 빠르게 볼 수 있습니다.

그 이유는 num++ 표시 86 시스템에서, 32 비트 정수를 증분하기 때문에, 실제로는, 원자 (단, 메모리 검색 없다고 가정하면 일어난다) 인 원자한다. 그러나 이것은 c ++ 표준에 의해 보장되지 않으며 x86 명령어 세트를 사용하지 않는 시스템에서도 마찬가지입니다. 따라서이 코드는 경쟁 조건에서 크로스 플랫폼 안전하지 않습니다.

또한 x86은 특별한 지시가없는 한 메모리에로드 및 저장을 설정하지 않기 때문에 x86 아키텍처에서도이 코드가 경쟁 조건으로부터 안전하다는 보장이 없습니다. 따라서 여러 스레드가이 변수를 동시에 업데이트하려고하면 캐시 된 (오래된) 값이 증가 할 수 있습니다.

따라서 우리가 가지고있는 이유 std::atomic<int>는 기본 계산의 원 자성이 보장되지 않는 아키텍처로 작업 할 때 컴파일러가 원자 코드를 생성하도록하는 메커니즘을 가지고 있기 때문입니다.


"x86 시스템에서 32 비트 정수를 증가시키는 것은 사실상 원자 적입니다." 이를 증명하는 문서에 대한 링크를 제공 할 수 있습니까?
Slava

8
x86에서도 원 자성이 아닙니다. 단일 코어 안전하지만 여러 코어가 있고 원자가 없다면 전혀 원자 적이 지 않습니다.
harold

x86은 add실제로 원 자성을 보장합니까? 레지스터 증분이 원자 적이라고해도 놀라지 않을 것입니다. 그러나 그다지 유용하지는 않습니다. 레지스터 증가를 다른 스레드에서 볼 수있게하려면 메모리에 있어야하며로드 및 저장을위한 추가 명령어가 필요하므로 원 자성을 제거해야합니다. 나는 이것이 lock지침에 접두사가 존재하는 이유입니다 . 유일하게 유용한 원자 add는 역 참조 된 메모리에 적용되며 lock접두어를 사용 하여 작업 기간 동안 캐시 라인이 잠겨 있는지 확인합니다 .
ShadowRanger

@Slava @Harold @ShadowRanger 답변을 업데이트했습니다. add변경 사항은 전 세계적으로 볼 수 없기 때문에 코드가 경쟁 조건에 안전하다는 것을 의미하지는 않습니다.
Xirema

3
@Xirema는 정의상 "원자가 아님"
해롤드
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.