공지사항
- 본 글은 배현직 저자님의 게임 서버 프로그래밍 교과서를 읽고 썼습니다.
- 본 글은 저자님의 요청으로 언제든지 지워질 수 있습니다.
멀티스레드 프로그래밍
멀티스레드 프로그래밍이 필요한 때
멀티스레드 프로그래밍이 필요한 경우는 다음과 같다.
- 오래 걸리는 일 하나와 빨리 끝나는 일 여럿을 같이 해야 할 때
- e.g. 게임 프로그램에서의 로딩
- 어떤 긴 처리를 진행하는 동안 다른 짧은 일을 처리해야 할 때
- e.g. 디스크에 액세스할 때
- 기기에 있는 CPU를 모두 활용해야 할 때
멀티스레드 프로그램 작성 시 고려사항
단순히 스레드를 만든다고 하여 멀티스레드 프로그램이 되는 것은 아니다. 스레드를 다룰 때는 문맥 교환(context switch)과 경쟁 상태(data race), 교착 상태(dead lock)를 고려해야 한다.
먼저, 문맥 교환은 프로세스가 현재 작업중인 스레드를 멈추고, 대기중인 스레드를 불러와 실행하는 것을 말한다. 문맥 교환때에는 실행 중이던 스레드의 상태를 레지스터에 저장하고, 과거에 실행했던 스레드를 불러와 그 상태를 복원하고, 실행하던 지점으로 강제 이동을 해야한다. 문맥 교환이 자주 일어날 경우 실제 연산보다 문맥 교환하는 데에 더 많은 자원을 쏟게될 수 있다. 그래서, 실행중인 스레드가 CPU 개수보다 많지 않도록 주의해야 한다.
경쟁 상태는 두 스레드가 같은 데이터에 접근해 그 데이터 상태를 예측할 수 없게 하는 것을 말한다. 앞서 살펴본 문맥 교환은 기계어 단위에서 무작위로 이뤄지기 때문에 프로그램의 어느 위치에서 발생할지 예측할 수 없다. 그러니까, 우리의 예측대로 프로그램이 실행되리라는 보장이 없다. 따라서, 이를 방지하기 위해 동기화(synchronization)가 필요하다. 동기화란 원자성(atomicity)과 일관성(consistency)을 유지하는 것을 말하는 데, 원자성은 한 스레드가 데이터를 읽고 쓰는 동안에는 다른 스레드가 접근할 수 없는 것을 말하며, 일관성은 관련되어 있는 모든 데이터가 정합성을 갖고 있는 것을 얘기한다.
교착 상태는 두 스레드가 서로를 기다리는 상황을 말한다. 다시 말해, 동기화를 위해 여러 기법이 사용되는데, 각 스레드가 보호 범위를 설정해놓고 서로 각각의 보호 범위에 있는 데이터에 접근하려고 할 때 발생한다. 예를 들어, X, Y라는 스레드가 각각 A와 B를 보호하면서, 각각 B와 A에 접근하려고 할 때, 교착 상태가 발생한다. 게임 서버에서 이는 굉장히 치명적인데, 게임 서버에서 교착 상태 발생시에는 다음과 같은 두 가지 증세가 나타난다.
- 동시접속자 수와 상관 없이, CPU 사용량이 현저히 낮거나 0%이다.
- 클라이언트가 서버를 이용할 수 없다.
동기화를 위한 방법
동기화를 위한 방법에는 뮤텍스(mutex), 세마포어(semaphore), 원자 조작(atomic operation), 이벤트(event) 등이 있다. 하나씩 살펴보자.
뮤텍스는 상호 배제 혹은 임계 영역이라고 부르며, 스레드에서 뮤텍스로 보호하고 있는 영역을 사용하고 있는 동안 다른 스레드가 접근하지 못하게 한다. 사용방법은 다음과 같다.
- 데이터를 보호하는 뮤텍스를 만든다.
- 스레드는 해당 데이터를 건드리기 전에 뮤텍스에게 ‘사용권을 얻겠다’고 요청한다.
- 스레드는 해당 데이터에 접근한다.
- 접근이 끝나면, 뮤텍스에 ‘사용권을 놓겠다’고 요청한다.
C++ 코드로는 다음과 같다.
뮤텍스를 사용할 때는 뮤텍스 범위를 어느정도 잡을지 고려해야 한다. 뮤텍스를 잘게 나누게 되면, 뮤텍스에 엑세스하는 과정 자체가 무겁고, 프로그램이 매우 복잡해지기 때문에 교착 상태가 쉽게 발생해 성능이 떨어진다. 따라서 적당히 넓게 나누는 것이 좋다. 병렬 연산에 유리한 부분은 잠금 단위를 나누고, 그렇지 않은 부분은 잠금 단위를 나누지 않는 것이 좋다.
잠금 순서 또한 고려해야 한다. 여러 뮤텍스를 사용할 때 교착 상태를 예방하려면 각 뮤텍스의 잠금 순서를 그래프로 그려야 한다. 그 다음, 잠금 순서 그래프를 보면서 거꾸로 잠근 것이 없는지 체크한다. 특히, 한 스레드가 뮤텍스를 여러 번 반복해서 잠글 시, 첫 잠 금에서 순서를 어기지 않도록 주의한다. 이 때, C++의 재귀 뮤텍스(std::recursive_mutex)를 이용하면 원활하게 처리할 수 있다. 잠금 해제 순서는 교착 상태에 영향을 주지 않는다.
세마포어는 뮤텍스와는 다르게 원하는 개수의 스레드가 자원을 액세스하게 한다. 사용방법은 크게 다르지 않다. 윈도에서는 CreateSemaphore()로 세마포어를 생성하고, WaitForSingleObject(), WaitForMultipleObject()로 자원 액세스를 요청하고, ReleaseSemaphore()로 자원 액세스가 끝났음을 통보하며, CloseHandle()로 세마포어를 파괴한다.
원자 조작은 뮤텍스나 임계 영역 잠금 없이도 여러 스레드가 안전하게 접근할 수 있는 조작을 말한다. 하드웨어 기능으로 대부분의 컴파일러는 이 기능을 쓸 수 있게 한다. 원자조작은 32비트나 64비트의 변수 타입에 여러 스레드가 접근할 때 한 스레드씩만 처리됨을 보장한다. 단점은 변수 값 2~3개 이하에서만 보호가 되고, 변수를 읽거나 쓰는 방식이 몇 개 안된다는 것이다. 종류에는 원자성을 가진 값 더하기, 원자성을 가진 값 맞바꾸기, 원자성을 가진 값 조건부 맞바꾸기가 있다.
이벤트는 잠자는(blocked) 스레드를 깨우는 도구로, 스레드 간 소통하며 일을 처리할 때 유용하다. 내부적으로는 다음과 같은 상태 값을 가진다.
- Reset : 정수 값 0으로, 이벤트가 없다는 의미이다.
- Set : 정수 값 1로, 이벤트가 있다는 의미이다.
윈도우에서는 CreateEvent()로 이벤트를 생성하고, SetEvent()로 이벤트에 신호를 주며, WaitForSingleObject(), WaitForMultipleObject()로 이벤트를 기다리고, CloseHandle()로 이벤트를 파괴한다. 이벤트에는 이벤트 모드라는 것이 있는데, 자동 이벤트(automatic event)와 수동 이벤트(manual event)가 있다.
자동 이벤트는 이벤트가 신호를 가질 때, 이벤트를 기다리던 스레드가 있으면 그 스레드를 깨우고, 이벤트 상태 값을 0으로 바꾼다. 다수의 스레드가 이벤트를 기다리면, 그 중 한 스레드만 깨우게 된다.
수동 이벤트는 대기중인 스레드를 깨우는 건 똑같지만 이벤트 상태 값을 수동으로 바꿔줘야 하며, 다수가 기다린다면 모든 스레드를 깨운다. 다만 이 경우에는 이벤트 상태 값을 어떤 시점에서 바꿔야 할지 알기 어려우므로, 맥박(pulse) 기능을 이용해 이벤트에 딱 1회만 Set시킬 수 있다.
성능을 위한 고려사항
멀티스레드 프로그램의 성능을 개선시키기 위해서는 메모리 바운드 타임(memory bound time)과 시리얼 병목(serial bottleneck)을 신경써야한다.
멀티스레드 프로그램이라고 할지라도 CPU는 메모리에서 데이터를 가져와야 한다. 이 때, 여러 CPU가 같은 메모리를 접근하게 되면 약간의 블로킹(blocking)이 생기게 되는데, 이 말은 멀티스레드로 작동해도 메모리에 접근하는 시간 동안에는 CPU 개수보다 더 적은 수의 스레드가 처리된다는 의미이다. 다시 말해, 메모리 액세스의 양을 최대한 줄이는 것이 곧 서버 성능으로 직결이 된다.
시리얼 병목은 어떤 이유로 병렬성(parallelism)이 제대로 수행되지 않는 현상을 말한다. 병렬성은 멀티프로세싱으로 동시 처리량을 올리는 것을 말한다. 시리얼 병목의 시간이 길어지면 총 처리량이 줄어들게 된다. 이는 CPU의 개수가 많을수록 더욱 심화될 것이다. 이를 암달의 법칙(Amdahl’s Law)이라고 한다.
시리얼 병목이 흔하게 발생하는 경우는 디바이스 타임(device time)이 발생할 때이다. 디바이스 타임은 기기에 있는 장치에 어떤 것을 요청해서 결과가 올 때까지 기다리는 시간을 의미하는데, 이 때 스레드는 블록된다. CPU가 낭비되는 것이다. 특히, 잠금을 한 후 디바이스 타임을 일으키는 코드를 넣지 않도록 해야 한다. 그렇게 되면, 심각한 시리얼 병목이 생기게 된다.
멀티스레드 프로그래밍의 흔한 실수들
멀티스레드 프로그래밍을 하며 자주 일어나는 실수들로는 다음이 있다.
- 읽기와 쓰기 모두에 잠금하지 않는 것
- 가끔 읽어들이는 값이 정상적이지 않을 수 있다.
- 잠금 순서 꼬임
- 잠금 순서가 꼬이게 되면 교착 상태가 발생할 수 있다.
- 잠금 순서 규칙을 최대한 적게 유지해야 한다.
- 너무 좁은 잠금 범위
- 잠금 객체 범위가 너무 넓으면 문맥 교환 발생 시, 운영체제가 해야 할 일이 매우 많아지고, 처리 병렬성이 떨어진다.
- 너무 좁으면 문맥 교환이 잦게 발생해 CPU가 낭비된다. 또한, 유지 보수하기도 어렵고 실수할 확률이 높아진다.
- 디바이스 타임이 섞인 잠금
- 그 지점에서 병목 현상이 발생한다.
- 잠금의 전염성으로 인한 실수
- 잠금의 전염성이란, 잠금으로 보호되는 리소스(변수 값 등)에서 얻어 온 값이나 포인터 주소 값 등이 로컬 변수로 있는 경우에도 잠금 상태를 계속 유지해야하는 것을 말한다. 이 점을 간과하고 잠금을 풀어버리게 되면 경쟁 상태로 이어지게 된다.
- 이는 lack of composability로 이어진다. 즉, 여러 모듈이나 로직을 쉽게 조합할 수 없게 된다.
- 잠금된 뮤텍스나 임계 영역 삭제
- 이 경우는 뮤텍스나 임계 영역의 파괴자 함수 안에 잠금 시 오류를 던지는 코드를 삽입함으로 예방할 수 있다.
- 일관성 규칙 깨기
- 잠금 범위가 여럿일 때, 일관성 규칙이 깨질 수 있다.
- 원자 조작이나 병렬 자료 구조 사용 시, 자신도 모르게 실수할 수 있으니 주의해야 한다.
싱글스레드 게임 서버와 멀티스레드 게임 서버
게임 서버를 구현하는 방법에는 크게 싱글스레드 게임 서버와 멀티스레드 게임 서버 두 가지로 나눌 수 있다.
싱글스레드 게임 서버
보통 서버의 환경은 멀티프로세서이므로, 싱글스레드 게임 서버는 CPU 개수만큼 프로세스를 띄운다. 각 서버 프로세스는 멀티플레이어 세션(multiplayer session)을 여러 개 가진다.
이 경우에는 디바이스 타임을 처리하는 과정에서 큰 시리얼 병목이 일어나게 된다. 방 개수만큼 스레드나 프로세스가 있으면, 스레드나 프로세스 간 문맥 교환의 횟수가 증가해 같은 동접자를 처리하는 서버라도 실제로 처리할 수 있는 동접자 수를 크게 떨어뜨리게 되므로 가급적 피해준다.
멀티스레드 게임 서버
멀티스레드로 서버를 개발하는 경우는 다음과 같다.
- 서버 프로세스를 많이 띄우기 곤란할 때
- e.g. 프로세스당 로딩해야 하는 게임정보의 용량이 매우 클 때
- 서버 한 대의 프로세스가 여러 CPU의 연산량을 동원해야 할 만큼 많은 연산을 할 때
- 코루틴이나 비동기 함수를 쓸 수 없고 디바이스 타임이 발생할 때
- 서버 인스턴스를 서버 기기당 하나만 두어야 할 때
- 서로 다른 멀티플레이어 세션이 같은 메모리 공간을 액세스 할 때
멀티스레드 게임 서버는 단일의 메인 데이터와 다수의 스레드로 이뤄지며, 이 스레드에는 멀티플레이어 세션들이 여러개가 있다. 데이터 구조와 멀티플레이어 세션은 클래스 1개로 표현한다.
멀티스레드 게임 서버는 잠금 단위를 설정해주어야 하는데, 게임 서버 메인용의 뮤텍스 하나, 각 세션마다 뮤텍스 하나로 설정해준다. 플레이어 행동에 대한 처리는 각 세션을 잠근 후에 한다. 어떤 플레이어 A에 대한 처리 방법은 다음과 같다.
- 공통 데이터(방 목록 등)를 잠근다.
- 플레이어 A가 들어 있는 방을 방 목록에서 찾는다.
- 공통 데이터를 잠금 해제한다.
- 찾은 방을 잠근다.
- 플레이어 A의 방 안에서 처리를 한다.
- 방을 잠금 해제한다.
멀티스레드 게임 서버는 효율적인 병렬 처리를 위해 스레드 풀링(thread pooling)기법을 이용한다. 스레드 풀링이란 스레드 개수를 고정하고, 가용한 스레드에 이벤트를 할당하는 방식을 말한다. 스레드 개수는 디바이스 타임에 따라 달라지는데, 없는 경우 서버의 CPU 개수와 동일하게 잡으며, 있는 경우 CPU 타임과 디바이스 타임의 비율을 이용해 CPU 개수보다 많이 잡는다.
멀티스레드 게임 서버는 특히, 시리얼 병목과 교착 상태에 주의해야 한다.
책 구매하러 가기
http://www.yes24.com/Product/Goods/71768958?scode=032&OzSrank=1
'Outdated > Game' 카테고리의 다른 글
[Summary] 게임 서버 프로그래밍 교과서 - Ch4 (0) | 2019.08.28 |
---|---|
[Summary] 게임 서버 프로그래밍 교과서 - Ch3 (0) | 2019.08.14 |
[Summary] 게임 서버 프로그래밍 교과서 - Ch2 (0) | 2019.07.31 |