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 객체의 소멸자는 그 작업이 완료될 때까지 차단된다.

모든 std::thread 객체는 합류 가능 상태에거나 합류 불가능 상태이다.

 

◾ 기본 생성된 std::thread

◾ 다른 std::thread 객체로 이동된 후의 std::thread 객체

◾ join에 의해 합류된 std::thread

◾ detach에 의해 탈착된 std::thread

 

위 4가지는 합류 불가능한 std::thread 객체이다.

std::thread의 합류 가능성이 중요한 이유 중 하나는, 합류 가능한 스레드의 소멸자가 호출되면 프로그램 실행이 종료되기 때문이다.

 

예를 들어 필터링 함수 filter와 최댓값 maxVal을 매개변수로 받는 함수 doWork에서 필터링 수행과 조건들의 만족 여부를 점검하는 데에 시간이 오래 걸린다면 두 작업을 동시에 실행하는 것이 합리적이다.

작업 기반 설계를 적용하는 것이 좋지만 필터링을 수행하는 스레드의 우선순위를 설정해야 하기 때문에 스레드의 네이티브 핸들을 받기 위해 어쩔 수 없이 스레드 기반 설계를 해야 한다.

 

constexpr auto tenMillion = 10000000;

bool doWork(std::function<bool(int)> filter, int maxVal = tenMillion) {
    std::vector<int> goodVals; // 필터를 통과한 값들
    
    std::thread t([&filter, maxVal, &goodVals]() {
                      for (auto i = 0; i <= maxVal; ++i) {
                          if (filter(i)) goodVals.push_back(i); }
                  });
    auto nh = t.native_handle(); // 네이티브 핸들을 이용해서 t의 우선순위 설정
    ...
    
    if (conditionAreSatisfied()) {
        t.join();
        performComputation(goodVals);
        return true; // 계산 수행 완료
    }
    
    return false; // 계산 미수행
}

일단 t를 실행시킨 뒤에 우선순위를 설정하는 것은 소 잃고 외양간 고치는 격이기 때문에 순서가 맞지 않다. 이 부분은 넘어가더라도 conditionAreSatisfied 함수가 true를 반환한다면 별 문제가 없지만 false를 반환하거나 예외를 던지면 t의 소멸자가 호출되는데, t는 여전히 합류 가능 상태이므로 프로그램 실행이 종료된다.

 

대체 왜 std::thread의 소멸자가 프로그램을 종료시키는가 하면 암묵적 join이나 암묵적 detach는 상황이 더 나쁘게 흘러가기 때문이다.

 

◾ 암묵적 join

std::thread의 소멸자가 바탕 비동기 실행 스레드의 완료를 기다리게 한다.

추적하기 어려운 성능 이상들이 나타날 수 있다.

 

◾ 암묵적 detach

std::thread의 소멸자가 std::thread 객체와 바탕 실행 스레드 사이의 연결을 끊는다.

암묵적 join보다 더 심각한 이슈가 발생할 수 있다.

 

예를 들어 goodVals는 지역 변수인데 doWork 함수가 종료되면 호출 스택이 pop 되어 프로그램 실행의 흐름이 doWork의 호출 지점 다음으로 이동되지만 t는 doWork의 호출 지점에서 계속 실행된다.

다음 함수 f가 실행되는 도중 t가 비동기적으로 실행되며 goodVals에 대해 push_back을 호출하게 되는데 goodVals가 차지하던 메모리는 이미 f의 스택 프레임에 존재하게 된다.

t가 push_back을 호출하면 f 입장에서는 자기가 소유중인 스택 프레임의 메모리가 갑자기 변해버리는 기현상을 겪게 되는 셈이다.

 

합류 가능 스레드를 파괴했을 때의 결과가 매우 절망적이기 때문에 파괴를 아예 금지하기로 정했고 그에 따라 합류 가능 스레드를 파괴하면 프로그램이 종료되도록 한 것이다.

 

따라서 std::thread 객체를 모든 경로에서 합류 불가능으로 만들어야 한다. 범위 바깥으로 나가는 모든 경로에서 어떤 동작이 반드시 수행되어야 한다고 할 때, 그 동작을 지역 객체의 소멸자 안에 넣는 것이 일반적이고 이를 RAII(Resource Acquisition Is Initilization) 객체라고 부른다. 핵심은 초기화가 아닌 파괴에 있다.

 

표준 컨테이너나 스마트 포인터는 RAII 클래스이지만 std::thread 객체에 대한 표준 RAII 클래스는 없다.

다만 작성하는 것이 어렵지는 않다.

 

class ThreadRAII {
public:
    enum class DtorAction { join, detach };
    
    ThreadRAII(std::thread&& t, DtorAction a) : action(a), t(std::move(t)) {}
    
    ~ThreadRAII {
        if (t.joinable()) {
            if (action == DtorAction::join) {
                t.join();
            }
            else {
                t.detach();
            }
        }
    }
    
    std::thread& get() { return t; }
    
private:
    DtorAction action;
    std::thread t;
};

생성자는 std::thread의 오른값만 받고 std::thread 객체는 마지막으로 선언하는 것이 좋다. 그래야 모든 멤버가 성공적으로 초기화 되고 안전하게 접근할 수 있다.

 

또한 소멸자에서 t에 대해 멤버 함수를 호출하기 전에 합류 가능인지 점검한다.

 

if (t.joinable()) {
    if (action == DtorAction::join) { // 혹시 이 사이에 race condition이 발생하면?
        t.join();
    }
    else {
        t.detach();
    }
}

joinable과 join 또는 detach의 호출 사이에 다른 스레드가 t를 합류 불가능하게 만들면 경쟁 조건이 발생하지 않을까 걱정될수도 있는데 다행히도 합류 가능한 std::thread 객체는 오직 멤버 함수 호출(join, detach) 또는 이동 연산에 의해서만 합류 불가능한 상태로 변경될 수 있다.

 

bool doWork(std::function<bool(int)> filter, int maxVal = tenMillion) {
    std::vector<int> goodVals;
    
    ThreadRAII t([&filter, maxVal, &goodVals]() {
                      for (auto i = 0; i <= maxVal; ++i) {
                          if (filter(i)) goodVals.push_back(i); }
                  }),
                  ThreadRAII::DtorAction::join);
    auto nh = t.get().native_handle();
    ...
    
    if (conditionAreSatisfied()) {
        t.get().join();
        performComputation(goodVals);
        return true;
    }
    
    return false;
}

join이 디버깅하기 어려운 성능 이상을 유발할 수 있다고 했지만 다른 두 선택지가 미정의 행동과 프로그램 종료이기 때문에 join이 상대적으로 나은 선택지가 되어버린다.

그런데 나중에 다루겠지만 성능 이상뿐만 아니라 프로그램이 멈추는 문제까지 발생할 수 있는데 이런 종류의 문제에 대해 제대로 된 해결책은 비동기적으로 실행중인 람다에게 일찍 반환하라고 알려주는 것이다.(interruptible thread)

 

ThreadRAII는 소멸자를 선언했기 때문에 이동 연산이 작성되지 않으므로 직접 작성해주어야 한다. 다만 자동으로 작성되는 이동 연산으로도 충분하기 때문에 default로 요청하면 된다.

 

◾ 모든 경로에서 std::thread를 합류 불가능으로 만들어라.

◾ 소멸 시 join 방식은 디버깅하기 어려운 성능 이상으로 이어질 수 있다.

◾ 소멸 시 detach 방식은 디버깅하기 어려운 미정의 행동으로 이어질 수 있다.

◾ 멤버 변수 목록에서 std::thread 객체를 마지막에 선언하라.

+ Recent posts