네트워크/게임 서버 프로그래밍

[게임 서버][1장]멀티 스레딩

우향우@ 2023. 3. 21. 22:16

개요

멀티 스레딩은 한 프로세스에서 여러 스레드를 활용하는 것을 의미한다. 서버 프로그래밍에서는 한 서버가 여러 클라이언트를 상대하야하므로 멀티 스레딩이 필수적이다.

프로세스와 스레드

한 프로세스는 여러 스레드를 가진다. 이때 프로세스의 힙은 각 스레드에 공유되며 각 스레드는 각자의 호출 스택을 가지게 된다.

보통 스레드는 한 스레드에서 특정 함수 포인터를 매개변수로 넘겨주며 스레드 생성 함수를 호출하여 생성한다. 그러면 해당 함수를 실행하는 스레드가 생성된다.

생성된 스레드들은 실행과 대기를 반복하다 수행이 끝나면 소멸된다.

실행중인 스레드들은 각자 코어를 하나씩 차치하여 실행되며 코어가 더 적다면 문맥 교환을 하며 번갈아 실행된다. 이때 문맥 교환은 가벼운 연산이 아니므로 스레드가 너무 많다면 이에 의한 성능 저하가 발생할 수 있다.

임계영역

임계영역은 여러 스레드가 동시에 처리하면 안되는 영역을 의미한다. 대부분 특정 공유 자원에 접근하는 경우가 해당되며 임계영역을 정의할때도 특정 자원 별로 정의하는 것이 대다수다. 스레드들은 전역 변수나 힙 데이터에는 다들 공유하여 접근 할 수 있기 때문에 해당 데이터에 접근 할때는 임계영역 처리가 필요하다.

만약 특정 자원에 여러 스레드가 동시에 읽거나 쓰는 구문이 있다면 반드시 임계영역 처리를 해주어야한다. 프로그래밍 언어의 명령어들로 봤을때는 문제가 없어 보인다 하더라도 반드시 임계영역을 처리해야하는데 프로그래밍 언어의 명령어들은 컴파일 시 여러개의 어셈블리 명령어로 분리되며 임계처리를 하지않으면 이 명령어들이 묶여서 원자성이 보장되지 않기때문이다. 따라서 단순 읽기 쓰기에도 문제가 발생할 수 있기에 임계영역 처리를 해야한다. 당연하지만 프로그래밍 언어 명령어 수준에서도 원자성이 필요하다면 그 영역 전체를 임계영역으로 묶어야 할 것이다.

임계영역 설정 방법

보통의 프로그래밍 언어에서는 임계영역을 다음과 같이 설정한다.

1. 임계영역 객체를 각 스레드가 공유할 수 있는 위치엥 생성한다.

2. 스레드 안에서 임계영역 시작전에 해당 객체로 lock을 건다.

3. 임계영역을 처리한다.

4. 해당 객체의 unlock을 호출한다.

특정 임계영역 객체의 lock함수는 만약 어디선가 해당 객체로 lock을 걸었다면 대기하다가 unlock호출해주면 한 스레드만 다시 lock 걸며 자신은 임계영역으로 들어가도록하는 기능을 가진다. 따라서 위 처리를 이용하면 해당 객체로 잠긴 영역은 동시에 한 스레드만 접근 할 수 있게된다.

여기서 보통 특정 자원마다 임계영역 객체를 만들고 해당 자원을 접근하는 부분을 그 임계영역 객체로 잠그는 방식을 개념적으로 사용한다.

임계영역 성능 관리

한 임계영역 객체가 여러 자원을 동시에 담당하는 개념으로 사용 할 수도 있는데 한 임계영역 객체가 얼마나 많은 자원을 담당할지 조절하는 것은 프로그래머의 몫이다. 여기서 자원별로 임계영역을 너무 잘게 나눈다면 설계의 난이도 상승 및 임계영역 처리 오버헤드 증가라는 단점이 발생한다. 반대로 너무 크게 나눈다면 각 임계영역의 범위가 너무 커져 프로그램의 병렬성이 줄어든다는 단점이 발생한다. 따라서 적절한 중용이 필요하다.

또한 임계영역안에서는 cpu대기가 발생하는 io들은 최대한 피해야한다. 마찬가지로 큰 성능이 들어가는 부분은 최대한 임계영역에 포함되지 않도록 해야 병렬 성능이 늘어난다.

교착 상태

