Post List

2015년 12월 3일 목요일

C++ Concurrency in Action : study ch.03 Sharing data between threads

Cover
Facebook C++ Korea의 Concurrency In Action 관련 스터디 모임에서의 내용을 정리했습니다.
  • 일시 : 2015년 11월 28일
  • 발표자 : 조영수 님 , 강민구 님

Chapter 03, Sharing data between threads

3장에서는 병렬처리로 thread를 사용할 때 데이터를 안전하게 수정하는 방법에 대해서 소개를 드리고 있습니다. Database의Transacion과 같은 개념이라고 생각하면 바로 이해가 되실 겁니다. 그 수단으로 lock과 mutex를 사용하는 방법과 예제코드를 소개하고 있습니다.
아래 순서는 책의 챕터와는 상관없이 정리하는 것이니 참고하시기 바랍니다.

3.1 기본 개념 소개

3.1.1 Invariants

두 개 이상의 값을 한번에 수정해야 할 경우 그 중 일부 값에 대한 수정이 누락된 체 작업이 완료된 경우 broken invariants라 표현합니다.
wiki에서 검색한 뜻과는 다르게 책에서 사용된 듯합니다. (프로그램 실행도중 그 전체나 코드의 특정 부분에서 항상 참인 조건)
책에 정확히 invariants란 말에 대한 정의가 나와 있지는 않지만, transaction 같이 서로 연관된 값에 대해서 항상 같이 변했을 경우 invariants를 만족한다는 정도로 해석하면 될것 같습니다.

3.1.2 Race condition

경쟁상태라고 해석을 합니다.
하나의 자원을 두고 2개 이상의 thread가 서로 가지려고 경쟁하는 상태를 의미합니다. 별도의 보호 매커니즘 없이 그냥 하나의 자원을 동시에 제어하는 것을 허락 할 경우 invariants가 깨지는 현상이 발생 할 수 있습니다.

3.1.3 Dead lock

교착상태라고 해석을 합니다.
경쟁조건을 제어하기 위해서 보호 매커니즘으로 해당 자원 사용시 lock을 걸고 사용할 경우,
  • thread T1이 A라는 자원에 lock을 걸었고
  • thread T2가 B라는 자원에 lock을 건 상태에서
  • thread T1이 B 자원을 사용하기 위해서 lock을 걸려고 시도하지만, 이미 누군가가 사용 중이어서 wait 상태가 되고
  • thread T2 역시 A 자원을 사용하기 위해서 lock을 걸려고 하지만, 이미 누군가가 사용 중이어서 wait 상태가 된 경우
  • T1, T2는 영원히 자기가 가진 자원에 대한 lock을 해제하지 못한체 기다리게되는 경우 위와 같은 교착상태에 빠질 수 있습니다.

3.1.4 Mutual Exclusion

상호배제라고 해석을 합니다. 줄여서 mutex라고도 합니다. 교착상태에 빠지지 않도록 자원에 대한 접근을 제어하는 것을 의미 합니다. Computer Science 전공 과목 중 Operationg System을 들으신 분들은 다들 들어봤을 겁니다.

3.2 std::mutexstd::lock_guard의 기본적인 사용법

std::mutex를 이용하여 특정 실행 코드에 대해서 상호배제를 구현 할 수 있습니다.
여기서 개념적으로 해깔릴수 있는데, std::mutex 자체가 특정 객체나 특정 코드를 임계영역(critical section)에 둘 수 있는 역할을 할 수 있지는 않습니다. 단지 해당 mutex 객체 자체에 lock을 걸고 release를 하는 것입니다. 즉 특정 객체(A라 가정)에 값을 읽고 쓰고자 할 경우 해당 mutex가 lcok인지 release 인지를 보고 계속 실행할지 안할지를 프로그래머가 해 주는 것입니다.
이 프로그램 내에서 A에 접근하기 위해서는 무조건 mutex M에 lock을 걸고 작업하자.
라고 약속을 하고 그렇게 프로그램을 짜야 합니다. 만약 누군가가 mutex M에 lock을 걸지않고 A에 접근하면 접근이 됩니다.
std::mtext에 lock을 거는 방법은 멤버함수 .lock()을 이용하고, 해제할때는 .unlock()을 이용하면 됩니다. 하지만 만약 해제하는 것을 잊어버리면... 그럴리가요 ? 누가 짜는 프로그램인데 라고 자만할 수 있겠지만, 실수하니깐 사람인거죠. ㅎㅎ
요즘은 RAII idiom 가 대세인건 모두들 아시죠 ?
그래서 보통 std::lock_guard를 많이 사용합니다. 이 객체는 생성시 자동으로 lock을 걸며, 소멸시 자동으로 unlock을 합니다. 그렇게 해서 실수를 줄여주자는 것이죠. 나는 실수란걸 모르는 프로그래머야. 이거 왜이러셔~ 라고 생각 할 수도 있겠지만... 그렇다면... 제가 경솔했네요. ㅎ 실수를 줄여주는게 아니라, 좀 저런 사소한 문제까지 신경쓰면서 프로그램하지 마시고, 저런 미천한 일은 컴파일러에 맡겨두고 좀 더 건설적인 일에 신경을 쓰도록 도와주는 역할을 해 준다 라고 하죠. ㅎ

