일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | ||||
4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 |
Tags
- 참조자
- virtual function
- c++
- implicit conversion
- 스마트 포인터
- std::async
- Effective c++
- 예외
- operator new
- effective stl
- 보편 참조
- 반복자
- iterator
- 게임
- effective modern c++
- 영화
- 상속
- lua
- UE4
- Smart Pointer
- 언리얼
- resource management class
- more effective c++
- 영화 리뷰
- 암시적 변환
- 티스토리챌린지
- reference
- 오블완
- exception
- universal reference
Archives
- Today
- Total
스토리텔링 개발자
[Effective Modern C++] 40. std::atomic, volatile 본문
Effective C++/Effective Modern C++
[Effective Modern C++] 40. std::atomic, volatile
김디트 2025. 4. 24. 11:26728x90
항목 40. 동시성에는 std::atomic을 사용하고, volatile은 특별한 메모리에 사용하라
volatile
- 사실 동시적 프로그래밍과는 무관하다.
- 하지만 다른 언어에서는 volatile이 동시적 프로그램에 유용하다.
- C++에서도 일부 컴파일러는 동시적 소프트웨어에 적용할 수 있을 수도 있다.
- volatile은 다중 스레드에서 거의 아무것도 보장하지 않는다!
volatile int vi(0);
vi = 10;
std::cout << vi;
++vi;
--vi;
- 위 코드를 실행하는 동안 다른 스레드들은 vi의 값을 0, 10, 11만 본다고 보장할 수 없다.
- 이처럼 writer와 reader가 동시에 접근하려고 하면 자료 경쟁(data race)이 발생할 수 있다.
- C++에서는 std::atomic를 사용해야 한다.
std::atomic
- 다른 스레드들이 반드시 원자적으로 인식하는 연산들을 제공한다.
- std::atomic 객체를 사용하면, 연산은 마치 뮤텍스로 보호되는 임계영역(critical section) 안에서 수행되듯 동작한다.
- 하지만 실제 뮤텍스를 사용할 때보다 좀 더 효율적인 특별한 기계어 명령들로 구현되는 것이 보통이다.
std::atomic<int> ai(0); // ai를 0으로 초기화한다.
ai = 10; // 원자적으로 ai를 10으로 설정한다.
std::cout << ai; // 원자적으로 ai의 값을 읽는다.
++ai; // 원자적으로 ai를 증가한다.
--ai; // 원자적으로 ai를 감소한다.
- 다음 코드를 실행하는 동안 ai를 읽는 다른 스레드들이 보게 되는 값은 0, 10, 11 뿐이다.
- 다른 값은 불가능하다.
- 이 예에서 주목할 점 두 가지
- std::cout << ai; 문장에서 원자적으로 처리되는 건 ai 읽기 처리 뿐이다.
- 전체 문장이 원자적으로 처리된다는 보장은 없다.
- ai의 값을 읽는 시점 / operator<<가 호출되어 기록되는 시점 사이에 다른 스레드가 ai의 값을 수정할 수 있다.
- 하지만 operator<<는 값 전달을 하기 때문에 사실 문제는 없는 코드.
- 증가 연산, 감소 연산은 읽기-수정-쓰기(read-modify-write, RMW) 연산이지만 합쳐서 원자적으로 수행된다.
- std::cout << ai; 문장에서 원자적으로 처리되는 건 ai 읽기 처리 뿐이다.
다중 스레드 환경에서의 동작 차이
std::atomic<int> ac(0); // 원자적 카운터
volatile int vc(0); // 휘발성 카운터
- 그리고 동시에 실행되는 두 스레드에서 각 카운터를 증가시킨다.
- std::atomic의 경우 반드시 2가 된다.
- volatile의 경우 2가 아닐 수 있다.
- 아래는 안 좋게 진행된 케이스
- 스레드 1이 vc의 값을 읽는다.(값 : 0)
- 스레드 2가 vc의 값을 읽는다.(값 : 0)
- 스레드 1이 읽은 값을 증가시켜 vc에 기록한다.(값 : 0 -> 1, 기록 : 1)
- 스레드 2가 읽은 값을 증가시켜 vc에 기록한다.(값 : 0 -> 1, 기록 : 1)
- 결과적으로 vc는 1이 된다.
- 아래는 안 좋게 진행된 케이스
컴파일러의 문장 순서 재배치 제약
std::atomic<bool> valAvailable(false);
// 아래 두 문장은 순서가 지켜져야 한다.
auto imptValue = computeImportantValue(); // 값 계산
valAvailable = true; // 다른 task에게 값이 준비되었음을 알린다.
- 값 계산이 valAvailable에 true를 할당하는 것보다 우선적으로 일어나야 함을 우리는 안다.
- 하지만 컴파일러는 두 라인을 독립적인 변수에 대한 할당으로 볼 뿐이다.
- 일반적으로는 서로 무관한 할당은 컴파일러가 순서를 임의로 바꾸는 것이 적법하다.
- std::atomic을 사용하면 코드의 순서 재배치에 대한 제약들이 생긴다.
- 소스 코드에서 std::atomic 변수를 기록하는 문장 이전에 나온 그 어떤 코드도 그 문장 이후에 실행되면 안된다.
- volatile로 선언하면 코드 재배치 제약이 가해지지 않는다.
- 컴파일러가 순서를 멋대로 바꿔버릴 수 있다.
volatile
- 적용된 변수가 사용하는 메모리가 보통의 방식으로 행동하지 않는다는 점을 컴파일러에게 알려주는 역할을 한다.
- '보통' 메모리
- 메모리의 한 장소에 어떤 값을 기록하면 다른 어떤 값을 덮어쓰지 않는 한 그 값이 유지된다.
-
auto y = x; y = x; // 컴파일러가 최적화로 해당 할당문을 제거할 수 있다.
-
- 메모리에 어떤 값을 할당한 후 그 값을 한 번도 읽지 않고 다른 값을 할당한다면 첫 번째 기록은 제거할 수 있다.
-
x = 10; // 컴파일러는 최적화로 다음 문장을 제거할 수 있다. x = 20;
-
- 메모리의 한 장소에 어떤 값을 기록하면 다른 어떤 값을 덮어쓰지 않는 한 그 값이 유지된다.
- '특별한' 메모리
- 보통 메모리처럼 행동하지 않는다.
- 메모리 대응 입출력(memory-mapped I/O)
- 보통의 메모리를 읽고 쓰는 게 아니라,
- 외부 감지기, 디스플레이, 프린터, 네트워크 포트같은 주변장치와 실제로 통신한다.
-
// 만일 x가 온도계가 보고하는 값이라면 auto y = x; y = x; // 이 시점에 x의 값은 변경되었을 수 있다. // 만일 x가 무선 신호 전송기의 포트라면 x = 10; x = 20; // 다른 명령을 요청하는 작업일 수 있다.
- 즉, volatile로 선언되었을 때,
- 컴파일러는 이 메모리에 대한 연산들에는 그 어떤 최적화도 수행하지 말라는 지시로 생각한다.
- 특별한 메모리에 대해서는 std::atomic이 적합하지 않다.
- C++ 표준은 std::atomic에 대한 남아도는 연산들을 컴파일러가 제거하는 걸 허용한다.
- 그리고 만일 x가 std::atomic이라면 아래 문장은 컴파일되지 않는다.
-
auto y = x; y = x;
- std::atomic의 복사 연산들은 삭제되었기 때문이다.(항목 11 참조)
- x, y 모두 std::atomic 타입이라면, x에서 읽기, y에 쓰기 연산이 모두 원자적이어야 한다.
- 하지만 이 두 연산을 원자적으로 처리하는 건 하드웨어 수준에서 지원하지 않는다.
std::atomic 변수들로 읽기-쓰기 처리하기
- load와 store 함수를 사용하면 된다.
std::atomic<int> y(x.load());
y.store(x.load());
- 다만 이 경우 읽기와 쓰기를 합쳐서 원자적으로 수행되진 않는다.
- 또한 이 경우 컴파일러가 x의 값을 두번 읽는 걸 최적화할 수도 있다.
// 컴파일러가 x를 한 번만 읽도록 수정한 코드
레지스터 = x.load();
std::atomic<int> y(레지스터);
y.store(레지스터);
- 헌데 이건 특수 메모리에서는 반드시 피해야 하는 종류의 최적화이다.
정리
- std::atomic은 동시적 프로그래밍에 유용하지만, 특별한 메모리의 접근에는 유용하지 않다.
- volatile은 특별한 메모리의 접근에 유용하나, 동시적 프로그래밍에는 유용하지 않다.
- 즉, 둘의 용도가 다르므로 함께 사용하는 것도 가능하다.
volatile std::atomic<int> vai; // 원자적이며, 최적화로 제거될 수 없다.
728x90
'Effective C++ > Effective Modern C++' 카테고리의 다른 글
[Effective Modern C++] 42. 삽입(insert) 대신 생성 삽입(emplace) 고려하기 (0) | 2025.04.30 |
---|---|
[Effective Modern C++] 41. 항상 복사되는 매개변수는 값 전달도 고려하기 (0) | 2025.04.29 |
[Effective Modern C++] 39. void future 객체 (0) | 2025.04.17 |
[Effective Modern C++] 38. 스레드 핸들 소멸자의 동작 (0) | 2025.04.15 |
[Effective Modern C++] 37. std::thread는 unjoinable하게 (0) | 2025.04.11 |
Comments