<유니티> 멀티쓰레드

상태
시작 전
담당자
날짜
숫자
0
유니티에서 대부분의 로직은 메인 스레드에서 실행됩니다. 그러나 복잡한 게임이나 고성능을 요구하는 애플리케이션에서는 여러 작업을 동시에 처리해야 할 필요가 있습니다. 여기서 등장하는 개념이 바로 멀티쓰레딩입니다.
쓰레드를 적절히 사용하면 CPU의 여러 코어를 활용해 성능을 극대화할 수 있습니다. 이번 글에서는 쓰레드와 코어의 개념, 다루는 방법, 사용 사례, 그리고 사용 시 주의할 점에 대해 살펴보겠습니다.

CPU 코어와 쓰레드의 개념

먼저 쓰레드 사용 방법에 대해 배우기 전에 코어와 쓰레드의 개념에 대해 알아보겠습니다. 멀티쓰레드 프로그래밍을 이해하려면 쓰레드와 CPU 코어의 관계를 깊이 이해하는 것이 중요하기 때문이죠.
현대 컴퓨터는 다수의 코어를 가진 멀티코어 CPU를 탑재하고 있으며, 이 코어들은 여러 쓰레드를 병렬로 실행할 수 있습니다.
코어(CPU Core) : 코어는 CPU 내부에 있는 물리적인 처리 장치로, 컴퓨터가 명령을 처리하는 기본 단위입니다. 각 코어는 독립적으로 작동하며, 하나의 코어는 동시에 하나의 작업(쓰레드)을 실행할 수 있습니다. 단일 코어는 하나의 작업만 처리할 수 있지만, 멀티코어 CPU는 여러 작업을 동시에 병렬로 처리할 수 있습니다.
쓰레드(Thread) : 쓰레드는 프로그램 내에서 실행되는 작은 작업 단위로, 프로세스가 실행할 수 있는 하나의 명령 집합입니다. 하나의 프로세스는 여러 쓰레드를 가질 수 있으며, 각 쓰레드는 독립적으로 실행됩니다. 또한, 쓰레드는 CPU의 자원을 사용해 작업을 처리하기 때문에 적절히 관리해서 사용해야 합니다.

코어와 쓰레드의 관계

1.
하나의 코어에서 여러 쓰레드를 처리 : CPU 코어는 한 번에 하나의 쓰레드만 직접 실행할 수 있지만, 매우 빠르게 여러 쓰레드 사이를 전환하면서 여러 작업을 동시에 처리하는 것처럼 보이게 할 수 있습니다. 이 과정을 시분할이라고 합니다. 하지만 쓰레드가 많을수록 전환하는 데 필요한 시간이 증가하므로 성능 저하가 발생할 수 있습니다.
2.
여러 코어에서 병렬 처리 : 멀티코어 CPU는 각 코어에서 한 쓰레드를 동시에 실행할 수 있기 때문에, 여러 코어가 있을 경우 더 많은 쓰레드를 병렬로 처리할 수 있습니다. 이는 성능 향상에 중요한 역할을 하게 됩니다.

예시

싱글코어 CPU : 한 코어가 하나의 쓰레드만 실행하고, 다른 쓰레드는 대기했다가 순서대로 실행됩니다. 이 경우 멀티쓰레딩이 제한적입니다.
듀얼코어 CPU : 두 개의 코어가 각각 하나의 쓰레드를 동시에 처리할 수 있어 두 개의 쓰레드가 병렬로 실행될 수 있습니다. 이를 통해 성능이 크게 향상됩니다.

C#에서 쓰레드의 개념

C#에서 쓰레드는 프로세스 내에서 실행되는 독립적인 단위입니다. 일반적으로 모든 코드 실행은 메인 쓰레드에서 이루어지며, 유니티에서도 대부분의 게임 로직이 메인 쓰레드에서 동작합니다. 하지만 CPU의 여러 코어를 활용하려면 작업을 여러 쓰레드로 분리해 동시에 처리하는 것이 필요할 수 있습니다.

쓰레드의 장점

긴 작업 (예 : 파일 처리, 네트워크 요청 등)을 백그라운드에서 처리할 수 있어 메인 쓰레드가 멈추지 않음.
CPU의 여러 코어를 활용해 병렬 처리 가능

유니티에서 쓰레드 사용하기