Listing 3.1 Protecting a list with a mutex

#include <list>
#include <mutex>
#include <algorithm>

std::list<int> some_list;
std::mutex some_mutex;

void add_to_list(int new_value)
{
    std::lock_guard<std::mutex> guard(some_mutex);
    some_list.push_back(new_value);
}
bool list_contains(int value_to_find)
{
    std::lock_guard<std::mutex> guard(some_mutex);
    return std::find(some_list.begin(), some_list.end(), value_to_find) != some_list.end();
}
  • std:::list<int> some_list : 전역 변수
  • std::mutex some_mutex는 std:::list<int> some_list를 보호하는 전역 인스턴스
  • add_to_list()와 list_contains()에서 std::lock_guard<std::mutex>의 사용함은 이 함수들에서의 접근들은 상호배제 되었음을 의미 합니다.
  • list_contains()는 절대로 add_to_list()가 수정하고 있는 리스트의 일부분을 볼 수 없습니다.
하지만 저런식의 C코드 스타일로 잘 짜지 않죠 ?
요즘 같은 OOP(Object-oriented programming) 시대에 말이죠.
(이젠 대세가 Functional Programming 이라고는 하지만.. 으흠...)
어쨌든 보통 mutex가 필요한 class 내에 멤버로 mutex를 선언하여 그것을 이용하는 방법으로 많이들 사용합니다.

Listing 3.2 Accidentally passing out a reference to protected data

class some_data
{
    int a;
    std::string b;
public:
    void do_something();
};

class data_wrapper
{
private:
    some_data data;
    std::mutex m;
public:
    template<typename Function>
    void process_data(Function func)
    {
        std::lock_guard<std::mutex> l(m);
        func(data); // Pass "protected" data to user-supplied function
    }
};

some_data* unprotected;

void malicious_function(some_data& protected_data)
{
    unprotected=&protected_data;
}

data_wrapper x;

void foo()
{
    x.process_data(malicious_function); // Pass in a malicious function
    unprotected->do_something(); //Unprotected access to protected data
}
  • 이 예제에서 process_data 를 이용하면 해당 데이터를 보호 할수 있습니다.
  • 하지만 malicious_function를 이용해 do_something()를 mutex의 lock 없이 부를 수 있게 됩니다.
이 부분을 C++에서 또는 라이브러리로 보호 할 방법이 있지는 않습니다.
즉 개발자가 알아서 잘 해야 한다는 것이죠.
그래서 가이드라인을 제시해 주기는 합니다.
함수로부터 반환되거나, 보이는 메모리 외부에 저장하거나, 사용자 지정 함수의 인자에 넘기는 것 등 lock의 범위 밖에 있는 보호되는 데이터의 pointer와 reference를 사용하지 말 것입니다.

3.3 경쟁상태에 대한 적절한 인터페이스 설계

일단 listing 3.3에서 보는 것 처럼 std::stack 컨테이너 어댑터와 같은 스택 자료구조를 보겠습니다.

Listing 3.3 The interface to the std::stack container adapter

template<typename T, typename Container=std::deque<T> >
class stack
{
public:
    explicit stack(const Container&);
    explicit stack(Container&& = Container());
    template <class Alloc> explicit stack(const Alloc&);
    template <class Alloc> stack(const Container&, const Alloc&);
    template <class Alloc> stack(Container&&, const Alloc&);
    template <class Alloc> stack(stack&&, const Alloc&);

    bool empty() const;
    size_t size() const;
    T& top();
    T const& top() const;
    void push(T const&);
    void push(T&&);
    void pop();
    void swap(stack&&);
};
  • 생성자와 swap()는 잠시 제쳐두고, std::stack의 멤버함수 5가지를 보겠습니다.
    • push() : 새로운 요소를 스택에 추가
    • pop() : 맨 위의 요소를 스택에서 제거
    • top() : 맨 위의 요소를 읽음
    • empty() : 스택이 비었는지 확인
    • size() : 요소의 갯수를 파악
위와 같이 만들었다면, 내부를 아무리 mutex를 이용하여 보호를 잘 해도, 본질적은 경쟁상태 (race condition)이 발생할 수 밖에 없습니다. 물론 single thread code에서는 무조건 안전하죠. 하지만 multi-thread code에서 깨지는 경우의 예를 한번 보겠습니다.

