해당 내용은 게임 서버 프로그래밍교과서의 내용을 참고했습니다.

멀티스레딩을 해야 하는 때

  1. 오래 걸리는 일 하나와 빨리 끝나는 일 여럿을 같이 해야 할 때
  2. 어떤 긴 처리를 진행하는 동안 다른 짧은 일을 처리해야 할 때
  3. 기기에 있는 CPU를 모두 사용해야 할 때

프로세스(process)

컴퓨터 내에서 실행중인 프로그램을 일컫는 용어

스레드(thread)

cpu가 독립적으로 처리하는 하나의 작업 단위

스레드가 여러개일때의 문제점

여러 프로세스와 여러 스레드를 동시에 실행해야 하는 운영체제는 일정 시간마다 프로세스와 그 안에 있는 스레드를 번갈아가며 실행한다.
각 스레드를 실행하다 말고 다른 스레드를 실행하는 것을 컨텍스트 스위치(context switch)라고 한다.

컨텍스트 스위치(context switch)

실행 중이던 스레드의 상태를 어딘가에 저장하고, 다른 스레드를 고른 후, 고른 스레드의 상태를 복원하고, 실행하던 지점으로 강제 이동을 해야 하기 때문에 복잡함.

때문에 컨텍스트 스위치를 많이 하면 성능이 안 좋아진다.

CPU개수와 스레드의 개수가 같다면 컨텍스트 스위치가 발생할 이유가 없다.
하지만 대부분의 운영체제에서는 CPU의 개수보다 훨씬 많은 수의 프로세스가 있고, 각 프로세스는 최소 1개의 스레드를 갖고 있기 때문에 컨텍스트 스위치가 빈번히 일어나야 한다.
하지만 실행중인 스레드의 개수를 조절하기 때문에 별 문제가 없다.

스레드 주의 사항

스레드 2개가 동일한 값에 접근 시 결과를 예측할 수 없는 값이 나오는 경우가 있다.
이는 컨텍스트 스위치가 무작위로 일어나기 때문에 발생하는 결과이며, 이를 막기 위해서 동기화(synchronize)라는 조작을 한다.
이에 대표적인 것이 임계영역과 뮤텍스, 잠금(lock)기법이다.

임계영역과 뮤텍스

뮤텍스는 상호 배제(mutual exclusion)의 줄임말,
코드는

std::mutex mx;	---1
mx.lock();		---2
read(x);		---3
write(y);		---4
sum(x);			---5
mx.unlock();	---6

이며, 2번째 줄에서 사용권을 요청한다.
만약 사용권이 다른 스레드에게 넘어가 있으면 사용권을 받지 못하고 사용권이 넘어올 때 까지 대기한다.
사용권을 받은 스레드는 6번째 줄에서 사용권을 다시 반납하며, 사용권이 필요한 다른 스레드가 사용한다.

하지만 중간에 예외가 발생하면 사용권 반납은 진행되지 않는다. 따라서 try catch 문을 이용하여 예외가 발생하더라도 unlock()을 실행할 수 있게 만들어야 한다.

c++에서는 지역변수 lock_guard를 이용하면 unlock을 따로 호출하지 않아도 지역변수가 사라질 때 자동으로 잠금 해제가 된다.

std::mutex mx;
{
	std::lock_guard<std::mutex> lock(mx);
    read(x);
    write(y);
    sum(x);
}

하지만 CPU를 모두 사용한다고 해서 성능이 그만큼 올라가는 건 아님.
뮤택스 때문에 모든 스레드가 동작하는 상태로 유지될 수 없고 메모리에 접근하는 시간이 들기 때문.
또한 뮤텍스 자체가 무겁기 때문에 뮤택스를 최대한 잘게 나누면 오히려 성능이 떨어진다.

따라서 뮤택스는 적당히 넓게 범위를 잡아야 한다.

교착 상태

a code

Thread()
{
	lock(a)
    {
    	a++;
        lock(b)
        {
        	b++
        }
    }
}

b code

Thread()
{
	lock(b)
    {
    	b++;
        lock(a)
        {
        	a++
        }
    }
}

