본문 바로가기
카테고리 없음

'게임 서버 프로그래밍' 1. 멀티스레딩 1

by 니키티스 2022. 2. 10.

해당 시리즈에서는 책 '게임 서버 프로그래밍 교과서'를 공부하고 얻은 지식들을 정리하려 합니다.

오류가 있다면 덧글로 남겨주신다면 감사하겠습니다.

 

게임 서버 프로그래밍 교과서 - YES24

 

게임 서버 프로그래밍 교과서 - YES24

네트워크 기초부터 고성능 서버 제작 기술까지 프라우드넷 개발자의 경험을 고스란히 담았다[세븐나이츠], [마블 퓨처 파이트], [마비노기 영웅전], [스트라이트 파이터5] 등 전세계 13개국, 190개

www.yes24.com

 

스레드

개념

 

  • 스레드란 프로세스처럼 명령어를 한 줄씩 실행하는 기본 단위
  • 스레드와 프로세스의 차이점은 세 가지 정도가 있다.
  • 1. 스레드는 한 프로세스 안에 여러 개가 있다.
  • 2. 한 프로세스 안에 있는 스레드는 프로세스 안의 메모리 공간을 같이 사용할 수 있다.
  • 3. 스레드마다 스택을 갖는다. 따라서, 각 스레드에서 실행되는 함수의 로컬 변수들이 스레드마다 존재한다.

생성

C언어에서는 스레드 생성 함수가 운영체제마다 달랐다.

  • Windows: DWORD threadID로 식별함
    • CreateThread() 함수에 threadID의 주소를 전달해줌
  • 리눅스, 유닉스: 구조체 pthread_t로 식별함
    • CreateThread() 함수에 pthread_t를 전달해준다.

그러나 모던 C++에서는 운영체제마다 달랐던 함수가 하나로 통합되게 된다.

모던 C++에서는 sd::thread로 통일되었다.

이는 다음과 같이 사용한다.

std::thread t1(ThreadProc, 123);

 

멀티스레드 프로그래밍이 필요한 경우

여러 개의 스레드로 작업을 나누어 처리하면 더 빨리 처리할 수 있다.

어떨 때에 멀티스레드 프로그래밍을 이용하면 좋을까?

  1. 오래 걸리는 일 하나와 빨리 끝나는 일 여럿을 같이 해야 할 때
    • ex) 로딩 화면
    • 캐릭터, 배경의 그래픽 리소스를 로딩하는 동안 로딩 그래프 화면을 띄우거나 부드러운 애니메이션을 뿌려주는 등의 상황에 멀티스레딩을 사용
    • 로딩 화면에서 미니 게임을 즐길 수도 있다!
      • 반다이남코의 특허였지만 2015년에 수명을 다했음
    • 다음 코드처럼 구성하면, 로딩 화면을 구현할 수 있다.
    bool isStillLoading; // 전역 변수
    
    Thread1
    {
    	isStillLoading = true;
    	while (isStillLoading)
    	{
    		FrameMove();
    		Render();
    	}
    }
    
    Thread2
    {
    	LoadScene();
    	LoadModel();
    	LoadTexture();
    	LoadAnimation();
    	LoadSound();
    	
    	isStillLoading = false;
    }
    
  2. 어떤 긴 처리를 진행하는 동안 다른 짧은 일을 처리해야 할 때
    • 게임 서버에서도 필요할 수 있음
    • ex) 플레이어 정보를 읽거나 쓰려고 디스크에 액세스 할 때
    • 디스크에 액세스하는 동안 CPU가 놀기 때문에, 멀티스레딩으로 이 시간을 플레이어에게 배분하면 서버 실행 성능을 향상할 수 있다.
    • 예를 들어 플레이어 정보 기록에 1만분의 1초만큼 걸린다고 하자(컴퓨터 기준 매우 긴 시간)
    • FPS 게임에서는 유저별로 위치를 계속 업데이트해주어야 유저가 느끼기에 실시간으로 움직인다고 느낄 것이다.
    • 클라이언트당 1초에 30회의 처리를 한다 치면...
    • 1만 개의 클라이언트가 들어오면 초당 [(10000 클라이언트) * 30 회 = 30만 번]의 처리를 해야 한다.
    • 싱글 스레드로는 1초에 1만 건만 처리할 수 있으므로 해결할 수 없음
      → 멀티스레딩 또는 비동기 프로그래밍이 필요하다.
  3. 기기에 있는 CPU를 모두 활용해야 할 때
    • 기존에는 CPU 클록수가 점점 증가했으나 현재에는 4 GHz의 벽으로 인해 CPU 코어의 수가 증가되는 추세로 변화하고 있다.
      • 4 GHz를 넘어서는 장비가 아예 안 나오는 것은 아니다.
      • 그러나 비용 대비 효과가 크지 않아 현재에는 대세가 코어 개수를 늘리는 것으로 바뀜
    • 싱글 스레드 프로그램은 단 하나의 코어만을 사용하므로 코어의 수가 증가해도 큰 차이가 없다...
    • 따라서 여러 개의 코어를 활용할 수 있도록 한 프로세스를 여러 스레드로 쪼개어 연산하도록 한다.

 