Table 3.1 A possible ordering of operations on a stack from two threads

Table 3.1
값을 보는 함수는 top() 이고 값을 지우는 함수는 pop() 이기 때문에 그 사이에 뭔가 일이 발생한다면 원치 않는 결과가 발생 할 수 있습니다. 위 경우 thread A, B 모두 같은 값은 top 값을 가지게 되며, stack에는 읽히지도 않은 2번째 값도 같이 삭제가 됩니다.
이런 문제를 해결 하는 방법으로는 아래의 선택 사항들이 있습니다.

Option 1: pop()의 인자로 꺼내진 값을 받을 reference를 넘기는 것

std::vector<int> result;
some_stack.pop(result);
다들 해봐서 아시겠지만, 리턴 받을 값을 미리 선언해서 참조로 넘기는 일은 불편한 작업입니다. 책에서는 나머지 안 좋은 점들에 대해서도 소개를 하고 있는데, 굳이 몰라도 상관없을 듯하여 생략하겠습니다.

Opiton 2: copy 생성자와 move 생성자의 예외가 없도록 구현

생성자의 예외가 없다는게 보장된다면 thread-safe stack으로 사용을 제한 할 수 있습니다.

Option 3: pop()의 꺼내진 값이나 그 포인터를 리턴

  • 값을 주는 것보다는 포인터로 주는 것이 예외 처리를 생각하지 않고 편하게 할 수는 있습니다.
  • 하지만, 관리가 힘들어 지므로 std::shard_ptr를 사용하는 것이 좋겠습니다.

Option 4: 1번과 2,3 중 하나를 선택해서 같이 적용

  • 제너릭 코드에서는 특히 유연성이 전혀 규정되어있지 않아야만 합니다.
  • 만약 옵션 2나 3을 선택했으면, 상대적으로 옵션 1을 제공하는 것 보다 쉽고, 이는 코드 사용자에게 가장 적은 비용을 들이면서 가장 적절한 어떤 옵션을 선택할 것인지를 제공합니다.

Example definition of a thread-safe stack

  • Listing 3.4는 인터페이스에서 race condition이 없는 옵션 1과 3을 구현한 스택 클래스 정의를 보여주고 있습니다.

Listing 3.4 An outline class definition for as thread-safe stack

#include <exception>
#include <memory> // for std::shared_ptr<>

struct empty_stack: std::exception
{
    const char* what() const throw();
};

template<typename T>
class threadsafe_stack
{
public:
    threadsafe_stack();
    threadsafe_stack(const threadsafe_stack&);
    // Assignment operator is deleted
    threadsafe_stack& operator=(const threadsafe-stack&) = delete;

    void push(T new_value);
    std::shared_ptr<T> pop();
    void pop(T& value);
    bool empty() const;
};
  • pop()을 옵션 1,3의 2가지로 중복구현(overloading) 하였습니다.
  • push()와 pop()만 인터페이스로 가지고 있습니다.
  • 구체적인 구현은 Listing 3.4에 나타냈습니다.

Listing 3.5 A fleshed-out class definition for a thread-safe stack

#include <exception>
#include <memory>
#include <mutex>
#include <stack>

struct empty_stack: std::exception
{
    const char* what() const throw();
};

template<typename T>
class threadsafe_stack
{
private:
    std::stack<T> data;
    mutable std::mutex m;
public:
    threadsafe_stack(){}
    threadsafe_stack(const threadsafe_stack& other)
    {
        std::lock_guard<std::mutex> lock(other.m);
        data=other.data;    // Copy performed in consturctor body
    }
    threadsafe_stack& opterator=(const threadsafe_stack&) = delete;

    void push(T new_value)
    {
        std::lock_guard(std::mutex> lock(m);
        data.push(new_value);
    }
    std::shared_ptr<T> pop()
    {
        std::lock_guard<std::mutex> lock(m);
        if(data.empty()) throw empty_stack(); // Check for empty before trying to pop value
        //Allocate return value before modifying stack
        std::shared_ptr<T> const res(std::make_shared<T>(data.top()));
        data.pop();
        return res;
    }
    void pop(T& value)
    {
        std::lock_guard<std::mutex> lock(m);
        if(data.empty()) throw empty_stack();
        value=data.pop();
        data.pop();
    }
    bool empty() const
    {
        std::lock_guard<std::mutex> lock(m);
        return data.empty();
    }
};

3.4 교착상태(Deadlock)에 대한 해결책

  • 고맙게도 C++ 표준 라이브러리는 std::lock에서 2개 이상의 mutex에 lock을 걸 수 있습니다.
  • 다음 리스트는 이를 단순한 swap 연산에서 어떻게 사용할지에 대해 보여줍니다.

Listing 3.6 Using std::lock() and std::lock_guard in a swap operation

class some_big_object;
void swap(some_big_object& lhs, some_big_object& rhs);

class X
{
private:
    some_big_object some_detail;
    std::mutex m;
public:
    X(some_big_object const& sd):some_detail(sd){}

