std::string 객체들을 담는 컨테이너에 삽입 함수를 이용해서 새 요소들을 추가한다고 하면 당연히 논리적으로 삽입 함수에 넘겨주는 인수의 타입은 std::string이어야 할 것이다.

그렇기는 한데 항상 참은 아닐 수도 있다.

 

std::vector<std::string> vs;
vs.push_back("xyzzy"); // 문자열 리터럴

문자열 리터럴은 std::string 객체가 아니라서 컨테이너의 타입과 다르다. 그런데 에러가 발생하지 않고 동작하는 이유가 무엇일까?

 

template<class T, class Allocator = allocator<T>>
class vector {
public:
    ...
    void push_back(const T& x);
    void push_back(T&& x);
    ...
};

push_back은 왼값과 오른값에 대해 오버로딩 되어있다.

문자열 리터럴이 인수로 전달되면 std::string != const char[] 이기 때문에 타입 불일치를 해소하기 위해 컴파일러는 문자열 리터럴로부터 임시 std::string 객체를 생성하고 push_back에 전달하는 코드를 산출한다.

 

vs.push_back("xyzzy");
vs.push_back(std::string("xyzzy"));

위의 코드를 마치 아래 코드처럼 취급하게 된다.

문제없이 작동하지만 성능을 고려한다면 그다지 좋지는 않다. 왜냐면 std::string의 생성자가 두번 호출되기 때문이다.

 

문자열 리터럴이 인수로 전달될 때, 임시 std::string 객체가 생성되면서 생성자가 호출된다.

임시 객체가 push_back의 오른값 매개변수 버전으로 전달되고 x의 참조에 묶이게 되는데, 이 때 이동 생성자에 의해 x의 복사본이 생성되며 생성자가 호출된다.

이후 push_back의 호출이 끝나는 즉시 임시 객체도 사라지며 소멸자가 호출된다.

총 2번의 생성과 한번의 소멸이 이루어진다.

 

만약 문자열 리터럴을 통해 임시 객체를 생성하지 않고 직접 전달할 수 있으면 임시 객체의 생성과 파괴 비용이 발생하지 않을 것이다.

 

 

이를 위해서는 push_back이 아닌 emplace_back을 호출하면 된다.

emplace_back은 전달된 인수를 이용해서 직접 객체를 생성하고 그 과정에 임시 객체가 관여하지 않게 된다.

완벽 전달을 이용하기 때문에 완벽 전달이 실패하는 경우들을 제외하고는 항상 효율적으로 동작한다.

 

push_back, push_front를 지원하는 표준 컨테이너는 emplace_back, emplace_front 역시 같이 지원한다.

그 외 삽입 함수들 역시 대응되는 생성 삽입 함수인 emplace를 제공한다.

 

생성 삽입 함수들은 삽입 함수들이 하는 모든 일을 할 수 있으며 이론적으로 적어도 삽입 함수들보다 덜 효율적으로 수행하는 경우는 없다.

 

 

항상 그런것은 아니지만 그렇지 않은 경우들을 특정짓는것은 쉽지 않고 대신 생성 삽입이 바람직할 가능성이 큰 상황들을 식별할 수 있는 발견법은 있다.

 

◾ 추가할 값이 컨테이너에 대입되는 것이 아니라 컨테이너 안에서 생성된다.

◾ 추가할 인수 타입들이 컨테이너가 담는 타입과 다르다.

◾ 컨테이너가 기존 값과의 중복 때문에 새 값을 거부할 우려가 별로 없다.

 

위의 세 가지 조건을 모두 만족하는 경우 거의 항상 생성 삽입이 삽입보다 성능이 좋다.

 

 

추가로 생성 삽입 함수를 사용할 때 고려하면 좋은 사항들도 알아보자.

 

자원 관리

자원 관리와 관련된 경우 emplace의 호출은 오히려 독이될수도 있다.

 

std::shared_ptr를 생성할 때, 커스텀 삭제자를 지정하는 경우에는 make_shared를 통해서 생성할 수 없다. 이 때는 std::shared_ptr의 생성자를 직접 호출하게 되는데, 이를 push_back을 통해 삽입하게 되면 임시객체로 인수를 전달하는것이 보통의 경우일 것이다.

임시 객체의 생성과 삭제 비용을 피하기 위해 emplace를 사용하는 것인데, 이런 경우에는 임시 객체의 생성 비용이 매우 가치가 있다.

 

만약 임시 std::shared_ptr 객체를 담을 복사본을 할당하는 도중 메모리 부족 예외가 발생한다면 예외가 push_back 밖으로 전파되며 임시 std::shared_ptr 객체가 파괴된다. 그와 함께 임시 std::shared_ptr 객체를 만들 때 동적 할당된 자원 역시 임시 std::shared_ptr 객체의 소멸자가 호출되며 같이 해제된다.

 

그러나 emplace_back을 호출하면 동적할당으로 만들어진 원시 포인터가 완벽 전달되고 할당이 실패하여 메모리 부족 예외가 발생하면 예외가 emplace_back 바깥으로 전파되며 원시 포인터가 사라지게 되어 메모리 누수가 발생한다.

 

 

