[Effective Modern C++] 40. 동시성에는 std::atomic을 사용하고, volatile은 특별한 메모리에 사용하라
atomic과 volatile의 차이점에 대해 알아보자.
atomic vs volatile
std::atomic은 뮤텍스 보호 없이 여러 스레드가 접근하는 데이터를 위한 것으로, 동시적 소프트웨어의 작성을 위한 도구이다. 보통 뮤텍스보다 더 효율적인 기계어 명령들로 구현된다.
아래의 예시를 보자.
std::atomic<int> ai(0); // ai를 0으로 초기화 ai = 10; // 원자적으로 ai를 10으로 설정 std::cout << ai; // 원자적으로 ai의 값을 읽음 ++ai; // 원자적으로 ai 증가 11 --ai; // 원자적으로 ai 감소 10
우리는 두 가지를 주목해야 한다.
첫째, "std::cout << ai"에서, std::atomic 객체가 보장하는 것은 ai의 읽기가 원자적이라는 것뿐이다. ai의 값을 읽는 시점과 operator<< 가 호출되는 시점 사이에 다른 스레드가 ai의 값을 수정할 수도 있다.
둘째, 마지막 두 문장처럼 증감 연산은 읽기-수정-쓰기(Read-Modify-Write:RMW) 연산이지만 각각 원자적으로 수행된다.
이처럼 일단 atomic 객체가 생성되고 나면, 그 객체에 대한 모든 멤버 함수는 혹은 RMW연산들을 수행하는 멤버 함수도 다른 스레드들에게는 반드시 원자적으로 보인다는 것이다.
하지만 volatile을 사용하는 다음 코드는 다중 스레드 문맥에서 거의 아무것도 보장하지 않는다.
volatile int vi(0); vi = 10; std::cout << vi; ++vi; --vi;
이 코드를 실행하는 동안 vi의 값을 다른 스레드들이 읽는다면, 그 스레드들은 어떤 값이라도 확인할 수 있다. 그리고 여러 스레드에서 증가 연산을 했는데 실제로는 딱 한 번만 증가하는 기이한 현상도 생길 수 있다. 메모리에 기록자들과 판독자들이 동시에 접근하려 해서 자료 경쟁이 일어나기 때문이다. 이런 코드는 미정의 행동을 유발한다.
또, 일반적으로 서로 무관한 대입의 순서를 컴파일러나 바탕 하드웨어가 임의로 바꾸는 것은 적법하다. 왜냐면, 순서를 바꾸면 코드가 더 빨리 실행되는 경우가 있기 때문이다.
그러나 atomic을 사용하면 이러한 코드 순서 재배치에 대한 제약들이 생긴다. 그런 제약 중 하나는 소스 코드에서 atomic변수를 기록하는 문장 이전에 나온 그 어떤 코드도 그 문장 이후에 실행되지 않아야 한다는 것이다.
하지만 volatile은 그런 제약이 없다.
아래의 예시를 보자
// atomic - 순서 못바뀜 std::atomic<bool> valAvailable(false); auto imptValue = computeImportantValue(); valAvailable = true; // volatile - 순서 바뀔 수 있음 volatile bool valAvailable(false); auto imptValue = computeImportantValue(); valAvailable = true; // 다른 스레드들은 이 대입을 imptValue 대입 이전에 볼 수 있다.
그렇다면 volatile은 언제 사용해야 하는지 보자.
int x; auto y = x; y = x; x = 10; x = 20;
위의 코드에서, "y = x " 문과 "x = 10" 이 불필요해 보이지만, 사실 이게 특별한 명령일 수도 있다. 이런 경우, x를 volatile로 만들어 주어야 한다.
volatile한정사는 해당 코드가 특별한 메모리를 다룬 다는 점을 컴파일러에게 알려주는 수단이다.
즉, 컴파일러는 volatile 메모리에 대한 연산들에는 어떤 최적화도 수행하지 않는다.
그리고 항목 2의 규칙들에 따라 비 참조, 비포인터 타입을 선언할 때는 const와 volatile한정사가 제거된다는 점을 기억하자.
만약 x를 "std::atomic <int> x;"로 바꾸면, 컴파일이 실패한다. "y = x" 문장이 실패하기 때문인데, 이는 std::atomic의 복사 연산들이 삭제되었기 때문이다(복사 생성과 복사 배정 둘 다). 대신 다음과 같이 수정하면 컴파일이 된다.
std::atomic<int> x; std::atomic<int> y(x.load()); y.store(x.load());
load와 store은 원자적인 연산이지만, 두 문장이 각자 하나의 원자적 연산으로 실행되리라고 기대할 수는 없다. 컴파일러는 x 값을 레지스터에 저장해 이러한 코드를 최적화할 수도 있다.
레지스터 = x.load(); std::atomic<int> y(레지스터); y.store(레지스터);
그리고 std::atomic과 volatile 은 용도가 다르므로, 함께 사용하는 것도 가능하다.
volatile std::atomic<int> vai;
vai에 대한 연산들은 원자적이며, 최적화에 의해 제거될 수 없다.
요약
- std::atomic은 뮤텍스 보호 없이 여러 스레드가 접근하는 데이터를 위한 것으로, 동시적 소프트웨어의 작성을 위한 도구이다.
- volatile은 읽기와 기록을 최적화로 제거하지 말아야 하는 메모리를 위한 것으로, 특별한 메모리를 다룰 때 필요한 도구이다.
- std::atomic과 volatile의 용도가 다르므로, 함께 사용하는 것도 가능하다.
'Books > Effective Modern C++' 카테고리의 다른 글
[Effective Modern C++] 42. 삽입 대신 생성 삽입을 고려하라 (1) | 2022.10.23 |
---|---|
[Effective Modern C++] 41. 이동이 저렴하고 항상 복사되는 복사 가능 매개변수에 대해서는 값 전달을 고려하라 (0) | 2022.10.23 |
[Effective Modern C++] 39. 일회성(one-shot) 사건 통신에는 void future 객체를 고려하라 (0) | 2022.10.23 |
[Effective Modern C++] 38. 스레드 핸들 소멸자들의 다양한 행동 방식을 주의하라 (0) | 2022.10.23 |
[Effective Modern C++] 23. std::move와 std::forward를 숙지하라 (0) | 2022.09.24 |
댓글
이 글 공유하기
다른 글
-
[Effective Modern C++] 42. 삽입 대신 생성 삽입을 고려하라
[Effective Modern C++] 42. 삽입 대신 생성 삽입을 고려하라
2022.10.23생성 삽입 함수들은 삽입 함수들이 하는 모든 일을 할 수 있다. 게다가 좀 더 효율적으로 수행될 수 있는 경우가 있으며, 적어도 이론적으로는, 덜 효율적으로 수행되는 경우는 결코 없다. std::vector vs; vs.push_back("xyzzy"); // 해석 ver vs.push_back(std::string("xyzzy")); // emplace_back은 완벽 전달을 이용하므로 문자열 리터럴이 vector안의 string생성자에게 그대로 전달된다. // 임시 객체의 생성과 소멸 비용이 들지않음 vs.emplace_back("xyzzy"); 생성삽입이 삽입의 성능을 능가하는 경우 1. 추가할 값이 컨테이너에 대입되는 것이 아니라 컨테이너 안에서 생성된다. 위의 예시에서 첫 코드 블럭의 "xyzz… -
[Effective Modern C++] 41. 이동이 저렴하고 항상 복사되는 복사 가능 매개변수에 대해서는 값 전달을 고려하라
[Effective Modern C++] 41. 이동이 저렴하고 항상 복사되는 복사 가능 매개변수에 대해서는 값 전달을 고려하라
2022.10.23매개변수 전달 방법을 3가지로 나누어 성능을 비교해보자. // 오버로딩 class Widget { public: void addName(const std::string& newName) { names.push_back(newName); } void addName(std::string&& newName) { names.push_back(std::move(newName); } …. private: std::vector names; }; // 보편 참조 class Widget { public: template void addName(T&& newName) { names.push_back(std::forward(newName)); } …. }; // 값 전달 class Widget { public: void… -
[Effective Modern C++] 39. 일회성(one-shot) 사건 통신에는 void future 객체를 고려하라
[Effective Modern C++] 39. 일회성(one-shot) 사건 통신에는 void future 객체를 고려하라
2022.10.23조건변수 기반 설계 스레드 간 통신을 수행할 때, 조건 변수(condition variable, 줄여서 condvar)를 사용하는 경우가 많다. 조건을 검출하는 과제를 검출 과제(detecting task)라고 부르고, 그 조건에 반응하는 과제를 반응 과제(reacting task)라고 부르도록 한다. 예시를 보자. std::condition_variable cv; // 조건변수 std::mutex m; // cv와 함께 사용할 뮤텍스 // 사건 검출 …. // 반응해야 하는 과제에게 알림. 반응 task가 여러개라면 notify_all을 사용하면 됨 cv.notify_one(); // 반응 task 개념 접근방식 …. { // 임계 영역을 열고, 뮤텍스를 잠근다 std::unique_lock lk(… -
[Effective Modern C++] 38. 스레드 핸들 소멸자들의 다양한 행동 방식을 주의하라
[Effective Modern C++] 38. 스레드 핸들 소멸자들의 다양한 행동 방식을 주의하라
2022.10.23합류 가능한 스레드는 바탕 시스템의 실행 스레드에 대응된다. 스레드와 비슷한 async의 future 객체도 시스템 스레드에 대응된다. 따라서 std::thread 객체와 future객체 모두 시스템 스레드에 대한 핸들이라고 할 수 있다. 그리고 join가능 스레드를 파괴하면 프로그램이 종료된다.(항목 37 참고) 하지만 future객체의 소멸자는 어떨 때는 암묵적으로 join을 수행한 것과 같은 결과를 내고 어떨 때에는 암묵적으로 detach를 수행한 것과 같은 결과를 내지만, 프로그램이 종료되는 일은 없다. 우선, future객체는 피호출자가 결과를 호출자에게 전송하는 통신 채널의 한쪽 끝이라는 점을 주목해야 한다. 피호출자는 보통은 std::promise객체를 통해서 자신의 계산 결과를 그 통신 채…
댓글을 사용할 수 없습니다.