[Part 4 - 게임 서버] 멀티쓰레드 프로그래밍1(개론과 스레드 생성)

<멀티쓰레드 개론>

1. 멀티쓰레드의 기본 개념 

- 컴퓨터에서 실행되는 여러개의 프로그램(프로세스)가 실행됨, 하지만 CPU 코어는 단 하나의 프로세스만을 구동할 수 있음 

-> 프로세스 내에서 실행되는 흐름의 단위를 스레드(Thread)라고 함, 일반적으로 한 프로그램은 하나의 스레드를 가지고 있지만, 프로그램에 경우에 따라 둘 이상의 스레드를 동시에 가지기도 한다.

- CPU가 여러개의 프로그램의 스레드를 아주 짧은 시간 내에 번갈아가면서 실행시켜서 프로그램들이 동시에 사용되는 것처럼 느끼게 하는것이 멀티쓰레드(Multi Thread)이다.

ex) 그림장, 메모장 등의 프로그램을 구동시키면 사용자는 동시에 프로그램이 실행되는 것처럼 느껴지지만, 실제로 CPU는 프로세스 하나의 스레드를 번갈아 가면서 실행시킴.

 

2. 커널 모드와 스케쥴링 

- 운영체제가 작동하는 커널 모드와 일반적인 프로그램을 실행하는 사용자 모드로 나뉜다. 

 -> 커널모드에서 운영체제의 핵심적인 로직이 실행: 다음에 실행 시킬 프로세스를 정함 => 스케쥴링

- 스케쥴링에서 실행될 프로세스에 공평하게 시간을 나누기 보다는 우선순위에 따라 CPU가 구동되는 시간과 우선순위가 달라진다. 

 -> 하나에 프로그램에 우선순위 비중이 몰아가서 나머지 프로세스가 실행되지 않는 상태를 기아 상태라고 한다. 

 

3. 멀티 코어

: CPU 코어 하나만 기능을 높이는 것보다 여러개의 코어를 두어 성능을 높이는 방식 

 

4. 스레드 

- 프로세스마다 여러개의 스레드가 각자의 기능을 수행하는 경우가 있음 

- 모든 스레드는 각자의 스택영역을 가지고 있지만, Heap 영역과 데이터 영역은 공용으로 접근할 수 있음 

 -> 동시에 공용의 영역에 접근하는 것은 문제가 될 수 있다. 

 ex) 데이터 변경시에 하나의 프로세스가 데이터를 수정하다 멈춘 상태에서 다른 프로세스가 해당 데이터를 가져가려고 할때 업데이트된 데이터를 가져가지못하는 상황 발생 

 

=> 여러개의 스레드를 제대로 관리해야 효율이 올라간다. 

 

<쓰레드 생성>

실제로 코드에서 쓰레드를 생성해 보자!

 

1. 기존에 환경 설정에서 만들었던 Sever 솔루션에서  SeverCore 프로젝트를 시작 프로젝트로 설정한다. (솔루션 탐색기에서 SeverCore 우클릭 후 시작 프로그램 설정)

 

2. 다음과 같이 스레드를 작성할 수 있다.

: 유니티에서 제공하는 using System.Threading을 통해 다음과 같이 코드 작성

메인 함수에서 Thread 객체를 통해 MainThread 함수를 담고 있는 스레드를 생성하고 t.start 함수를 부르면 스레드와 메인 함수가 동시에 실행된다.

-> 하나에 프로그램에 메인 함수(메인 스레드라고 생각)와 내가 작성한 스레드가 생성됨 

Ctrl + F5를 통해 실행결과를 확인해보면 "Hello Thread"와 "Hello World"가 둘다 출력됨 

* 이때 순서는 알수 없음 t.start함수가 Console.WriteLine("Hello World!"); 보다 먼저 있어서 Hello Thread가 먼저 출력될 것 같지마는 운영체제 스케쥴링은 알 수 없다. 

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace SeverCore
{
    class Program
    {
        static void MainThread()
        {
            Console.WriteLine("Hello Thread");
        }
        static void Main(string[] args)
        {
            Thread t = new Thread(MainThread);
            t.Start();

            Console.WriteLine("Hello World!");
        }
    }
}

 

