Notice
Recent Posts
Recent Comments
Link
«   2024/12   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30 31
Archives
Today
Total
관리 메뉴

게임 개발 메모장

[ C++ ] 멀티스레드 프로그래밍 본문

언리얼 엔진/C++

[ C++ ] 멀티스레드 프로그래밍

Dev_Moses 2024. 1. 6. 14:48

경쟁 상태 : 여러 스레드가 공유 리소스를 동시에 접근.

: x86 프로세서에서 제공하는 INC는 아토믹하지 않으므로,

두 스레드의 작업이 겹치게 되면 예상치 못한 결과를 얻을 수 있다.

 

테어링

* 읽기 테어링 : 1번 스레드가 메모리에 데이터의 일부분만 쓰고 나머지 부분을 다 쓰지 못한 상태에서 2번 스레드가 이 데이터를 읽으면 두 개의 스레드가 보는 값이 달라진다.
* 쓰기 테어링 : 두 스레드가 동일한 데이터의 다른 부분을 각각 쓰는 경우 각자 수행한 결과가 달라진다.

 

데드락(교착 상태) : 경쟁 상태를 막기 위해 상호 배제와 같은 기법을 사용할 때 발생하는 문제.

예시 : 1번 스레드는 A를 확보하고 B를 추가적으로 필요로 하는 동시에 2번 스레드는 B를 확보하고 A를 추가적으로 필요로 하는 상황.

 

잘못된 공유(false sharing) : 캐시는 x86의 경우 64바이트 캐시 라인 단위로 처리되는데, 캐시 라인에 데이터를 쓰려면 캐시 라인 전체를 lock해야함. 두 스레드가 두 가지 데이터 영역을 사용하는 데 동일한 캐시 라인에 있는 경우 성능이 떨어진다.

C++17부터 추가된 <new> 헤더 파일의 hardware_destructive_interference_size란 상수를 이용하면 동시에 접근하는 객체가 같은 캐시라인에 있지 않도록 최소한의 오프셋을 제시해준다.

alignas 키워드를 사용해 두 데이터 사이의 거리를 보장한다.


std::thread

#include<thread>
스레드가 할 일을 지정하는 데 전역함수 / 함수 객체의 operator() / 람다 표현식 / 클래스 멤버함수 등등의 방법을 사용할 수 있다.

 

함수 포인터로 스레드 만들기

#include <iostream>
#include <thread>


void func(int arg1, double arg2)
{
    std::cout << "arg1 : " << arg1 << ", arg2 : " <<   arg2 << std::endl;
}

int main()
{
    std::thread t1(func, 1, 2.0);
    std::thread t2(func, 3, 4.0);
    t1.join();
    t2.join();
}

 


std::atomic

 

- atomic type은 동기화 기법을 적용하지 않고 읽기, 쓰기를 동시에 처리하는 atomic access가 가능하다.
- atomic_bool, atomic_int, ...

- 모든 종류의 타입에 대해 적용 가능. 예시 : std::atomic<float>, std::atomic<Struct>
- 단 사용자 정의 타입의 경우 쉽게 복제할 수 있는 경우에만 lock_free() = true이다.

 

아토믹 타입 사용예

#include <atomic>
#include <chrono>
#include <iostream>
#include <thread>
#include <vector>

using namespace std::chrono;

void func(std::atomic<int>& arg1)
{
	for (int i = 0; i < 100; ++i)
	{
		
		++arg1;
		std::this_thread::sleep_for(1ms);
	}
}

int main()
{
	std::atomic<int> arg1(0);
	std::vector<std::thread> threads;
	for (int i = 0; i < 10; ++i)
	{
		threads.push_back(std::thread{func, std::ref(arg1)});
	}

	for (auto& thread : threads)
	{
		thread.join();
	}

	std::cout << "arg1 : " << arg1 << std::endl; // -> 1000
	return 0;
}

 

- 매 for 루프마다 arg1에 접근하기보다는 하나의 변수에 sum을 계산한 후에

  마지막에 arg1에 sum을 더해주는 편이 더 좋다.

- 아토믹 연산
   fetch_add, fetch_sub, fetch_xor, ++, --, += 등의 연산을 지원함.


상호 배제

 

- 한 번에 한 스레드만 접근할 수 있도록 lock.
- 표준 라이브러리에서는 mutex, lock 클래스를 이용해 상호 배제.

 

1) non timed mutex

- std::mutex, recursive_mutex, shared_mutex(C++17)

- 메서드
* lock() : 대기 시간 없이 락을 검.
* try_lock() : 락을 시도. 다른 스레드가 락을 걸었다면 호출 즉시 리턴, try_lock()은 false를 리턴함.
* unlock() : 걸어둔 락을 해제.

 

- recursive_mutex : lock()/try_lock()을 여러번 호출할 수 있다.

  락을 해제하려면 unlock()을 lock()/try_lock() 횟수 만큼 호출해야함.

- shared_mutex : 읽기-쓰기 락.
* 스레드는 락에 대한 독점 소유권(write lock) or 공유 소유권(read lock)을 얻음.
* 독점 소유권은 다른 스레드가 독점/공유 소유권을 가지고 있지 않을 때만 얻을 수 있음.
* 공유 소유권은 다른 스레드가 독점 소유권을 가지고 있지 않은 경우 얻을 수 있다.
* 기존의 lock, try_lock, unlock은 독점 소유권 메서드이고 lock_shared, try_lock_shared, unlock_shared의 공유 소유     
권 메서드가 있다.

2) timed mutex