자원 관리 객체들을 담은 컨테이너의 삽입 함수를 호출하는 경우, 일반적으로 삽입 함수의 매개변수 타입들은 자원의 획득과 그 자원을 관리하는 객체의 생성 사이에 아무것도 끼어들지 않음을 보장한다.

그에 반해 생성 삽입 함수는 완벽 전달에 의해 컨테이너의 메모리 안에서 생성할 수 있는 시점까지 지연된다.

그 사이에 예외가 발생하면 위와 같은 문제가 발생하는 것이다.

 

std::shared_ptr<Widget> spw(new Widget, killWidget);

ptrs.push_back(std::move(spw)); // 삽입
ptrs.emplace_back(std::move(spw)); // 생성 삽입

이번 예제에 한해서는 new 연산자를 통해 생성된 임시 객체를 직접 push_back이나 emplace_back에 전달하기보다 별도로 생성하여 오른값으로 전달해 주는것이 더 바람직하다.

 

explicit 생성자들과의 상호작용

C++11에 추가된 정규표현식(regex)은 nullptr을 받을 수 없다.

 

std::regex r = nullptr;
regexes.push_back(nullptr);

이것들은 컴파일이 안되는데,

 

std::vector<std::regex> regexes;

regexes.emplace_back(nulltpr);

이 코드는 컴파일이 된다.

 

std::regex는 암시적 변환에 의한 복사 연산이 일어나면 비용이 커질 우려가 있기 때문에 생성자가 explicit로 선언되어있다.

explicit 생성자는 복사 초기화를 사용할 수 없지만 직접 초기화는 사용할 수 있는 특징이 있다.

대입이나 push_back은 암시적 변환이 일어나며 복사 초기화를 시도하여 컴파일 에러가 발생하지만, emplace_back은 std::regex 객체로 변환될 어떤 객체가 아니라 std::regex의 생성자에 전달될 인수에 불과하기 때문에 컴파일이 가능한 것이다.

 

근데 컴파일만 가능할 뿐이지 미정의 동작이 일어날것은 뻔하다.

좀 더 일반화하자면 생성 삽입 함수는 직접 초기화를 사용하여 explicit 생성자를 지원하고 삽입 함수는 복사 생성자를 사용하여 explicit 생성자를 지원하지 않는다.

 

◾ 이론적으로, 생성 삽입 함수들은 종종 해당 삽입 버전보다 더 효율적이어야 하며, 덜 효율적인 경우는 절대로 없어야 한다.

◾ 실질적으로, 만일 (1)추가하는 값이 컨테이너로 대입되는 것이 아니라 생성되고, (2)인수 타입들이 컨테이너가 담는 타입과 다르고, (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<std::string> names;
};

보편 참조를 사용하면 코드를 더 줄일 수 있을 것 같다.

 

class Widget {
public:
    template<typename T>
    void addName(T&& newName) { names.push_back(forward<T>(newName)); }
};

깔끔해졌지만 선언과 구현을 분리시킬 수 없기 때문에 구현부가 반드시 헤더파일에 있어야만 하는 문제가 있다.

게다가 왼값과 오른값에 대해 다르게 인스턴스화 되고 다른 타입들에 대해서도 다르게 인스턴스화 되어서 목적 파일이 커질 가능성이 있다. 뿐만 아니라 보편 참조로 전달하지 못하는 타입도 존재하고 부적절한 타입의 인수를 전달하면 난해한 에러 메시지를 출력할 수도 있다.

 

addName처럼 왼값은 복사하고 오른값은 이동하되 소스 코드와 목적 코드 양쪽에서 함수 하나만으로 다룰 수 있고 보편 참조의 여러 문제점들도 피할 수 있으면 완벽할 것만 같다.

이 문제점은 여지껏 준수해왔던 규칙중 하나를 포기하면 해결할 수 있다. 바로 사용자 정의 타입의 객체는 값으로 전달하지 말라는 것이다.

 

class Widget {
public:
    void addName(std::string newName) { names.push_back(std::move(newName)); }
    ...
};

std::move로 매개변수를 오른값으로 넘겨주지만 복사본을 이동시키는 것이기 때문에 호출자에게 영향이 미치지 않는다.

함수도 단 하나이기 때문에 코드 중복도 없고 위에서 언급한 문제점들도 발생하지 않는다.

 

그런데 값 전달로 인한 복사는 비용이 크다고 알고 있지 않았나?

그건 C++98까지의 이야기이고 C++11에서는 인수가 왼값일때만 복사되고 오른값일 때에는 이동 생성에 의해 생성된다.

 

Widget w;

std::string name("Bart");
w.addName(name); // 왼값 호출
w.addName(name + "Jenne"); // 오른값 호출

왼값 호출이 일어나는 경우 C++98때처럼 복사 생성이 일어난다. 하지만 오른값 호출이 일어나는 경우 이동 생성이 일어난다.

 

