Engineering/C++

Chapter3. Sharing Data Between Threads

luckydipper 2025. 1. 20. 22:55
반응형

해당 챕터는 Thread 간 데이터를 공유하는 방법에 관해 다룬다. Race Condition (경쟁 상태)란 여러 개의 Thread가 같은 데이터에 한 번에 접근해서 생기는 문제점이다. 데이터를 읽고 쓰기를 동시에 할 때 일어난다. 실행할 때마다 결과가 다르게 나오는 거지 같은 상황이 나올 수 있다.

1. Race Condition해결 방법

  1. Mutex 기반
    Critical Section에 접근할 수 있는 Thread를 제한한다. C++에서 가장 많이 쓰는 방법이다.
  2. Lock-Free Programming
    자료구조를 바꿔서, 여러 과정을 통해 자료구조의 부분이 변하게 한다. (Chapter5. Memory Model, Chapter 7 Lock-Free Data structure)
  3. Update를 Data Transaction으로 간주
    하나의 변화 과정을 로깅하고, 커밋하고, 저장한다. software transactional memory(STM)이란 기술 활용하나 C++ 에선 지원하지 않는다.

2. Mutex

  • lock_guard
    해당 block {}에서 lock과 unlock을 자동으로 해준다.(RAII 철학) C++17에선 scoped_lock을 쓰기를 추천한다. std::scoped_lock lock(mut1, mut2)으로 여러 개의 mutex를 한 번에 잠글 수 있다. 여러 뮤텍스 잠금 시 데드락 방지 기능도 있다.
#include <mutex>

std::mutex m1
void add_to_list(int new_value)
{
    std::lock_guard<std::mutex> guard(m1); //C++17 부터 template argument deduction 가능! lock_guard gaurd(m1)으로 됨.
    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();
}

mutex가 없다면, 아래 코드에서 empty()와 top() 사이에, 또한 top()과 pop() 사이에서 race condition이 일어난다.

stack<int> s;
if(!s.empty())
{
    int const value=s.top();
    s.pop();
    do_something(value);
}

3. Mutex 기반 Shared Data 관리하는 Class 만들기

mutex가 들어가는 shared data 부분들은 class로 묶어 관리한다. 이때 멤버함수의 인터페이스 설계가 중요하다.

Rule 1. Pointer나 Reference를 Parameter로 받거나 Return 값으로 쓰지 말기.

보안상 Call by Value로 설계해야 한다. 아래는 call by reference의 보안상 위험을 나타내는 코드이다.

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);
    }
};
some_data* unprotected;
void malicious_function(some_data& protected_data)
{
    unprotected=&protected_data;
}
data_wrapper x;
void foo()
{
    x.process_data(malicious_function);
    unprotected->do_something();
}

위 코드에서 두 가지 문제점이 있다. 첫째, 공격자가 malicious 함수를 만들어서, protected_data를 unprotected 전역변수로 가져올 수 있다. 둘째, 외부에서 실행한 do_something은 mutex로 보호받지 않아, race condition 유발 가능하다.

Rule 2. 내재적 race condition 문제 해결 하기

stack.pop을 return by value로 바꿔도 내재적(inheritant) race condition 문제가 있다. pop()은 1. 가장 처음에 들어간 element 복사와 2. 제거가 둘 다 일어난다. 즉 메모리가 가득 채워졌다면 복사 부분에서 out of memory 에러가 나고, 제거만 일어날 수 있다. 이를 방지하고자 3가지 대안을 제시한다.

    1. pass in a reference
    2. std::vector<int> result; some_stack.pop(result);

와 같이 parameter로 결과 값의 reference를 넘긴다.

    1. REQUIRE A NO-THROW COPY CONSTRUCTOR OR MOVE CONSTRUCTOR
      복사 생성자 또는 이동 생성자가 반드시 예외를 던지지 않아야 한다.
      아래와 같은 코드를 적용할 수 있다.
    2. #include <type_trait> std::is_nothrow_copy_constructible; std::is_nothrow_move_constructible; noexcept// stack data의 무결성 보장한다.
    1. RETURN A POINTER TO THE POPPED ITEM
      shared_ptr 같은 스마트 포인터를 return 시킨다. 기본 타입 (int double)을 스마트 포인터로 관리하면 오버헤드가 커질 수 있다.

Tom Cargill pointed out that a combined call can lead to issues if the copy constructor for the objects on the stack can throw an exception

