멀티 쓰레드 환경?
: 일반적인 STL Container 문법은 멀티 쓰레드 환경에서 동작하지 않음
1. vector : 메모리 동적할당 방식.
1. 문제 : 멀티 스레드 환경에서는 순서가 보장되지 않아, 메모리 공간을 삭제하거나 옮기는 과정에서 crash 발생, 메모리 손실 발생
2. 해결법1 : vector.reserve()를 사용하면 메모리 공간을 삭제하거나 옮기는 일이 발생하지 않아서 crash는 나지 않지만, 메모리 손실 발생
3. 해결법2 : Lock사용
2. volatile : 컴파일러에게 최적화를 하지 말아달라고 부탁
int32 a = 0;
a=1; a=2; a=3; a=4;
cout << a << '\n';
: Debug모드가 아닌 Release모드(최적화 진행)로 빌드하면 a = 4만 남게 됨
volatile 키워드를 추가해주면(volatile int32 a = 0), a=1 ~ a=4까지 남아있음
쓰레드 관련 문법
1. std::thread t.join() : main 스레드에서 worker스레드의 동작을 기다려줌. main스레드가 worker스레드보다 먼저 동작이 끝나면 오류가 발생함
int main()
{
std::thread t;
auto thread_id = t.get_id(); //이때는 id = 0
t = std::thread(Hello);
thread_id = t.get_id(); //이때는 id = 80304라는 고유한 값 가짐
cout << "Thread" << '\n';
int count = t.hardware_concurrency();
if(t.joinable()) //이런식으로 사용하는게 일반적
t.join();
return 0;
}
2. std::thread t.hardware_concurrency() : 논리적으로 사용할 수 있는 프로세스 개수, CPU 코어 개수 반환
3. std::thread t.get_id() : 어떤 스레드인지 구분
4. std::thread t.detach() : 워커 스레드를 메인 스레드와 분리(detach) 시켜 독립적으로 실행되도록 함. 백그라운드 스레드에서 독립적으로 실행됨. 소유권이 스레드 관리 시스템으로 넘어가며, 더 이상 메인 스레드가 해당 스레드의 상태를 추적할 수 없게 됨. 따라서 main스레드가 worker스레드 보다 먼저 작업을 끝내도 오류가 나지 않음. t.join을 호출할 필요가 없음
<사용 예시>
ㄴ 데몬 스레드
- 로그 기록, 상태 점검 등 지속적으로 실행되어야 하는 작업.
ㄴ 실시간 데이터 수집
- 네트워크 데이터 처리, 센서 데이터 읽기 등.
ㄴ UI 업데이트
- GUI 애플리케이션에서 사용자 작업에 응답하지 않고 백그라운드 작업 실행.
*멀티 스레드 환경에서는 실행 순서가 보장되지 않는다*
1. Atomic : All-Or-Nothing
: 연산이 많이 느리기 때문에 병목현상 발생 가능. 꼭 필요할 때에만 사용하기
#include <atomic>
atomic<int32> sum = 0;
void Add()
{
for (int32 i = 0; i < 1'000'000; i++)
{
/* atomic문법 sum.fetch_add(1)로 대체 가능 */
sum++;
}
}
void Sub()
{
for (int32 i = 0; i < 1'000'000; i++)
{
/* atomic문법 sum.fetch_add(-1)로 대체 가능 */
sum--;
}
}
int main()
{
std::thread t1(Add);
std::thread t2(Sub);
t1.join();
t2.join();
cout << sum << '\n';
}
이렇게 하면 실행 순서가 보장된다.
스레드 동기화 방법 1 : Mutex Lock
: mutex.lock(), mutex.unlock()을 이용한 부분을 실행할때에는 다른 스레드가 동작하지 못하도록(싱글 스레드로 동작하도록) 막는 자물쇠.
- lock과 unlock을 통해 경합하는 과정이 있기 때문에 일반적인 상황보다는 느림.
- 상호배타적 특성(먼저 lock에 도달하면 unlock까지는 하나의 스레드만 선점하는 방식)
- Mutex Lock 재귀적 호출 불가, Recursive Mutex Lock은 재귀적 호출 가능
- RAII (Resource Acquisition is Initialization) : wrapper class의 생성자에서 lock(), 소멸자에서 unlock()할 경우 사용하는 패턴. 리소스 관리를 객체의 수명에 의존하게 함으로써, 리소스를 안전하고 효율적으로 관리함
#include <mutex>
/* 자물쇠 역할 */
mutex m;
vector<int32> v;
void Push()
{
for (int32 i = 0; i < 10'000; i++)
{
/* v.push_back(1) 작업중에 다른 스레드 작업 막기 */
m.lock();
v.push_back(i);
m.unlock();
}
}
int main()
{
std::thread t1(Push);
std::thread t2(Push);
t1.join();
t2.join();
cout << v.size() << '\n';
}
Mutex Lock - LockGuard : MutexLock을 RAII패턴으로 관리
: Mutex Lock 재귀적 호출 불가하다는 단점 보완
RAII (Resource Acquisition is Initialization) : wrapper class의 생성자에서 lock(), 소멸자에서 unlock()할 경우 사용하는 패턴. 리소스 관리를 객체의 수명에 의존하게 함으로써, 리소스를 안전하고 효율적으로 관리함
#include <mutex>
/* 자물쇠 역할 */
mutex m;
vector<int32> v;
/* RAII */
template<typename T>
class LockGuard
{
public:
LockGuard(T& m)
{
_mutex = &m;
_mutex->lock();
}
~LockGuard()
{
_mutex->unlock();
}
private:
T* _mutex;
};
void Push()
{
for (int32 i = 0; i < 10'000; i++)
{
/* LockGuard 객체가 더이상 유효하지 않으면 알아서 소멸자 호출, unlock() */
/* std::lock_guard<std::mutex> lockGuard(m)으로 대체 가능*/
LockGuard<std::mutex> lockGuard(m);
v.push_back(i);
}
}
int main()
{
std::thread t1(Push);
std::thread t2(Push);
t1.join();
t2.join();
cout << v.size() << '\n';
}
Mutex Lock - Unique Lock
: 재귀적 호출이 불가능한 Mutex Lock의 단점 보완
LockGuard에 lock시점을 뒤로 미루는 기능이 추가된 버전
- lock_guard보다는 느림. 간단한 경우에는 lock_guard 사용하기
/* 자물쇠 역할 */
mutex m;
vector<int32> v;
void Push()
{
for (int32 i = 0; i < 10'000; i++)
{
/* lock 시점을 뒤로 미룰 수 있음, unlock은 객체 소멸시 알아서 호출됨 */
std::unique_lock<std::mutex> uniqueLock(m, std::defer_lock);
/* lock */
uniqueLock.lock();
v.push_back(i);
}
}
int main()
{
std::thread t1(Push);
std::thread t2(Push);
t1.join();
t2.join();
cout << v.size() << '\n';
}
DeadLock
: lock() 이후 unlock()을 하지 않을때 발생 가능
두 개 이상의 스레드(또는 프로세스)가 서로가 소유한 리소스를 요청하며, 무한히 기다리는 상태.
<예시 상황>
UserManager.h
class User
{
//TODO
}
class UserManager
{
public:
static UserManager* Instance()
{
static UserManager instance;
return &instance;
}
User* GetUser(int32 id)
{
std::lock_guard<std::mutex> guard(_mutex);
return nullptr;
}
void ProcessSave();
private:
mutex _mutex;
}
UserManager.cpp
#include "UserManager.h"
#include "AccountManager.h"
void UserManager::ProcessSave()
{
/* user lock */
std::lock_guard<std::mutex> guard(_mutex);
/* account lock */
Acocunt* account = AccountManager::Instance()->GetAccount(100);
}
AccountManager.h
class Account
{
//TODO
}
class AccountManager
{
public:
static AccountManager* Instance()
{
static AccountManager* instance;
return &instance;
}
Account* GetAccount(int32 id)
{
std::lock_guard<std::mutex> guard(_mutex);
return nullptr;
}
void ProcessLogin();
private:
mutex _mutex;
}
AccountManager.cpp
#include "AccountManager.h"
#include "UserManager.h"
void AccountManager::ProcessLogin()
{
/* account lock */
std::lock_guard<std::mutex> guard(_mutex);
/* user lock */
User* user = UserManager::Instance()->GetUser(100);
}
위와 같은 상황일때, 아래 Main함수를 실행해보자.
#include "AccountManager.h"
#include "UserManager.h"
void Func()
{
for(int32 i=0; i<1000; i++)
{
UserManager::Instance()->ProcessSave();
}
}
void Func2()
{
for(int32 i=0; i<1000; i++)
{
AccountManager::Instance()->ProcessLogin();
}
}
void main()
{
std::thread t1(Func);
std::thread t2(Func2);
t1.join();
t2.join();
cout << "Jobs are Done" << '\n';
}
"Jobs are Done"이 잘 출력될때도 있지만, 출력이 멈추는 경우가 대부분이다.
이유는 아래와 같다.
1. ProcessSave()에서 GetAccount()값을 받기를 대기하는 상황
2. ProcessLogin()에서 GetUser()값을 받기를 대기하는 상황
이 두 경우가 한번에 발생했기 때문에 무한히 응답을 기다리는 상황이 발생한 것이다.
이게 바로 데드락 현상이다.
해결방법 1 : lock(m1, m2) + adopt_lock 사용
#include "AccountManager.h"
#include "UserManager.h"
void main()
{
// 동시에 m1, m2를 활용하는 상황에서는
std::mutex m1;
std::mutex m2;
// 내부적으로 일관적인 순서를 이용해 lock()
std::lock(m1, m2); // m1.lock() ->m2.lock()
// adopt_lock : 이미 m1이 lock된 상태이니까 소멸될때 unlock()만 해주게 함
lock_guard<std::mutex> g1(m1, std::adopt_lock);
}
해결방법 2 : 그래프 알고리즘을 적용한 lockManager 사용