다만 주의사항이 몇가지 있다.

 

1. 왼값과 오른값에 대한 함수 오버로딩

호출자가 넘겨준 인수가 왼값이든 오른값이든, 해당 인수는 newName이라는 참조에 묶인다.

왼값의 경우 복사 1회, 오른값의 경우 이동 1회가 일어난다.

 

2. 보편 참조

함수 오버로딩과 마찬가지로 호출자의 인수는 newName 참조에 묶인다.

마찬가지로 왼값의 경우 복사 1회, 오른값의 경우 이동 1회가 일어난다.

 

3. 값 전달

호출자의 인수가 왼값이든 오른값이든 매개변수 newName은 반드시 생성된다.

왼값의 경우 복사 생성 1회, 오른값의 경우 이동 생성 1회가 일어난다.

다만 위의 구문같은 경우 move 연산이 한번 더 이뤄지므로 이동 1회가 추가된다.

 

 

그래서 무엇이 좋다는걸까?

항목의 제목을 다시 살펴볼 필요가 있다.

 

"이동이 저렴하고 항상 복사되는 복사 가능 매개변수에 대해서는 값 전달을 고려하라"

 

값 전달을 고려

값 전달을 "사용하라" 가 아니라 "고려하라" 이다.

장단점이 존재하기 때문에 값 전달이 정답이라고 말하기에는 무리가 있다.

 

복사 가능 매개변수

복사 가능 매개변수에 대해서만 값 전달을 고려해야 한다.

복사할 수 없는 매개변수는 반드시 이동 전용 타입일 것이고 해당 매개변수의 복사본이 생성된다면 반드시 이동 생성자를 통해서 생성된다.

또한 이동 전용 타입은 복사 생성자가 비활성화 되어있기 때문에 왼값 인수를 위한 함수 오버로딩을 정의해둘 필요가 없고 오른값 인수를 위한 함수만 제공하면 된다.

 

예를 들어 std::unique_ptr<std::string> 멤버 변수와 setter 멤버 함수가 있는 클래스를 생각해보자.

 

class Widget {
public:
    ...
    void setPtr(std::unique_ptr<std::string>&& ptr) { p = std::move(ptr); } // 보편 참조
private:
    std::unique_ptr<std::string> p;
};

 

이 때 오른값으로 setter를 호출하면 인수는 매개변수 ptr의 참조에 묶이게 되어 비용이 발생하지 않고 std::move에 의한 이동 연산 1회만 일어난다.

 

void setPtr(std::unique_ptr<std::string> ptr) { p = std::move(ptr); }

 

 

그런데 매개변수가 값으로 받게 구현되어 있다면 매개변수 ptr은 이동생성되고 std::move에 의해 한번 더 이동되어 총 2회의 이동 연산이 일어난다.

함수 오버로딩 방식에 비해 2배의 비용이 발생하게 된다.

 

이동이 저렴

이동이 저렴한 경우에는 이동 전용 타입을 값으로 전달하더라도 비용이 크게 문제되지 않는다.

 

항상 복사되는

class Widget {
public:
    void addName(std::string newName) {
        if ((newName.legnth() >= minLen) &&
            (newName.length() <= maxLen))
           {
               names.push_back(std::move(newName));
           }
    }
private:
    std::vector<std::string> names;
};

문자열의 길이가 너무 짧으면 컨테이너에 추가하지 않는 멤버 함수 addName의 구현이다.

이 구현의 문제는 조건이 충족되지 않아서 컨테이너에 아무것도 추가하지 않더라도 newName의 생성과 파괴는 함수 호출마다 이루어진다는 점이다. 만약 참조 전달 접근방식들이라면 발생하지 않았을 비용이다.

 

 

안타깝게도 이동이 저렴하고 복사 가능 타입에 대해 항상 복사를 수행하는 함수라고 하더라도 값 전달이 적합하지 않은 경우가 종종 발생한다.

그 이유는 생성에 의한 복사, 대입에 의한 복사 두 종류가 존재하기 때문이다.

 

생성에 의한 매개변수의 복사가 이뤄지는 경우 std::move호출에 의해 이동 연산이 한번씩 더 수행된다.

 

대입에 의한 매개변수의 복사가 이뤄지는 경우는 상황이 조금 더 복잡해진다.

 

class Password {
public:
    explicit Password(std::string pwd) : text(std::move(pwd)) {} // 생성에 의한 복사
    void changeTo(std::string newPwd) { text = std::move(newPwd); } // 대입에 의한 복사
private:
    std::string text;
};

 

std::string initPwd("very important and long long password");
Password p(initPwd);

이 경우 생성자에서 값 전달이 사용되기 때문에 이동 생성 1회의 비용이 발생한다. 오버로딩이나 완벽 전달을 사용했다면 발생하지 않는다.

 

std::string newPassword = "another password";
p.changeTo(newPassword);

도중에 값을 바꿀일이 발생하면 이제는 생성자가 아닌 대입 연산이 이뤄져야 한다.

이 때, 함수의 값 전달 접근방식 때문에 비용이 아주 커질 가능성이 생긴다.

 

