source

num++는 'int num'의 원자일 수 있습니까?

factcode 2022. 8. 17. 23:44
반응형

num++는 'int num'의 원자일 수 있습니까?

일반적으로는int num,num++(또는++num)는, 읽기/쓰기 조작으로서 atomic이 아닙니다.다만, GCC등의 컴파일러가 다음의 코드를 생성하는 것을 자주 볼 수 있습니다(여기서 시험해 보십시오).

void f()
{
  int num = 0;
  num++;
}
f():
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-4], 0
        add     DWORD PTR [rbp-4], 1
        nop
        pop     rbp
        ret

5행째, 이것은 다음 행에 대응하고 있습니다.num++한 가지 지시입니다만, 우리가 결론을 내릴 수 있을까요?num++ 케이스에 원자력이 있나요?

그렇다면 데이터 경쟁의 위험 없이 동시(멀티 스레드) 시나리오에서 이렇게 생성된 제품을 사용할 수 있다는 의미입니까?std::atomic<int>(어쨌든 원자력이기 때문에) 관련 비용을 부과하는 것은 어떨까요?

갱신하다

이 질문은 증분이 원자적인지 여부가 아닙니다(증분이 원자적인지 여부도 아니고 질문의 첫줄이며, 이 행은 원자적인지 여부도 아닙니다).특정 시나리오, 즉 특정 상황에서 하나의 명령 특성을 이용하여 데이터 처리의 오버헤드를 회피할 수 있는지 여부입니다.lock프레픽스또한 이 답변뿐만 아니라 유니프로세서머신에 관한 항에서도 인정된 답변이 언급되었듯이 코멘트 등의 컨버세이션에 의해 설명되고 있습니다(C 또는 C++는 아니지만).

이것은 C++가 정의한 데이터 레이스로서 정의되지 않은 동작을 일으키는 것입니다.단, 1개의 컴파일러가 일부 타깃머신에서 원하는 코드를 생성했을 경우에도 마찬가지입니다.를 사용해야 합니다.std::atomic신뢰할 수 있는 결과를 얻을 수 있지만memory_order_relaxed다시 주문하는 것에 신경 안 쓴다면요.코드와 asm 출력의 예에 대해서는,fetch_add.


하지만 먼저 어셈블리의 언어 부분:

