본문 바로가기
게임개발/게임서버

멀티 쓰레드 프로그래밍

by do_ng 2024. 4. 17.

프로그램 

실행되기 전까지 HDD, SDD 같은 보조기억장치에 저장되어 있는 데이터 덩어리 

 

프로세스

프로그램이 메모리에 적재되어서 실행될 때 프로세스가 만들어짐

 

* 백그라운드 프로세스 

: 사용자와 상호작용하지 않는 프로세스로 데몬(유닉스 체계), 서비스(윈도우)가 있다. 

메모리에 상주하면서 특정요청이 오면 즉시 대응할 수 있도록 대기중인 프로세스이다. 

 

PCB(Process Control Block)

프로세스가 실행되기 위해서 CPU를 필요로 하는데 CPU 자원은 한정되어 있으므로 모든 프로세스가 CPU를 점유할 수  없다. 프로세스들은 차례대로 한정된 시간만큼 CPU를 점유하고 한정된 시간이 끝나면 CPU 점유권을 다른 프로세스에게 넘긴다. 이러한 방식을 운영체제는 PCB를 이용해서 빠르게 번갈아 수행하는 프로세스의 실행순서를 관리하고 프로세스에게 CPU의 점유권을 넘겨준다.

 

PCB는 커널 영역에 생성되며 각각의 프로세스 생성시에 만들어지고 프로세스가 종료(메모리에서 제거)되면 폐기된다.

* PCB에 담기는 대표 정보 

  • 프로세스 ID(PID)
    특정 프로세스를 식별하기 위해 부여하는 고유번호 
  • 레지스터 값 
  • 프로세스 상태
  • CPU 스케줄링 정보 
    프로세스가 언제 어떤 순서로 CPU를 할당 받을지에 대한 정보
  • 프로세스가 저장되어 있는 메모리 주소

 

문맥 교환(Context Switching)

 

프로세스 A가 운영체제로부터 CPU를 할당받아 실행되다가 CPU 점유 시간이 다 되어서 프로세스 B에 CPU 사용을 양보하는 경우라고 가정해 보자. 

프로세스 A는 지금까지 실행된 각종 정보들을 백업해야지 다음에 다시 실행될때 이전까지 실행했던 내용에 이어 다시 실행을 재개할 수 있다.

다음에 수행을 재개하기 위해 기억해야 할 정보를 문맥(Context)이라고 한다. 

각각의 프로세스 문맥은 해당 프로세스의 PCB에 기록되어 있으며, 프로스세가 CPU를 사용할 수 있는 시간이 다 되거나 예기치 못한 상황이 발생하여 인터럽트가 발생하면 운영체제는 해당 프로세스의 PCB에 문맥을 백업한다. 

 

기존 프로세스의 문맥을 PCB에 백업하고, 새로운 프로세스를 실행하기 위해서 문맥을 PCB로부터 복구하여 새로운 프로세스가 CPU를 점유해 실행되는 것을 문맥교환(Context Switching)이라고 한다.

 

문맥 교환이 자주 일어나면 프로세스는 그만큼 빨리 번갈아 가며 수행되기 때문에 프로세스들이 동시에 실행되는 것처럼 보인다. 그러나 문맥 교환을 너무 자주 하면 오버헤드가 발생할 수 있기 때문에 주의해야 한다.

ex) 10분 공부하고 10분 운동한다고 했을 때 10분 공부가 끝나고 운동하기 위해 준비하는 시간이 10분일 때 

공부시간을 70분 채워야 한다고 하면 공부하고 운동하기 위해 전환하는 시간이 60분이나 잡아먹어 버리게 된다. 

이럴 경우에 차라리 70분 공부하고 10분 운동하러 나가는 방법이 이전 상황보다 문맥 교환이 적기 때문에 시간 관리 측면에서 더 효율적이다.

 

프로세스의 메모리 영역

1. 코드 영역(텍스트 영역)

데이터가 아닌 CPU가 실행할 명령어가 담겨 있기 때문에 쓰기가 금지되어 있음 

 

2. 데이터 영역 

프로그램이 실행되는 동안 유지할 데이터가 저장되는 공간 

ex) 전역 변수, static 변수

 

3. 힙 영역 

메모리 공간을 직접 할당하고 해제할 수 있는 공간

 

4. 스택 영역

데이터를 일시적으로 저장하는 공간

ex) 매개변수, 지역변수

 

힙 영역과 스택 영역은 실시간으로 크기가 변할 수 있기 때문에 동적 할당 영역이라고 부른다.

일반적으로 힙 영역은 메모리의 낮은 주소에서 높은 주소로 할당되고, 스택 영역은 높은 주소에서 낮은 주소로 할당된다. 

그래야만 힙 영역과 스택 영역에 데이터가 쌓여도 새롭게 할당되는 주소가 겹칠 일이 없기 때문이다.

 

 

스레드

하나의 프로세스는 여러 개의 스레드를 가질 수 있다.

스레드를 이용하면 하나의 프로세스에서 여러 부분을 동시에 실행할 수 있다. 

즉, 프로세스를 구성하는 여러 명령어를 동시에 실행할 수 있다. 

 