changeTo에 왼값 인수가 전달되기 때문에 매개변수 newPwd가 생성될 때는 std::string의 복사 생성자가 호출된다.

std::string의 복사 생성자는 새로운 메모리를 할당하고 기존 메모리를 해제하게 된다.

생성과 해제 총 2번의 동적 메모리 관리가 일어나게 되는 것이다.

 

그런데 기존에 저장된 문자열이 새로운 문자열보다 더 길기때문에 메모리를 해제하지 않고 그대로 사용해도 무방하지만 별도로 처리하지 않으면 메모리 관리 2회가 발생하게 된다.

 

만약 기존에 저장된 문자열이 새로운 문자열보다 짧은 경우에는 반드시 메모리의 할당-해제가 일어나고 값 전달이나 참조 전달이나 비슷한 속도로 동작하게 된다.

한마디로 대입 기반 매개변수 복사의 비용은 대입에 관여하는 객체의 값에 의존하게 되는 것이다.

 

보통 이런 잠재적인 비용 증가는 왼값 인수가 전달되어 실제 복사 연산이 일어날 때만 필요하고 오른값 인수는 대부분 이동 연산으로 충분하다.

 

 

값 전달 방식이 효율적인 코드를 산출한다는 점을 확신할 수 없다면 오버로딩이나 보편 참조를 사용하면 된다.

이번 항목에서 다뤘던 예제에 한해서는 값 전달 방식이 1회의 이동 연산만 추가되었지만 연쇄적으로 값 전달 방식 함수 호출이 여러번 이루어진다면 연산 횟수가 누적되어 비용이 상당히 커질 수 있다.

 

성능과 무관하게 값 전달은 잘림 문제(slicing problem)가 발생할 여지가 있다.

함수가 기반 클래스 타입이나 파생 클래스 타입을 매개변수로 받는 경우 해당 매개변수는 값 전달 방식으로 선언하지 않는 것이 좋다.

 

◾ 이동이 저렴하고 항상 복사되는 복사 가능 매개변수에 대해서는 값 전달이 참조 전달만큼이나 효율적이고, 구현하기가 더 쉽고, 산출되는 목적 코드의 크기도 더 작다.

◾ 왼값 인수의 경우 값 전달(즉, 복사 생성) 다음의 이동 대입은 참조 전달 다음의 복사 대입보다 훨씬 비쌀 가능성이 있다.

◾ 값 전달에서는 잘림 문제가 발생할 수 있으므로, 일반적으로 기반 클래스의 매개변수 타입에 대해서는 값 전달이 적합하지 않다.

사실 volatile은 동시적 프로그래밍과 무관하다. 다만 다른 언어에서는 유용한 경우가 있고 C++에서도 일부 컴파일러가 동시적 소프트웨어에 적용할 수 있는 의미론을 volatile에 부여한다.

 

volatile와 std::atomic을 혼동하는 경우가 종종 있다.

std::atomic은 다른 스레드들이 반드시 원자적으로 인식하는 연산들을 제공한다. 외부에서 봤을때는 std::atomic 객체에 대한 연산은 마치 뮤텍스에 의해 보호되는 임계 영역에서의 연산처럼 보인다.

 

std::atomic<int> ai(0);
ai = 10; // 원자적으로 10으로 설정
std::cout << ai; // 원자적으로 ai를 읽음
++ai; // 원자적으로 1 증가
--ai; // 원자적으로 1 감소

위 문장들을 실행하는 동안 ai를 읽는 스레드들이 보는 값은 0, 10, 11 세가지밖에 없다.

 

일단 몇가지 짚고 넘어가자.

ai를 출력하는 문장에서 ai가 std::atomic 객체라는 점이 보장되는 것은 읽기가 원자적이라는 것 뿐이다. 전체 문장이 원자적으로 처리된다는 보장은 없다.

ai를 읽는 시점과 operator<<가 호출되는 사이에 다른 스레드가 ai의 값을 수정할 수도 있다. 다만 이 문장에서는 그런 상황이 발생되어도 행동에 영향을 미치지 않을 뿐이다. 다만 이 개념을 이해하고 넘어가야 한다.

 

증감 연산에 대한 부분은 읽기-수정-쓰기 과정이 순차적으로 일어나는 연산이고 각각 원자적으로 수행된다.

일단 std::atomic 객체가 생성되면 해당 객체의 모든 멤버 함수는 다른 스레드들에게 반드시 원자적으로 보이게 된다.

 

 

volatile int vi(0);
vi = 10;
std::cout << vi;
++vi;
--vi;

하지만 volatile을 사용하는 경우에는 다중 스레드 환경에서 아무것도 보장하지 않는다.

위 코드를 실행하는 동안 vi의 값을 다른 스레드들이 읽는다면 어떤 값이라도 볼 수 있게 되고 그 값으로 무언가를 하게된다면 미정의 행동을 유발하게 된다.

volatile은 std::atomic도 아니고 뮤텍스도 아니기 때문에 값이 전혀 보호받지 못한다.

 

 