    friend void swap(X& lhs, X& rhs)
    {
        if (&lhs==&rhs)
            return;
        std::lock(lhs.m, rhs.m); // (1)
        std::lock_guard<std::mutex> lock_a(lhs.m, std::adopt_lock); // (2)
        std::lock_guard<std::mutex> lock_b(rhs.m, std::adopt_lock); // (3)
        swap(lhs.some_detail, rhs.some_detail);
    }
};
  • (1) : 2개의 mutex에 동시에 lock을 걸었습니다.
  • (2),(3) : 이미 걸린 lock을 RAII형태로 관리하도록 std::lock_guard로 생성하였습니다.
    • 2개의 mutex는 이미 lock이 걸려 있으므로 std::adopt_lock 인자를 이용하여 lock의 소유권을 공유하게 됩니다.

3.4.1 교착상태(deadlock)를 피하기위한 간단한 방법

  • 교착상태는 lock에서만 일어나는 것이 아닙니다.
  • 2개의 thread가 서로를 join()으로 기다리게 하면 바로 교착상태를 만들 수 있습니다.

중첩 lock 금지

  • 하나의 lock을 잡은 상태에서는 다른 lock을 시도하지 마세요.
  • 너무 간단해서 말도 안나오죠 ? ㅎ
  • 만약 다중 lock이 필요하다면 ??? std::lock과 함께 단일 lock처럼 하세요. (위에서 본 예제 처럼요)

lock을 정해진 순서대로 얻기

  • 2개 이상의 lock을 따로 얻어야 해서 std::lock 단일 연산으로 얻을 수 없는 경우도 있습니다.
  • 그럴땐 모든 thread에서 lock을 얻는 순서를 미리 정해두는 것입니다.
  • 아주 간단한 방법이지만 굉장히 유용합니다.

hierarchical_mutex 사용하기

  • 이미 lock이 걸린 mutex가 다른 lock을 요구 할 때 자신보다 낮은 계층일 경우에만 허용하는 방식입니다.
  • 아래의 리스트는 계층화 된 mutex를 두개의 쓰레드가 사용하는 예를 보여줍니다.

Listing 3.7 Using a lock hierarchy to prevent deadlock

hierarchical_mutex high_level_mutex(10000); // (1)
hierarchical_mutex low_level_mutex(50000);  // (2)

int do_low-level_stuff();

int low_level_func()
{
    std::lock_guard<hierarchical_mutex> lk(low_level_mutex);    // (3)
    return do_low_level_stuff();
}

void high_level_stuff(int some_param);

void high_level_func()
{
    std::lock_guard<hirearchical_mutex> lk(high_level_mutex);   // (4)
    high_level_stuff(low_level_func()); // (5)
}

void thread_a() // (6)
{
    high_level_func();
}

hierarchical_mutex other_mutex(100);    // (7)
void do_other_stuff();

void other_stuff()
{
    high_level_func();  // (8)
    do_other_stuff();
}

void thread_b() // (9)
{
    std::lock_guard<hierarchical_mutex> lk(other_mutex);    // (10)
    other_stuff();
}
  • (6) thread_a()가 high_level_func()를 호출합니다.
    • (4)에서 (1) high_level_mutex(10000)로 만들어진 mutex에 lock을 걸고 (5) low_level_func() 작업을 합니다.
      • (3)에서 (2) low_level_mutex(50000)로 만들어진 mutex에 lock을 시도하므로 해당 작업은 성공합니다.
  • (9) thread_b()가 (10)에서 (7) other_mutex(100)에 lock을 걸고 other_stuff()을 호출합니다.
    • 여기서 (8) high_level_func()을 호출합니다.
      • (4)에서 (1) high_level_mutex(10000)로 만들어진 mutex에 lock을 시도하므로 실패합니다.
  • mutex가 스스로 서로의 lock 순서를 강요하기 때문에 계층구조의 mutex 사이에서의 교착상태가 일어나지 않습니다.
  • hierarchical_mutex는 표준이 아니지만 작성하기 쉽습니다.
  • listing 3.8에서 단순한 구현을 보여줍니다.

Listing 3.8 A simple hierarchical mutex

class hierarchical_mutex
{
    std::mutex internal_mutex;
    unsigned long const hierarchy_value;
    unsigned long previous_hierarchy_value;
    static thread_local unsigned long this_thread_hierarchy_value;  // (1)