실행의 흐름 단위가 하나면 해당 프로세스는 싱글 스레드 프로세스라고 볼 수 있다. 

실행의 흐름 단위가 2개 이상이면 멀티 스레드 프로세스라고 볼 수 있다.

프로세스 내에서 스레드들은 각각 다른 ID, 프로그램 카운터, 레지스터 값을 가지고 있기 때문에 각자 다른 코드를 실행할 수 있다. 

 

 Q) 동일한 작업을 수행하는 단일 스레드 프로세스 여러 개를 실행하는 것과 하나의 프로세스를 멀티 스레드로 실행하는 것은 어떤 차이가 있을까?

 

 

단일 스레드 프로세스로 여러 개 실행하면 각각의 프로세스는 서로 다른 메모리 공간에 저장되어 있고 하나의 프로세스를 멀티 스레드로 실행하는 경우는 동일한 메모리 공간에서 여러 개의 스레드들이 코드, 데이터, 힙 영역과 같은 자원을 공유하기 때문에 여러 개의 프로세스를 실행하는 것보다 메모리를 더 효율적으로 사용할 수 있다.

 

그러나 각각의 스레드들이 프로세스의 자원을 공유하기 때문에 어떤 스레드에서 그 공유자원을 오염시켜 버리거나 그 공유자원을 사용 중이던 스레드에서 문제가 발생하는 경우 다른 스레드도 영향을 받기 때문에 주의해서 사용해야 된다.

 

멀티 스레드 실습 예시

1. 스레드를 직접 만들기

 

피파라는 프로그램을 실행했을 때 프로세스에 2개의 스레드가 각각의 명령어를 실행시킨다고 가정해보자.

만약에 해당 스레드가 담당하고 있는 작업이 끝나고 사용되지 않는 경우라면 스레드 자원이 낭비되기 때문에 스레드풀을 만들어서 관리하는 방식이 주로 사용된다.

class Program
{
    static void ThreadA(object obj)
    {
        for (int i = 0; i < 5; i++)
            Console.WriteLine("상대방과 통신 연결중..");
    }

    static void Main(string[] args)
    {
        Thread threadA = new Thread(ThreadA);
        threadA.Start();

        for (int i = 0; i < 5; i++)            
            Console.WriteLine("플레이어 이동중..");                       
    }
}

 

 

2. 스레드 풀 방식

 

스레드 풀은 작업이 끝난 스레드가 있으면 풀로 반환해서 다른 작업에 스레드를 재사용하는 방식으로 사용이 가능하지만 스레드 풀에 있는 모든 스레드가 작업을 담당하고 있는데 새로운 작업을 처리해야 하는 경우 스레드가 없기 때문에 처리가 불가능한 문제가 발생한다. 이를 Task라는 것을 사용해서 처리가 가능하다.

class Program
{
    static void ThreadA(object obj)
    {
        while(true) Console.WriteLine("상대방과 통신 연결중..");
    }

    static void Main(string[] args)
    {
        ThreadPool.SetMinThreads(1, 1);
        ThreadPool.SetMaxThreads(5, 5);            
        ThreadPool.QueueUserWorkItem((obj) => { while (true) { Console.WriteLine("플레이어 이동중"); } });
        ThreadPool.QueueUserWorkItem((obj) => { while (true) { Console.WriteLine("플레이어 쉬는중"); } });
        ThreadPool.QueueUserWorkItem((obj) => { while (true) { Console.WriteLine("플레이어 공격중"); } });
        ThreadPool.QueueUserWorkItem((obj) => { while (true) { Console.WriteLine("플레이어 아이템 창 관리중"); } });        
        ThreadPool.QueueUserWorkItem((obj) => { while (true) { Console.WriteLine("플레이어 상점 서핑중"); } });

        while(true)
        {

        }
	}
}

 

 

3. Task를 이용한 멀티 쓰레드

 

해당 스레드가 담당하고 있는 작업이 너무 오래 걸릴 것 같다고 하면 "TaskCreationOptions.LongRunning" 키워드를 붙여서 그 스레드는 작업이 오래 걸리니 만약에 스레드 풀에서 스레드를 꺼내야 되는데 스레드가 없으면 쓰레드를 하나 더 생성해서 사용하는 방식으로 ThreadPool의 단점을 커버할 수 있다.

class Program
{
    static void ThreadA(object obj)
    {
        while(true) Console.WriteLine("상대방과 통신 연결중..");
    }

    static void Main(string[] args)
    {
        ThreadPool.SetMinThreads(1, 1);
        ThreadPool.SetMaxThreads(2, 2);            
        for (int i = 0; i < 2; i++)
        {
            Task task = new Task(() => { while (true) { Console.WriteLine("Task" + i); } }, TaskCreationOptions.LongRunning);                
            task.Start();
        }                     

        ThreadPool.QueueUserWorkItem(ThreadA);

        while(true)
        {

        }
	}
}

 

 

Q) 스레드를 무조건 많이 만드는 게 작업 처리가 빨라지는가?

CPU의 코어에 따라서 실행될 수 있는 스레드 수가 제한되기 때문에 무조건 좋다고 말할 수 없음

