Multi Thread
카테고리: Server
인프런에 있는 루키스님의 게임 서버 강의를 듣고 정리한 내용입니다.
모두의 코드 씹어먹는 C++ 자료를 참고했습니다.
Thread
Thread 특징
- heap, data 영역은 서로 공유
- stack 메모리는 각 thread마다 소유
Thread 생성은 유저 레벨에서 마음대로 할 수 없고, 커널에게 만들어달라고 요청해야 한다.
기존에는 운영체제에 따라 별도의 Thread API가 존재했는데 (Windows 따로, Linux 따로) 다행히 C++ 11
부터 Thread 라이브러리가 C++ 표준으로 들어왔다.
std::thread
public:
template <class _Fn, class... _Args, enable_if_t<!is_same_v<_Remove_cvref_t<_Fn>, thread>, int> = 0>
_NODISCARD_CTOR_THREAD explicit thread(_Fn&& _Fx, _Args&&... _Ax) {
_Start(_STD forward<_Fn>(_Fx), _STD forward<_Args>(_Ax)...);
}
~thread() noexcept {
if (joinable()) {
_STD terminate();
}
}
thread(thread&& _Other) noexcept : _Thr(_STD exchange(_Other._Thr, {})) {}
thread& operator=(thread&& _Other) noexcept {
if (joinable()) {
_STD terminate();
}
_Thr = _STD exchange(_Other._Thr, {});
return *this;
}
thread(const thread&) = delete;
thread& operator=(const thread&) = delete;
- thread를 생성할 때 넘겨주는 함수는 template으로 받기 때문에, 모든 Callable 객체를 받을 수 있음
- Function, Lambda, Funtor, …
- 함수에 들어갈 인자들은 뒤에 넘겨주면 됨.
- 복사 생성자, 복사 대입 연산자는 delete로 막아놓음
- 소유권을 명확하게 하기 위해 이동 생성자, 이동 대입 연산자만 허용
Method
- hardware_comcurrency() : 동시에 실행될 수 있는 쓰레드 개수 (논리 프로세서)
- get_id() : 쓰레드 id
- detach() : 해당 쓰레드와 연결을 끊고 독립적으로 실행 (메인 쓰레드가 종료되도 상관없이 실행)
- joinable() : 쓰레드가 살아있는지 확인 (null_check 느낌)
- join() : 해당 쓰레드가 끝날 때까지 대기
Thead Example
1 ~ 10000 의 값을 더하는 프로그램 (병렬 처리)
void worker(vector<int>::iterator start, vector<int>::iterator end, int* result) {
int sum = 0;
for (auto it = start; it < end; ++it) {
sum += *it;
}
*result = sum;
}
int main()
{
vector<int> data(10000);
for (int i = 0; i < 10000; i++) {
data[i] = i;
}
vector<int> partial_sum(4);
vector<thread> workers;
for (int i = 0; i < 4; i++) {
workers.push_back(thread(worker, data.begin() + i * 2500, data.begin() + (i + 1) * 2500, &partial_sum[i]));
}
for (int i = 0; i < 4; i++) {
workers[i].join();
}
int total = 0;
for (int i = 0; i < 4; i++) {
total += partial_sum[i];
}
cout << "Total Sum : " << total << endl;
}
- 주의) 메인 쓰레드에서 join으로 기다리지 않으면 Exception 던져서 에러 발생
- 참고로 위 예시는 공유 자원을 사용하지 않는 가장 간단한 형태의 멀티 쓰레드
Lock
Race Condition
int32 counter = 0;
void Add() {
for (int i = 0; i < 100000; i++) {
counter += 1;
}
}
void Sub() {
for (int i = 0; i < 100000; i++) {
counter -= 1;
}
}
void main()
{
for (int i = 0; i < 5; i++) {
thread t1(Add);
thread t2(Sub);
t1.join();
t2.join();
cout << counter << endl;
}
}
위 코드를 실행하면 놀랍게도 실행할 때마다 다른 결과가 나오게 된다.
counter += 1
코드가 어떻게 컴파일 되는지 assem 디버거를 찍어보면 다음과 같은 코드로 컴파일 된다.
mov rax, qword ptr [rbp - 8]
mov ecx, dword ptr [rax]
add ecx, 1
mov dword ptr [rax], ecx
따라서 위와 같이 counter += 1
코드가 진행되는 도중에 다른 쓰레드가 실행되어 버리면 분명 counter+=1을 두 번 더했는데 1만 증가되는 결과가 나오는 것이다. 물론 운이 좋다면 다른 쓰레드가 끼어드는 일 없이 쭉 실행되서 정상적인 결과가 나올 수 있다. 하지만 쓰레드 스케쥴링은 운영체제가 정하는 것이기 때문에 항상 이런 행운을 바랄 수는 없다. 다른 말로 하면 멀티 쓰레드 프로그램은 프로그램 실행 마다 그 결과가 달라질 수 있고, 디버깅이 매우 어렵다는 뜻이다.
또한 STL 또한 멀티 쓰레드 환경에서 제대로 작동하지 않는다. vector
를 예로 들면 같은 인덱스에 서로 값을 집어 넣을 수도 있고, vectpr의 capacity가 초과되어 기존 메모리를 해제하는 작업에서 double-free 문제가 발생할 수도 있다.
Mutex
위 문제가 발생하는 근본적인 이유는
counter += 1;
코드를 여러 쓰레드에서 동시에 실행시켰기 때문이다. C++에서 Mutex
라는 객체를 이용하면 특정 영역을 한 쓰레드에서만 실행시키게 할 수 있다.
int32 counter = 0;
mutex m;
void Add() {
for (int i = 0; i < 100000; i++) {
m.lock();
counter += 1;
m.unlock();
}
}
void Sub() {
for (int i = 0; i < 100000; i++) {
m.lock();
counter -= 1;
m.unlock();
}
}
void main()
{
for (int i = 0; i < 5; i++) {
thread t1(Add);
thread t2(Sub);
t1.join();
t2.join();
cout << counter << endl;
}
}
m.lock()
은 “mutex m을 내가 쓰겠다” 라는 뜻이다. 이 때 중요한 점은 한 번에 한 쓰레드에서만 m의 사용 권한을 갖는다는 것이다. 만약 다른 쓰레드에서 m.lock()
을 걸어 놓은 상태라면 m을 소유한 쓰레드가 m.unlock()
을 통해 m을 반환할 때까지 기다리게 된다. 이렇게 lock과 unlock 사이에 한 쓰레드만 유일하게 실행할 수 있는 코드 부분을 임계 영역(critical section)이라 한다.당연한 이야기지만, mutex를 이용하는 경우 싱글 쓰레드처럼 작동하기 때문에 연산이 느려지게 된다.
만약 어떤 쓰레드에서 까먹고 unlcok()
을 빼먹으면 어떻게 될까? 코드가 복잡해지는 경우 충분히 발생할 수 있는 문제이다. 이런 경우 어떤 스레드도 연산을 진행하지 못하고 무한 대기 상태에 빠지는 데드락 상태가 발생한다.
Lock Guard
void Add() {
for (int i = 0; i < 100000; i++) {
lock_guard<mutex> lock(m);
counter += 1;
}
}
포인터를 공부할 때에도 비슷한 문제가 있었는데, RAII 패턴을 통해 이를 해결했었다. Mutex도 마찬가지로 사용 후 해제 패턴을 따르기 때문에 lock_guard
라는 Wrapper 클래스로 감싼 후 생성자, 소멸자로 관리할 수 있다.
댓글 남기기