유니티는 자체적으로 멀티쓰레드를 지원하지 않기 때문에, 쓰레드를 사용할 때는 C#의 System.Threading 네임스페이스를 활용합니다.
using System; using System.Threading; namespace ServerCore { class Program { static void MainThread() { while (true) Console.WriteLine("Hello Thread!"); } static void Main(string[] args) { Thread t = new Thread(MainThread); t.IsBackground = true; t.Start(); Console.WriteLine("Hello World!"); } } }
C#
복사
static void MainThread(): MainThread 메서드는 별도의 스레드에서 실행할 작업을 정의한 함수입니다. 이 함수는 무한 루프를 돌면서 "Hello Thread!"라는 메시지를 출력합니다.
무한 루프 (while(true)): while (true)는 무한 루프를 의미합니다. 즉, 이 코드가 실행되면 Console.WriteLine("Hello Thread!");가 계속해서 실행됩니다. 실제 사용에서는 무한 루프를 피하거나 적절한 조건으로 종료하는 것이 중요하지만, 이 예제에서는 스레드가 지속적으로 실행되도록 설정된 것입니다.
Thread t = new Thread(MainThread): 새로운 스레드를 생성합니다. Thread 클래스는 .NET에서 멀티스레딩을 구현하기 위한 클래스입니다.
MainThread는 새롭게 생성된 스레드에서 실행할 작업입니다. 즉, 이 코드는 별도의 스레드에서 MainThread 메서드를 실행하도록 준비하는 부분입니다.
스레드를 사용하는 이유: 메인 스레드(기본 스레드)에서 긴 작업이나 반복 작업을 처리하면 게임이나 프로그램의 다른 중요한 작업들이 영향을 받습니다. 스레드를 생성해서 특정 작업을 별도로 처리하면 메인 스레드의 성능 저하를 방지할 수 있습니다.
t.IsBackground = true;: 생성한 스레드를 백그라운드 스레드로 설정합니다. 백그라운드 스레드는 프로그램이 종료될 때 자동으로 중지됩니다. 백그라운드 스레드는 기본적으로 false로 되어있지만 true로 설정함으로써 백그라운드에서
왜 백그라운드 스레드로 설정하나요? 백그라운드 스레드는 프로그램이 종료될 때 강제로 종료됩니다. 이 설정을 하지 않으면, 프로그램이 종료될 때에도 이 스레드가 계속 실행되어 종료되지 않을 수 있습니다. 예를 들어, MainThread는 무한 루프를 돌고 있기 때문에, 이 스레드를 백그라운드로 설정하지 않으면 프로그램이 종료되지 않고 계속 실행될 수 있습니다.
t.Start(): 새롭게 생성한 스레드에서 MainThread 메서드를 실행합니다. 이 호출을 통해 별도의 스레드에서 무한 루프가 시작됩니다. 스레드를 실행하지 않으면, 작업이 스레드에 할당되지만 실행되지 않기 때문에 반드시 호출해야 합니다.
t.Join(): 이 메서드는 메인 스레드가 생성한 스레드가 끝날 때까지 기다리게 합니다. 예를 들어, 메인 스레드가 다른 스레드의 작업이 완료될 때까지 그 이후의 작업을 멈춰야 할 때 사용합니다.
사용 이유: 스레드 t가 끝나지 않으면 메인 스레드가 바로 다음 작업을 실행해 버릴 수 있기 때문에, 스레드가 완료될 때까지 기다리게 할 필요가 있습니다. 예를 들어, 데이터 처리 같은 작업이 완료된 후 다음 작업을 이어서 해야 할 경우 Join()을 사용합니다.

쓰레드풀링이란?

여러분이 코드를 작성하다보면, 짧은 작업이나 빈번한 작업들이 있을 것입니다.
하지만 이럴 때마다 새로운 쓰레드를 생성하고 작업이 끝날 때마다 이를 소멸시킨다면 많은 리소스와 시간이 소모될 것입니다.
쓰레드풀을 사용하면 이러한 쓰레드 생성/소멸 비용을 줄일 수 있으며, 미리 생성된 쓰레드를 필요한 만큼만 할당받아 작업을 처리할 수 있습니다.
쓰레드풀링은 미리 일정 수의 쓰레드를 생성해두고, 요청이 들어오면 해당 쓰레드들을 재사용하여 작업을 처리하는 방식입니다. 쓰레드풀은 C#과 같은 고급 프로그래밍 언어에서 자원을 효율적으로 사용하고, 성능을 최적화하기 위해 제공되는 중요한 개념입니다.