위 두 스레드가 실행중일 때,
a code는 a의 사용권을 받은 후 b의 사용권을 요청하고
b code는 b의 사용권을 받은 후 a의 사용권을 요청한다.
하지만 이는 맞물려 서로 사용권을 기다리기만 하는 교착상태가 발생할 수 있다.

이러한 교착상태는 CRITICAL_SECTION 내용을 디버거로 확인하여 교착상태가 어디서 시작되었는지 알 수 있다.

잠금 순서의 규칙

교착상태를 예방하는 방법
각 뮤텍스의 잠금 순서를 그래프로 그려야 한다.
A->B->C순서로 잠가야 하며, 이를 지키면 교착 상태를 예방할 수 있다.
A->B, B->C, A->C순서대로 잠그는 것 모두 가능하며, 거꾸로 된 방향만 아니면 된다.

병렬성

뮤텍스가 보호하는 영역이 너무 넓으면 스레드가 여럿이라 하더라도 하나일 때와 별반 차이가 없다.
여러 CPU가 각 스레드의 연산을 실행하여 동시 처리량을 늘리는 것이 병렬성이다.
하지만 어떠한 이유로 병렬로 실행되게 만든 프로그램이 정작 한 CPU만 실행되면 이는 시리얼 병목(serial bottleneck)이라 부른다.

병렬로 처리할 수 없는 공간, 시리얼 병목이 있을 때, CPU의 개수가 많을수록 처리 효율성이 떨어지는 것을 암달의 법칙, 혹은 암달의 저주라 한다.

Visual Studio에서는 Concurrency Visualizer라는 것이 있으며, 이를 이용하면 멀티스레드 프로그램이 여러 가지 일을 정말로 동시에 잘 해결하는지 분석하여 이를 시각화해서 보여준다.

싱글 스레드 게임 서버

많은 상용 서버는 CPU가 여러 코어로 구성되어 있음. 따라서 게임 서버를 싱글 스레드로 구동하는 경우 비효율성 문제가 생긴다.
멀티스레드 프로그래밍은 싱글스레드 프로그래밍보다 어렵기 때문에 싱글스레드 서버를 만들고 서버를 CPU개수만큼 띄우는 사람도 있다.

  1. 방 개수만큼 스레드나 프로세스가 있으면 스레드나 프로세스 간 컨텍스트 스위치의 횟수가 증가한다.
  2. 같은 동시접속자를 처리하는 서버라고 하더라도 실제로 처리할 수 있는 동시접속자 수는 떨어진다.

멀티스레드 게임 서버

멀티스레드로 게임을 개발하는 경우
1. 서버 프로세스를 많이 띄우기 곤란할 때, 예를들어 프로세스당 로딩해야 하는 용량이 매우 클 때
2. 서버 한 대의 프로세스가 여러 CPU연산량을 동원해야 할 만큼 많은 연산을 할 때
3. 코루틴이나 비동기 함수를 쓸 수 없고 디바이스 타임이 발생할 때
4. 서버 인스턴스를 서버 기기당 하나만 두어야 할 떄
5. 서로 다른 방이 같은 메모리 공간을 액세스해야 할 때

멀티스레드 게임 서버는 잠금 범위를 설정 하는데, 보통은 방 단위로 잠금 범위를 설정하는 것이 적당하다.

각 방은 뮤텍스를 가진다. 게임 서버 메인 자체도 뮤텍스를 가지며, 다음과 같이 동작한다.
1. 공통 데이터를 잠근 후 원하는 방을 방 목록에서 찾는다.
2. 공통 테이터 잠금을 해제한다.
3. 찾은 방을 잠근다.
4. 해당 방에서 데이터를 처리한다.
5. 방을 잠금 해제한다.

이때, 크게 주의할 점은 시리얼 병목과 교착 상태이다.

스레드 풀링

쉬운 개발 방법 중 하나는 클라이언트마다 스레드를 배정해 주는 것이다.
하지만 스레드가 많아지면 해당 스레드가 메모리를 과다하게 사용하게 된다.
또한 클라이언트가 서버와 통신하는 횟수가 많아지면 많은 스레드가 컨텍스트 스위칭을 하게 되므로 서버에 불필요한 CPU연산량이 발생한다.