std::atomic<int> ac(0); // 원자적 카운터
volatile int vc(0); // 휘발성 카운터

두 카운터 변수가 두개의 스레드에서 증가 연산이 이뤄진다고 했을 때, ac의 값은 원자적 연산이 이뤄졌기 때문에 반드시 2라는 것이 보장된다.

하지만 vc는 읽기-수정-쓰기의 연산이 원자적으로 이뤄지지 않고 두 스레드에서 동시에 이뤄질수도 있기 때문에 값이 2일수도, 1일수도 있다.

두 스레드에서 vc를 읽어서 1을 증가시킨 값을 기록하게 되는데 만약 동시에 읽어서 양쪽 다 0으로 읽게되면 둘 다 1을 대입하기 때문에 vc의 최종 값은 1이 될수도 있다.

일반적으로 실행할때마다 결과가 다르기 때문에 vc의 값은 예측할 수 없다.

 

 

std::atomic과 volatile의 차이는 RMW 연산뿐만 아니라 코드 재배치 제약에 관한 문제도 있다.

 

std::atomic<bool> valAvailable(false);
auto imptValue = computeImportantValue();
valAvailable = true;

이전 항목에서 다뤘던 공유 플래그에 의한 두 작업의 통신이다.

위 코드를 보면 반드시 imptValue가 먼저 대입되고 이후 valAvailable의 대입이 이뤄져야 한다는 것을 알 수 있다.

하지만 컴파일러는 두 대입문을 서로 독립적인 변수에 대한 대입으로 볼 뿐이라서 대입 연산의 순서를 임의로 바꿀수도 있다.

std::atomic을 사용하면 코드 재배치에 대한 제약이 생겨서 std::atomic 변수를 기록하는 문장 이전의 코드들은 그 뒤에 실행되도록 재배치되지 않는다.

 

volatile의 사용 여부는 코드 재배치에 대한 제약이 가해지지 않기 때문에 컴파일러에 의해 두 대입 연산의 순서가 바뀔수도 있다.

 

 

결과적으로 volatile은 연산의 원자성을 보장하지 않고 코드 재배치에 대한 제약이 충분하지 않다는 점을 통해 동시성 프로그래밍에 유용하지 않다는 결론을 이끌어낼 수 있다.

그러면 대체 volatile은 어디에 유용할까?

간단하게 얘기하면 volatile이 적용된 변수가 사용하는 메모리는 일반적인 방식으로 행동하지 않는다는 점을 컴파일러에게 알려주는 역할을 한다.

일반적인 메모리에는 메모리의 한 장소에 어떤 값을 기록하면 값을 덮어쓰지 않는 한 그 값이 유지된다.

 

auto y = x;
y = x;

위의 코드는 같은 일을 수행하기 때문에 컴파일러가 두번째 대입문을 제거해서 최적화 시킬 수 있다.

 

auto y = x;
y = x;
x = 10;
x = 20;

다른 값들이 대입되어도 해당 값들이 한 번도 사용되지 않는다면 첫 번째 기록을 제거할 수 있다는 특성도 있다.

 

auto y = x;
x = 20;

즉, 컴파일러가 이렇게 취급할 수 있다는 것이다.

불필요한 코드를 사람이 작성할일이 있을까 싶지만 사람이 직접 작성하지 않더라도 템플릿 인스턴스화와 인라인화, 순서 재배치 최적화 등을 거치고 나면 드물지 않게 컴파일러에 의해 생겨날 수도 있는 것이다.

이러한 최적화는 메모리가 '일반적인' 방식으로 행동할 때에만 이뤄지는거고 '특별한' 메모리는 언급한 최적화가 이루어지지 않는다.

 

흔한 예시로 메모리 대응 입출력(memory-mapped I/O)이 있다. 이 메모리는 일반적인 메모리를 읽거나 쓰는 것이 아닌 PC 주변장치들과 통신하게 된다.

 

auto y = x;
y = x;

만약 x가 온도계가 보고하는 실시간 값이라면 컴파일러가 보기엔 같은 값을 대입하는것으로 보여서 최적화를 수행하겠지만 실제로는 중복 행동이 아니기 때문에 최적화가 이뤄져서는 안된다.

 

이 때, volatile를 붙여주면 해당 변수가 특별한 메모리를 다룬다는 점을 컴파일러에게 알려주어서 최적화에서 제외되도록 한다.

 

std::atomic 객체 역시 최적화 대상에 포함되기 때문에 특별한 메모리를 다루게 된다면 std::atomic은 적합하지 않게 된다.

 

std::atomic<int> x;

auto y = x;
y = x;

x = 10;
x = 20;

/* 최적화 이후 */
auto y = x;
x = 20;

개념적으로는 위의 코드가 최적화가 이뤄질 것 같지만 컴파일 자체가 안된다.

원자적인 복사 생성 연산을 하드웨어 수준에서 지원하지 않아서 std::atomic의 복사 연산들이 삭제되었기 때문이다.

