포스트

멀티쓰레드(Multi-thread)

서버를 만들기 위해서는 멀티쓰레드의 개념을 알아야 합니다!

먼저, 다들 컴퓨터에는 CPU가 있다는 것 알고 계실텐데요, CPU는 몇 개의 코어 프로세서로 이루어져 있습니다. 요새 가장 최신의 하이엔드 일반소비자용 AMD CPU는 7900x인데요, 12개의 코어로 이루어져 있습니다.

쓰레드는 여기 있는 코어가 빠르게 움직여서 마치 여러개 있는 것처럼 동작할 때 동시에 처리할 수 있는 일거리의 개수입니다. 예를들어 1개의 코어가 2가지의 일을 동시에 한다면 2개의 쓰레드를 가지는 멀티쓰레드인 셈이죠! 그러나 헷갈릴 수 있는 점! 여기서 쓰레드라는 용어는 물리적 쓰레드/ 논리적 쓰레드가 엄연히 다릅니다. 위에서 본 7900x의 24쓰레드는 물리적 쓰레드입니다. **이번 포스팅과 관련이 있는 것은 물리적 쓰레드가 아닌 논리적 쓰레드입니다. ** 다음 표를 보시면 어떠한 차이가 있는지 이해가 되실 것 같습니다.

물리적 쓰레드하나의 물리적 코어가 허용할 수 있는 쓰레드 개수. 운영체제가 스케쥴링을 할 때 동시에 실행 가능한 쓰레드 수.
논리적 쓰레드프로세스 내에서 실행되는 세부 작업의 단위 개수. 메모리가 허용하는 한도 내에서 얼마든지 커질 수 있음.

https://the-boxer.tistory.com/24 논리적 쓰레드는 물리적 쓰레드와는 달리 소프트웨어적으로 얼마든지 할당이 가능하지만, 실제로는 물리적 쓰레드가 감당할 수 없을 만큼의 논리적 쓰레드를 생성할 시, 운영체제에 의해 실행되지 않는 논리적 쓰레드는 잠들어 있다가 운영체제가 물리적 쓰레드의 할당을 해주면 잠들어있던 논리적 쓰레드가 실행될 수 있습니다. 도서관에서 빌리는 책에 비유하자면 12코어 24쓰레드 CPU는 상/하권으로 분권된 책이 12세트(24권) 있는 것과 같습니다. 이를 읽고 싶은 독자(논리적 쓰레드)는 굉장히 많지만 동시에 책을 빌릴 수 있는 사람은 24명이 한계입니다. 나머지는 책이 반납될 때까지 기다려야 합니다.


멀티쓰레드 프로그래밍은 왜 사용하면 좋을까요?

멀티쓰레드 프로그래밍은 하나의 프로세스를 다수의 실행 단위로 구분하여 자원을 공유하고, 자원의 생성과 관리의 중복성을 최소화하여 수행 능력을 향상시키기 위해서 사용합니다. 여러개의 일을 하기 위해서 단일쓰레드를 가지는 멀티프로세스로 실행하는 것보다 멀티쓰레드를 가지는 단일프로세스로 실행하는게 더 유리하다는 의미입니다. 프로세스를 이용해서 동시에 처리하던 일을 쓰레드로 구현할 경우 해당 쓰레드만을 위한 독자적인 스택공간(실행 흐름조건을 위한 최소한의 공간)을 제외하고 많은 공간을 차지하는 영역들(Code, Data, Heap)을 부모 프로세스의 것으로 공유하기 때문에 메모리 공간과 시스템 자원의 소모가 줄어들게 됩니다. 그 외에도 멀티쓰레드 시스템은 단일쓰레드&멀티프로세스시스템과는 달리 컨텍스트 스위칭Context Switching을 할 때 캐시 메모리를 비울 필요가 없기 때문에 더 빠르다는 장점이 있습니다. 물론 단점도 있습니다. 데이터와 힙 영역을 공유하기 때문에 서로 다른 쓰레드가 사용중인 변수를 의도치 않게 불러와서 수정하지 않도록 신경써주어야 하고, 이를 위해서 작업 처리 순서를 관리하고 동기화 해주는 과정에서 줄을 세우다보니 병목 현상이 발생하지 않도록 신경써주어야 합니다. 그리고 이후 우리가 배워볼 락lock도 과도하게 사용하면 심각한 성능저하(병목 현상)을 불러올 수 있으니 신경써주어야 합니다.