대신에 사용하는 방법이 스레드 풀링이다.
적정한 개수의 스레드를 만들고 처리할 이벤트를 작업이 끝난 스레드에서 작업한다.

  • 어떤 서버가 노는 시간 없이 연산만 하는 스레드라면 스레드 풀의 스레드 개수는 서버의 CPU수와 같이 잡아도 충분하다.
  • 서버에서 데이터베이스나 다른 파일 등에 엑세스 하면서 노는 시간이 발생할 때에는 더 많은 스레드가 필요하다.

이벤트

잠자는 스레드를 깨우는 도구로

  • Reset: 이벤트가 없음, 0으로 표현됨
  • Set: 이벤트가 있음, 1로 표현됨
Event event1;
void Thread1()
{
	//이벤트에서 신호가 있을 때 까지 잔다.
	event.Wait();
}

void Thread2()
{
	//이벤트에 신호를 준다.
	event.SetEvent();
}

이를 많이 사용한다.

세마포어

오로지 1개의 스레드만 자원에 액세스 할 수 있는 뮤텍스나 임계 영역과는 달리 세마포어는 원하는 개수의 스레드가 자원에 액세스 할 수 있게 한다.

Semaphore sema1;

void Main()
{
	sema1 = new Semaphore(2);
}

void Thread1()
{
	sema1.Wait();
    
    sema1.Release();
}

void Thread2()
{
	sema1.Wait();
    
    sema1.Release();
}

void Thread3()
{
	sema1.Wait();
    
    sema1.Release();
}

3개의 스레드가 세마포어에 액세스를 요청한다.
하지만 세마포어는 2개의 스레드에만 액세스를 허용해 주고, 나머지를 실행한다.
일을 마친 스레드는 세마포어에 액세스가 끝났음을 통보하고, 세마포어는 나머지 하나의 스레드에게 액세스를 허용한다.

원자 조작

뮤텍스나 임계 영역 잠금 없이도 여러 스레드가 안전하게 접근할 수 있는 것
32비트나 64비트의 변수 타입에 여러 스레드가 접근할 때 한 스레드씩만 처리됨을 보장함.

멀티프로그래밍 프로그래밍의 흔한 실수

읽기와 쓰기 모두에 잠금하지 않기

가끔 발생하는 버그이며, 값을 읽을 때 잠그지 않으면 값이 가끔 정상적이지 않게 나올 경우가 있다.

잠금 순서 꼬임

잠금 순서 규칙을 지키면 되지만 실수하는 일이 많다 조심하자.

너무 좁은 잠금 범위

잠금 객체 범위가 너무 넓으면 컨텍스트 스위치가 발생할 때 운영체제가 해야 할 일이 매우 많아진다.
그리고 처리 병렬성이 떨어지기 때문에 멀티스레드 프로그래밍의 이유가 퇴색된다.

반면 잠금 범위를 좁히면 컨텍스트 스위치의 확률이 떨어지지만 임계 영역 잠금이 많은 처리시간을 차지한다.

따라서 적당한 수준에서 임계 영역을 나누면 된다.

디바이스 타임이 섞인 잠금

디바이스타임이 있을때(연산이 딜레이 될 때)다른 스레드가 자주 접근하는 리소스에 잠금을 하는 경우 성능이 떨어진다.
특히 printf나 cout같은 함수의 경우 연산량이 많다. 따라서 로그 출력은 많은 시간을 차지한다.
메모리 영역에 잠금을 한 후 콘솔 출력 등의 연산을 실행하게 되면 연산이 처리되는 동안 해당 메모리에 다른 스레드가 접근하지 못하게 된다.

잠금의 전염성으로 발생한 실수

목록에서 무언가를 꺼내 포인터 주소값을 저장한 후 목록을 잠금 해제하면 잠금의 전염성이 발생하며, 그 상태에서 수정하는 경우 데이터 레이스가 발생하여 버그가 발생한다.

잠금된 뮤텍스나 임계 영역 삭제

잠금된 뮤텍스나 임계 영역을 삭제할 경우 문제가 발생한다.
이는 delete함수 안에 잠금하고 있으면 오류를 내라는 기능을 추가해서 감지할 수 있다.

profile
코린이

0개의 댓글