2 코어 16 스레드 CPU와 4 코어 8 스레드 CPU를 비교했을 때 각각의 프로세스에서 작업해야 될 명령어들이 많으면 스레드가 많을수록 그리고 코어수도 그만큼 많아야지 좋음

예를 들어서 A라는 프로세스에서 작업해야될 명령어가 10가지인데 2 코어 16 스레드인 경우는 최대 2개의 스레드가 CPU를 점유할 수 있고 4 코어 8 스레드인 경우는 최대 4개의 스레드가 CPU를 점유해서 작업을 할 수 있기 때문에 후자가 더 빠른 처리를 할 수 있다.

 

 


 

캐시 메모리

CPU가 메모리에 접근하는 시간은 CPU 연산 속도보다 느리기 때문에 CPU가 연산을 빨리 한다 해도 메모리에 접근하는 시간이 느리면 CPU의 빠른 연산 속도는 큰 효율을 내지 못한다. 

이를 극복하기 위한 저장장치가 "캐시 메모리" 이다.

 

저장 장치 계층 구조(Memory Hierarchy)

 

CPU에 얼마나 가까운가를 기준으로 레지스터가 CPU와 가장 가깝기 때문에 속도가 빠르다.

 

CPU가 프로그램을 실행하는 과정에서 메모리에 빈번하게 접근하는데 속도가 느리기 때문에 메모리에 접근하는 속도보다 더 빠른 "캐시 메모리" 라는 SRAM 기반의 저장장치를 두었다.

CPU가 매번 메모리에 왔다 갔다 하는 건 시간이 오래 걸리니까 메모리에서 CPU가 사용할 일부 데이터를 캐시 메모리로 가져와서 사용하는 것이다.

 

여러 개의 캐시 메모리가 있는데 CPU 내부에 있는 캐시 메모리와 CPU 외부에 있는 캐시 메모리로 나뉜다.

 

 

참조 지역성 원리

캐시 메모리는 메모리보다 용량이 작기 때문에 메모리에 있는 모든 데이터를 가져다 저장할 수 없다.

보조기억장치는 전원이 꺼져도 데이터를 저장하고 있고 메모리는 전원이 켜져 있는 동안 실행 중인 대상을 저장하고 캐시 메모리는 전원이 커져 있는 동안 CPU가 사용할 법한 대상을 예측하여 저장한다. 

 

자주 사용될 것으로 예측한 데이터가 실제로 들어맞아 캐시 메모리 내 데이터가 CPU에서 활용될 경우를 "캐시 히트"라고 한다. 

자주 사용될 것으로 예측하여 캐시 메모리에 저장했지만, 예측이 틀려 메모리에서 필요한 데이터를 직접 가져와야 하는 경우를 "캐시 미스"라고 한다. 

캐시가 히트되는 비율을 "캐시 적중률"이라고 한다. 

 

캐시 메모리의 이점을 활용하기 위해서 CPU가 사용할 법한 데이터를 제대로 예측해서 캐시 적중률을 높여야 하는데 CPU가 사용할 법한 데이터를 어떻게 알 수 있을까?

 

1. CPU는 최근에 접근했던 메모리 공간에 다시 접근하려는 경향이 있다.(시간 지역성)

ex) LOL이라는 프로그램이 실행되고 있을때 내가 선택한 캐릭터는 게임이 끝날때까지 계속 접근하기 때문에 그 캐릭터와 관련된 변수(Hp, Mp..등)들을 캐시 메모리에 저장한다.

 

2. CPU는 접근한 메모리 공간 근처를 접근하려는 경향이 있다.(공간 지역성)

 

캐시 메모리는 연속적인 주소에 값이 할당될거라고 생각해서 값을 할당한 공간 근처의 주소를 가져왔지만 배열의 연속성을 무시하고 띄엄띄엄 값이 저장되면 연속적으로 값을 저장하는 것보다 속도가 느리게 나옴

class Program
{
    static void Main(string[] args)
    {
        int[,] data = new int[10000, 10000];

        {
            long now = DateTime.Now.Ticks;
            for(int i = 0; i < 10000; i++)
            {
                for (int j = 0; j < 10000; j++)
                {
                    data[i, j] = 1;
                }
            }
            long end = DateTime.Now.Ticks;
            Console.WriteLine($"(i, j) 순서 걸린 시간 { end - now}");
        }

        {
            long now = DateTime.Now.Ticks;
            for (int i = 0; i < 10000; i++)
            {
                for (int j = 0; j < 10000; j++)
                {
                    data[j, i] = 1;
                }                
            }
            long end = DateTime.Now.Ticks;
            Console.WriteLine($"(j, i) 순서 걸린 시간 { end - now}");
        }
    }
}

 

 

'게임개발 > 게임서버' 카테고리의 다른 글

멀티스레드 InterLocked  (1) 2024.04.18
메모리 배리어  (0) 2024.04.18
멀티쓰레드 Lock 구현 방식  (2) 2024.01.15
소켓 프로그래밍  (0) 2023.10.29
TLS(Thread Local Storage)  (0) 2023.10.28