해당 시리즈에서는 책 '게임 서버 프로그래밍 교과서'를 공부하고 얻은 지식들을 정리하려 합니다.
오류가 있다면 덧글로 남겨주신다면 감사하겠습니다.
게임 서버 프로그래밍 교과서 - 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);
멀티스레드 프로그래밍이 필요한 경우
여러 개의 스레드로 작업을 나누어 처리하면 더 빨리 처리할 수 있다.
어떨 때에 멀티스레드 프로그래밍을 이용하면 좋을까?
- 오래 걸리는 일 하나와 빨리 끝나는 일 여럿을 같이 해야 할 때
- ex) 로딩 화면
- 캐릭터, 배경의 그래픽 리소스를 로딩하는 동안 로딩 그래프 화면을 띄우거나 부드러운 애니메이션을 뿌려주는 등의 상황에 멀티스레딩을 사용
- 로딩 화면에서 미니 게임을 즐길 수도 있다!
- 반다이남코의 특허였지만 2015년에 수명을 다했음
- 다음 코드처럼 구성하면, 로딩 화면을 구현할 수 있다.
bool isStillLoading; // 전역 변수 Thread1 { isStillLoading = true; while (isStillLoading) { FrameMove(); Render(); } } Thread2 { LoadScene(); LoadModel(); LoadTexture(); LoadAnimation(); LoadSound(); isStillLoading = false; }
- 어떤 긴 처리를 진행하는 동안 다른 짧은 일을 처리해야 할 때
- 게임 서버에서도 필요할 수 있음
- ex) 플레이어 정보를 읽거나 쓰려고 디스크에 액세스 할 때
- 디스크에 액세스하는 동안 CPU가 놀기 때문에, 멀티스레딩으로 이 시간을 플레이어에게 배분하면 서버 실행 성능을 향상할 수 있다.
- 예를 들어 플레이어 정보 기록에 1만분의 1초만큼 걸린다고 하자(컴퓨터 기준 매우 긴 시간)
- FPS 게임에서는 유저별로 위치를 계속 업데이트해주어야 유저가 느끼기에 실시간으로 움직인다고 느낄 것이다.
- 클라이언트당 1초에 30회의 처리를 한다 치면...
- 1만 개의 클라이언트가 들어오면 초당 [(10000 클라이언트) * 30 회 = 30만 번]의 처리를 해야 한다.
- 싱글 스레드로는 1초에 1만 건만 처리할 수 있으므로 해결할 수 없음
→ 멀티스레딩 또는 비동기 프로그래밍이 필요하다.
- 기기에 있는 CPU를 모두 활용해야 할 때
- 기존에는 CPU 클록수가 점점 증가했으나 현재에는 4 GHz의 벽으로 인해 CPU 코어의 수가 증가되는 추세로 변화하고 있다.
- 4 GHz를 넘어서는 장비가 아예 안 나오는 것은 아니다.
- 그러나 비용 대비 효과가 크지 않아 현재에는 대세가 코어 개수를 늘리는 것으로 바뀜
- 싱글 스레드 프로그램은 단 하나의 코어만을 사용하므로 코어의 수가 증가해도 큰 차이가 없다...
- 따라서 여러 개의 코어를 활용할 수 있도록 한 프로세스를 여러 스레드로 쪼개어 연산하도록 한다.
- 기존에는 CPU 클록수가 점점 증가했으나 현재에는 4 GHz의 벽으로 인해 CPU 코어의 수가 증가되는 추세로 변화하고 있다.
임계 영역과 뮤텍스
- 멀티스레딩의 문제점은 데이터 경쟁 상태(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번 스레드에서 A 뮤텍스와 B 뮤텍스를 순서대로 lock하려 하고,
- 2번 스레드에서 B 뮤텍스와 A 뮤텍스를 순서대로 lock하려 하면
- 이를 동시에 실행하면...
- 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를 요구할 때 생김
- 게임 서버에서 교착 상태가 되면 발생하는 일
- CPU 사용량이 현저히 낮거나 0%가 됨. 동시접속자 수와 관계없다.
- 클라이언트가 서버를 이용할 수 없다. 예를 들어 로그인을 못하거나 뭔가 요청을 보냈는데 응답이 오지 않음
- 교착 상태가 발생했을 때 디버거로 원인을 찾는 방법
- 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
댓글