    void check_for_hierarchy_violation()
    {
        if(this_thread_hierarchy_value <= hierarchy_value)  // (2)
        {
            throw std::logic_error("mutex hierarchy violated");
        }
    }
    void update_hierarchy_value()
    {
        previous_hierarchy_value=this_thread_hierarchy_value;   // (3)
        this_thread_hiearchy_value=hierarchy_value;
    }
public:
    explicit hierarchical_mutex(unsigned long value);
        hierarchy_value(value),
        previous_hierarchy_value(0)
    {}

    void lock()
    {
        check_for_hierarchy_violation();
        internal_mutex.lock();  // (4)
        update_hierarchy_value();   // (5)
    }
    void unlock()
    {
        this_thread_hierarchy_value=previous_hierarchy_value;   // (6)
        internal_mutex.unlock();
    }
    bool try_lock()
    {
        check_for_hierarchy_violation();
        if(!internal_mutex.try_lock())  // (7)
            return false;
        update_hierarchy_value();
        return true;
    }
};
thread_local unsigned long
    hierarchical_mutex::this_thread_hierarchy_value(ULONG_MAX); // (8)
  • (1) 현재 쓰레드의 계층값을 thread_local로 저장하고 있습니다.
    • (8)에서 이 값은 ULONG_MAX로 초기화 되어 처음에는 어떤 mutex도 lock이 가능하도록 설정합니다.
  • 처음 lock()을 호출하면 무조건 통과하게 됩니다.
    • (2) 에서 this_thread_hierarchy_value 보다 작은 값에 대해서만 통과시키며, 아닐 경우 예외를 발생합니다.
    • (4) 에서 internal_mutex.lock()를 실행해서 lock을 걸고,
    • (5) 에서 this_thread_hierarchy_value의 값을 자신의 값으로 update 합니다.
      • 여기서 눈여겨 봐야 할 부분이 (3) 에서 이전의 this_thread_hierarchy_value를 previous_hierarchy_value로 저장하는 부분입니다. unlock() 부분에서 설명드리겠습니다.
  • 이후 this_thread_hierarchy_value보다 큰 값에 대해서는 lock()을 걸수가 없게 되며,
  • this_thread_hierarchy_value보다 작은 값에 대해서는 lock()을 걸고 해당 값으로 this_thread_hierarchy_value를 update합니다.
  • unlock()을 하는 부분을 보면 먼저 previous_hierarchy_value값으로 this_thread_hierarchy_value를 update 합니다.
    • 이제 자신은 unlock 되었으니 자신보다 바로 앞의 값으로 변경해줘서 해당 값보다 낮은 lock을 걸수 있게 설정해 줍니다.
    • 그런 뒤 자신의 internal_mutex 를 .unlock() 합니다.
  • try_lock()은 만약 internal_mutex-(7)에서 try_lock() 호출이 실패한다면, lock을 소유하지 않고, 그래서 계층값을 갱신하지 못하며, true대신 false를 반환한다는 것을 제외하고는 lock()과 똑같습니다.

lock에 대한 가이드라인 확장

  • 교착상태는 단지 lock에 의해서만 발생하는 것이 아닙니다.
  • thread에서도 발생이 가능합니다.
  • thread가 lock을 잡고 있는데, 해당 thread를 join() 하는 것은 교착상태를 발생 시킬 수 있습니다.
  • 그러므로 thread 또한 hierarchy_value를 두어서 위와 같이 관리하는 것도 생각해 볼 수 있습니다.
위에서 본 것과 같이 교착상태를 피하는 코드를 std::lock() ,std::lock_guard를 이용해서 단순한 lock의 경우를 살펴보았습니다만, 떄때로는 이것으로 해결이 안되는 경우도 있습니다. 그러한 경우에는 std::unique_lock를 이용하여 좀 더 유연하게 사용을 해야 합니다.

3.5 std::unique_lock 사용하기

  • std::unique_lock 은 invariants 를 완화시켜 std::lock_guard 보다 조금 더 flexibility 한 기능을 제공합니다.
    • std::unique_lock 인스턴스는 mutex 소유권 이전을 허용합니다.
  • std::unique_lock 생성자에 두번째 인자로 std::adopt_lock 을 전달하면 mutex를 lock 상태로 생성합니다.
  • std::unique_lock 생성자에 두번째 인자로 std::defer_lock 을 전달하면 mutex를 unlocked 상태로 생성합니다.
    • 이후 lock을 걸려면 std::unique_lock 객체의 .lock() 을 호출, 또는 std::lock()에 std::unique_lock 객체를 인자로 전달해야 합니다. (mutex객체가 아닌 std::unique_lock 객체임을 주의해야 합니다.)

3.5.1 std::unique_lock의 특징