3. 다음과 같이 MainThread에서 While문으로 Hello Thread를 출력하게 하고 실행하면 Hello World가 계속 실행된다. 

static void MainThread()
        {
            while(true)
                Console.WriteLine("Hello Thread");
        }

 

4. 쓰레드는 기본적으로 Forground에서 실행된다. 

: 프로세스가 종료할때 forground 프로세스가 종료되야 프로세스 작업이 종료된다. 이때, background 프로세스가 구동되고 있어도 상관없이 종료된다. (background 프로세스와 상관없이)

아래의 코드를 보면 c#에서 제공하는 IsBackground를 통해 forground와 background 프로세스를 조절할 수 있다. 

다음 프로그램을 실행해보면 스레드가 백그라운드로 돌고 있기때문에 메인 프로세스가 종료되면 while 문에 관계없이 프로세스가 종료된다. 

static void Main(string[] args)
        {
            Thread t = new Thread(MainThread);
            t.IsBackground = true;
            t.Start();

            Console.WriteLine("Hello World!");
        }

5. Join을 통해 스레드가 끝날때까지 메인 스레드가 멈출 수 있다. 

다음과 같이 코드를 작성한 후 실행하면 t 스레드가 종료될때까지 메인 스레드는 종료되지않지만, t 스레드가 while문으로 무한 반복하기 때문에 프로그램이 종료되지 않는다. 

	static void Main(string[] args)
        {
            Thread t = new Thread(MainThread);
            t.IsBackground = true;
            t.Start();

            Console.WriteLine("Wating for Thread");
            t.Join(); // 해당 스레드가 종료될때까지 기다리겠다. 

            Console.WriteLine("Hello World!");
        }

6. ThreadPool

: 새롭게 스레드를 고용하는 것은 비용이 많이 든다. -> 간단하게(간단한 기능을 수행하는) 스레드를 사용하고 싶을 때 ThreadPool 사용

QueueUserWorkItem: 어떤 일을 할 것인지, SetMinThreads: 최소 스레드 개수 설정 등을 통해 스레드 설정할 수 있다. 

아래와 같이 코드를 작성한 후 실행하면 console창에 아무것도 안뜨고 바로 종료됨 

-> ThreadPool은 Background로 돌아가는 것을 확인할 수 있다. 

forground 프로세스(메인 스레드가) 종료되어 MainThread가 바로 종료됨

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace SeverCore
{
    class Program
    {
        static void MainThread(object state)
        {
            for(int i = 0; i < 5; i++)
                Console.WriteLine("Hello Thread!");
        }
        static void Main(string[] args)
        {
            ThreadPool.QueueUserWorkItem(MainThread);
        }
    }
}

아래와 같이 메인 스레드를 종료시키지 않기 위해 While 문을 작성하면 forground 프로세스가 종료되지 않아 MainThread의 함수가 실행되는 것을 확인할 수 있다. 

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace SeverCore
{
    class Program
    {
        static void MainThread(object state)
        {
            for(int i = 0; i < 5; i++)
                Console.WriteLine("Hello Thread!");
        }
        static void Main(string[] args)
        {
            ThreadPool.QueueUserWorkItem(MainThread);

            while (true)
            {

            }
        }
    }
}

 