그렇다고 값을 넣는것이 불가능하다는 것은 아니고 std::atomic의 멤버 함수 load와 store를 사용하면 된다.

 

 

기나긴 얘기 끝에 다다른 결론은 다음과 같다.

 

◾ std::atomic은 동시적 프로그래밍에 유용하나, 특별한 메모리의 접근에는 유용하지 않다.

◾ volatile은 특별한 메모리의 접근에 유용하나, 동시적 프로그래밍에는 유용하지 않다.

 

std::atomic과 volatile의 용도가 다르므로 같이 사용하는것도 당연히 가능하다.

 

volatile std::atomic<int> vai;

vai에 대한 연산들은 원자적이고, 최적화에 의해 제거되지 않게 된다. 이런 코드는 여러 스레드가 동시에 접근할 수 있는 메모리 대응 입출력 장소일 때 유용하게 사용할 수 있다.

 

◾ std::atomic은 뮤텍스 보호 없이 여러 스레드가 접근하는 자료를 위한 것으로, 동시적 소프트웨어의 작성을 위한 도구이다.

◾ volatile은 읽기와 기록을 최적화로 제거하지 말아야 하는 메모리를 위한 것으로, 특별한 메모리를 다룰 때 필요한 도구이다.

어떤 특정한 사건(event)이 일어나야만 작업을 진행할 수 있는 비동기 작업에게 그 사건이 발생했음을 알려주는 또 다른 작업을 두는 것이 유용한 경우가 있다.

예를 들어 자료구조의 초기화, 계산 과정 중 특정 단계의 완료 등이 있다.

스레드 간 통신을 처리하는 방법 몇 가지를 알아보자.

 

 

조건 변수 사용

검출 작업(조건을 검출), 반응 작업(조건에 반응)으로 나누어 수행한다.

 

std::condition_variable cv;
std::mutex m;

위와 같은 객체들이 있을 때,

 

... // 사건 검출
cv.notify_one(); // 반응 작업에게 통보

검출 작업은 매우 간단하다.

 

... // 반응 준비
{ // 임계 영역 오픈
    std::unique_lock<std::mutex> lk(m); // 뮤텍스 잠금
    cv.wait(lk); // 통보 대기
    ... // 사건 반응
} // 임계 영역 닫음
... // 계속 반응

반응 작업의 코드는 조금 복잡하다.

여기서 문제는 뮤텍스가 필요 없을수도 있는데 뮤텍스를 사용한다는 점이다. 그렇다고 뮤텍스를 사용한다고 해서 엄청난 문제가 있는것은 아니다.

 

그보다 다른 문제들이 있다.

 

◾ 만약 반응 작업이 wait를 실행하기 전에 검출 작업이 조건 변수를 통지하면 반응 작업이 멈추게 된다(hang). 반응 작업이 wait를 실행하기 전에 검출 작업이 통지를 실항하게 되면 반응 작업은 그 통지를 놓치게 되어 영원히 기다리게 된다.

 

◾ wait 호출문은 가짜 기상을 고려하지 않는다. 조건 변수가 통지되지 않았는데도 깨어날 수 있다. 이 경우 조건 변수가 깨어나자마자 실제로 조건이 발생했는지 체크를 해야한다.

 

cv.wait(lk, []{ return 사건 발생 여부; });

wait에 람다를 넘겨주면 되는데 사건 발생 여부를 검출하는 것은 검출 작업의 몫이기 때문에 반응 작업은 사건 발생 여부를 판단하지 못할 수 있다. 애초에 판단이 됐으면 조건 변수를 기다리지도 않았을 것이다.

 

 

공유 bool 플래그 사용

std::atomic<bool> flag(false);
...
flag = true; // 사건 검출

반응 작업 스레드에서는 bool 플래그를 계속 폴링한다.

조건 변수 사용에서 발생하는 단점들이 모두 사라지지만 폴링 비용이 모든 장점을 다 깎아먹는다.

플래그가 설정되기 전까지 반응 작업은 사실상 차단된 상태지만 계속 폴링을 해야한다.

 

 

조건 변수와 공유 플래그 결합

조건 변수와 공유 플래그 사용을 결합해서 사용하는 경우도 흔히 있다.

사건 발생 여부를 플래그로 나타내고 그 플래그에 대한 접근을 뮤텍스로 동기화 하는 것이다.

문제점은 없지만 검출 작업이 반응 작업과 기묘하게 통신한다는게 꺼림직하다.

 

검출 작업은 조건 변수를 통지할 뿐만 아니라 플래그도 설정하고 반응 작업은 통지를 받아도 확신하지 못하고 반드시 플래그를 점검해야 한다.

 

 

검출 작업이 설정한 future 객체를 반응 작업이 기다리게 하기

검출 작업과 반응 작업은 호출자와 피호출자의 관계는 아니지만 반드시 해당 관계에서만 사용할 수 있는것은 아니다.

단순히 전송 단자가 std::promise이고 수신 단자가 future 객체일 뿐이다. 이를 사용하면 프로그램 내에서 서로 통신해야 하는 모든 상황에 사용할 수 있다.

 