임계 영역과 뮤텍스

  • 멀티스레딩의 문제점은 데이터 경쟁 상태(Data race)를 유발한다는 점이다.
  • N개의 스레드가 하나의 데이터에 동시에 접근하면 문제가 발생함
  • 데이터를 읽어오고(read) 쓰는 작업(write)은 기본적으로 원자적(atomic)이지 않다.

 

어셈블리어로 뜯어오면, a=a+3이라는 간단한 코드만 실행해도

 

  • a의 값을 메모리에서 레지스터로 가져오고
  • 레지스터에 3을 더하고
  • 결과 값(a+3)을 메모리에 다시 써야 한다.

 

이 세 과정을 다 마치기 전에 다른 스레드가 실행된다면 문제가 생길 수 있음

다른 스레드가 a의 값에 접근하기라도 하면... 같은 값을 덮어 씌우는 일도 일어날 수 있다.

 

이를 해결하기 위한 것이 뮤텍스(Mutex: mutual exclusive; 상호 배제)이다.

  • C++에서는 std::mutex, std::recursive_mutex로 뮤텍스를 구현한다.
  • mutex와 recursive_mutex의 차이?
    • mutex는 lock을 호출한 함수에서 unlock을 호출하지 않고, 같은 mutex를 다시 lock 하면 알 수 없는 동작을 한다(데드락 상황에 빠질 수 있음)
    • recursive_mutex가 이 점을 해결할 수 있다.
    • recursive_mutex는 같은 함수에서 unlock하지 않고 다시 lock 할 수 있다.
    • 다만 lock 한 횟수만큼 unlock 하여야 lock이 해제된다.

lock과 unlock을 명시적으로 호출하는 것은 번거롭다.

C#에서는 lock 구문 블록으로 간단히 해결된다.

그러나 C++에서는 unlock을 호출해야 하는데 unlock 이전에 예외가 발생해서 unlock이 제대로 호출되지 않을 수도 있다.

이럴 때 lock_guard<T> 클래스가 해결해준다.

  • lock_guard<T> 객체가 소멸할 때 소멸자에서 자동으로 unlock 하도록 함
// C++ 코드: 다음과 같이 할 수 있다.
std::mutex mx;
{
	std::lock_guard<std::mutex> lock(mx);
	read(x);
	write(y);
	sum(x);
}

 

멀티스레딩과 성능

멀티스레딩을 하면 코어마다 작업을 나누어 처리할 수 있다.

그렇다면 멀티스레딩을 하면 코어 수에 비례해서 속도가 올라갈까?

 

답은 '그렇지 않을 수 있다'이다.

 

  • CPU 가용율이 높아지는 건 사실이지만, 메모리 접근 시간은 스레드와 관계없이 동일하다.
  • 메모리 접근 시간은 CPU 처리 시간에 비해 상당히 크다
  • 즉, CPU를 최적화해도 메모리 접근이 너무 많으면 CPU 성능을 제대로 발휘하기 어려울 수도 있다.
    ⇒ 멀티 스레딩에서는 메모리 접근을 줄이는 것이 매우 중요함

 

그렇다면 뮤텍스를 최대한 잘게 나누면 안 될까?

그러면 메모리에 접근하는 시간이 짧아질 것 같지만, 다음과 같은 문제가 생긴다.

 

  • 프로그램의 성능이 떨어짐. 뮤텍스 액세스 과정이 무거움
    • 멀티스레딩에서도 일관성을 유지할 수 있게 하기 위해, 한 자원에 하나의 스레드만 접근할 수 있게 하기 때문이다.
  • 교착 상태(deadlock) 문제 발생 → 프로그램의 복잡성이 증가
    • 임계 영역(critical section)에 대한 뮤텍스를 할당한 순서대로 임계영역 변수에 접근해야 함
    • 뮤텍스의 해제 순서도 중요해짐
    • 변수의 접근 순서 규칙을 지켜야 함

 

이게 무슨 말이냐 하면,

  1. 1번 스레드에서 A 뮤텍스와 B 뮤텍스를 순서대로 lock하려 하고,
  2. 2번 스레드에서 B 뮤텍스와 A 뮤텍스를 순서대로 lock하려 하면
  3. 이를 동시에 실행하면...
    • 1번 스레드는 2번 스레드가 이미 점유한 B를 요구하고
    • 2번 스레드는 1번 스레드가 이미 점유한 A를 요구한다.

 

이렇게 되면 1번도 2번도 A와 B를 동시에 점유할 수가 없게 되는 사태가 발생할 수 있다.

이러한 상태를 교착 상태(dead lock)라 한다.

데드락을 해결하는 방법은 다음 파트에서 알아보겠지만, 기본적으로는 변수 접근 순서에 따라 해결할 수 있다.

 