3. Dead Lock

dead lock은 2개 이상의 thread가 lock이 풀릴 때까지 기다리면서 프로그램이 멈추는 현상을 칭한다.

3.1 Dead Lock을 막는 가이드라인

순서대로 mutex를 잠그는 것이 기초이다.

  1. AVOID NESTED LOCKS
  2. AVOID CALLING USER-SUPPLIED CODE WHILE HOLDING A LOCK
  3. ACQUIRE LOCKS IN A FIXED ORDER
  4. USE A LOCK HIERARCHY

4. 여러 개의 mutex를 한 번에 잠가야 할 때 ex) swap operation

교착 상태를 피하기 위해서는 순서대로 Lock 하는 것이 중요하다. C++ 에선 이를 자동화한 std::lock()과 std::scoped_lock이 있다. C++17 이후 컴파일러에선 scoped_lock 쓰자. scoped_lock은 variadict template(typename...  Since C++11)을 이용해서 구현한 lock_gaurd이다. 즉 여러 개의 mutex를 쓰더라도 lock_guard처럼 자동으로 lock 하고 unlock 해준다.

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);
           std::lock_guard<std::mutex> lock_a(lhs.m,std::adopt_lock); // here
           std::lock_guard<std::mutex> lock_b(rhs.m,std::adopt_lock);
           swap(lhs.some_detail,rhs.some_detail);
        //std::unique_lock<std::mutex> lock_a(lhs.m,std::defer_lock); 
        //std::unique_lock<std::mutex> lock_b(rhs.m,std::defer_lock); 
        //std::lock(lock_a,lock_b);
    }
};

void swap(X& lhs, X& rhs)
    {
        if(&lhs==&rhs)
            return;
        std::scoped_lock guard(lhs.m,rhs.m); //template parameter deduction. scoped_lock<std::mutex,std::mutex> 
        swap(lhs.some_detail,rhs.some_detail);
    }

5. lock_guard vs unique_lock

unique_lock은 lock_guard에 확장된 기능을 수행한다. lock_guard는 lock 상태의 mutex를 갖는다. 그러나 adapt_lock은 mutex를 소유할 수도 안 할 수 도 있다. (unique_lock은 내부적으로 mutex를 lock 해놨으면 unlock 하고 unlock 해 놨으면 가만히 둔다.)

  1. unique_lock은 lock_guard 보다 느리다.
  2. 다양한 lock tag(try_to_lock, unlock, defer_lock)[각주:1]이 가능하다.
  3. copy는 불가능하지만 movable 하다. 함수의 return 값과 같은 r-value로 다른 함수에 줄 수 있다.
    unique_lock은 lock_guard 보다 느리나, unlock try_lock, defer_lock 등을 사용할 수 있다.
  • 예를 들어 mutli thread로 데이터들이 이동할 때, 몇 개의 thread safe queue가 사용된다고 보자. 원하는 queue를 mutex로 혼자서 접근하고 싶을 때 일관된 gateway class가 될 수 있다. (movable 하게 만들어졌을 것이다.)
  • 또한 unique_lock이 사라지기 전에 unlock 해서 다른 함수를 실행시킬 수 있다.
// gateway class member function
std::unique_lock<std::mutex> get_lock()
{
    extern std::mutex some_mutex;
    std::unique_lock<std::mutex> lk(some_mutex);
    prepare_data();
    return lk;
}

//
void process_data()
{
    std::unique_lock<std::mutex> lk(get_lock());
    do_something();
    lk.unlock();
    do_others();
}

6. 초기화 단계에서 Shared Data 초기화하기

6.1. Lazy Initialization을 위한 multi thread initializing

Lazy Initialization을 위해선, 외부 전역 변수를 하나의 thread에서만 초기화해야 한다.

Lazy Initialization이란, 큰 객체가 초기화가 돼야 할 때, 함수가 실행될 때 초기화 되는 것을 뜻 한다. [각주:2] DB_connection, 크기가 큰 메모리 객체를 초기화하는 것이 예시이다. 이런 경우 mutex보다는 call_once, once_flag를 사용하는 것이 좋다.

// 오리지널 C++ multi thread에서 객체 초기화
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();
}

