멀티 플레이어 게임을 개발을 하고 있다보니, 단순 게임의 로직을 개발을 하는 것으로는 부족하다는 것을 알았다. 일전에, 화상채팅으로 커리어 상담을 받았을 때, 단순 게임로직 개발은 당연히 할 줄 알아야 하고, 더 나아가서 본인의 강점을 키워야 한다고 했던게 생각이 났다. 그래서 이왕 해야하는 김에 네트워크와 서버에 관해서 강점을 키우고자 마음을 먹게 되었다. 어려운 만큼 돌아오는 것이 크리라 생각이 된다.
프로그램과 프로세스
프로그램은 디스크 메모리에 저장되어 있는 명령어와 데이터 덩어리이다. 이자체로는 무엇도 하지 않고, 단순히 메모리에 눌러 앉아 있는 것이다. 더블클릭을 하는 순간, 이 명령어와 데이터 덩어리가, ram 메모리에 로딩이 되며, 하나의 프로세스를 생성하며 명령어들이 돌아가게 된다. (프로세스가 여러개 돌아가 멀티 프로세싱이 될수도 있지만, 지금은 중요하지 않으니 넘어간다.)
프로세스는 이 데이터와 명령어들과 더불어 힙과 스택을 가지게 된다. 스택에는 현재 실행 중인 함수들의 호출 기록과 사용 중인 로컬 변수들이 들어있다. 각 프로세스에는 독립된 메모리 공간이 있으며 서로 다른 프로세스는 상대방의 메모리 공간을 읽고 쓸 수 없다.
스레드
스레드는 프로세스의 실행 단위이다. 프로그램의 실행 단위가 프로세스인 것 처럼, 스레드는 프로세스의 실행 단위이다.
이 스레드가 기본으로 존재하는 스레드를 메인 스레드라하는 데 이 스레드 하나만 존재하는 경우 싱글 스레드, 여러개 존재 하는 경우 멀티 스레드라고 한다.
같은 프로세스 안의 스레드는 heap 메모리 영역을 공유하는 데, 각각의 스레드는 각각의 스택 메모리를 갖는다.
이 각각의 스택 메모리엔, 함수는 실행이 끝나면 자신을 호출했던 지점으로 되돌아가야 하는데, 이 정보와 각 함수 안에 선언된 지역 변수도 같이 들어있다.
스레드는 실행 지점이 서로 다를 수 밖에 없다. 왜냐하면 스레드를 생성하려면 운영체제나 런타임 라이브러리에서 제공하는 스레드 생성용 함수를 호출하고 함수 인자로 스레드가 최초로 실행할 함수와 매개변수를 넣어준다. 이때, 함수 인자에 따라 각 스레드의 실행 지점이 서로 달라지며 같은 함수를 실행한다 하더라도 인자나 메모리 상태 등이 다르므로 결국 다른 실행 지점을 가리키게 된다.
스레드의 주기는 생성 -> Runnable -> Block(wait) or 소멸 -> 소멸의 주기를 갖는다. 하지만, 만약에 메인 스레드가 먼저 종료하고 그외 스레드가 살아있다면, 프로그램이 종료되지 않는 좀비 프로세스가 되어 버릴수 있다. 또한, 멀티 스레딩을 잘못 사용하면 deadlock 혹은 멀티 스레딩의 이점을 살리지 못한 비효율성 등 문제점이 발생할수가 있다. 이를 주의하기 위해, 주의사항들이 여럿 있다.
- 임계영역과 뮤텍스
- 스레드 풀링
- 이벤트
- 세마포어
등이 있다.
뮤텍스
앞서 언급했다싶이, 하나의 프로세스안의 각각의 스레드는 같은 힙영역을 공유하며, 고유의 스택 메모리를 갖는다. 그렇다면, 이 스레드들이 무분별하게 힙영역의 데이터에 엑세스를 하면 어떻게 될까. 데이터 레이싱 현상이 일어난다. 예로,
스레드1이 int a에 2를 더하려한다. 스레드 2는 이를 다시 5 더하려 하고 있다. 그리하여, 스레드 2 이후의 기댓값은 a에 7의 값이 더해지는 것이다. 하지만, 만약 스레드 1에서 2를 더한 값을 heap에 저장을 하기 전에 스레드2가 실행이 된다면, 결국 스레드가 끝나는 시점엔 5만 더해져 있을수가 있다.
이것이 가능한 이유는, 컨텍스트 스위치 때문이다. 이는, 하나의 스레드에서 하던 동작을 멈추고 다른 스레드로 넘어가는 현상을 의미하는데, 현상 발생 시점이 명령어 단위에 랜덤이기 때문이다. 위의 예시에서 int a += 2 라는 코드가 있다면, r1 = a; r1 = r1 + 2; a = r1; (r1은 레지스터에 존재) 의 명령어 순으로 실행되는데, 저 3라인 중 어디서 동작을 멈추고 스레드2가 실행이 될지는 아무도 모른다는 것이다.
이를 방지 하기 위해서, mutex라는 변수를 만들어서 이변수를 lock하여, 이 mutex가 unlock이 될때까지 다른 스레드는 대기하게 된다. 이 mutex를 사용하게 되면, 병렬적으로 코드를 실행 할수 있게 된다. 하지만, 4개의 스레드를 돌려서 4배의 효율을 보고 싶은 데, 그런 효율은 갖지 못한다.
디스크에서 데이터를 가져올 때, 시간이 오래걸리기 때문이다. 모터기반의 기기이기 때문에 ram이나 cpu보다 처리 속도가 현저히 떨어진다. 또한, lock에 걸려 다른 스레드가 대기 상태로 전환하는 상황이 발생했기 때문이다.
뮤텍스 사용에도 주의가 필요하다.
- 뮤텍스의 사용 자체가 무겁기 때문에 성능 저하의 원인이 될수 있다.
- 교착상태(deadlock)이 발생 할수 있다.
deadlock이란, 서로다른 스레드가 서로를 기다리는 상태를 의미한다. 스레드1이 a를 잠그고, b를 기다리는 사이, 스레드 2가 b를 잠그고 a를 기다리면, 서로 교착 상태에 빠지게 된다.
이를 방지 하려면, 잠금 순서가 중요한데, 하나만 기억하자. 잠근 순서 그대로 unlock 하기.
동시에 연산하면 유리한 부분을 잠금 단위로 나누고 병렬로 하지 않아도 성능에 영향을 주지 않는 부분들은 잠금 단위를 나누지 않는 것이 좋다.
뮤텍스 사용에서 생길수 있는 또다른 문제점은 시리얼 병목 현상이다. 여러 CPU가 각 스레드의 연산을 실행하여 동시 처리량을 올리는 것을 병렬성이라 하는 데, 방금, 뮤텍스로 lock 을 걸면 다른 스레드는 대기 상태에 빠진다고 설명한 부분이 있다. 이런 병렬성이 병목 현상으로 빠지게 될수도 있는 데, 하나의 cpu가 이를 처리하는 동안 나머지 cpu가 놀고 이는 대기 cpu가 많을 수록 병렬성의 비효율성이 높아지게 된다. 이런 비효율적인 현상을 암달의 저주라고 한다.
이 비효율성은 뮤텍스 외에 다른 부분에서도 발생하기도 한다. 디바이스 타임에는 스레드는 대기 상태에 빠지게 되는 데, 이동안 다른 cpu를 위한 연산을 하는 것이 효율적이다.
디바이스 타임 동안(ReadFromDisk)에는 뮤텍스 잠금을 하지 않는다. 단, 디바이스 타임 동안 X가 변경되었을 가능성이 있으니 뮤텍스 잠금 후 A의 상태 체크를 다시 해야 한다.
스레드 사용량 확인
이런 쓰레드의 사용량을 확인 할수 있는 방법이 concurrency visulizer이다. 다음 블로그에서 어떻게 사용하는 지 나와있으니, 필요한 경우 참고해보자.
https://hwan-shell.tistory.com/287
싱글 스레드 게임서버
상용되는 게임서버의 cpu를 보면 여러 코어가 존재하는 데, 여러 코어를 놔두고 하나의 코어만 사용하는 경우 비효율적이다. 그래서 싱글스레드 서버의 경우 cpu 개수만큼 프로세스를 띄운다. 이런 싱글 스레드 서버의 경우 다음과 같은 특징들을 갖는다.
- 각 서버 프로세스는 방을 여러 개 가지고 방에서는 플레이어 하나 이상이 싱글 플레이 혹은 멀티 플레이를 한다.
- 플레이어 정보를 로딩할 때 발생하는 디바이스 타임을 처리하는 과정에서 큰 시리얼 병목이 일어난다. 이를 해결하고자 비동기 함수나 코루틴 같은 것을 사용하기도 한다.
- 방 개수만큼 스레드나 프로세스가 있으면 스레드나 프로세스 간 컨텍스트 스위치의 횟수가 증가한다.
- 같은 동시접속자를 처리하는 서버라고 하더라도 실제로 처리할 수 있는 동시접속자 수를 크게 떨어뜨린다.
멀티 스레드 게임서버
다음과 같은 경우에 사용하자.
- 서버 프로세스를 많이 띄우기 곤란할 때: 프로세스당 로딩해야 하는 게임 정보의 용량이 매우 클 때
- 서버 한 대의 프로세스가 여러 CPU의 연산량을 동원해야 할 만큼 많은 연산을 할 때
- 코루틴이나 비동기 함수를 쓸 수 없고 디바이스 타임이 발생할 때
- 서버 인스턴스를 서버 기기당 하나만 두어야 할 때
- 서로 다른 방이 같은 메모리 공간을 액세스해야 할 때
- 위의 그림에서 보면 가기 다른 스레드는 다른 스레드는 다른 스택 메모리를 가지고 있기 때문에 독립적이다.
그림으로 싱글 스레드와 멀티 스레드 방의 비교를 해보자면, 싱글 스레드는 프로세스 별로 cpu를 사용하고 있고, 다른 연산을 처리 해줄 cpu의 개수가 모자르다. 하지만, 멀티 스레드의 경우 하나의 프로세스에 멀티 스레드를 사용해서 다른 cpu는 다른 무거운 연산을 처리해줄수 있는 상태이다.
- 게임 서버 메인은 방 목록을 가지며 방 목록에는 각 방이 들어간다.
- 각 방은 뮤텍스를 가지며 또 게임 서버 메인 자체도 뮤텍스를 가진다.
- 플레이어 행동에 대한 처리는 각 방을 잠근 후에 한다.
- 공통 데이터(방 목록 등)를 잠근다.
- 플레이어 A가 들어 있는 방을 방 목록에서 찾는다.
- 공통 데이터를 잠금 해제한다.
- 찾은 방을 잠근다.
- 플레이어 A의 방 안에서 처리를 한다.
- 방을 잠금 해제한다.
스레드 풀링
멀티 스레드 게임 서버의 각 스레드의 역할배정에서 가장 쉬운 방법은 클라이언트마다 스레드를 배정해 주는 것이다.
이러한 방법은 개발은 쉽지만 스레드 개수가 매우 많아지므로 여러 가지 문제가 발생할 수 있다.
- 각 스레드는 호출 스택을 가지는데 이를 저장할 메모리 공간이 매우 커진다.
- 컨텍스트 스위치 연산은 무거운 작업인데 이를 매우 자주해야 한다.
이런 문제를 해결하기 위해 스레드 풀링을 사용한다
여러 이벤트가 비어있는 스레드를 기다리고, 스레드가 사용가능 해진 순간 다음 순서의 이벤트를 처리하는 것이 풀링이다. 오브젝트 풀링과 매우 흡사한 개념이다. 이런 풀링으로 다음과 같은 이점을 살릴수가 있다.
- 어떤 서버의 주 역할이 디바이스 타임이 없고 CPU 연산만 하는 스레드라면, 스레드 풀의 스레드 개수는 CPU 개수와 동일하게 잡아도 충분하다.
- 서버에서 데이터베이스나 파일 등 다른 것에 액세스 하면서 디바이스 타임이 발생할 때 스레드 개수는 CPU 개수보다 많아야 한다.
이벤트
이벤트는 스레드를 깨우는 도구로 Reset(이벤트 없음, 0), Set(이벤트 있음, 1) 상태 값을 가진다.
Event event1;
void Thread1()
{
// 이벤트가 신호를 일으킬 때까지 기다린다.
event1.Wait();
}
void Thread2()
{
// 이벤트에 신호를 준다.
event1.SetEvent();
}
이벤트는 스레드 간 소통 할 때 유용하다.
세마포어
오로지 스레드 1개의 자원을 액세스 할 수 있게 하는 뮤텍스와 임계영역과 달리 세마포어는 원하는 개수의 스레드가 자원을 액세스 할 수 있게 한다.
Semaphore sema1;
void Main()
{
// 스레드 2개만 자원을 액세스할 수 있게 제한한다.
sema1 = new Semaphore(2);
}
void Thread1()
{
// 리소스를 액세스할 수 있을 때까지 기다린다.
sema1.Wait();
// 리소스 액세스가 다 끝났음을 세마포어에 알린다.
sema1.Release();
}
void Thread2()
{
// 리소스를 액세스할 수 있을 때까지 기다린다.
sema1.Wait();
// 리소스 액세스가 다 끝났음을 세마포어에 알린다.
sema1.Release();
}
void Thread3()
{
// 리소스를 액세스할 수 있을 때까지 기다린다.
sema1.Wait();
// 리소스 액세스가 다 끝났음을 세마포어에 알린다.
sema1.Release();
}
세마포어는 상태 값을 갖고 있으며 초기값은 설정했던 최대 액세스 가능한 스레드 개수이다. 스레드가 세마포어에 자원 액세스를 요청하면 상태 값을 1 감소하며 자원 액세스가 끝난 후엔 1 증가한다. 세마포어와 이벤트는 비슷하지만 이벤트는 상태 값이 0, 1로 제한되지만 세마포어는 0 이상의 값이란 차이가 있다.
멀티 스레딩 프로그래밍의 주의점 - 다음 블로그를 참고하자.
https://1-taek-gameprogramming.tistory.com/35
'Game Dev > Game Server' 카테고리의 다른 글
게임 서버 프로그래밍 교과서 - 5장) 게임 네트워킹 (0) | 2023.09.24 |
---|---|
게임 서버 프로그래밍 교과서 - 4장) 게임 서버와 클라이언트 (0) | 2023.09.16 |
게임 서버 프로그래밍 교과서 - 3장) 소켓 프로그래밍 (0) | 2023.09.16 |
게임 서버 프로그래밍 교과서 - 2장) 컴퓨터 네트워크 (1) | 2023.09.16 |