- std::timed_mutex, recursive_timed_mutex, shared_timed_mutex(C++17)
- non-timed mutex의 메서드를 모두 지원하고 추가적으로 try_lock_for(), try_lock_until(), try_lock_shared()... 등

  주어진 시간 안에 락을 걸지 못하면 false를 리턴하는 메서드가 존재한다.

 


mutex의 lock, unlock을 직접 호출하는 구조는 권장되지 않는다. 

스마트 포인터와 같이 RAII 원칙을 따르는 lock 클래스를 사용하는 것이 적절하다.

 

lock 클래스

- RAII 원칙을 만족하는 클래스.

 

- lock 클래스 소멸자는 확보한 mutex를 자동으로 unlock함.

 

- std::lock_guard, unique_lock, shared_lock, scoped_lock(C++17)을 제공함.

 

- lock_guard : 아래의 2가지 생성자를 지원.
  * lock_guard(mutex_type& m) : 뮤텍스 레퍼런스를 인수로 받음.
  * lock_guard(mutex_type& m, adopt_lock_t) : 추가적으로 std::adopt_lock_t 인스턴스를 인수로 받음.

    인수로 받은 뮤텍스  에 이미 락을 건 상태에서 추가로 락을 걸 때 사용.

 

- unique_lock : owns_lock()을 통해 락이 걸렸는 지 확인 가능.
   * unique_lock(mutex_type& m)
   * unique_lock(mutex_type& m, defet_lock_t) : 곧바로 락을 걸지 않고 나중에 다시 검.
   * unique_lock(mutex_type& m, try_to_lock_t) : 락을 시도, 실패 시 블록하지 않고 나중에 다시 시도.
   * unique_lock(mutex_type& m, const chrono::time_point<Clock, Duration>& abs_time) : 지정 시간동안 락 시도.
   * unique_lock(mutex_type& m, rel time) : 지정 시간동안 락 시도.
   * unique_lock(mutex_type& m, adopt_lock_t) : 복수의 락을 걸 때 사용.

 

- shared_lock : 위의 shared_mutex의 기능을 함. unique_lock 클래스와 동일한 인터페이스를 가진다.

- lock()은 가변 인수 템플릿 함수로 여러 개의 뮤텍스 객체를 데드락 걱정 없이 한번에 lock을 걸 수 있다.
  어느 하나라도 lock에 대해 exception을 던지면 unlock을 수행함.

 

- scoped_lock : 인수 개수에 제한이 없음 + lock_guard와 같이 스코프를 벗어나면 lock이 해제됨.

- std::call_once(), std::once_flag : call_once의 인수로 지정한 함수/메서드는 단 한 번만 실행된다.
  주로 공유 자원을 초기화하는 데 사용된다.(처음 한 번만 초기화해야하므로)

 

thread safe std::cout 예시

#include <thread>
#include <mutex>
#include <iostream>

class C
{
public:
    C(int arg1, double arg2)
        : mArg1(arg1), mArg2(arg2)
    {}

    void operator()() const
    {
        for (int i = 0; i < 100; ++i)
        {
            std::lock_guard<std::mutex> lock(sMutex);
            std::cout << "arg1 : " << mArg1 << ", arg2 : " << mArg2 << std::endl;
        }
        
    }
private:
    int mArg1;
    double mArg2;
    static std::mutex sMutex;
};

std::mutex C::sMutex;

int main()
{
    std::thread t1{ C{1, 2.0} };
    C c(3, 4.0);
    std::thread t2(c);

    t1.join();
    t2.join();

    return 0;
}

timed mutex를 사용한 버전.

 

#include <thread>
#include <mutex>
#include <chrono>
#include <iostream>

using namespace std::chrono;

class C
{
public:
    

    C(int arg1, double arg2)
        : mArg1(arg1), mArg2(arg2)
    {}

    void operator()() const
    {
        for (int i = 0; i < 100; ++i)
        {
            std::unique_lock<std::timed_mutex> lock(sTimedMutex, 200ms);
            if (lock.owns_lock())
            {
                std::cout << "arg1 : " << mArg1 << ", arg2 : " << mArg2 << std::endl;
            }
        }

    }
private:
    int mArg1;
    double mArg2;
    static std::timed_mutex sTimedMutex;
};

std::timed_mutex C::sTimedMutex;

int main()
{
    std::thread t1{ C{1, 2.0} };
    C c(3, 4.0);
    std::thread t2(c);

    t1.join();
    t2.join();

    return 0;
}

 

이중 검사 락 패턴 : 리소스를 단 한 번만 초기화할 때 사용할 수 있는 패턴. 

사용하지 않는 것을 권장하며 앞서 설명한 std::once() 또는 magic static을 사용하는 게 좋다.


Condition Variable

- 조건 변수를 사용해 다른 스레드가 조건을 설정하기 전/ 지정한 시간이 경과하기 전까지 스레드를 멈추게 할 수 있음.

 

- <condition_variable>에 정의돼 있다.

 

- std::condition_variable : unique_lock<mutex>만을 기다림.

 

- std::condition_variable_any : 모든 종류의 객체를 기다림.

 

- 메서드
1) notify_one() : 조건 변수를 기다리는 스레드 중 하나를 깨움.
2) notify_all() : 조건 변수를 기다리는 모든 스레드를 깨움.
3) wait(unique_lock<mutex>& l) : wait을 호출한 시점부터 조건변수로부터 notification이 오기를 기다린다. 여기서 wait를 호출하는 스레드는 반드시 l에 대한 락을 걸고 있어야 한다.(l.owns_lock()=true인 상태) 그리고 wait를 호출하면 l.unlock()을 호출하고, 이후에 notification이 오면 다시 lock을 걸어준 뒤에 함수가 종료된다.
4) 일정 시간동안 wait하는 wait_for, wait_until 함수가 있다.