  1. lock()try_lock()unlock() 멤버함수를 지원합니다.
    • 즉, 생성 후 언제든지 lock, unlock 작업을 유연하게 사용이 가능합니다. (std::lock_guard의 경우 생성, 소멸만 가능하지 별도의 멤버함수가 없어서 해당 객체의 scope로만 조절이 가능하지 사용자가 원하는 시점에 맞게 세밀하게 조정이 힘듭니다.)
  2. std::lock()의 인자로 사용 될수 있습니다. (1의 멤버 함수가 지원되기 때문이죠.)
  3. mutex의 소유권 정보를 저장하고 있는 flag를 가지고 있습니다. (그래서 이만큼의 공간이 더 필요하고, 관련 연산으로std::lock_guard보다 좀 더 느리고 큰 공간을 차지합니다.)
  4. flag를 확인해서 소멸시 내부 mutex를 unlock()시킵니다.
  5. lock의 소유권을 다른 범위(scope)로 이동시킬 수 있어서 좀더 유연하게 사용이 가능합니다.
    • 이런 유연성이 필요없고 선언한 범위 안에서만 사용해도 된다면 std::lock_guard로 충분합니다.
  6. move 연산은 지원하지만 copy 연산은 지원하지 않습니다. (범위밖으로 소유권 이전시 이점에 유의해야 합니다.)
  • Listing 3.6 의 std::lock_guard 와 std::adopt_lock 을 std::unique_lock 와 std::defer_lock 로 대체하면 Listing 3.9 와 같이 쉽게 쓰일 수 있습니다. (라인수도 같으며 하는 동작도 같습니다.)

Listing 3.9 Using std::lock() and std::unique_lock in a swap operation

class some_big_object;
void swap(some_big_object& lhs,some_big_object& rhs);

class X
{
    private:
        some_big_object some_detail;
        std::mutex m;
    public:
        X(some_big_object const& sd):some_detail(sd){}

        friend void swap(X& lhs, X& rhs)
        {
            if(&lhs == &rhs)
                return;

            std::unique_lock<std::mutex> lock_a(lhs.m,std::defer_lock); /* (1) */
            std::unique_lock<std::mutex> lock_b(rhs.m,std::defer_lock); /* (1) */

            std::lock(lock_a,lock_b); /* (2) */
            swap(lhs.some_detail,rhs.some_detail);
        }
};
이 예제는 앞에서 이미 보았던 deferred locking 입니다.
먼저 선언 한 뒤 다음에 동시에 lock을 걸었습니다.
(앞에서 본 List 3.6에서는 먼저 2개를 동시에 lock을 걸고 2개의 mutex에 RAII를 적용하는 방식이었습니다.)

3.5.2 범위밖으로 mutex의 소유권 전달

  • std::unique_lock 인스턴스는 mutex 의 소유권을 인스턴스 사이에 이동을 통해 전달 가능합니다.
    1. lvalue : std::unique_lock타입을 리턴
    2. rvalue : std::move() 함수 호출
  • std::unique_lock 은 함수가 mutex 에 대한 lock 과 호출자에 대한 lock 의 소유권 이전을 허용하여, 호출자는 동일한 lock 상태에서 작업이 가능해집니다.
  • 아래의 코드는 이러한 예제 중 하나입니다. get_lock() 함수는 mutex 의 lock 을 획득 하고 호출자에게 lock 을 반환하기 전에 prepare_date() 를 수행합니다.
std::unique_lock<std::mutex> get_lock()
{
    extern std::mutex some_mutex;

    std::unique_lock<std::mutex> lk(some_mutex);
    prepare_data();

    return lk; /* (1) */
}
void process_data()
{
    std::unique_lock<std::mutex> lk(get_lock()); /* (1) */
    do_something();
}
  • process_data() 함수내에서 다른 곳에서 전달 받은 lock을 이용해서 작업을 수행하는 간단한 코드 입니다.
  • 위 과정을 객체화한 gateway class로 생성하여 이용하면 됩니다.
    • 데이터로의 모든 접근은 이 gateway class 를 통해서 get_lock() 같은 함수로 객체를 획득해서 수행하는 방법입니다.

3.6 Lock 범위에 대한 고찰