멀티 스레딩 프로그래밍에서 가장 많이 발생하는 문제중 하나가 교착상태이다. 교착상태는 서로 다른 스레드가 서로 상대가 가진 자원을 동시에 기다리게 되어 영원히 기다리는 현상을 말한다. 한 스레드에서는 a자원을 가진채로 b자원을 기다리고 다른 스레드는 b자원을 가진채로 a자원을 기다리면 발생하는 현상이다.

교착 상태는 한 자원의 임계영역이 다른 자원의 임계영역을 둘러쌀때 발생가능성이 생긴다. 따라서 임계영역을 둘러싸는 로직을 만들때는 순서 규칙이라는 것을 따라야한다.

순서 규칙은 특정 자원 (또는 임계영역 객체)에 대해 겹겹히 잠금을 걸때 미리 정해둔 한 순서로만 잠금을 시작해야한다는 규칙이다. 예를 들엉 a,b,c 세 자원이 서로 겹쳐 잠겨질때 a->b->c라는 순서를 정해두고 해당 순서를 지키면서 겹쳐 잠겨져야한다는 것이다. 이때 순서는 각 자원에 대한 첫 잠금이 어떤 순서로 발생했는지를 말하며 순서만 지킨다면 다양한 조합 모두 가능하다. 즉 abc, ab, bc , ac 등 모두 가능하다.

이벤트

이벤트는 각 스레드간에 동기화를 도와주는 도구이다. 보통 다음과 같이 사용한다.

  1. 공유 공간에 이벤트 객체를 만든다.
  2. 한 스레드에서 해당 이벤트 객체로 wait를 한다. 해당 스레드는 대기 상태가 된다.
  3. 다른 스레드에서 해당 이벤트 객체에 setEvent(1)을 하면 wait중인 스레드가 깨어나게 된다.

이벤트에는 상태라는 것이 존재하는데 1은 wait가 대기하지않고 지나가는 것을 의미하며 0은 대기 하는 것을 의미한다.

이때 이벤트는 자동 이벤트와 수동 이벤트가 있는데 상태가 1일때 wait가 호출되면 자신은 지나가고 바로 상태를 0으로 다시 만든다. 수동 이벤트는 상태가 1일때 그냥 지나갑니다. 만약 여러 스레드가 자고 있을때 자동 이벤트가 setEvent(1)을 호출하면 한명만 깨어나고 상태가 0이 되지만 수동 이벤트면 상태가 1이 되며 모두가 깨어난다.

세마 포어

세마 포어는 이벤트와 비슷하지만 상태가 0,1이 아닌 0이상의 정수를 가진다. 그리고 보통 자동 이벤트와 동일한 로직을 사용합니다. 이는 다음 두가지와 같이 활용할 수 있다.

1) n개의 스레드가 접근 가능한 임계영역

1.한 세마포어를 기본값을 n으로 하여 공유 영역에 생성한다.

2. 각 스레드는 임계영역 시작에 해당 세마포어 wait를 끝날때 release를 호출한다.

3. 그러면 임계영역에 들어갈때 세마포어를 1줄이고 들어가고 release에서 1상승시킨다.

4. 이때 진입시 세마포어 값이 0이면 대기하고 다른 스레드가 반납하여 1이 상승되면 한 스레드만 통과한다.

즉 n개의 스레드가 동시 접근 가능한 임계영역이 된다.

2) 접근 가능 횟수 통제

한 스레드에서는 큐에 데이터를 push하고 다른 스레드에서는 들어온 데이터 별로 한번씩 pop을 호출한다고 해보자. 다음과 같이 구현한다.

넣는 스레드

while(true)

큐에 데이터를 넣고 세마포어를 1 올린다.

꺼내는 스레드

while(true)

세마포어 wait를 하다가 큐에서 데이터를 꺼낸다.

이러면 큐에 들어간 횟수만큼 세마포어가 오르며 큐에서 꺼낼때마다 세마포어가 줍니다. 그러다 세마포어가 0이면 꺼내기를 멈춘다.

원자 조작

각 운영체제는 프로그래밍 언어에 원자성이 보장되는 원자 조작 연산자를 제공한다. 라이브러리나 함수로 제공되는 임계영역 객체등은 이 연산자들을 이용하여 구현된다.

보통 원자 조작 연산자는 다음과 같은 것들이 있다.

  1. 원자 조작 가능 변수 선언 (volatile int a = 0)

2. 원자 조작 가능 변수에 특정 값 더하기

3. 원자 조작 가능 변수에 값 맞 바꾸기

4. 원자 조작 가능 변수에 조건부 값 맞 바꾸기

해당 연산자들은 일반 연산자들보다 성능을 많이 먹으므로 오버헤드를 감수해야한다.

참고서적

게임 서버 프로그래밍 교과서