나는 그것이 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 원자 및 메모리 모델에 대한 자세한 내용을 보려면 다음 링크가 유용 할 수 있습니다.
- C ++ 원자 및 메모리 순서
- 비교 : C ++ 11에서 원자를 사용한 잠금없는 프로그래밍 대 뮤텍스 및 RW 잠금
- C ++ 11은 표준화 된 메모리 모델을 도입했습니다. 무슨 뜻인가요? 그리고 C ++ 프로그래밍에 어떤 영향을 미치나요?
- C ++ 11의 동시성
일반적인 사용 사례의 경우 오버로드 된 산술 연산자 또는 다른 세트를 사용합니다 .
std::atomic<long> value(0);
value++; //This is an atomic op
value += 5; //And so is this
연산자 구문을 사용하면 메모리 순서를 지정할 수 없으므로 std::memory_order_seq_cst
C ++ 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()
(자체 원자이다)이 값 사이, 덧셈 12
및 a.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
이 메모리 순서의 저장 조작은 해제
조작을 수행합니다 .이 저장 후에 는 현재 스레드에서 읽기 또는 쓰기를 다시 정렬 할 수 없습니다
. 현재 스레드의 모든 쓰기는 동일한 원자 변수를 얻는 다른 스레드에서 볼 수 있습니다
sharedData
는while
루프 종료 후에 사용 되므로 afterload()
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;
}
컴파일, 실행 및 분해 :
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가 왜을 선택했는지 잘 모르겠습니다 .inc
add
add
ARMv8은 최신 CPU에서 LDAXR + STLXR 또는 LDADD를 사용할 수 있습니다. 일반 C에서 스레드를 시작하려면 어떻게해야합니까?
Ubuntu 19.10 AMD64, GCC 9.2.1, Lenovo ThinkPad P51에서 테스트되었습니다.