  • 어려운 내용이 아니라 간단히 설명하겠습니다.
  • lock을 잘게 쪼갤수록 병렬성은 올라가지만, 실제로 보호되어야할 데이터 전체 범위 이하로 쪼개면 일관성이 깨질수 있습니다.
  • lock을 크게 잡을수록 데이터 보호는 보장하지만, 병렬성이 떨어집니다.
  • 누가봐도 오랜시간이 걸리는 작업 (예를 들어서 File I/O 같은 경우)에는 lock을 획득한 상태에서 하지 말아야 합니다.
  • std::unique_lock이 이런 상황에서 좋다고 하는데.... 왜 좋은지는 예제를 봐도 잘 이해가... ???
void get_and_process_data()
{
    std::unique_lock<std::mutex> my_lock(the_mutex);
    some_class data_to_process = get_next_data_chunk();
    my_lock.unlock(); /* (1) */

    result_type result = process(data_to_process);

    my_lock.lock(); /* (2) */
    write_result(data_to_process,result);
}
  • Listing 3.6 과 3.9 의 교환 연산은 두개의 mutex 의 locking 을 필요로 합니다.
  • 아래의 Listing 3.10에서는 한번에 1개의 mutex만 lock을 걸어서 비교연산을 수행합니다.

Listing 3.10 Locking one mutex at a time in a comparison operator

class Y
{
    private:
        int some_detail;
        mutable std::mutex m;
        int get_detail() const
        {
            std::lock_guard<std::mutex> lock_a(m); /* (1) */
            return some_detail;
        }
    public:
        Y(int sd):some_detail(sd){}
            friend bool operator == (Y const& lhs, Y const& rhs)
            {
                if(&lhs==&rhs)
                    return true;
                int const lhs_value = lhs.get_detail(); /* (2) */
                int const rhs_value = rhs.get_detail(); /* (3) */
                return lhs_value == rhs_value; /* (4) */
            }
};
  • 결론적으로 잘못된 결과를 초래할 수 있습니다.
  • lhs 와 rhs가 한 순간도 같은 적이 없었음에도 같다고 판단이 가능한 코드 입니다.
    • 즉 너무 경합 범위를 줄이려다가 잘못될 수가 있으므로 주의해야 합니다.
  • 이런 경우는 std::mutex말고 다른 방식이 더 좋을 수 있습니다.

3.7 공유데이터에 대한 접근