https://goodgid.github.io/What-is-Multi-Thread/

자, 그러면 이제 C#에서 멀티쓰레드(논리적 멀티쓰레드) 프로그래밍을 구현해봅시다.


'Visual Studio 2022'를 열어서 다음과 같이 새 프로젝트를 만들어줍시다. '최상위 문 사용 안 함'에는 체크를 해주셔야 합니다. 최상위문은 최신 visual studio버전에서 추가된 기능인데, 이 포스팅에서는 필요가 없습니다.

총 3가지 방법으로 쓰레드를 구현해볼 수 있습니다. 그 전에 먼저, 이번에 쓰레드로 실행해볼 테스트함수를 만들어봅시다. 간단하게 while문으로 “Hello Thread!”를 반복하는 함수입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
using System;
using System.Threading;

namespace ServerCore
{
    internal class Program
    {
        static void MainThread(object state)
        {
            while (true)
                Console.WriteLine("Hello Thread!");
        }
        static void Main(string[] args)
        {
        }
    }
}
  • Thread()
  • ThreadPool()
  • Task()

Thread

먼저 Main 실행문 안에다가 Thread를 정의해줍시다. 쓰레드의 이름은 “Test Thread”이고, foreground에서 실행되도록 하였기 때문에 만약 쓰레드가 실행되는 도중에 Main실행문이 종료가 되어버려도 쓰레드는 종료가 되지 않습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
using System;
using System.Threading;

namespace ServerCore
{
    internal class Program
    {
        static void MainThread(object state)
        {
            while (true)
                Console.WriteLine("Hello Thread!");
        }

        static void Main(string[] args)
        {
            Thread t = new Thread(MainThread);
            t.Name = "Test Thread";
            t.IsBackground = false;
            t.Start();
            Console.WriteLine("Main is Done! Stop background!");

        }
    }
}

이제 F5를 눌러 실행해봅시다.

Main 함수가 끝났는데도 thread는 종료가 되지 않고 계속 실행 중인 모습을 볼 수 있습니다. 만약 IsBackground 를 true로 하고 다시 실행해본다면 어떻게 될까요?

