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

haeryong·2023년 5월 31일
1
  • 경쟁 상태 : 여러 스레드가 공유 리소스를 동시에 접근.

    • 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();
    
	return 0;
}
  • thread 클래스 생성자는 "가변 인수 템플릿"이기 때문에 인수 개수를 원하는 만큼 추가할 수 있다.
  • join() : 원래 스레드에서 스레드 객체의 join을 호출하면 원래 스레드는 블록되며 join한 스레드의 작업이 끝날 때까지 대기한다.
  • detach() : thread 객체를 OS 내부의 스레드와 분리한다. OS 스레드는 독립적으로 실행된다.

함수 객체로 스레드 만들기

class C
{
public:
	C(int arg1, double arg2)
    	: mArg1(arg1), mArg2(arg2)
    {}
    
    void operator()() const
    {
    	std::cout << "arg1 : " << mArg1 << ", arg2 : " <<   mArg2 << std::endl;
    }
private:
	int mArg1;
    double mArg2;
};

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

람다 표현식으로 스레드 만들기

int main()
{
	int arg1 = 1;
    double arg2 = 2.0;
    std::thread t1([arg1, arg2]
    	{
    		std::cout << "arg1 : " << arg1 << ", arg2 : " <<   arg2 << std::endl;
    	});
    
    t1.join();
	return 0;
}

멤버 함수로 스레드 만들기

class C
{
public:
	C(int arg1, double arg2)
    	: mArg1(arg1), mArg2(arg2)
    {}
    
	void threadMethod()
    {
    	std::cout << "arg1 : " << mArg1 << ", arg2 : " <<   mArg2 << std::endl;
    }
private:
	int mArg1;
    double mArg2;
};

int main()
{
	C c(1, 2.0);
    std::thread t1{&C::threadMethod, &c};
    t1.join();    
	return 0;
}
  • 스레드 취소하기 : 공유 변수를 사용해 값을 확인하며 중단 여부를 결정하는 것이 좋음. 공유 변수는 아토믹이나 조건 변수로 만드는 것이 좋음.

  • 스레드 종료 후 최종 결과 가져오기

    • 인수로 변수에 대한 포인터, 레퍼런스를 전달해 결과를 저장.
    • 클래스 멤버 변수에 결과 저장 후 스레드 종료 시 가져오기.
    • 이 경우 std::ref()를 이용해 함수 객체의 레퍼런스를 thread 생성자에 전달해야함.

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 클래스를 이용해 상호 배제.

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의 공유 소유권 메서드가 있다.

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

0개의 댓글