- Thread를 아예 새롭게 만드는 것과 Thread Pool을 사용하는 것에 차이점 

   1) Thread를 새롭게 생성하는 경우에는 스레드에 새롭게 할당하는 개념, 이와 반대로 ThreadPool은 이미 대기 중인 스레드에게 해야할 일을 던져줌 -> 할일이 끝난 다음에는 다시 대기 상태에서 기다리는 것 

   2) 아래와 같이 Thread를 1000개 생성하여 실행시키면 돌아가긴 하지만, 스레드가 많아진다고 프로그램의 효율이 증가하는 것은 아님 

   -> CPU 코어의 개수가 8개 이면, 실제로 함께 동작하는 스레드의 개수가 최대 8개이기 때문에 스레드가 많아진다고 효율이 증가하는 것은 아님 

   -> 오히려 스레드가 번갈아가는 비용이 더 들 수 있다. 

	static void Main(string[] args)
        {
            //ThreadPool.QueueUserWorkItem(MainThread);

            for (int i = 0; i < 1000; i++)
            {
                Thread t = new Thread(MainThread);
                //t.IsBackground = true;
                t.Start();
            }
            
            while (true)
            {

            }
        }

   3) ThreadPool은 동시에 실행되는 수(쓰레드)를 제한해 놓음

       할일을 던져줄때 기존에 Thread를 다 수행한 후 실행한다. 

    -> 10개의 ThreadPool이 실행되고 있을 때, 새로운 작업을 수행하기 위해 새로운 스레드를 생성하는 것이 아닌 기존의 ThreadPool 중 작업이 완료된 스레드를 기다린 후에 해당 스레드가 실행된다. 

    -> ThreadPool에 할당된 작업이 완료되기까지 오래걸리면, 오히려 효율이 적어질 수 있다.

=> ThreadPool이 먹통이 되는 현상에 대한 코드는 아래와 같음 

ThreadPool.SetMinThreads와 ThreadPool.SetMaxThreads를 통해 최소와 최대 스레드의 개수를 정한 다음

QueueUserWorkItem을 통해 해야할 일을 준다. 이때, While 문을 통해 무한 반복 루프를 만든 후

QueueUserWorkItem을 통해 MainThread를 실행시키도록 할 때, Ctrl + F5 버튼을 누르면 콘솔창에 아무것도 적혀있지 않음 

스레드 5개가 모두 무한 반복 While문을 실행하고 있어 완료가 되지 않아 MainThread 함수를 실행할 스레드가 없다. 

if> for문에 4개에 스레드에만 무한 반복 While 문을 실행하도록 하면 MainThread 함수가 실행된다. 

	static void Main(string[] args)
        {
            ThreadPool.SetMinThreads(1, 1);
            ThreadPool.SetMaxThreads(5, 5); // 5개의 스레드가 일감이 있으면, 다른 요청이 오더라도 스레드 실행할 수 없음
            // 최소 스레드는 1개고 최대 5개까지 가능 

            for(int i = 0; i < 5; i++)
                ThreadPool.QueueUserWorkItem((obj) => { while (true) { } });

            ThreadPool.QueueUserWorkItem(MainThread);

            while (true)
            {

            }
        }

7. Task: ThreadPool의 단점을 극복한 

: 직원을 고용하기 보다는 직원이 해야할 일감 단위를 정해주겠다.

아래와 같이 코드를 작성하면, 위와 같이 5개의 스레드에 무한 반복 While문을 할당하여도 TaskCreationOptions.LongRunning을 통해 오래걸리는 Task로 새롭게 할당하여 실행하겠다(WorkThread에서 뽑아서 사용하는 것이 아닌 선언하면, MainThread의 함수가 실행됨 

* Thread: 정직원의 개념, ThreadPool: 일용직 노동자 개념

	static void Main(string[] args)
        {

            ThreadPool.SetMinThreads(1, 1);
            ThreadPool.SetMaxThreads(5, 5); // 5개의 스레드가 일감이 있으면, 다른 요청이 오더라도 스레드 실행할 수 없음
            // 최소 스레드는 1개고 최대 5개까지 가능 

            for (int i = 0; i < 5; i++)
            {
                Task t = new Task(() => { while (true) { } }, TaskCreationOptions.LongRunning);
                // 2번째 인자는 오래걸리는 Task라는 것을 암시 -> WorkThread에서 뽑아서 사용하는 것이 아닌 별도 처리를 한다. 
                t.Start();
            }

            ThreadPool.QueueUserWorkItem(MainThread);
            
            while (true)
            {

            }
        }

 

하지만, LongRunning이라고 하지않으면, WorkThread에서 뽑아서 실행되기 때문에 MainThread 함수가 실행되지 않는다. 

 

=> 실제 C#에서는 Thread를 사용하는 것보다 ThreadPool의 기능을 주로 사용한다.