메인이 종료될 때 background도 종료가 되므로, background에서 실행되는 thread도 종료되었습니다. 그런데 잘 보시면, 쓰레드보다 나중에 실행되는 “Main is Done! Stop background!” 출력문이 더 일찍 출력된 것을 보실 수 있습니다. 실제로 background가 종료되는 시점이 “Main is Done! Stop background!”를 출력하는 시점보다 좀더 이후라서 그런 것 같습니다. 그래서 만약 쓰레드가 다 실행되고 난 후에 “Main is Done! Stop background!”를 출력하고 싶다면, Join()함수를 사용해주면 됩니다. Join()함수는 쓰레드가 다 종료될 때까지 기다려줍니다. 다음과 같이 코드를 바꿔줍시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
        static void MainThread(object state)
        {
            for (int i &#x3D; 0; i < 5; i++)
                Console.WriteLine("Hello Thread!");
        }

        static void Main(string[] args)
        {
            Thread t &#x3D; new Thread(MainThread);
            t.Name &#x3D; "Test Thread";
            t.IsBackground &#x3D; true;
            t.Start();
            Console.WriteLine("Waiting for Thread!");
            t.Join();
            Console.WriteLine("Main is Done! Stop background!");

        }

Join함수가 쓰레드가 다 실행될 때까지 기다려주었습니다.


ThreadPool

쓰레드풀은 C# 닷넷 프레임워크에서 자체적으로 제공하는 더 편리한 쓰레드 메소드입니다. 위의 예제에서 생성했던 쓰레드는 1개였지만, 만일 생성하려는 쓰레드가 굉장히 많아진다면 위의 Thread()메소드로는 계속 new Thread()로 생성을 해주어야 했기 때문에 부하가 심해질 수 있습니다. 그래서 오히려 어느 순간부터는 쓰레드를 적게 사용할 때보다 성능이 저하될 수 있습니다.

그래서 이러한 관리를 쉽게 하기 위해서 ThreadPool() 메소드에는 Queue를 사용하는 기능이 있습니다. 동시에 할 수 있는 최대 쓰레드 개수만큼을 제한해두고, 이를 넘는다면 기존 쓰레드가 종료될 때까지 기다렸다가 다음 쓰레드를 시작시켜줍니다. 그런데 ThreadPool은 background에서 실행되므로, 메인에 while문을 넣어서, 종료되지 않도록 해줍시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
using System;
using System.Threading;

namespace ServerCore
{
    internal class Program
    {
        static void MainThread(object state)
        {
            for (int i &#x3D; 0; i < 4; i++)
                Console.WriteLine("Hello Thread!");
            Console.WriteLine("This Thread is completed!");
        }

        static void Main(string[] args)
        {
            ThreadPool.SetMinThreads(1, 1);
            ThreadPool.SetMaxThreads(5, 5);

            for (int i &#x3D; 0; i < 5; i++)
                ThreadPool.QueueUserWorkItem(MainThread);

            while (true)
            {
            }
        }
    }
}

여기서 SetMinThread와 SetMaxThread에 들어가는 값은 각각 ThreadPool (인력사무소?)에서 운용할 수 있는 workerThreads (일꾼)의 최소, 최대 수입니다. 위의 예시에서는 총 5개의 workerThreads를 두었는데, 사실 위와 같은 단순한 작업에 대해서는 쓰레드가 한개만 있어도 괜찮습니다. 그렇다면 이 workerThreads의 수가 중요하게 되는 때는 언제일까요? 극단적인 예를 들어서 총 6개의 작업 중 5개의 작업이 절대 끝나지 않는 작업이라고 해봅시다. 그러면 마지막 1개의 쉬운 작업에는 workerThread가 투입될 수가 없게 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
using System;
using System.Threading;

namespace ServerCore
{
    internal class Program
    {
        static void MainThread(object state)
        {
            for (int i &#x3D; 0; i  { while (true) { } });

            ThreadPool.QueueUserWorkItem(MainThread); //쉬운 작업

            while (true)
            {
            }
        }
    }
}

6번째 작업은 실행할 수가 없습니다. 그러면 MaxThread의 workerThreads수를 1개 늘려봅시다.

1
ThreadPool.SetMaxThreads(6, 6);

정상적으로 마지막 6번째 작업도 완료되었습니다.


Task

Task()메소드는 위의 Thread() 와 ThreadPool()의 양쪽 장점만을 결합한 메소드입니다.(혹시 다른 이견이 있다면 댓글로 알려주세요.) 왜냐하면 Task()메소드는 사용법은 Thread()와 비슷하여서 new 생성자로 생성해주고, Start()로 간편하게 시작을 해줄 수 있습니다. 그리고 내부 동작 원리는 ThreadPool의 기능을 가지고 있어서 자동으로 Queue 관리를 해주지만, 만약 하려는 작업이 굉장이 무겁고 오래 걸리는 작업이라서 안끝날 것 같다면, 'TaskCreationOptions.LongRunning'옵션을 사용하는 방법이 있습니다. 'TaskCreationOptions.LongRunning'옵션을 사용한다면 ThreadPool에서 일꾼을 가져와 쓰는게 아니라 별도의 일꾼을 따로 고용해서(new) 해당 작업에 할당해줍니다. 그래서 기존과 같이 5명의 Max workerThread를 두었을 때** 'TaskCreationOptions.LongRunning'옵션**이 없다면,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
using System;
using System.Threading;

namespace ServerCore
{
    internal class Program
    {
        static void MainThread(object state)
        {
            for (int i &#x3D; 0; i  { while (true) { } });
                t.Start();
            }

            ThreadPool.QueueUserWorkItem(MainThread); // 일꾼이 부족하여 실행되지 않음.

            while (true)
            {
            }
        }
    }
}

다음과 같이 마지막 Thread는 실행할 수 없습니다.

반면, 'TaskCreationOptions.LongRunning'옵션을 사용한다면,

1
Task t &#x3D; new Task(() &#x3D;> { while (true) { } }, TaskCreationOptions.LongRunning );

다음과 같이 마지막 Thread도 실행되었습니다.


여기까지 C#에서 Thread 기능을 사용하기 위해 사용할 수 있는 세 가지 메소드(Thread, ThreadPool, Task)에 대해 알아보았습니다. 감사합니다.


본 포스팅은 인프런의 '[C#과 유니티로 만드는 MMORPG 게임 개발 시리즈] Part4: 게임 서버' 4강을 듣고 개인적으로 정리하기 위해 작성하였습니다. 감사합니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.