num++는 하나의 명령(add dword [num], 1이 경우 num++는 원자라고 결론지을 수 있습니까?

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

다른 CPU로부터의 메모리 동작은 로드와 스토어 사이에서 글로벌하게 확인할 수 있습니다.즉, 2개의 스레드 실행add dword [num], 1서로의 가게를 밟을 수도 있어요(좋은 그림은 @Margaret의 답변을 참조하십시오).2개의 스레드 각각에서 40,000씩 증가하면 실제 멀티코어 x86 하드웨어에서는 카운터가 최대 60,000(80,000이 아님)만 증가했을 수 있습니다.


"원자"는 분리할 수 없다는 뜻의 그리스 단어에서 유래한 것으로, 어떤 관찰자도 작전을 별개의 단계로 볼 수 없다는 것을 의미한다. 모든 비트에 대해 물리적/전기적으로 동시에 발생하는 것은 로드 또는 저장 시 이를 달성하는 한 가지 방법일 뿐이지만, ALU 작업에서는 가능하지 않습니다.x86Atomicity에 대한 답변에서 순수 부하와 순수 저장소에 대해 더 자세히 설명했지만, 이 답변은 읽기-수정-기입에 초점을 맞추고 있습니다.

프리픽스는 시스템의 모든 가능한 관찰자(CPU 핀에 연결된 오실로스코프가 아닌 다른 코어 및 DMA 장치)에 대해 전체 작업을 원자적으로 만들기 위해 많은 읽기-수정-쓰기(메모리 대상) 명령에 적용할 수 있습니다.그것이 존재하는 이유입니다.(이 Q&A도 참조).

원자도 마찬가지야.이 명령을 실행하는 CPU 코어는 로드가 캐시에서 데이터를 읽을 때부터 저장소가 결과를 캐시로 다시 커밋할 때까지 캐시 라인을 개인 L1 캐시에 수정 상태로 고정시킵니다.이를 통해 MESI 캐시 일관성 프로토콜(또는 멀티코어 AMD/Intel CPU에서 사용되는 MESSI/MESIF 버전)의 규칙에 따라 시스템 내의 다른 캐시가 로드에서 저장까지 캐시 라인의 복사본을 갖지 못하게 됩니다.따라서 다른 코어에 의한 작업은 실행 중이 아니라 실행 전후에 발생하는 것으로 보입니다.

미포함lockprefix, 다른 코어가 캐시 라인의 소유권을 가져와서 로드 후 스토어 전에 변경할 수 있습니다.그러면 로드와 스토어 사이에 다른 스토어가 글로벌하게 표시되게 됩니다.몇 가지 다른 답변이 틀리고 있습니다.lock동일한 캐시 라인의 중복되는 복사본을 얻을 수 있습니다.이는 일관성 있는 캐시가 있는 시스템에서는 절대 발생할 수 없습니다.

(만약,locked 명령은 2개의 캐시 라인에 걸쳐 있는 메모리에서 작동하며 오브젝트 양쪽 부분의 변경이 모든 옵서버에 전파될 때 원자적인 상태를 유지하도록 하려면 훨씬 더 많은 작업이 필요합니다. 따라서 옵서버는 찢어지는 것을 볼 수 없습니다.데이터가 메모리에 도달할 때까지 CPU는 메모리 버스 전체를 잠가야 할 수 있습니다.원자 변수를 잘못 정렬하지 마십시오.

주의:lockprefix는 명령어를 완전한 메모리 장벽(MFENCE 등)으로 변환하여 모든 런타임 순서를 중지하고 시퀀스의 일관성을 제공합니다.(Jeff Presing의 훌륭한 블로그 투고를 참조해 주세요.그의 다른 게시물들도 모두 훌륭하며, x86 및 기타 하드웨어 세부 사항부터 C++ 규칙까지 잠금 없는 프로그래밍에 대한 많은 장점을 명확하게 설명하고 있습니다.)


단일 프로세서 머신 또는 단일 스레드 프로세스에서는 단일 RMW 명령이 실제로는 Atomic입니다.lock프레픽스다른 코드가 공유 변수에 액세스할 수 있는 유일한 방법은 CPU가 컨텍스트 전환을 수행하는 것입니다. 이 전환은 명령 도중 수행될 수 없습니다.그래서 평범해dec dword [num]는 싱글코어 프로그램과 그 신호 핸들러 또는 싱글코어 머신 상에서 동작하는 멀티코어 프로그램으로 동기화할 수 있습니다.다른 질문에 대한 답변의 후반부와 그 아래에 있는 코멘트를 참조해 주세요.이것에 대해서는, 한층 더 자세하게 설명합니다.


C++로 돌아가기:

사용하기에는 완전히 가짜입니다.num++컴파일러에 컴파일러가 1개의 읽기-쓰기-쓰기 구현으로 컴파일할 필요가 있음을 알리지 않습니다.

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

이것은, 다음의 값을 사용하는 경우에 매우 가능성이 있습니다.numlater: 컴파일러는 증분 후 레지스터에 라이브로 유지합니다.그래서 네가 확인해도num++주변 코드를 변경하면 영향을 미칠 수 있습니다.

(나중에 값이 필요 없는 경우는,inc dword [num]권장되는 것은 최신 x86 CPU입니다.최소한 메모리 수신처 RMW 명령어는 3개의 개별 명령을 사용하는 것과 같은 효율로 실행됩니다.재미있는 사실: (Pentium) P5의 슈퍼칼라 파이프라인은 P6 및 이후 마이크로아키텍처처럼 복잡한 명령어를 여러 간단한 마이크로 오퍼레이션에 디코딩하지 않았기 때문에 실제로 이 명령어가 발생합니다.상세한 것에 대하여는, Agner Fog 의 설명표/마이크로 아키텍쳐(architecture) 가이드를 참조해 주세요.또, 많은 링크에 는, x86 태그 Wiki 를 참조해 주세요(인텔의 x86 ISA 메뉴얼은 PDF 로서 무료로 입수할 수 있습니다).


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

컴파일 타임의 순서변경할 수 있습니다.std:: atomic을 통해 얻을 수 있는 다른 부분은 컴파일 시간 순서 변경에 대한 제어입니다.num++는 다른 조작 후에만 글로벌하게 표시됩니다.

전형적인 예:일부 데이터를 다른 스레드가 볼 수 있도록 버퍼에 저장한 다음 플래그를 설정합니다.x86은 로드/릴리스 스토어를 무료로 취득하지만 컴파일러에 명령어를 사용하여 재주문하지 않도록 해야 합니다.flag.store(1, std::memory_order_release);.

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

// int flag;  is just a plain global, not std::atomic<int>.
flag--;           // Pretend this is supposed to be some kind of locking attempt
modify_a_data_structure(&foo);    // doesn't look at flag, and the compiler 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는 std:: atomic을 대체할 수 없습니다.std:: atomic은 메모리 내의 값이 다음과 같이 비동기적으로 변경될 수 있다고 컴파일러가 가정하도록 합니다.volatile(실제로 volatile int와 std::atomic 사이에는 pure-load 및 pure-store 조작을 위한 mo_relaxed와의 유사성이 있지만 RMW에는 해당되지 않습니다).또한.volatile std::atomic<int> foo반드시 같은 것은 아니다std::atomic<int> foo다만, 현재의 컴파일러는 아토믹스를 최적화하지 않기 때문에(예를 들어 같은 값의 2개의 백투백스토어), 휘발성 아토믹은 코드 gen을 변경하지 않습니다.

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


말씀드렸듯이 x86 프리픽스는 완전한 메모리 장벽이기 때문에num.fetch_add(1, std::memory_order_relaxed);x86에서 다음과 같은 코드를 생성합니다.num++(디폴트는 시퀀셜의 일관성입니다만, 다른 아키텍처(ARM 등)에서는 훨씬 효율적입니다.x86에서도 릴렉스 기능을 사용하면 컴파일 타임의 순서를 변경할 수 있습니다.

이것은 GCC가 x86에서 실제로 실행하는 동작입니다.std::atomic글로벌 변수

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 Presing의 「Memory Reordering Caught in the Act」는 MFENCE를 사용하지 않을 경우의 결과를 실제 하드웨어에서 발생하는 순서를 나타내는 실제 코드를 사용하여 보여줍니다.


Re: 컴파일러가 std:: atomic 연산을 하나의 명령으로 병합하는 것에 대한 @Richard Hodges의 답변에 대한 설명:

동일한 주제에 대한 별도의 Q&A:컴파일러가 중복된 std::atomic writes?Marge하는 것은 어떨까요?여기서 제 답변은 제가 아래에 쓴 많은 내용을 재작성하고 있습니다.

현재 컴파일러는 실제로 (아직은) 이 작업을 수행하지는 않지만, 권한이 없기 때문에 수행하지는 않습니다.C++ WG21/P0062R1: 컴파일러는 언제 아토믹스를 최적화해야 합니까?에서는 컴파일러가 "최적화" 최적화를 하지 않을 것이라는 많은 프로그래머들의 예상과 프로그래머에게 제어권을 부여하기 위해 표준이 무엇을 할 수 있는지에 대해 설명합니다.N4455 에서는, 이것을 포함해 최적화할 수 있는 많은 예에 대해 설명하고 있습니다.그것은 인라이닝과 지속적인 전파가 다음과 같은 것들을 도입할 수 있다고 지적한다.fetch_or(0)어쩌면 이 모든 것이 단지 한낱 인간으로 변할 수도 있다.load()(단, 시멘틱스 취득과 릴리스는 아직 유효합니다).원래의 소스에는 명백한 장황한 atomic ops가 없었던 경우에도 마찬가지입니다.

컴파일러가 그것을 하지 않는 진짜 이유는 (1) 컴파일러가 그것을 안전하게(틀리지 않고) 할 수 있는 복잡한 코드를 아무도 작성하지 않았기 때문이고, (2) 그것은 잠재적으로 덜 놀랍다는 원칙을 위반할 수 있기 때문입니다.잠금 없는 코드는 애초에 올바르게 쓰기에 충분히 어렵다.따라서 핵무기를 사용할 때 무심코 사용해서는 안 됩니다.핵무기는 저렴하지도 않고 최적화도 잘 되지 않습니다.에 의한 중복된 원자공작전을 피하는 것이 항상 쉬운 일은 아닙니다.std::shared_ptr<T>단, 그것의 비원자 버전은 없기 때문에 (여기서 답변 중 하나가 정의하기 쉬운 방법을 제공하지만)shared_ptr_unsynchronized<T>(gcc의 경우)


로 되돌아가다num++; num-=2;마치 그런 것처럼 편찬하다num--: 컴파일러는 이 작업을 수행할 수 있습니다.단,numvolatile std::atomic<int>정렬이 가능한 경우 as-if 규칙에 따라 컴파일러는 컴파일 시 항상 이와 같이 처리됨을 결정할 수 있습니다.관찰자가 중간값을 볼 수 있다는 것을 보증하는 것은 아무것도 없습니다.num++결과)를 참조해 주세요.

즉, 이러한 조작 사이에 아무것도 글로벌하게 표시되지 않는 순서가 소스 순서 요건(타깃 아키텍처가 아닌 추상 머신의 C++ 규칙에 따라)과 호환성이 있는 경우 컴파일러는 1개의 명령어를 내보낼 수 있습니다.lock dec dword [num]대신lock inc dword [num]/lock sub dword [num], 2.

num++; num--다른 스레드와 동기화 관계가 있기 때문에 삭제할 수 없습니다.numacquire-load와 릴리스 스토어 모두 이 스레드 내의 다른 작업의 순서를 변경할 수 없습니다.x86 의 경우, 이것은 MFENCE 로 컴파일 할 수 있습니다.lock add dword [num], 0(즉,num += 0).

PR0062에서 설명한 바와 같이 컴파일 시 인접하지 않은 원자 연산을 보다 적극적으로 병합하는 것은 나쁠 수 있습니다(예: 프로그레스 카운터는 모든 반복이 아닌 마지막에 한 번만 갱신됩니다). 그러나 다운사이드 없이 퍼포먼스에 도움이 될 수 있습니다(예: 복사 시 참조 카운트의 원자 연산을 건너뛰는 것).shared_ptr컴파일러가 다른 컴파일러를 증명하면 생성 및 파괴됩니다.shared_ptr오브젝트는 일시적인 라이프 사이클 전체에 걸쳐 존재합니다).

심지어.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

많은 복잡함 없이 다음과 같은 지시사항add DWORD PTR [rbp-4], 1매우 CISC 스타일입니다.

피연산자를 메모리에서 로드, 증가, 피연산자를 메모리에 다시 저장하는 세 가지 작업을 수행합니다.
이러한 조작 중에 CPU는 버스를 취득하고 해방합니다.그 사이에 다른 에이전트도 버스를 취득할 수 있기 때문에, 이것은 원자성을 위반합니다.

AGENT 1          AGENT 2

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

X는 1회만 증가합니다.

...이제 최적화를 실현합니다.

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::atomics.

c++ 규격은 '마치' 규칙을 가지고 있는데, 이 규칙에 의해 컴파일러가 코드를 재정렬할 수 있으며, 결과가 단순히 코드를 실행한 것과 동일한 관찰 가능한 효과(부작용 포함)를 갖는다면 코드를 다시 쓸 수도 있습니다.

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

x86 컴퓨터가 하나의 CPU를 가지고 있던 시절에는 하나의 명령어를 사용하여 인터럽트가 읽기/수정/기입을 분할하지 않도록 했습니다.메모리를 DMA 버퍼로도 사용하지 않는 경우에는 실제로는 원자적인 것입니다(또한 C++는 표준에서 스레드를 언급하지 않았기 때문에 이 문제는 해결되지 않았습니다).

고객의 데스크탑에 듀얼 프로세서(듀얼 소켓 Pentium Pro 등)를 탑재하는 일이 드물었을 때는 싱글 코어 머신에서 LOCK 프리픽스를 사용하지 않고 성능을 향상시키기 위해 이 프로세서를 효과적으로 사용했습니다.

현재는 동일한 CPU 어피니티로 설정되어 있는 여러 스레드에 대해서만 도움이 됩니다.따라서 우려되는 스레드는 타임슬라이스가 만료되어 같은 CPU(코어)에서 다른 스레드를 실행하는 경우에만 작동합니다.그것은 현실적이지 않다.

최신 x86/x64 프로세서에서는 하나의 명령어가 몇 가지 마이크로 오퍼레이션으로 분할되어 메모리 읽기 및 쓰기가 버퍼링됩니다.따라서 서로 다른 CPU에서 실행되는 다른 스레드에서는 이것이 비원자적인 것으로 간주될 뿐만 아니라 메모리에서 무엇을 읽는지, 그리고 그 시점에서 다른 스레드가 무엇을 읽었다고 가정하는지에 대해서도 일관되지 않은 결과가 나타날 수 있습니다.정상적인 동작을 복원하려면 메모리 펜스를 추가해야 합니다.

싱글코어 x86 머신에서는add명령어는 일반적으로 CPU1 상의 다른 코드에 대해서는 원자적입니다.인터럽트는 하나의 명령어를 중간에 분할할 수 없습니다.

단일 코어 내에서 명령이 한 번에 하나씩 순서대로 실행되는 착각을 유지하려면 순서가 맞지 않는 실행이 필요합니다.따라서 동일한 CPU에서 실행되는 명령은 모두 추가 전 또는 후에 발생합니다.

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

작은 임베디드 PC를 대상으로 코드를 다른 곳으로 이동할 계획이 없는 경우 "추가" 명령의 원자적 특성이 악용될 수 있습니다.한편, 운영이 본질적으로 원자적인 플랫폼이 점점 더 희박해지고 있습니다.

(단, C++로 쓰는 경우에는 도움이 되지 않습니다.컴파일러에 필요한 옵션은 없습니다.num++메모리에 컴파일하다lock프레픽스로드를 선택할 수도 있습니다.num레지스터에 저장하여 별도의 명령으로 증분 결과를 저장합니다.또, 그 결과를 사용하는 경우는, 그렇게 할 가능성이 있습니다.


각주 1:lockI/O 디바이스는 CPU와 동시에 동작하기 때문에 프리픽스는 원래 8086에도 존재합니다.싱글 코어 시스템의 드라이버는,lock add디바이스에서 값을 변경할 수 있는 경우 또는 DMA 액세스에 관해 디바이스 메모리의 값을 atomically적으로 증가시킵니다.

num++에 대응하는 5행은 하나의 명령이므로, 이 경우 num++는 atomicic이라고 결론지을 수 있습니까?

"역 공학" 생성 조립품에 기초하여 결론을 도출하는 것은 위험하다.예를 들어 최적화를 해제한 상태에서 코드를 컴파일했다고 가정합니다.그렇지 않으면 컴파일러는 해당 변수를 폐기하거나 1을 호출하지 않고 직접 로딩했을 것입니다.operator++생성된 어셈블리는 최적화 플래그, 타깃 CPU 등에 따라 크게 달라질 수 있으므로 모래를 기준으로 결론을 내립니다.

또한, 하나의 조립 명령이 운영이 원자임을 의미한다는 당신의 생각 또한 틀렸습니다.이것.addx86 아키텍처에서도 멀티 CPU 시스템에서는 atomic이 되지 않습니다.

컴파일러가 항상 이것을 원자적인 조작으로 내보냈다고 해도numC++11 및 C++14 표준에 따라 데이터 레이스를 구성하고 프로그램은 정의되지 않은 동작을 하게 됩니다.

하지만 그것보다 더 심각하다.첫째, 앞서 언급한 바와 같이 변수를 증가시킬 때 컴파일러에 의해 생성되는 명령은 최적화 수준에 따라 달라질 수 있습니다.둘째, 컴파일러는 다른 메모리액세스의 순서를 변경할 수 있습니다.++num한다면num는 원자성이 아닙니다.

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(개인적인 희망과는 달리) 언어 규칙에서 관찰 가능한 동작에 영향을 주지 않기 때문에 변수와 체크 루프가 완전히 일치합니다.

실제로 지난해 Meeting C++ 컨퍼런스에서 2명의 컴파일러 개발자로부터 언어규칙이 허락하는 한 올바르게 기술된 프로그램에서 약간의 성능 향상이라도 나타나는 한 순진하게 기술된 멀티 스레드 프로그램이 잘못 동작하도록 최적화를 구현한다는 이야기를 들었습니다.

마지막으로 휴대성에 관심이 없고 컴파일러가 마법처럼 좋은 경우에도 사용하고 있는 CPU는 슈퍼칼라 CISC 타입일 가능성이 높기 때문에 명령어를 마이크로 ops로 분해하거나 순서를 변경하거나 추측적으로 실행하거나 둘 다 (인텔에서) primitive를 동기화하는 것만으로 한정됩니다.LOCK프리픽스 또는 메모리 펜스(memory fence)를 사용하여 초당 동작을 최대화합니다.

한마디로 스레드 세이프 프로그래밍의 당연한 책임은 다음과 같습니다.

  1. 사용자의 의무는 언어 규칙(특히 언어 표준 메모리 모델)에 따라 명확하게 정의된 동작을 가진 코드를 작성하는 것입니다.
  2. 컴파일러의 역할은 타깃아키텍처의 메모리모델에서 명확하게 정의된(관찰 가능한) 동작을 가진 머신코드를 생성하는 것입니다.
  3. CPU의 역할은 관찰된 동작이 자체 아키텍처의 메모리 모델과 호환되도록 이 코드를 실행하는 것입니다.

독자적인 방법으로 하고 싶은 경우는, 경우에 따라서는 기능하는 경우도 있습니다만, 보증이 무효인 것을 이해해 주세요.불필요한 결과에 대해서는 고객님께서 전적으로 책임을 지셔야 합니다. :-)

PS: 올바르게 기술된 예:

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 검문 전에 일어나다ready0이 아닌 것으로 간주되며 이러한 조작을 중심으로 다른 조작의 순서를 변경할 수 없습니다.그 이유는++ready체크는 순차적으로 일치합니다.이것은 C++ 메모리모델에 기재되어 있는 또 다른 용어이며, 이 특정 순서 변경을 금지하고 있습니다.따라서 컴파일러는 명령어를 다시 정렬하지 않아야 하며 CPU에 예를 들어 쓰기를 연기해서는 안 된다고 알려야 합니다.vec증액 후에ready언어 표준에서 원자 공학에 관한 가장 강력한 보증은 순차적으로 일관됩니다.예를 들어, 보다 적은(및 이론적으로 저렴한) 보증을 이용할 수 있습니다.std::atomic<T>단, 이는 전문가 전용으로 컴파일러 개발자에 의해 최적화되지 않을 수 있습니다.이는 거의 사용되지 않기 때문입니다.

컴파일러가 인크리먼트에 명령어를 1개만 사용하고, 머신이 싱글 스레드인 경우, 코드는 안전합니다.^^

add 명령은 atomic이 아닙니다.메모리를 참조하고 있으며, 2개의 프로세서코어가 해당 메모리의 다른 로컬캐시를 가질 수 있습니다.

IIRC 추가 명령의 원자 변형은 lock xadd라고 불립니다.

아니요. https://www.youtube.com/watch?v=31g0YE61PLQ ('The Office'의 'No' 장면 링크일 뿐)

이 프로그램이 다음과 같이 출력될 수 있다는 데 동의하십니까?

출력 예:

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

만약 그렇다면, 컴파일러는 어떤 방식으로든 그 프로그램을 위해 가능한 유일한 출력을 만들 수 있습니다.즉, main()은 100을 출력합니다.

이것이 "만약" 규칙입니다.

또, 출력에 관계없이, 스레드 동기화에 대해서도 같은 방법으로 생각할 수 있습니다(스레드 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가 어떻게든 해결해 줄 것입니다.하지만 결과는 당신이 기대했던 것과 다를 수 있습니다.

둘째, 메모리 순서, 즉 보증 전에 다른 문구가 발생합니다.원자 명령에서 가장 중요한 것은 원자라는 이 아니다.주문하고 있어요.

메모리 측면에서 발생하는 모든 것이 "이전부터" 보장된 명확한 순서로 실현된다는 보증을 적용할 수 있습니다.이 주문은 필요에 따라 "완전" 또는 "없음"으로 읽습니다.

예를 들어 일부 데이터 블록(예: 일부 계산 결과)에 포인터를 설정한 다음 "데이터 준비 완료" 플래그를 원자적으로 해제할 수 있습니다.이제 이 플래그를 획득한 사람은 포인터가 유효하다고 생각하게 됩니다.그리고 사실, 그것은 항상 유효한 포인터가 될 이며, 결코 다른 것은 아닐 것이다.그것은 포인터에 쓰는 일이 원자 작전 전에 일어났기 때문이다

특정 CPU 아키텍처에 대한 단일 컴파일러의 출력이 최적화가 비활성화되어 있는 경우(gcc는 컴파일도 하지 않기 때문에)++로.addquick&quot;의 예에서 최적화하는 경우)는 이 방법으로 증가한다고 해서 표준 준거가 되는 것은 아닌 것 같습니다(액세스하려고 하면 정의되지 않은 동작이 발생합니다).num틀렸습니다.왜냐하면addx86은 원자성이 아닙니다.

주의: 원자 공학은lock명령 접두사)는 x86에서는 상대적으로 무겁지만(관련 답변 참조), 여전히 뮤텍스보다 현저히 적습니다. 이 사용 예에서는 그다지 적합하지 않습니다.

다음 결과는 clang++ 3.8에서 가져온 것입니다.-Os.

참조에 의한 int의 증분을 「일반」의 방법으로 실시합니다.

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

이것은 다음과 같이 컴파일 됩니다.

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

참조에 의해 전달된 int의 증가 automic way:

#include <atomic>

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

이 예에서는 일반적인 방법보다 그다지 복잡하지 않지만,lock에 추가된 프레픽스inclinstruction - 단, 앞서 말한 바와 같이 이것은 싸지 않습니다.조립이 짧아 보인다고 해서 빠른 것은 아닙니다.

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

x86 이외의 머신에서도 같은 코드를 컴파일 해 보면, 매우 다른 어셈블리 결과를 금방 알 수 있습니다.

이유num++ x86 머신에서는 32비트 정수를 늘리는 것은 사실상 원자성이기 때문입니다(메모리 취득은 행해지지 않습니다.그러나 이는 c++ 규격에 의해 보장되지 않으며 x86 명령어 세트를 사용하지 않는 머신에서도 해당되지 않을 수 있습니다.따라서 이 코드는 레이스 조건으로부터 안전한 크로스 플랫폼이 아닙니다.

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

그렇다면, 우리가 가진 이유는std::atomic<int>또, 기본적인 계산의 원자성이 보증되지 않는 아키텍쳐(architecture)로 작업하고 있는 경우, 컴파일러가 원자 코드를 생성하도록 하는 메카니즘이 있습니다.

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

반응형