검출 작업에는 std::promise 객체를 하나 두고 반응 작업에는 대응되는 future 객체 하나를 둔다.

사건이 발생하면 검출 작업은 std::promise를 설정하게 되고 반응 작업은 future 객체에 대한 wait을 호출해 둔 상태이기 때문에 std::promise가 설정될 때까지 차단된다.

 

그런데 std::promise나 future객체나 둘 다 타입 매개변수를 요구하는 템플릿이다. 그런데 딱히 넘겨줄 자료는 없고 그저 반응 작업이 std::promise가 설정되었는지만 체크하면 된다.

이 때 void를 사용하게 된다.

 

std::promise<void> p;

p.set_value(); // 검출 작업
...
p.get_future().wait(); // 반응 작업

검출 작업은 std::promise<void>, 반응 작업은 std::future<void> 또는 std::shared_future<void>를 사용하면 된다.

반응 작업이 검출 작업에게 아무런 데이터도 받지 않지만, 검출 작업이 set_value를 호출해서 무언가 기록되었다는 사실은 알 수 있다. 이를 통해 서로 통신할 수 있다.

 

플래그를 이용한 접근방식과 마찬가지로 뮤텍스가 필요하지 않고 반응 작업이 wait로 대기하기 전에 검출 작업이 먼저 std::promise를 설정해도 잘 작동할뿐만 아니라 가짜 기상 문제도 발생하지 않는다. 가짜 기상 문제는 조건 변수에서만 일어나기 때문이다.

또한 반응 작업은 wait 호출 후 진짜로 차단되기 때문에 대기하는 동안 시스템 자원을 전혀 소모하지 않는다.

 

전체적으로 봤을때 많은 문제들을 회피할 수 있기 때문에 좋지만 몇 가지 주의해야 할 점들이 있다.

우선 std::promise와 future 객체 사이에는 공유 상태가 있고 공유 상태는 동적으로 할당되기 때문에 동적할당 및 해제에 대한 비용이 발생할 수 있다고 가정해야한다.

그것보다 더 중요한것은 조건 변수나 플래그 설정과 다르게 std::promise는 오직 한번만 설정할 수 있다.

std::promise와 future 객체 사이의 통신 채널은 여러번 사용할 수 없고 오직 단발성(일회성) 통신에만 사용될 수 있다.

왜냐면 검출 작업에서 set_value 호출로 인해 무언가 기록되었다는 여부만 확인할 뿐이기 때문이다.

그러나 이런 단발성 제약이 생각보다 크게 문제되지는 않는다.

 

std::promise<void> p;

void react(); // 반응 작업
void detect() { // 검출 작업
    std::thread t([]
                  {
                      p.get_future().wait(); // future 객체가 설정될 때까지 t를 대기시킨다
                      react();
                  });
    ...
    p.set_value(); // t의 대기 해제
    
    ... // 추가 작업 수행
    t.join(); // t를 합류 불가능으로 만듬
}

스레드를 한 번만 유보시키는 경우의 기본적인 구조이다.

 

void detect() {
    ThreadRAII tr {
        std::thread t([]
                      {
                          p.get_future().wait();
                          react();
                      }),
        ThreadRAII::DrotAction::join
    };
    ...
    
    p.set_value();
    ...
}

RAII 클래스를 사용한다면 위와 같은 구조가 된다.

그런데 만약 p.set_value가 호출되기 전에 예외가 발생한다면 p.set_value의 호출은 일어나지 않게 되고 람다 안의 wait 호출은 계속해서 차단되어 스레드가 완료되지 않는 문제가 생긴다. 하지만 이 부분은 이번에 따로 다루지 않는다.

 

 

RAII 클래스를 적용하기 이전 코드를 기준으로 반응 작업 하나가 아니라 여러 개의 반응 작업을 유보시키고 풀도록 확장하는 것이 가능하다. 이 경우 std::future 대신 std::shared_future를 사용하면 된다.

 

std::promise<void> p;

void detect() {
    auto sf = p.get_future().shared(); // std::shared_future<void>
    
    std::vector<std::thread> vt; // 반응 작업(스레드)들을 담는 컨테이너
    
    for (int i = 0; i < threadsToRun; ++i) { // 이제는 단일 작업이 아니라 여러 작업이 담긴다
        vt.emplace_back([sf]{ sf.wait();
                              react(); });
    }
    
    ...
    
    p.set_value(); // 모든 스레드의 유보 해제
    ...
    
    for (auto& t : vt) { // 모든 스레드를 합류 불가능으로 만든다
        t.join();
    }
}

std::shared_future와 컨테이너를 이용해서 다수의 반응 작업을 동시에 제어하는 일반적인 코드이다.

주의할 점은 각 반응 작업 스레드마다 std::shared_future의 복사본을 두어야 한다는 것이다. 그래야 람다가 값으로 캡쳐할 수 있다.

 