  • 특정 작업(일반적으로 초기화 나 데이터 갱신)에서는 하나의 thread에서만 접근해야 하지만, 읽기 같은 작업은 동시에 해도 문제가 없는 데이터가 있는 경우가 많습니다.
  • 이런 경우에 위에 배운것과 같은 lock 매커니즘을 사용하면 많이 비효율 적이 되겠죠.

3.7.1 초기화 과정동안 공유데이터를 보호하는 간단한 방법

std::shared_ptr<some_resource> resource_ptr;
void foo()
{
    if(!resource_ptr)
    {
        resource_ptr.reset(new some_resource); /* (1) */
    }
    resource_ptr->do_something();
}
  • 그 동안 많이 사용해 왔던 방법입니다.
  • 먼저 초기화 되었는지 확인을 하고 안되었으면 (1)을 통해서 초기화 하고, 그런 다음 사용하는 것입니다.
  • 위에서는 std::shared_ptr의 상태를 확인하는 것으로 초기화 여부를 판단하였습니다.
  • 하지만 아무런 보호장치가 없으므로 싱글 스레드 환경이 아닌 경우에는 동시에 초기화가 일어 날 수 있습니다.

Listing 3.11 mutex를 사용하여 thread-safe lazy initialization

std::shared_ptr<some_resource> resource_ptr;
std::mutex resource_mutex;
void foo()
{
    std::unique_lock<std::mutex> lk(resource_mutex);
    if(!resource_ptr)
    {
        resource_ptr.reset(new some_resource);
    }
    lk.unlock();
    resource_ptr->do_something();
}
  • 게으른 초기화 (lazy initialization)는 객체 생성시 초기화하는게 아니라 필요할때 초기화 한다는 뜻입니다. (singleton을 생각하시면 됩니다.)
  • 다음의 예는 listing 3.11 과 같은 작업을 하지만, std::call_cone 를 이용하였습니다.
std::shared_ptr<some_resource> resource_ptr;
std::once_flag resource_flag; /* (1) */
void init_resource()
{
    resource_ptr.reset(new some_resource);
}
void foo()
{
    std::call_once(resource_flag, init_resource);
    resource_ptr->do_something();
}
이 예제에서, 초기화 되는 std::once_flag (1) 과 데이터는 네임스페이스 범위 영역 객체입니다.
class로 표현은 list 3.12 와 같이 하면 됩니다.

Listing 3.12 std::call_once를 사용하여 클래스 멤버를 Thread-safe lazy initialization로 처리

class X
{
    private:
        connection_info connection_details;
        connection_handle connection;
        std::once_flag connection_init_flag;
        void open_connection()
        {
            connection=connection_manager.open(connection_details);
        }
    public:
        X(connection_info const& connection_details_):
            connection_details(connection_details_)
        {
        }
        void send_data(data_packet const& data) /* (1) */
        {
            std::call_once(connection_init_flag,&X::open_connection,this); /* (2) */
            connection.send_data(data);
        }
        data_packet receive_data() /* (3) */
        {
            std::call_once(connection_init_flag,&X::open_connection,this); /* (4) */
            return connection.receive_data();
        }
};
  • 초기화는 (1) send_data() 의 첫 호출 또는 (3) receive_data() 의 첫 호출 때 이루어 집니다.
  • 데이터 초기화를 위한 멤버 함수 open_connection()는 std::call_once에 포인터로 전달되어야 합니다.
  • (2) std::call_once() 에 함수포인터의 인자(this)를 전달하여 사용 할 수 있습니다.
  • 이것은 말할필요도 없이 std::mutex 처럼 std::once_flage 인스턴스는 복사할수도 이동할수도 없습니다.
class 내의 static 변수가 thread-safety를 가지지 않습니다. (이건 컴파일러마다 다르니 그냥 가지지 않는다고 생각을 하고 짜시는게 편합니다.) 그래서 static 변수를 이용해서 초기화를 구현하면 교착 상태에 빠질 수 있습니다. std::call_once를 사용하는 것이 가장 안전한 대안입니다.
class my_class;
my_class& get_my_class_instance()
{
    static my_class instance; (1)
    return instance;
}
일반적으로 많이 보던 singleton이랑 유사한 코드 입니다. 위 코드의 경우 멀티쓰레드 환경에서 읽기 전용일 경우 교착 상태의 위험 없이 사용이 가능하지만, 데이터가 갱신되는 구조라면 별도의 보호 매커니즘이 필요합니다.

3.7.2 가끔씩만 수정되는 데이터에 대한 보호

std::mutex를 사용하면 읽기 과정에서도 lock을 걸게 함으로 읽기 작업의 동시성이 안됩니다.
따라서 우리는 다른 종류의 mutex 가 필요합니다.
reader-writer mutex라는 것이 있습니다.
  • wirter작업은 하나의 쓰레드만 가능하고, 그 동안 다른 쓰레드는 읽기 작업이 안되며,
  • reader작업은 동시에 여러 쓰레드가 가능합니다.
하지만 안타깝게도 C++ 표준 위원회에 제안했음에도 채택되지 않았습니다.
따라서, Boost 라이브러리를 사용해야 합니다.
앞으로 lock-free 매커니즘을 배울 것입니다.
lock을 거는 것은 성능상 아주 안 좋은 방법입니다.
성능을 위해서라면 std::mutex 보다는 boost::shard_mutex를 사용해 보세요.
  • std::lock_guard<boost::shared_mutex>std::unique_lock<boost::shared_mutex>로 lock을 걸 수 있습니다.
  • 읽기 작업을 할때는 boost::shared_lock<boost::shared_mutex>로 접근하면 됩니다.
list 3.13에서 std::mapboost::shared_mutex를 이용하여 간단한 DNS 캐시를 구현하였습니다.

Listing 3.13 boost::shared_mutex를 사용하여 만든 DNS 캐시

#include <map>
#include <string>
#include <mutex>
#include <boost/thread/shared_mutex.hpp>

class dns_entry;
class dns_cache
{
    std::map<std::string,dns_entry> entries;
    mutable boost::shared_mutex entry_mutex;
public:
    dns_entry find_entry(std::string const& domain) const
    {
        boost::shared_lock<boost::shared_mutex> lk(entry_mutex); /* (1) */
        std::map<std::string,dns_entry>::const_iterator const it= entries.find(domain);
        return (it==entries.end())?dns_entry():it->second;
    }
    void update_or_add_entry(std::string const& domain, dns_entry const& dns_details)
    {
        std::lock_guard<boost::shared_mutex> lk(entry_mutex); /* (2) */
        entries[domain]=dns_details;
    }
};
  • find_entry() 는 boost::shraed_lock<>을 이용하여 (1) read-only 접근을 허용합니다.
    • 다른 스레드들도 동시에 find_entry()로 접근이 가능합니다.
  • update_or_add_entry()는 std::lock_guard<>를 사용하므로 다른 쓰레드의 접근을 막습니다.
    • 해당 작업동안 find_entry()의 호출까지 모두를 블록합니다.

3.8 std::recursive_mutex

  • std::mutex가 lock 된 상태에서 다시금 lock 을 시도하면 에러가 발생합니다.
  • std::recursive_mutex은 반복적으로 lock 획득이 가능합니다.
    • lock을 획득한 횟수만큼 모두 해제해야 다른 쓰레드에서 접근이 가능하게 됩니다.
  • 재귀 함수로 뭔가를 구현했을 때는 std::recursive_mutex을 이용하여 구현을 하면 편리합니다.

Summary

  • C++ 표준 라이브러리가 제공하는 std::lock()std::mutex의 사용법을 살펴보았습니다.
  • 데드락을 피하는 방법을 소개하였습니다.
  • std::call_once()를 이용한 초기화 방법을 소개하였습니다.
  • boost::shared_mutex를 이용한 공유 데이터 접근법을 소개하였습니다.

댓글 없음:

댓글 쓰기