쓰레드와 쓰레드풀의 차이

1.
쓰레드
개별적인 쓰레드 생성 : Thread 클래스는 필요할 때마다 새로운 쓰레드를 생성합니다.
새로운 쓰레드 생성 비용 : 새로운 쓰레드가 생성될 때마다 자원을 할당하고 스케줄링해야하므로, 생성, 실행, 종료 시점을 직접 관리해야합니다.
사용 시기 : 특정 작업을 개별 쓰레드에서 처리해야 하거나, 정밀한 쓰레드 제어가 필요할 때 사용합니다.
2.
쓰레드풀
미리 생성된 쓰레드 : 쓰레드풀은 일정 개수의 쓰레드를 미리 생성해놓고, 작업 요청이 있을 때 재사용합니다.
리소스 절약 : 쓰레드를 새로 생성하는 대신, 기존에 만들어진 쓰레드를 재사용하므로, 쓰레드를 새로 만드는 비용을 줄일 수 있습니다.
자동 관리 : 쓰레드의 생성과 소멸, 스케줄링 등을 시스템이 자동으로 관리합니다.
사용 시기 : 작업이 많지만 각각의 작업이 짧거나 빈번할 때, 혹은 명시적으로 쓰레드를 관리할 필요가 없을 때 사용합니다.

쓰레드풀의 장점

리소스 관리 효율성 : 쓰레드풀은 쓰레드를 생성하고 소멸시키는 비용을 줄여 시스템 리소스를 절약합니다.
성능 최적화 : 적은 수의 쓰레드로 많은 작업을 처리할 수 있으며, 시스템의 부하를 줄입니다.
자동 관리 : 쓰레드풀은 쓰레드의 생성, 실행, 종료를 자동으로 관리해줍니다.
안전한 쓰레드 관리 : 쓰레드풀은 시스템에 의해 관리되기 때문에, 수동으로 쓰레드를 관리하는 경우보다 쓰레드 안전성이 향상됩니다

쓰레드풀 사용 방법

