728x90
1장. 멀티스레딩
1.1 프로그램과 프로세스
- 프로그램은 크게 코드(code)와 데이터(data)로 구성되어 있다.
- 프로그램은 실행하면 이를 프로세스라고 한다. 이런 프로세스가 여러개 실행되고 있는것을 멀티 프로세싱 이라고 한다.
1.2 스레드
- 각 프로세스는 독립된 메모리 공간이 있고, 기본적으로 서로 다른 프로세스는 상대방의 메모리 공간에 쓸 수 없다.
- 일반적으로 많이 쓰는 운영체제는 대부분 스레드(thread)라는 기능을 제공한다. 스레드 역시 프로세스처럼 명령어를 한 줄씩 실행하는 기본 단위이다.
스레드와 프로세스의 차이
- 스레드는 한 프로세스 안에 여러 개가 있다.
- 한 프로세스 안에 있는 스레드는 프로세스 안에 있는 메모리 공간을 같이 사용할 수 있다.
- 스레드마다 스택을 가진다. 이는 각 스레드에서 실행되는 함수의 로컬 변수들이 스레드 마다, 있다는 의미이다.
‘프로그램을 실행하면 프로세스가 생성된다. 프로세스안에는 유일한 스레드가 있고 그 안에서 프로그램이 실행된다.’
- 하나의 스레드만 실행되는 프로그램, 즉 지금까지 우리가 알던 ‘동시에 하나만 실행되는 프로그램’을 싱글 스레드 프로그램이라고 한다. 그리고 싱글 스레드로만 작동하도록 프로그램을 설계하고 구현하는 것을 싱글 스레드 모델이라고 한다.
- 프로그램을 실행할 때 기본으로 존재하는 스레드를 메인 스레드 라고 한다.
- 여러 스레드가 동시에 여러가지 일을 처리하게 하는 것을 멀티 스레드 모델 혹은 멀티스레딩이라고 한다.
- 각 스레드는 실행 지점이 서로 다를 수 밖에 없다. 스레드를 실행할 때 그 스레드가 최초로 실행할 함수를 지정하는데, 이때 함수가 다르기 떄문이다. 같은 함수를 실행한다 하더라도 그 함수에 넘긴 인자나 메모리 상태등이 다르므로 결국 다른 실행 지점을 가리킬 수 밖에 없다.
- 스레드를 생성하려면 운영체제나 런타임 라이브러리에서 제공하는 스레드 생성용 함수를 호출한다. 이때 함수 인자로 스레드가 최초로 실행할 함수와 그 함수가 받아들일 매개변수를 넣어주어야 한다.
- 스레드를 생성하라는 함수가 실행하면 스레드가 생성되면서 사용자가 지정한 함수를 실행한다. 즉, 스레드가 시작된다. 일단 시작한 스레드는 runnable 상태, 즉 프로그램의 명령어를 실행하는 상태로 바뀐다. 그러다가 다른 무언가를 기다릴 때는 blocked 상태가 된다. 이때는 스레드가 일시정지 상태다. 이후 기다리고 있던 무언가에서 무슨 일이 생기면, 스레드는 기다림을 끝내고 다음 명령을 실행한다. 모든 일을 다 마치면 스레드는 dead 상태가 되어 소멸된다.
- 메인 스레드가 종료되었지만 다른 스레드를 그냥 남겨둔다면, 프로그램이 종료되지 않는 한 이상한 현상을 겪게 된다. 이를 좀비 프로세스라고 한다.
1.3 멀티스레드 프로그래밍은 언제 해야 할까?
- 오래 걸리는 일 하나와 빨리 끝나는 일 여럿을 같이 해야 할 때.
- 어떤 긴 처리를 진행하는 동안 다른 짧은 일을 처리해야 할 때.
- 기기에 있는 CPU를 모두 활용해야 할 때.
1.4 스레드 정체
- 여러 프로세스와 여러 스레드를 동시에 실행해야 하는 운영체제는 이렇게 여러 프로세스와 각 프로세스 안에 있는 스레드들을 일정시간마다 번갈아가면서 실행한다. 각 스레드를 실행하다 말고 다른 스레드를 마저 실행하는 과정을 컨텍스트 스위치라고 한다.
- 컨텍스트 스위치 실행은 기본적으로 ‘사람입장에서 쾌적할 수 있는’ 가급적 긴 시간 단위로 이루어진다. 이 시간 단위를 타임 슬라이스라고 한다.
- CPU개수와 스레드 개수가 같거나 스레드 개수가 더 적으면 컨텍스트 스위치가 발생할 이유가 없다. 하지만 스레드 개수가 더 많으면 컨텍스트 스위치가 발생한다.
- 다만 runnable 상태의 스레드가 CPU개수보다 많을 경우 성능 문제가 될 뿐이다. waitable 상태의 스레드는 이러한 성능 문제가 없다. 실제로 대부분 운영체제에는 CPU 개수보다 훨씬 많은 수의 프로세스가 있고, 각 프로세스는 스레드를 최소 1개는 갖고 있으므로 성능에 큰 문제가 되어야 할 텐데, 그렇지 않은 이유는 이 때문이다. 프로그램의 스레드가 많더라도, runnable인 스레드 개수가 CPU 개수보다 적다면 별 문제가 없다.
- 컨텍스트 스위치는 기계어 명령어 단위로 이루어진다. 이 부분이 주의해야 할 부분이다.
1.5 스레드를 다룰 때 주의사항
- 두 스레드가 데이터에 접근해서 그 데이터 상태를 예측할 수 없게 하는 것을 경쟁상태 혹은 데이터 레이스(data race)라고 한다.
- 컨텍스트 스위치가 무작위로 발생하다보니 결과를 예측할 수 없다.
- 두 멤버 변수를 건드리는 동안에는 다른 스레드가 절대 건드리지 못하게 하는 것을 원자성(atomicity) 이 라고 한다. 그래야 변수는 항상 일관성 있는 상태를 유지 할 수 있고, 이를 일관성이라고 한다.
- 멀티스레드 프로그래밍을 하다보면 원자성과 일관성을 유지하는ㅌ 특수 조치를 해야 할 때가 있다. 이러한 조치들을 통칭하여 동기화라고 하며, 대표적인 것이 임계영역과 뮤텍스, 락 기법이다.
1.6 임계 영역과 뮤텍스
- 경쟁 상대를 해결하려 할 때 뮤텍스(mutex)를 사용한다.
- c++에서는 로컬변수가 파괴될 때, 파괴자 함수에서 여러분이 원하는 코드가 실행되게 할 수 있다. 이를 이용하면 예외가 발생하더라도 파괴자 함수로 unlock()이 자동으로 실행될 것이다.
- c++에서는 뮤텍스 잠금 상태를 로컬 변수로 저장하고, 그 변수가 사라질 때 자동으로 잠금 해제가 되게하는 클래스를 제공한다. 바로 lock_guard이다
- 지역변수 lock_guard를 이용하면 unlock을 호출하지 않아도 그 지역변수가 사라질 떄 자동으로 잠금해제가 된다.
- CPU가 여러개지만, 결국 CPU는 메모리에 접근한다. CPU가 다루는 메모리는 기판에 연결되어있는 다른 부품에 있다. 기판회로를 통해 데이터를 주고 받는 과정은 CPU 입장에서는 매우 긴 시간이다. CPU 안에 캐시 메모리가 있는 이유도 이 시간을 줄이기 위해서 이다. 하지만 CPU안에 캐시된 메모리 또한 여러 CPU가 접근할 때는 CPU안에서 블로킹이 약간 발생한다. 즉, 멀티 스레드로 작동한다 하더라도 메모리에 접근하는 시간 동안에는 CPU 개수보다 더 적은 수의 CPU를 처리하게 된다는 의미이다. 이렇게 메모리에 접근하는 시간을 메모리 바운드 시간 이라고 한다.
- 뮤텍스를 잘게 나누면 다음과 같은 문제가 발생한다.
- 오히려 프로그램 성능이 떨어진다. 뮤텍스를 액세스하는 과정 자체가 무겁기 때문이다.
- 프로그램이 매우 복잡해진다. 특히 뒤에서 설명할 교착상태(DEAD LOCK) 문제가 쉽게 발생한다.
- 뮤텍스는 어느 정도 굵직하게 잠금 범위를 잡아야 한다. 그렇다고 범위를 너무 넓게 잡으면 안된다. 극단적인 예로 프로그램 내부 전체 데이터를 한꺼번에 보호하는 뮤텍스가 단 하나만 있는 경우 싱글 스레드 프로그램과 다를바 없다.
1.7 교착상태
- 멀티 스레드 프로그래밍에서 교착상태란 두 스레드가 서로를 기다리는 상황을 의미한다.
- 게임 서버에서 교착상태가 되면 발생하는 현상은 다음과 같다.
- CPU 사용량이 현저히 낮거나 0%이다.
- 클라이언트가 서버를 이용할 수 없다.
- 교착상태를 일으켰을 때 디버거로 원인을 찾을 수 있다. 윈도에서 제공하는 기능인 CRITICAL_SECTION 내용을 디버거로 확인하면 교착 상태가 어디서 시작됐는지 알 수 있다.
- 임계영역 생성은 InitializeCriticalSectionEx로 한다. 이때 CRITICAL_SECTION객체가 생성된다.
- 임계영역 제거는 DeleteCriticalSection 으로 한다.
- 임계영역 잠금은 EnterCriticalSection 으로 한다.
- 임계영역 잠금 해제는 LeaveCriticalSection 으로 한다.
1.8 잠금 순서의 규칙
- 여러 뮤텍스를 사용할 때 교착상태를 예방하려면 각 뮤텍스의 잠금 순서를 먼저 그래프로 그려두어야 한다. 그리고 잠금을 할 때는 잠금 순서 그래프를 보면서 거꾸로 잠근 것이 없는지 체크해야 한다.
- 뮤텍스는 재귀성을 가지는 것과 가지지 않는 것이 있다. 재귀 뮤텍스(recursive mutex)는 한 스레드가 뮤텍스를 여러 번 반복해서 잠그는 것을 원활하게 처리해준다.
- 이미 쓰레드 1이 뮤텍스 m의 lock() 함수를 호출해서 잠갔는데, 스레드1이 또 lcok(m)을 호출하는 경우 재귀 뮤텍스는 스레드 1이 중복해서 잠그는 것을 이미 알고 있다. 따라서 내부적으로 단지 이렇게만 상태 업데이트를 한다. ‘스레드 1이 나를 두 번 잠갔다.’ 이 상태에서 스레드 1이 unlock(m)을 한번만 호출하면 ‘두번 잠금 → 한번 잠금’으로 상태가 바뀔 뿐, 실질적인 잠금 해제는 일어나지 않는다. 이 상태에서 한번 더 unlock(m)을 호출해야 비로서 잠금 해제가 일어난다.
- 교착 상태를 예방하려면 잠금 순서를 지켜야 한다. 잠금을 해제하는 순서는 교착 상태에 영향을 주지 않는다.
1.9 병렬성과 시리얼 병목
- 여러 CPU가 각 스레드의 연산을 실행하여 동시 처리량을 올리는 것을 병렬성이라고 한다. 그런데 어떤 이유로 이러한 병렬성이 제대로 나오지 않는 것, 즉 병렬로 실행되게 프로그램을 만들었는데 정작 한 CPU만 연산을 수행하는 현상을 시리얼 병목이라고 한다.
- 시리얼 병목이 있을 때, CPU 개수가 많을수록 총 처리 효율성이 떨어지는 떨어지는 현상을 가리켜 암달의 법칙, 혹은 암달의 저주라고 한다.
- 암달의 저주를 줄이려면 시리얼 병목이 발생하는 구간을 최소로 줄여야 한다.
- visual stdio에서는 concurrency visualizer 라는 것이 있다. 멀티 스레드 프로그램이 여러가지 일을 정말로 동시에 잘 수행하는지 분석하여 이를 시각화해서 보여주는 도구이다.
- 병렬 병목 현상은 먼저 디바이스타임과 CPU 타임에 흔히 일어난다. 디바이스 타임은 기기에 있는 장치에 뭔가를 요청해서 결과가 올 때까지 기다리는 시간을 의미한다.
1.10 싱글 스레드 게임서버
- 싱글 스레드로 게임서버를 만드는 경우, 디스크에서 플레이어 정보를 로딩할 대 발생하는 디바이스 타임을 처리하는 과정에서 큰 시리얼 병목이 일어난다. 이를 해결하고자 비동기 함수나 코루틴 같은 것을 사용하기도 한다. 부득이 한 경우가 아니면 방 개수나 플레이어 개수 만큼 스레드 혹은 프로세스를 띄우는 것은 피해라
- 방 개수 만큼 스레드나 프로세스가 있으면 스레드나 프로세스간 컨텍스트 스위치의 횟수가 증가한다.
- 따라서 같은 동시 접속자를 처리하는 서버라고 하더라도 실제로 처리할 수 있는 동시 접속자 수를 크게 떨어뜨린다.
1.11 멀티 스레드 게임서버
- 멀티 스레드로 서버를 개발하는 경우
- 서버 프로세스를 많이 띄우기 곤란할 때, 예를 들어 프로세스당 로딩해야 하는 게임 정보의 용량이 매우 클 때.
- 서버 한 대의 프로세스가 여러 CPU의 연산량을 동원해야 할 만큼 많은 연산을 할 때
- 코로틴이나 비동기 함수를 쓸 수 없고, 디바이스 타임이 발생할 때.
- 서버 인스턴스를 서버 기기당 하나만 두어야 할 때
- 서로 다른 방이 같은 메모리 공간을 액세스 해야 할 때
- 멀티 스레드 게임서버는 잠금 범위를 설정해주어야 하는데, 보통은 방 단위로 잠금범위를 설정하는 것이 적당하다.
- 멀티 스레드 게임서버를 만들 때 크게 주의할 점은 시리얼 병목과 교착 상태다.
1.12 스레드 풀링
- 어떤 서버의 주 역할이 CPU 연산만 하는 스레드라면(즉, 디바이스 타임이 없다면) 스레드 풀의 스레드 개수는 서버의 CPU개수와 동일하게 잡아도 충분하다.
- 서버에서 데이터 베이스나 파일 등 다른 것에 액세스 하면서 디바이스 타임이 발생할 때 스레드 개수는 CPU 개수보다 많아야 한다.
1.13 이벤트
- 이벤트는 잠자는 스레드를 깨우는 도구로, 내부적으로 다음 상태 값을 가진다.
- Reset : 이벤트가 없음, 정수 값 0
- Set : 이벤트가 있음, 정수 값 1
- 윈도에서의 이벤트 관련 함수
- CreateEvent : 이벤트 생성
- CloseHandle : 이벤트 파괴
- WaitForSingleObject : 이벤트를 기다린다.
- SetEvent : 이벤트에 신호를 준다.
- 윈도에서는 이벤트가 자동이벤트와 수동이벤트 라는 이벤트 모드를 취할 수 있다. 자동 이벤트 모드에서는 이벤트가 신호를 가질 때 즉, 이벤트 상태 값이 1이 되었을 때 이벤트를 기다리던 스레드가 있으면 그 스레드를 깨운다. 그리고 상태값이 ‘자동’ 으로 0으로 바뀐다.
- 수동 이벤트 모드에서는 이벤트 상태 값이 1이 되었을 때 이벤트를 기다리던 스레드가 깬다. 그러나 상태 값은 여전히 1로 남는다. 이를 0으로 바꾸는 것은 수동으로 해야 한다.
- 자동 이벤트 모드는 구현이 간단하지만 제한적인 상황에만 사용할 수 있고, 수동 이벤트 모드는 개발자가 직접 제어해야 하지만 상황에 맞게 유연하게 사용할 수 있다.
1.14 세마포어
- 세마포어는 원하는 개수의 스레드가 자원을 액세스 할 수 있게 한다.
- 윈도에서 세마포어 관련 함수
- CreateSemaphore : 세마포어 생성, 자원을 몇 개 허가하는지도 이때 설정한다.
- WaitForSingleObject : 세마포어가 자원 액세스를 요청하고, 허락할 때 까지 기다린다.
- ReleaseSemaphore : 세마포어에 자원 액세스가 끝났음을 통보한다.
- CloseHandle : 세마포어를 파괴한다.
1.15 원자조작
- 원자조작(atomic operation)은 뮤텍스나 임계영역 잠금 없이도 여러 스레드가 안전하게 접근 할 수 있는것을 의미한다. 원자조작을 하드웨어 기능이며, 대부분 컴파일러는 원자조작 기능을 쓸 수 있게 한다. 원자조작은 32비트나 64 비트의 변수 타입에 여러 스레드가 접근 할 때 한 스레드씩만 처리됨을 보장한다. 그러나 변수값 2~3개 이하에서만 보호해주며, 변수를 읽거나 쓰는 방식도 몇 개 안된다.
- 원자조작은 대표적으로 다음과 같은 것이 있다.
- 원자성을 가진 값 더하기
- 원자성을 가진 값 맞바꾸기
- 원자성을 가진 값 조건부 맞바꾸기
1.16 멀티 스레드 프로그래밍 흔한 실수들
- 읽기와 쓰기 모두에 잠금하지 않기
- 잠금 순서 꼬임
- 제일 좋은 것은 잠금 순서 규칙을 최대한 적게 유지하는 것이다.
- 너무 좁은 잠금 범위
- 잠금 범위를 좁히면 컨텍스트 스위치 확률이 떨어지기는 하지만 임계영역 잠금이 컨텍스트 스위치보다는 훨씬 적더라도 단순한 산술연산보다는 더 많은 처리 시간을 차지한다. 따라서 임계영역을 적당한 수준에서 나누면 좋다.
- 잠금범위가 너무 좁으면, 즉 임계영역 개수가 너무 많으면 프로그램을 유지보수하기도 어렵고, 그만큼 프로그래머가 실수할 확률도 높다.
- 디바이스 타임이 섞인 잠금
- 디바이스 타임이 있을 때는 다른 스레드가 자주 접근하는 리소스에 대한 잠금을 하지 말아야하는데도, 디바이스 타임이 섞인 잠금을 하는 실수를 하곤 한다. 그 중 특히 자주하는 실수는 로그출력이나 콘솔출력이다.
- 잠금의 전염성으로 발생한 실수
- 잠금으로 보호되는 리소스(변수 값 등)에서 얻어온 값이나 포인터 주소 값등이 로컬변수로 있는 경우에도 잠금 상태를 계속 유지해야 할 때가 있다. 이를 잠금의 전염성이라고 한다.
- 잠금된 뮤텍스나 임계영역 삭제
- 일관성 규칙 깨기
728x90
'책 > 게임 서버 프로그래밍 교과서' 카테고리의 다른 글
게임 서버 프로그래밍 교과서 7장. 데이터베이스 기초 (0) | 2024.04.12 |
---|---|
게임 서버 프로그래밍 교과서 5장. 게임 네트워킹 (0) | 2024.04.10 |
게임 서버 프로그래밍 교과서 4장. 게임 서버와 클라이언트 (0) | 2024.04.10 |
게임 서버 프로그래밍 교과서 3장. 소켓 프로그래밍 (0) | 2024.04.10 |
게임 서버 프로그래밍 2장. 컴퓨터 네트워크 (0) | 2024.04.06 |