◾ 간단한 사건 통신을 수행할 때, 조건 변수 기반 설계에는 여분의 뮤텍스가 필요하고, 검출 작업과 반응 작업의 진행 순서에 제약이 있으며, 사건이 실제로 발생했는지를 반응 작업이 다시 한번 확인해야 한다.

◾ 플래그 기반 설계를 사용하면 그런 단점들이 없지만, 대신 차단이 아니라 폴링이 일어난다는 단점이 있다.

◾ 조건 변수와 플래그를 조합할 수도 있으나, 그런 조합을 이용한 통신 매커니즘은 필요 이상으로 복잡하다.

◾ std::promise와 future 객체를 사용하면 이러한 문제점들을 피할 수 있지만, 그런 접근방식은 공유 상태에 힙 메모리를 사용하며, 단발성 통신만 가능하다.

합류 가능 std::thread는 바탕 시스템의 실행 스레드에 대응되듯이 지연되지 않은 작업에 대한 future 객체도 시스템 스레드에 대응된다. 따라서 std::thread 객체와 future 객체 모두 시스템 스레드에 대한 핸들이라고 할 수 있다.

 

그런데 std::thread 객체에 대한 파괴는 이전 항목에서 다뤘듯이 모두 안좋은 상황을 만들어내지만 future 객체의 소멸자는 어떨 때는 암묵적인 join, 어떨 때는 암묵적인 detach를 수행한 것 같은 결과를 내고 프로그램이 종료되는 일은 없다.

 

future 객체는 피호출자가 결과를 호출자에게 전송하는 통신 채널의 한쪽 끝과 같다.

보통 비동기적으로 실행되는 피호출자는 자신의 계산 결과를 std::promise 객체를 통해 통신 채널에 기록한다.

그 뒤 호출자는 future 객체를 이용해서 그 결과를 읽게 된다.

 

그런데 호출자가 future 객체에 대해 get을 호출하기도 전에 피호출자의 실행이 끝날수도 있는데 피호출자의 결과는 어디에 저장되는걸까? std::promise는 피호출자의 지역 범위에 있어서 피호출자가 완료되면 같이 파괴되기 때문에 std::promise도 안된다.

future 객체도 안된다. 왜냐면 해당 future 객체를 이용해서 std::shared_future를 생성할 수 있는데, 원본 future가 파괴된 후에도 std::shared_future가 여러번 복사될 수 있기 때문이다.

 

호출자, 피호출자 양쪽 다 피호출자의 결과를 담기에는 적합하지 않다. 그래서 양쪽이 공유할 수 있는 외부 장소에 결과를 담는다.

 

공유 상태의 존재가 중요한 이유는 future 객체 소멸자의 행동을 연관된 공유 상태가 결정하기 때문이다. 조금 더 구체적으로 다음과 같다.

 

◾ std::async를 통해서 시동된 지연되지 않은 작업에 대한 공유 상태를 참조하는 마지막 future 객체의 소멸자는 작업이 완료될 때까지 차단된다. 이 경우 암묵적인 join을 수행한다.

◾ 다른 모든 future 객체의 소멸자는 그냥 해당 future 객체를 파괴한다. 이 경우 암묵적인 detach를 수행하는 것과 비슷하다.

 

쉽게 풀어서 얘기하면 future 객체의 소멸자는 future 객체를 파괴한다는 것이다.

단, 예외가 한가지 존재한다.

 

◾ future 객체가 std::async 호출에 의해 생성된 공유 상태를 참조한다.

◾ 작업의 시동 방침이 std::launch::async이다.

◾ future 객체가 공유 상태를 참조하는 마지막 future 객체이다. std::future의 경우에는 이 조건이 항상 성립한다.

 

위의 세 조건이 모두 성립할 때에만 future 객체의 소멸자는 비동기적으로 실행되는 작업이 완료될 때까지 소멸자의 실행이 차단된다.

std::async로 생성한 작업을 실행하는 스레드에 대해 암묵적인 join을 호출하는 것에 해당된다.

 

 

future 객체에 대한 API는 해당 객체가 std::async 호출에 의해 생긴 공유 상태를 참조하는지를 판단할 수 있는 수단을 제공하지 않아서 소멸자의 차단 여부를 알아내는 것은 불가능하다.

 

물론 예외가 발생하지 않음을 미리 알 수 있으면 future 객체의 소멸자가 차단되지 않는다는 것을 확신할 수 있지만 std::async뿐만 아니라 여러 원인으로도 공유 객체가 생성될 수 있다. 그 중 하나가 std::packaged_task의 사용이다.

 

std::packaged_task로 생성된 객체는 임의의 스레드에서 실행할 수 있고 소멸자의 특별한 행동을 고려한 코드를 작성할 필요가 없다. 이미 해당 std::thread를 조작하는 코드에서 결정이 내려지기 때문이다.

 

◾ future 객체의 소멸자는 그냥 future 객체의 자료 멤버들을 파괴할 뿐이다.

◾ std::async를 통해 시동된 비지연 과제에 대한 공유 상태를 참조하는 마지막 future 객체의 소멸자는 그 작업이 완료될 때까지 차단된다.

+ Recent posts