using System; using System.Threading; class Program { static void Main(string[] args) { ThreadPool.SetMinThreads(1,1); // 쓰레드풀 최소 쓰레드 수를 1로 설정 ThreadPool.SetMaxThreads(5,5); // 쓰레드풀 최대 쓰레드 수를 5로 설정 ThreadPool.QueueUserWorkItem(DoWork); // ThreadPool을 사용하여 작업 등록 Console.WriteLine("Main thread"); Console.ReadLine(); // 프로그램 종료 방지 } static void DoWork(object state) { Console.WriteLine("Thread is working"); Thread.Sleep(2000); // 작업 시뮬레이션 (2초 대기) Console.WriteLine("Work completed."); } }
C#
복사
ThreadPool.SetMinThreads(): 쓰레드풀이 최소한으로 유지해야 하는 쓰레드 수를 설정합니다. 이 설정은 새로운 작업 요청이 발생했을 때, 자동으로 최소한 지정된 수의 스레드를 유지하도록 합니다.
첫 번째 인자: 작업 스레드의 최소 수를 설정합니다. 여기서는 1로 설정했으므로, 쓰레드풀은 항상 최소한 1개의 작업 스레드를 유지합니다.
두 번째 인자: IO 작업을 수행하는 최소 스레드 수를 설정합니다. 여기에서도 1로 설정하였으므로, IO 스레드도 최소 1개를 유지합니다.
ThreadPool.SetMaxThreads() : 쓰레드풀이 최대한 유지할 수 있는 쓰레드 수를 설정합니다. 이 설정은 쓰레드풀이 동시에 실행할 수 있는 최대 작업 수를 정의합니다.
첫 번째 인자: 최대 작업 스레드 수를 설정합니다. 여기서는 5로 설정했으므로, 쓰레드풀은 동시에 최대 5개의 작업 스레드를 가질 수 있습니다.
두 번째 인자: IO 작업을 수행하는 최대 스레드 수를 설정합니다. 여기에서도 5로 설정하였으므로, IO 스레드도 최대 5개를 유지할 수 있습니다.
ThreadPool.QueueUserWorkItem(): 작업을 쓰레드풀에 직접 등록하는 메서드입니다. 이 메서드를 사용하면 쓰레드풀이 관리하는 스레드에서 작업이 실행됩니다.
익명 델리게이트 사용: QueueUserWorkItem 메서드는 인자로 델리게이트를 받기 때문에, 람다 표현식이나 익명 메서드로 작업을 전달할 수 있습니다.

쓰레드풀과 관련된 다른 개념

1.
작업 큐
쓰레드풀은 작업 큐를 사용하여 작업이 할당된 쓰레드가 이미 다른 작업을 처리 중일 경우, 대기열에 작업을 저장합니다. 작업을 완료한 쓰레드는 이 큐에서 다음 작업을 받아서 처리합니다. 이를 통해 쓰레드가 효율적으로 사용됩니다.
2.
동기화 고려사항
멀티쓰레딩 환경에서는 여러 쓰레드가 동시에 동일한 자원에 접근할 경우 동기화 문제가 발생할 수 있습니다. 쓰레드풀을 사용할 때도 데이터의 안전한 접근을 보장해야 하므로, lock이나 동기화 도구를 사용하여 데이터 충돌을 방지할 필요가 있습니다.
lock 에 대해서는 다음에 자세히 알아보도록 하겠습니다.

쓰레드풀의 제약사항

고정된 최대 쓰레드 수 : 쓰레드풀은 시스템에 의해 관리되며, 기본적으로 최대 쓰레드 수가 고정되어 있습니다. 너무 많은 작업을 동시에 처리하려고 하면 큐에 작업이 쌓이게 되고, 지연이 발생할 수 있습니다.
ThreadPool.SetMaxThreads(): 최대 스레드 수를 설정할 수 있지만, 적절한 수를 넘기면 오히려 성능이 저하될 수 있습니다.
긴 작업에 부적합 : 쓰레드풀은 짧은 작업을 빠르게 처리하는 것에 최적화되어 있습니다. 긴 작업을 실행하면 쓰레드풀이 다른 작업을 처리하는 데 느려질 수 있습니다. 긴 작업에는 개별 쓰레드를 사용하는 것이 더 나은 선택일 수 있습니다.

태스크란?

멀티쓰레딩을 구현할 때, Thread 클래스나 쓰레드풀(Thread Pool)을 직접 사용하는 방법도 있지만, C#의 Task 클래스는 훨씬 더 직관적이고 관리하기 쉬운 방식을 제공합니다. Task는 쓰레드풀과 깊이 연관되어 있으며, 개발자가 쓰레드를 직접 관리하지 않고도 비동기 작업을 처리할 수 있게 해줍니다.

태스크를 쓰는 이유?

Task 는 쓰레드풀을 자동으로 관리하여 쓰레드를 생성, 실행, 스케줄링, 종료하는 작업을 간소화합니다.
비동기 작업을 손쉽게 처리할 수 있으며, 비동기 프로그래밍 패턴에 맞춰 설계되어 있어 코드의 가독성과 유지보수성을 높입니다.
Task 는 작업 완료 시점에 대한 다양한 컨트롤과 결과 반환 기능을 지원하여 고급 멀티쓰레딩을 구현할 때 매우 유용합니다.

태스크의 정의

Task 는 비동기 작업을 나타내며, 주로 백그라운드에서 실행되는 작업을 관리합니다.
Task 는 일반적으로 쓰레드풀에서 실행되며, 개발자가 명시적으로 쓰레드를 생성하거나 관리할 필요 없이 작업을 스케줄링하고 실행할 수 있습니다.

Task와 쓰레드풀의 관계

쓰레드풀 기반 : Task 는 대부분 쓰레드풀을 통해 실행됩니다.
쓰레드풀의 쓰레드를 사용하여 태스크를 비동기로 실행하므로, 자원을 효율적으로 사용할 수 있습니다.
자동 관리 : 쓰레드풀에서 관리되는 쓰레드를 Task 를 통해 사용하면, 쓰레드 생성/소멸 등의 부담을 줄이고 작업 완료와 결과 반환 같은 고급 기능을 쉽게 사용할 수 있습니다.

Task 사용 방법

3.1 기본적인 Task 생성 및 실행

using System; using System.Threading.Tasks; class Program { static void Main(string[] args) { // Task를 사용하여 백그라운드에서 작업 실행 Task task = Task.Run(() => { Console.WriteLine("Task is running in the background."); }); task.Wait(); // Task가 완료될 때까지 대기 Console.WriteLine("Task completed."); } }
C#
복사
Task.Run(): 주어진 작업을 쓰레드풀에서 비동기로 실행합니다. 이 메서드는 쓰레드풀을 사용하여 작업을 관리하며, 내부적으로 ThreadPool.QueueUserWorkItem을 사용합니다.
task.Wait(): 작업이 완료될 때까지 현재 스레드가 대기합니다. 비동기 작업을 기다려야 할 경우 사용합니다.

3.2 Task의 결과 반환

Task는 단순히 작업을 수행할 뿐만 아니라, 결과를 반환할 수도 있습니다.
using System; using System.Threading.Tasks; class Program { static void Main(string[] args) { // Task에서 결과를 반환하는 방법 Task<int> task = Task.Run(() => { Console.WriteLine("Calculating result..."); return 42; // 작업 결과 반환 }); int result = task.Result; // 결과가 나올 때까지 대기하고 값을 반환받음 Console.WriteLine($"Task result: {result}"); } }
C#
복사
Task<int>: Task결과값을 반환하는 제네릭 타입입니다. 작업이 완료되면 Result 속성을 통해 값을 반환받을 수 있습니다.

3.3 Task와 비동기/병렬 처리

Task는 비동기 작업을 병렬로 처리할 때 매우 유용합니다. 여러 작업을 동시에 실행하고, 그 결과를 비동기적으로 기다리거나 합칠 수 있습니다.
using System; using System.Threading.Tasks; class Program { static void Main(string[] args) { Task<int> task1 = Task.Run(() => { return 5; }); Task<int> task2 = Task.Run(() => { return 10; }); // 두 Task가 모두 완료될 때까지 기다리고, 결과를 더함 Task<int> sumTask = Task.WhenAll(task1, task2).ContinueWith(tasks => { return task1.Result + task2.Result; }); Console.WriteLine($"Sum of task results: {sumTask.Result}"); } }
C#
복사
Task.WhenAll(): 여러 태스크가 완료될 때까지 기다리고, 이후의 작업을 처리할 수 있습니다.
ContinueWith(): 작업이 완료된 후 연속 작업을 수행할 수 있도록 하는 메서드입니다. 이 방식으로 작업 체이닝을 구현할 수 있습니다.

4. Task와 쓰레드의 차이

직관성: TaskThread보다 훨씬 직관적입니다. 태스크는 기본적으로 비동기 패턴을 따르며, 결과 반환에러 처리 등을 더 쉽게 처리할 수 있습니다.
쓰레드 관리: Task는 대부분의 경우 쓰레드풀에서 실행되므로, 직접 스레드를 생성하고 관리하지 않아도 됩니다. 반면, Thread는 수동으로 스레드를 생성하고 제어해야 합니다.
성능 최적화: Task는 짧고 빈번한 작업에 적합하며, 작업이 완료되면 쓰레드를 재사용할 수 있는 쓰레드풀과 결합되어 성능이 최적화됩니다. Thread는 직접 스레드를 생성하는 비용이 더 크므로, 반복적인 작업이나 비동기적 처리에 비효율적일 수 있습니다.

5. 고급 Task 기능

5.1 Task의 비동기/Await 패턴

C#의 async/await 키워드를 사용하여 비동기 작업을 더욱 간편하게 처리할 수 있습니다.
using System; using System.Threading.Tasks; class Program { static async Task Main(string[] args) { int result = await LongRunningTask(); Console.WriteLine($"Result from async task: {result}"); } static async Task<int> LongRunningTask() { Console.WriteLine("Task is starting..."); await Task.Delay(2000); // 2초 대기 (비동기) return 42; } }
C#
복사
await: 비동기 작업이 완료될 때까지 기다리면서, 다른 작업을 계속 진행할 수 있게 해줍니다. 메인 스레드는 중단되지 않으며, 결과가 나오면 이어서 실행됩니다.
Task.Delay(): 비동기적으로 지정된 시간 동안 대기하는 메서드입니다.

5.2 Task의 예외 처리

Task는 비동기 작업 중 발생하는 예외를 Task 내부에서 관리하며, 이를 적절하게 처리할 수 있는 기능을 제공합니다.
Task.Run(() => { throw new InvalidOperationException("Something went wrong!"); }).ContinueWith(task => { if (task.IsFaulted) { Console.WriteLine("Exception caught: " + task.Exception.InnerException.Message); } });
C#
복사
IsFaulted: 작업이 실패했는지 여부를 확인하는 속성입니다. 예외 발생 시 해당 예외를 처리할 수 있습니다.