애초에 A 뮤텍스 먼저 접근하게 하고, 그 다음 B 뮤텍스에 접근하도록 규칙을 세우면 된다.

그러면 1번 스레드와 2번 스레드가 순서를 지키는 한 별 문제가 발생하지 않는다.

 

 

이러한 이유로, 결론은 다음과 같이 난다.

  • 따라서 뮤텍스 범위는 적당히 넓게 잡자.
  • CPU가 병렬로 연산하면 유리한 부분은 잠금 단위를 나누자.
  • 그렇지 않은 부분은 잠금 단위를 나누지 말자.

 

교착 상태(dead lock)

  • 교착 상태: 멀티스레드 프로그래밍에서 데드락은 두 스레드가 서로의 자원 점유 해제를 기다리는 상황
  • 대표적인 케이스: A thread와 B thread가 각각 a, b를 점유한 상태에서 b, a를 요구할 때 생김
  • 게임 서버에서 교착 상태가 되면 발생하는 일
    1. CPU 사용량이 현저히 낮거나 0%가 됨. 동시접속자 수와 관계없다.
    2. 클라이언트가 서버를 이용할 수 없다. 예를 들어 로그인을 못하거나 뭔가 요청을 보냈는데 응답이 오지 않음
  • 교착 상태가 발생했을 때 디버거로 원인을 찾는 방법
    • Windows에서 제공하는 임계 영역 기능 CRITICAL_SECTION 내용을 디버거로 확인하여 교착 상태의 시작점을 찾을 수 있다.

 

잠금(LOCK) 순서의 규칙

(예시) 뮤텍스 순서 그래프

여러 뮤텍스를 사용할 때 교착 상태를 예방하려면 순서를 잘 지켜야 한다.

 

  • 뮤텍스의 잠금 순서를 그래프로 그려둔다.
  • 반드시 그래프의 순서대로 뮤텍스를 잠그어야 한다.

 

뮤텍스 A, B, C가 있고 위 그림처럼 잠금 순서를 A→B→C로 정하였다.

그럼 반드시 A→B→C 순서로 잠그고 C→B→A 순서로 해제해야 한다.

 

// 잠금 A->B->C
lock(A) // 1
lock(B) // 2
lock(C) // 3
unlock(C)
unlock(B)
unlock(A)

 

A→B→C 순서로 가던가, B를 뛰어넘고 A→C 순서로 가던가 뭐든 상관 없다. 그러나 B→A처럼 그래프를 역행하는 일은 없어야 한다.

 

이렇게 순서를 정하면 교착 상태가 일어나지 않게 예방할 수 있다.

 

  • 1번 스레드에서 A→B를 잠그고, 2번 스레드에서 B→C를 잠근다 하면
  • 1번에서 잠가버렸으므로 2번에서는 B→C에 접근할 수 없고, 1번은 정상적으로 자원을 잠그고 자원을 이용할 수 있다.

 

재귀 뮤텍스(recursive_mutex)를 사용해도 이 원리는 동일하게 적용된다.

 

1. 정상적인 예

// 잠금 A->B->C->B->A
lock(A) // 1
lock(B) // 2
lock(C) // 3
lock(B) // 4
lock(A) // 5
unlock(C)
unlock(B)
unlock(A)
  • 3, 4, 5는 그래프 순서의 반대로 가고 있다.
  • 그러나 최초에는 그래프의 잠금 순서를 잘 지켰으므로 문제가 없다.
  • 3, 4, 5를 잠글 땐 이미 잠근 것을 다시 잠그는 것이기 때문.

 

2. 교착이 발생할 수 있는 예

// 잠금 A->C->B->C->A
lock(A) // 1
lock(C) // 2
lock(B) // 3
lock(C) // 4
lock(A) // 5
unlock(C)
unlock(B)
unlock(A)
  • 1은 안전하고 2도 그래프 순서를 지킴(A→(B)→C)
  • 그러나 3에서 C→B로 가면서 그래프 순서를 지키지 않았다.
  • 따라서 교착 상태를 일으킬 수 있다.
    • 다른 스레드에서 B→C 순으로 뮤텍스를 잠근다면, 이 스레드에서 A→C→B로 잠그려고 할 때 B를 획득하지 못하는 경우도 발생할 수 있다.

 

정리

“교착 상태를 예방하려면 첫 번째 잠금 순서를 지켜야 한다(거꾸로 가지 말아야 한다.)”

 

 

 

 

 

 

참고

Programmer J :: [ VC11-C++11 ] 병렬 프로그래밍 recursive_mutex (tistory.com)

 

[ VC11-C++11 ] 병렬 프로그래밍 recursive_mutex

std::mutex의 lock 멤버 함수의 설명을 보면 lock을 호출한 함수에서 unlock을 호출하지 않은 상태에서 또 다시 lock을 호출하면 알 수 없는 동작을 한다고 되어 있습니다. 스레드에서 lock을 호출한 후 다

jacking.tistory.com

 

댓글