IT

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

lottoking 2020. 6. 13. 09:35
반응형

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


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

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

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

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

최신 정보

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


이것은 하나의 컴파일러가 일부 대상 시스템에서 원하는 것을 수행하는 코드를 생성하더라도 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를하기 때문에 't 디코드 여러 간단한 마이크로 작업 복잡한 지시 P6 이후 마이크로 아키텍처가하는 방법입니다. 참고 항목 Agner 안개의 지시 테이블 / 마이크로 아키텍처 가이드 대한 추가 정보를 원하시면을하고 있는 인텔의 x86 ISA 매뉴얼, 등 많은 유용한 링크 (대한 태그 위키 PDF로 무료로 제공)).


대상 메모리 모델 (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임시의 전체 수명 동안 다른 객체가 존재 함을 입증 할 수 있는 경우, 사본의 생성 및 소멸 시 원자 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

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

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

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

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

AGENT 1          AGENT 2

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

X는 한 번만 증가합니다.


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

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


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

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

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


컴파일러가 항상 원자 연산으로 이것을 방출하더라도 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>그러나 이들은 전문가 전용이며 컴파일러 개발자는 거의 사용하지 않기 때문에 컴파일러에 의해 많이 최적화되지 않을 수 있습니다.

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

단일 코어 내에서 순서대로 한 번에 하나씩 실행되는 명령어의 환상을 유지하려면 순서가 잘못된 실행이 필요하므로 동일한 CPU에서 실행되는 명령어는 추가 전 또는 후에 완전히 수행됩니다.

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

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

당신 ++ C에있어 쓰는 경우 (이하지만, 도움이되지 않습니다. 컴파일러가 필요로하는 옵션이없는 num++메모리 - 대상 추가로 컴파일 또는 XADD 할 수 없이lock 접두사를. 그들은로드하도록 선택할 수 있습니다 num레지스터 및 저장에 별도의 명령으로 결과를 증가시키고 결과를 사용하면 증가 할 것입니다.)


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


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

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

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

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


아니요. 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++;

:-/


네,하지만...

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

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

스레드 안전합니까?

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

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

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

메모리 측면에서 발생하는 모든 것이 "이전에 발생 했음"을 보증하는 확실하고 명확한 순서로 실현된다는 보장을 시행 할 수 있습니다. 이 순서는 "이완 된"(아무것도 읽지 않음) 또는 필요한만큼 엄격 할 수 있습니다.

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


최적화가 비활성화 된 특정 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

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


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

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

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

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

참고 URL : https://stackoverflow.com/questions/39393850/can-num-be-atomic-for-int-num

반응형