본문 바로가기
공부/네트워크

게임 서버 프로그래밍 - 멀티 스레딩 2 / 스레드 주의사항, 뮤텍스

by 라이티아 2026. 9. 14.

5. 스레드를 다룰 때 주의 사항

경쟁 / 데이터 레이스

두 스레드가 데이터에 접근해서 그 데이터 상태를 예측할 수 없게 하는 것

 

스레드 2개가 값 하나를 번갈아 가면서 접근하는 경우

 

x = 1;

// Thread A

t1 = x + 3;
x = t1;

// Thread B

t2 = x + 5;
x = t2;

x is ?

이와 같을때 절차지향적으로 생각한다면 x가 9가 되는 것을 기대하지만

 

실상은 스레드의 순서를 알 방법이 없기 때문에 

x = 1;

// Thread A

t1 = x + 3; // x is 1
x = t1; // x = 4

// Thread B

t2 = x + 5; // x is 1
x = t2; // x = 6

x is 4??

이와같이 스레드 순서가 꼬이게 된다면 이해할 수 없는 결과를 맞이하게 된다.

그림으로 보는 갱신 손실

위 코드는 스레드별 작업을 나누어 적은 것이다. 아래 그림은 A 읽기 → B 읽기 → B 쓰기 → A 쓰기로 실행이 끼어들어, 최종값이 4가 되는 과정을 단순화한 개념도이다.

실제 C++에서 일반 변수에 데이터 레이스가 발생하면 정의되지 않은 동작(UB)이므로, 결과를 4·6·9 중 하나로 한정할 수 없다. 참고: C++ 표준 초안 — 데이터 레이스

 

앞선 1.4의 예제도 같은 값을 여러 스레드에서 접근했기에 문제가 된다.

책에서 예제는 Array<int>에 Add()를 통해서 값을 넣는 것을 예제로 든다.

 

Array<int>는 배열 객체를 가리키는 포인터 변수와 배열 크기를 멤버 변수로 가진다. 배열 객체는 메모리에서 힙에 할당 되고, 메모리 힙은 현재 실행 중인 프로세스의 런타임 라이브러리로 다루어진다.

 

각 스레드가 소수를 찾아서 Add()를 하게 되면 배열 맨 뒤를 채우게 되고 배열 객체에 더 이상 넣을 수 없다면, 메모리를 재할당 한다.

두 스레드가 동시에 Array<int>에 Add()를 하게되면 여러 스레드가 Array<int> 변수들을 변경하고, 이때 두 변수 중 하나만 변경된 상태에서 다른 스레드가 그대로 배열을 액세스 할 수 있다. 이 과정에서 포인터 변수는 미지의 값 ex - 힙에서 해제된 메모리를 가리킬 수 있다.

 

Array<int>를 스레드가 액세스 할 때는 Array<int>안의 두 멤버 변수를 모두 변경하던지 하나도 바꾸지 않게 하는 과정이 필요하다. 이를 원자성이라 한다.

 

이때 Array<int>의 두 변수는 항상 일관성 있는 상태를 유지할 수 있는데, 이를 일관성이라 한다.

 

멀티스레드 프로그래밍을 할 시 원자성과 일관성을 유지해야 할때가 있는데 이러한 것을 통칭하여 동기화라고 한다. 이에 대표적인 것들이 임계 영역과 뮤텍스, 잠금 기법이다.

 

6. 임계 영역과 뮤텍스

경쟁 상태를 해결하는 방법중 하나는 아래와 같다.

 

특정 n이 있을때 다른 스레드가 사용중이라면 해당 스레드가 사용을 끝낼때 까지 기다린다

 

이때 사용되는 것이 뮤텍스(mutex)이다.

 

상호 배제의 줄임말이며, 사용법은 아래와 같다.

  1. n을 보호하는 뮤텍스 X를 만든다.
  2. 스레드가 n에 접근하기 위해 X에게 접근 권한을 얻는다.
  3. 스레드가 n에 접근하여 사용한다.
  4. 사용이 끝나면 X에게 사용권을 반납한다.
// 1
std::mutex X;
// 2
X.lock();
// 3
read(n);
write(n);
// 4
X.unlock();

뮤텍스(mutex)는 상호 배제를 위한 동기화 도구이고, 임계 영역(critical section)은 공유 데이터에 접근하는 코드 구간이다.

그림으로 보는 뮤텍스의 동작

두 스레드가 같은 뮤텍스 X를 사용하면 한 스레드가 읽기·계산·쓰기를 마칠 때까지 다른 스레드는 기다린다. A가 4를 저장하고 잠금을 해제한 뒤, B가 그 값을 읽어 9를 저장한다. 쓰기만 잠그지 말고 갱신에 필요한 전체 구간을 보호해야 한다.

 

멀티스레드 프로그래밍에서 뮤텍스에 대한 사용권을 얻는 과정은 잠근다 = lock이라고 표현하기도 하고 반대는 잠금 해제 = unlock이라고 표현한다.

 

이때 만약 3의 과정에서 중도에 오류가 나면 어떻게 해결하는가에 대한 질문이 존재한다. 이때 해당 부분을 로컬 변수로 저장하고 해당 변수가 사라질때 잠금 해제가 되게 하는 CPP 클레스, lock_guard가 있다.

 

std::recursive_mutex X;
lockguard<recursive_mutex> lock(X);
read(n);
write(n);

 

위와 같이 처리할시 lock이 사라질때 자동으로 X.unlock()이 실행된다.

 

C#에서는 다음과 같이 사용한다

 

  1. 보호할 변수 자체를 뮤텍스처럼 사용하여 잠근다. 혹은 변수를 가르키는 임의의 객체를 만들어 그 객체를 잠근다.
  2. 다 사용하면 잠금을 해제한다.
object X = new object;
lock(X)
{
	read(n);
    write(n);
}

 

 

멀티 스레딩을 한다면 CPU을 여러개 사용하기 때문에 효율이 좋아진다 하지만 n개의 CPU를 사용한다 하더라도 n배만큼 빨라지지는 않는다. 이는 메모리 바운드 시간 때문이다.

 

메모리 바운드 시간
CPU가 여러개 존재하더라도 결국 CPU도 메모리에 접근을 해야하고 이는 기판 회롤 통해서 데이터를 주고 받는 시간이 필요하다. 이를 줄이기 위해서 CPU 내부에 캐시 메모리가 존재하지만 여러 CPU가 접근할때 내부에서 블로킹이 발생한다. 이와 같이 메모리에 접근하는 시간에는 CPU가 처리를 하지 못하고 쉬게되는 시간이 생기게 된다.

 

뮤텍스를 잘게 나누면 해결이 되는가?

  1. 뮤텍스 자체가 무거운 자원을 요구하기에 프로그램 성능이 떨어진다.
  2. 프로그램이 복잡해진다. 후자에 나오는 교착 상태 문제에 취약해진다.

모든 클레스에 뮤텍스를 두게 되면 항상 순서를 지켜서 접근을 해야 하고 이는 규칙의 증가를 가져올 수 있다. 이는 생산성을 크게 해칠 수 있다.