본문

테크
멀티 스레드 프로그래밍 #1

작성일 2023년 08월 02일

들어가며

동시에 작동해야 하는 코드를 작성하거나 실행 효율을 높이기 위해서 스레드를 사용하게 되는데요. 스레드를 사용하면 코드가 복잡해지고 디버깅이 어려워지기 때문에 스레드를 사용할 때는 주의해서 사용해야 합니다. 또한 스레드를 잘못 사용했을 때에는 오히려 성능을 해치는 경우도 발생합니다.

이 글에서는 스레드를 효과적으로 그리고 안전하게 사용하는 방법에 대해서 알아보겠습니다.


Producer-consumer pattern (생산자-소비자 패턴)

생산자-소비자 패턴은 멀티스레딩 환경에서 자주 사용되는 디자인 패턴 중 하나입니다. 이 패턴은 두 개 이상의 스레드가 작업을 분배하고 처리하는 방식을 설명합니다.

이 패턴의 주요 구성 요소는 다음과 같습니다.


  • 생산자(Producer): 작업을 생성하고 큐에 추가하는 역할을 합니다. 예를 들어, 식당에서 주문을 받아 주문서를 작성하고 주문서 철에 추가하는 직원이 생산자의 역할을 합니다.
  • 소비자(Consumer): 큐에서 작업을 가져와 처리하는 역할을 합니다. 예를 들어, 식당에서 요리사가 주문서를 가져와 요리를 만드는 것이 소비자의 역할입니다.
  • 큐(Queue): 작업이 저장되는 공간입니다. 생산자가 생성한 작업은 큐에 저장되며, 소비자는 큐에서 작업을 가져와 처리합니다. 이 큐는 일종의 버퍼 역할을 하며, 생산자와 소비자 사이의 작업 흐름을 조절합니다.

이 패턴의 핵심은 생산자와 소비자가 독립적으로 작동하면서도 효율적으로 작업을 분배하고 처리하는 것입니다. 생산자는 작업을 생성하고 큐에 추가하는 것에 집중하며, 소비자는 큐에서 작업을 가져와 처리하는 것에 집중합니다. 이렇게 함으로써 각 스레드는 자신의 작업에만 집중할 수 있으며, 이는 코드의 복잡성을 줄이고 프로그램의 전반적인 효율성을 향상시킵니다.

그러나 이 패턴을 사용할 때 주의해야 할 점이 있습니다. 생산자가 작업을 너무 빨리 생성하거나 소비자가 작업을 충분히 빨리 처리하지 못하면 큐가 가득 차거나 메모리를 고갈시킬 수 있습니다. 이 경우, 생산자는 큐에 더 이상 작업을 추가할 수 없게 되며, 이는 프로그램의 성능을 저하 및 심각한 오류를 발생시킬 수 있습니다.

따라서 이 패턴을 사용할 때는 생산자와 소비자의 작업 속도에 대한 고려가 필요합니다. 소비자 스레드의 수를 늘리거나 가능하다면 생산량을 조절하는 등의 조치가 필요할 수도 있습니다.


producer consumer pattern생산자 소비 패턴

  • Producer는 처리해야 할 일(task)이 발생하면 queue에 쌓아(push) 둡니다.
  • Consumer는 queue에서 일을 가져와서(pop) 처리합니다.
  • 처리할 일이 없을(nil) 때는 무시하고 일이 발견될 때까지 반복합니다.

간단한 예제를 만들어서 좀 더 자세하게 설명해 보겠습니다. 예제의 상황을 설명드리면 다음과 같습니다.

게임에서는 플레이어가 전투를 통해 아이템을 획득하거나 경험치를 얻는 등의 이벤트가 발생합니다. 이러한 정보는 게임의 진행 상황을 유지하기 위해 데이터베이스(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();}}
         

Guarded Suspension Pattern

생산자-소비자 패턴에서는 생산자가 요청한 작업 외에도 다른 작업을 처리할 수 있습니다. 예를 들어, 생산자가 생성한 작업을 처리하는 동안 주기적으로 현재 상황을 로그에 기록해야 하는 경우 등이 있습니다. 그러나 큐에 쌓인 작업만 처리하는 경우, 생산자-소비자 패턴에서는 작업이 없어도 프로세스가 계속 반복되어 CPU 자원을 낭비하게 됩니다.

소비자가 큐에 들어온 작업만 처리해야 하는 경우에는 Guarded Suspension 패턴을 고려해야 합니다. Guarded Suspension 패턴에서는 작업이 없으면 스레드가 완전히 멈추어 기다립니다. 즉, 새로운 작업이 들어와 스레드를 깨울 때까지 완전히 멈추게 됩니다. 이렇게 함으로써 CPU 자원의 낭비를 방지할 수 있습니다.

Guarded Suspension PatternGuarded Suspension Pattern

  • 생산자-소비자 패턴에서는 소비자 스레드가 주기적으로 큐에게 데이터를 요청하지만, Guarded Suspension 패턴에서는 소비자 스레드가 pulse 신호를 받았을 때 동작하는 점이 다릅니다.
            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");}}}
            
  • Producer 메소드: 생산자 스레드가 실행하는 메소드입니다. 이 메소드는 무한 루프 내에서 작업을 생성하고 큐에 추가합니다. 작업을 큐에 추가한 후에는 Monitor.Pulse(lockObject)를 호출하여 대기 중인 소비자 스레드를 깨웁니다.
  • Consumer 메소드: 소비자 스레드가 실행하는 메소드입니다. 이 메소드는 무한 루프 내에서 큐에서 작업을 가져와 처리합니다. 큐가 비어 있을 경우 Monitor.Wait(lockObject)를 호출하여 소비자 스레드를 대기 상태로 만듭니다.

이렇게 하면, 소비자 스레드는 큐에 작업이 있을 때만 작업을 처리하고, 큐가 비어 있을 때는 대기 상태로 전환하여 CPU 자원을 절약할 수 있습니다.

SuspensionQueue

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 자원을 효율적으로 활용하면서 동시에 작업의 안전성을 보장하는 데 중요한 역할을 합니다.

다음 글에서는 다른 멀티 스레드 프로그래밍 기법들을 소개하겠습니다. 계속해서 많은 관심 부탁드립니다.

멀티 스레드 모니터링은 와탭으로!
와탭 가입하고 모니터링 시작하기
류종택[email protected]
Development TeamAPM Agent Developer

지금 바로
와탭을 경험해 보세요.