Multi Thread

Date:     Updated:

카테고리:

태그:

인프런에 있는 루키스님의 게임 서버 강의를 듣고 정리한 내용입니다.
모두의 코드 씹어먹는 C++ 자료를 참고했습니다.


Thread

Pasted image 20240603015331

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;
	}
}

thread3

위 코드를 실행하면 놀랍게도 실행할 때마다 다른 결과가 나오게 된다.

counter += 1 코드가 어떻게 컴파일 되는지 assem 디버거를 찍어보면 다음과 같은 코드로 컴파일 된다.

  mov rax, qword ptr [rbp - 8]
  mov ecx, dword ptr [rax]
  add ecx, 1
  mov dword ptr [rax], ecx

thread4

따라서 위와 같이 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;
	}
}

thread5

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 클래스로 감싼 후 생성자, 소멸자로 관리할 수 있다.



맨 위로 이동하기

Server 카테고리 내 다른 글 보러가기

댓글 남기기