// 위와 같이 코딩 하는 것 대신, 아래와 같이 call_once로 가능하다. 
std::shared_ptr<some_resource> resource_ptr;
std::once_flag resource_flag;
void init_resource()
{
    resource_ptr.reset(new some_resource);
}
void foo() {
    std::call_once(resource_flag,init_resource);
    resource_ptr->do_something();

6.2 Static으로 외부 전역 변수를 하나의 thread에서만 초기화하기

외부 전역 변수를 하나의 thread에서만 초기화하는 것은 다른 방식으로도 구현된다. C++11 이상에서 lazy_initalization 기능 없는 multithread 객체 초기화 할 때 static local variabel을 사용하면 된다. 하나의 전역 객체만 필요할 때 사용가능하다.

class my_class;
my_class& get_my_class_instance()
{

static my_class instance;
    return instance;
}

7. Protecting rarely updated data structures

거의 변하지 않는 DNS 같은 시스템에서는 읽기보다 쓰기가 더 자주 일어난다. 이때 reader-writer mutex가 사용된다. read와 wirte를 분리해서 동기화하는 것이다. 복수개의 reader thread와 한 개의 writer thread가 사용된다. 구현은 두 가지 방법으로 이뤄진다. 1. std::shared_mutex 2. std::shared_timed_mutex C++17부터 둘 다 사용 가능하고 C++14에선 shared_timed_mutex만 사용 가능하다. shared_mutex가 time mutex보다 더 가벼운 기능을 제공한다.
shared mutex는 exclusive_lock이나 shared_lock 한다. exclusive_lock는 1개의 shared mutex를 잠그는 것이고 shared_lock은 복수의 mutex를 잠그는 것이다. DNS 시스템의 예시에서 읽는 것은 여러 번 일어나고 수정은 가끔 일어난다. 이때, 여러 번 일어나는 shared mutex를 읽기 연산을 shared_lock으로, 가끔 일어나는 연산을 exclusive_lock(lock_guard, unique_lock)으로 묶는다.
exclusive_lock이 걸리면(쓰기 과정이 실행되면) shared_lock의 다른 모든 mutex들은 exclusive_lock이 unlock 될 때까지 멈춘다. 만약 shared_lock이 걸리면 exclusive_lock은 실행이 불가능하다.

#include <map>
#include <string>
#include <mutex>
#include <shared_mutex>
class dns_entry;
class dns_cache
{
    std::map<std::string,dns_entry> entries;
    mutable std::shared_mutex entry_mutex; //mutable keyward는 const method에서도 변한다.
public:
    dns_entry find_entry(std::string const& domain) const
    {
    std::shared_lock<std::shared_mutex> lk(entry_mutex);
    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<std::shared_mutex> lk(entry_mutex);
        entries[domain]=dns_details;
    }
};

8. recursive_mutex

recursive_mutex를 이용하면 mutex를 여러번 잠그고 해제할 수 있다. lock을 n번 하면 unlock도 n번 해야 한다. 재귀 함수에 쓸 수는 있으나, 좋지 않은 디자인이 될 가능성이 크다.

ps.

  • Ownership: 리소스의 수명과 해제를 책임지는 주체이다. shared 혹은 unique로 Ownership을 관리한다. reference는 ownership을 가져오지 않고 대상을 가리킬 수 있다. pointer는 ownership을 가져올 수도 안 가져올 수도 있다. unique_lock은 move는 가능하나 copy는 불가능하다. 함수 return과 같은 r-value는 자동으로 move 되나. 아닌 경우 std::move()로 명시적으로 r-value로 만들어야 한다.
  • 메모리의 경우 malloc(new)으로 만들어 진경우가 own 된 경우이다. ownership이 끝날 때 free(delete) 되어야 한다. mutex를 own 한다는 것도 mutex를 lock을 갖고 있고 ownership이 끝날 때 unlock 한다.
  • automatic variable stack frame에서 관리되는 변수들 [각주:3]
  • memory의 shared data접근할 때만 mutex를 잠가라.(mutex를 잡고 fileI/O는 절대 하지 마라)
  • double-checked locking pattern

ref.

  1. lock tag엔 defer_lock, try_to_lock, adopt_lock가 있다. 차례대로 defer(미루다)는 나중에 잠근다고 가정한다. try_to_lock은 안 잠겨 있으면 잠근다. adopt_lock은 잠겼다고 가정한다. [본문으로]
  2. cf.Eager Initialization: 프로그램이 시작될 때 한 번만 초기화됨 [본문으로]
  3. https://en.wikipedia.org/wiki/Automatic_variable#cite_note-2 [본문으로]
반응형