동시에 작동해야 하는 코드를 작성하거나 실행 효율을 높이기 위해서 스레드를 사용하게 되는데요. 스레드를 사용하면 코드가 복잡해지고 디버깅이 어려워지기 때문에 스레드를 사용할 때는 주의해서 사용해야 합니다. 또한 스레드를 잘못 사용했을 때에는 오히려 성능을 해치는 경우도 발생합니다.
이 글에서는 스레드를 효과적으로 그리고 안전하게 사용하는 방법에 대해서 알아보겠습니다.
생산자-소비자 패턴은 멀티스레딩 환경에서 자주 사용되는 디자인 패턴 중 하나입니다. 이 패턴은 두 개 이상의 스레드가 작업을 분배하고 처리하는 방식을 설명합니다.
이 패턴의 주요 구성 요소는 다음과 같습니다.
이 패턴의 핵심은 생산자와 소비자가 독립적으로 작동하면서도 효율적으로 작업을 분배하고 처리하는 것입니다. 생산자는 작업을 생성하고 큐에 추가하는 것에 집중하며, 소비자는 큐에서 작업을 가져와 처리하는 것에 집중합니다. 이렇게 함으로써 각 스레드는 자신의 작업에만 집중할 수 있으며, 이는 코드의 복잡성을 줄이고 프로그램의 전반적인 효율성을 향상시킵니다.
그러나 이 패턴을 사용할 때 주의해야 할 점이 있습니다. 생산자가 작업을 너무 빨리 생성하거나 소비자가 작업을 충분히 빨리 처리하지 못하면 큐가 가득 차거나 메모리를 고갈시킬 수 있습니다. 이 경우, 생산자는 큐에 더 이상 작업을 추가할 수 없게 되며, 이는 프로그램의 성능을 저하 및 심각한 오류를 발생시킬 수 있습니다.
따라서 이 패턴을 사용할 때는 생산자와 소비자의 작업 속도에 대한 고려가 필요합니다. 소비자 스레드의 수를 늘리거나 가능하다면 생산량을 조절하는 등의 조치가 필요할 수도 있습니다.
간단한 예제를 만들어서 좀 더 자세하게 설명해 보겠습니다. 예제의 상황을 설명드리면 다음과 같습니다.
게임에서는 플레이어가 전투를 통해 아이템을 획득하거나 경험치를 얻는 등의 이벤트가 발생합니다. 이러한 정보는 게임의 진행 상황을 유지하기 위해 데이터베이스(DB)에 저장해야 합니다.
그러나 DB에 정보를 저장하는 과정은 상대적으로 시간이 많이 소요되는 작업입니다. 만약 게임 로직을 처리하는 메인 스레드가 직접 DB에 데이터를 저장하려고 하면, DB 저장 작업 동안 게임 로직 처리가 일시 중단되어 게임이 멈추는 현상이 발생할 수 있습니다.
이 문제를 해결하기 위해 생산자-소비자 패턴을 사용할 수 있습니다. 게임 로직을 처리하는 메인 스레드(생산자)는 DB에 저장할 정보를 큐에 추가만 하고, 그 외의 게임 로직 처리를 계속 진행합니다. 이렇게 하면 메인 스레드는 DB 저장 작업으로 인한 지연 없이 게임 로직을 빠르게 처리할 수 있습니다.
한편, 별도의 DB 저장용 스레드(소비자)는 백그라운드에서 동작하여 큐에 쌓인 데이터를 DB에 저장하는 작업을 수행합니다. 이 스레드는 DB 저장 작업에 필요한 시간이 걸리더라도 메인 스레드의 작업에는 영향을 주지 않으므로, 게임이 멈추는 현상을 방지할 수 있습니다.
이렇게 생산자-소비자 패턴을 사용하면, 게임 로직 처리와 DB 저장 작업을 독립적으로 수행할 수 있어 게임의 성능과 반응성을 향상시킬 수 있습니다.
아래는 위에서 설명한 상황을 C#을 사용하여 ConcurrentQueue와 두 개의 스레드를 사용하는 간단한 콘솔 애플리케이션 예제입니다. 이 예제에서 한 스레드는 "DB 작업" 문자열을 큐에 추가하고, 다른 스레드는 큐에서 문자열을 가져와 콘솔에 출력합니다.
using System;using System.Collections.Concurrent;using System.Threading;classProgram{static ConcurrentQueue queue = new ConcurrentQueue();staticvoidMain(string[] args){// DB 작업을 큐에 추가하는 스레드 생성Thread producer = new Thread(() =>{for (int i = 0; i < 10; i++){queue.Enqueue($"DB 작업 {i}");Thread.Sleep(100);}});// 큐에서 작업을 가져와 콘솔에 출력하는 스레드 생성Thread consumer = new Thread(() =>{stringvalue;while (true){if (queue.TryDequeue(outvalue)){Console.WriteLine(value);}Thread.Sleep(50);}});// 스레드 시작producer.Start();consumer.Start();// 스레드가 완료될 때까지 대기producer.Join();consumer.Join();}}
생산자-소비자 패턴에서는 생산자가 요청한 작업 외에도 다른 작업을 처리할 수 있습니다. 예를 들어, 생산자가 생성한 작업을 처리하는 동안 주기적으로 현재 상황을 로그에 기록해야 하는 경우 등이 있습니다. 그러나 큐에 쌓인 작업만 처리하는 경우, 생산자-소비자 패턴에서는 작업이 없어도 프로세스가 계속 반복되어 CPU 자원을 낭비하게 됩니다.
소비자가 큐에 들어온 작업만 처리해야 하는 경우에는 Guarded Suspension 패턴을 고려해야 합니다. Guarded Suspension 패턴에서는 작업이 없으면 스레드가 완전히 멈추어 기다립니다. 즉, 새로운 작업이 들어와 스레드를 깨울 때까지 완전히 멈추게 됩니다. 이렇게 함으로써 CPU 자원의 낭비를 방지할 수 있습니다.
using System;using System.Collections.Generic;using System.Threading;publicclassProgram{privatestatic Queue queue = new Queue();privatestaticobject lockObject = newobject();publicstaticvoidMain(){Thread producerThread = new Thread(Producer);Thread consumerThread = new Thread(Consumer);producerThread.Start();consumerThread.Start();producerThread.Join();consumerThread.Join();}privatestaticvoidProducer(){while (true){Thread.Sleep(1000);lock (lockObject){queue.Enqueue("task");Console.WriteLine("Task has been produced.");Monitor.Pulse(lockObject);}}}privatestaticvoidConsumer(){while (true){string task = null;lock (lockObject){while (queue.Count == 0){Monitor.Wait(lockObject);}task = queue.Dequeue();}Console.WriteLine(task + " --> used");}}}
이렇게 하면, 소비자 스레드는 큐에 작업이 있을 때만 작업을 처리하고, 큐가 비어 있을 때는 대기 상태로 전환하여 CPU 자원을 절약할 수 있습니다.
Guarded Suspension 패턴은 매우 자주 사용되는 디자인 패턴입니다. 이를 편리하게 사용하기 위해, 저는 SuspensionQueue <T> 라는 재사용 가능한 클래스를 미리 정의해 두었습니다. 이 클래스의 코드는 아래와 같이 매우 간결합니다.
using System.Threading;using System.Collections.Generic;namespaceWhaTap.Utils{publicclassSuspensionQueue<T>{publicvoidEnqueue(T item){lock (_lock){_queue.Enqueue(item);Monitor.Pulse(_lock);}}public T Dequeue(){lock (_lock){while (_queue.Count == 0){Monitor.Wait(_lock);}return _queue.Dequeue();}}privatereadonlyobject _lock = newobject();privatereadonly Queue<T> _queue = new Queue<T>();}}
이 SuspensionQueue<T> 클래스를 사용하여 Guarded Suspension 패턴을 구현한 예제는 아래와 같습니다. 모니터를 사용한 락 메커니즘을 캡슐화함으로써 코드의 가독성이 향상되었습니다.
using System;using System.Threading;using WhaTap.Utils;publicclassProgram{privatestatic SuspensionQueue<string> queue = new SuspensionQueue<string>();publicstaticvoidMain(){Thread producerThread = new Thread(Producer);Thread consumerThread = new Thread(Consumer);producerThread.Start();consumerThread.Start();producerThread.Join();consumerThread.Join();}privatestaticvoidProducer(){while (true){Thread.Sleep(1000);queue.Enqueue("task");Console.WriteLine("Task has been produced.");}}privatestaticvoidConsumer(){while (true){string task = queue.Dequeue();Console.WriteLine(task + " --> used");}}}
이번 글에서는 멀티 스레드 프로그래밍의 중요한 패턴인 생산자-소비자 패턴과 Guarded Suspension 패턴에 대해 알아보았습니다. 이 두 패턴은 멀티스레딩 환경에서 작업을 효율적으로 분배하고 처리하는 데 큰 도움이 됩니다. 특히, Guarded Suspension 패턴은 CPU 자원을 효율적으로 활용하면서 동시에 작업의 안전성을 보장하는 데 중요한 역할을 합니다.
다음 글에서는 다른 멀티 스레드 프로그래밍 기법들을 소개하겠습니다. 계속해서 많은 관심 부탁드립니다.