병렬 프로그래밍을 통해 애플리케이션의 처리량과 응답성을 높일 수 있다. C# 기본 문법은 아니지만, 닷넷에서 제공하는 클래스 라이브러리를 통해 병렬 처리를 구현할 수 있다. 이 주제는 깊이가 깊으므로 대학교에서 운영체제 시간에 배운 개념 일부와 C#에서의 간단한 사용법 정도만을 다루고 넘어가도록 하겠다.
프로세스(Process)와 스레드(Thread)
직관적인 이해를 위해서 스레드의 개념을 단순화 및 추상화한다면, 하나의 프로젝트를 맡은 팀(프로세스)에서 각 작업을 담당할 직원(스레드)이 함께 프로젝트를 위해 일한다고 보면 편하다. 구체적으로는, CPU의 1개의 코어는 기본적으로 하나의 작업만을 진행할 수 있다. 그러나 짧은 시간 동안 작업을 번갈아 가면서 하는 것으로 마치 여러 작업이 동시에 실행되는 것처럼 느끼게 해준다. 프로세서 시간 할당 단위(스레드)를 적절히 분배하는 것으로 동시 작업을 가능케하고, 응답 속도를 빠르게 할 수 있다.
- 프로세스 : 현재 실행 중인 프로그램
- 스레드 : 운영 체제가 프로세서 시간을 할당하는 기본 단위
스레드에 우선 순위를 부여하고, 스레드의 상태를 조절하는 것으로 원하는 방식으로 프로세스가 동작하도록 유도할 수 있다. 짧은 실행 시간을 가진 프로세스에 더 큰 우선순위를 부여한다면, 긴 실행 시간을 가진 프로세스에 의해 짧은 실행 시간을 가진 프로세스가 장기간 대기해야 하는 경우를 막아 반응 시간을 줄일 수 있다. 단, 짧은 실행 시간을 가진 프로세스가 많은 경우 긴 실행 시간을 가진 프로세스가 동작하지 못하는 기아 현상 등 부작용이 생길 수 있으므로, 상황에 따라 적절히 판단하는 것이 중요하다. 이 내용은 이후 CS지식의 OS파트를 포스팅할 때 더 구체적으로 다루고, 이 포스팅에서는 C#에서 스레드를 사용하는 방법을 간단히 다루도록 하겠다.
.NET 스레드 사용법
닷넷에서 제공하는 Thread 클래스를 사용하는 방법을 간단히 알아보자
- 스레드 생성 기본 문법: System.Threading 네임스페이스를 선언한 후, ThreadStart 대리자를 사용
- ThreadStart 대리자에 스레드에 담을 메서드를 등록한 후, Thread 클래스에 전달
- Thread 클래스의 메서드를 활용해 호출, 종료, 일시정지, 우선순위 설정
using System;
using System.Threading;
class ThreadPractice
{
//쓰레드에 담을 메서드 작성
public static void ThreadFunc() {Console.WriteLine("put this in the thread");}
static void Main()
{
//쓰레드 클래스 생성 및 ThreadStart 대리자를 사용해 메서드 등록하기
Thread t = new Thread(new ThreadStart(ThreadFunc));
//쓰레드 시작
t.Start();
}
}
- Thread 클래스 주요 속성 및 메서드
- Priority : 스레드 우선순위 부여. ThreadPriority 열거형의 Highest, Normal, Lowest 값 가짐
- Abort() : 스레드 종료
- Sleep() : 스레드를 설정한 밀리초(1/1000초)만큼 일시 중지
- Start() : 스레드 시작
- 스레드 생성 및 호출 예제
using System;
using System.Threading;
class ThreadPractice
{
static void LeeWorker()
{
Console.WriteLine("Lee starts working!");
Thread.Sleep(1000);
Console.WriteLine("Lee ends working!");
}
static void Main()
{
Console.WriteLine("Kim starts working...");
var leeW = new Thread(new ThreadStart(LeeWorker));
leeW.Start();
Thread.Sleep(500);
Console.WriteLine("Kim ends working...");
}
}
위의 예제에는 Sleep() 메서드를 사용해 직관적인 흐름으로 실행되도록 구성했지만, 이런 장치 없이 동시에 여러 스레드가 동작하는 경우 어떤 스레드가 먼저 실행될지는 파악하기 어렵다. Join 메소드와 같은 자주 쓰는 다른 메소드들은 이후에 추가로 포스팅하겠다.
- 다중 스레드 생성 및 호출 예제
using System;
using System.Threading;
class ThreadPractice
{
//쓰레드에 담을 메서드 작성
private static void ThreadA() {Console.WriteLine("A");}
private static void ThreadB() {Console.WriteLine("B");}
private static void ThreadC() {Console.WriteLine("C");}
private static void ThreadD() {Console.WriteLine("D");}
private static void ThreadE() {Console.WriteLine("E");}
static void Main()
{
//쓰레드 클래스 생성 및 ThreadStart 대리자를 사용해 메서드 등록하기
Thread tA = new Thread(new ThreadStart(ThreadA));
Thread tB = new Thread(new ThreadStart(ThreadB));
Thread tC = new Thread(new ThreadStart(ThreadC));
Thread tD = new Thread(new ThreadStart(ThreadD));
Thread tE = new Thread(new ThreadStart(ThreadE));
//쓰레드 시작
tA.Start();
tB.Start();
tC.Start();
tD.Start();
tE.Start();
}
}
위의 예제를 실행해보면 실행할 때마다 실행되는 쓰레드의 순서가 달라지는 것을 확인할 수 있다.
스레드 동기화 (lock)
여러 스레드를 동시에 실행할 때, 한 스레드가 공유 리소스를 사용하는 동안 다른 스레드도 같은 리소스를 사용하려고 한다면 예기치 않은 흐름으로 이어질 수 있다. 이를 해결하기 위해 스레드가 공유 리소스를 활용하는 동안 그 스레드만 접근할 수 있도록 잠그는(lock) 것을 동기화라고 한다.
- 스레드 동기화가 필요한 이유
using System;
using System.Threading;
class ThreadPractice
{
public static int result = 0;
public static void Add()
{
for (int i = 0; i < 100000; i++)
{
result += 1;
}
}
public static void Subtract()
{
for (int i = 0; i < 100000; i++)
{
result -= 1;
}
}
static void Main()
{
var adder = new Thread(new ThreadStart(Add));
var subtracter = new Thread(new ThreadStart(Subtract));
adder.Start();
subtracter.Start();
adder.Join();
subtracter.Join();
//0이 출력되어야 하지만, 실행할 때마다 다른 값이 나온다
Console.WriteLine(result);
}
}
두 스레드가 병렬 실행되며 공유 리소스인 result는 한 쪽은 1씩 100000번 더하고, 다른 한 쪽은 1씩 100000번 뺀다. 이론 상 더하고 뺀 값이 같으니 0이 출력되어야 할 것 같지만, 실제로 위의 코드를 실행해보면 항상 다른 값이 나온다. 이는 스레드가 공유 리소스인 result의 값을 복사하고, 복제한 값에 연산을 하고, 연산한 값을 다시 result에 저장하는 일련의 과정이 원자적(한 몸처럼 다 실행)이지 않아, 그 사이에 다른 스레드가 result의 값을 수정해 예기치 않은 변화가 발생한 것이다. 만약 0만 나온다면 반복 횟수를 더 늘리면 된다. 각 쓰레드가 1회동안 실행할 수 있는 시간이 정해져 있기 때문에 전환과정에서 발생하는 현상이다. 따라서 이를 막기 위해서는 적어도 중간에 연산한 값을 온전히 저장해서 다른 쓰레드에 넘겨주기 전까지는 공유 리소스를 잠글(lock) 필요가 있다.
- lock을 이용한 스레드 동기화
using System;
using System.Threading;
class ThreadPractice
{
public static int result = 0;
//동기화를 해주는 객체 선언 및 생성
public static object lockObj = new Object();
public static void Add()
{
for (int i = 0; i < 100000; i++)
{
//동기화하려는 코드 부분에 넣기
lock (lockObj)
{
result += 1;
}
}
}
public static void Subtract()
{
for (int i = 0; i < 100000; i++)
{
//동기화하려는 코드 부분에 넣기
lock (lockObj)
{
result -= 1;
}
}
}
static void Main()
{
var adder = new Thread(new ThreadStart(Add));
var subtracter = new Thread(new ThreadStart(Subtract));
adder.Start();
subtracter.Start();
adder.Join();
subtracter.Join();
Console.WriteLine(result);
}
}
문제점이 있던 예제를 lock 키워드를 이용해 동기화해준 코드이다. 필드에 lock을 하기 위한 object를 생성하고 동기화를 원하는 부분에 위의 예제처럼 추가해주면 된다. 위의 코드를 실행해보면 이제는 항상 0이 나오는 것을 확인할 수 있다. 단, lock(this)를 쓰거나 메소드 내에 lock object를 생성하는 것은 피하도록 하자.
lock은 공유 리소스에 대해 여러 스레드가 접근하려 할때 꼭 필요하다. 예를 들어 은행 인출 시스템과 같은 정확한 연산과 작업이 요구되는 경우 더욱 그렇다. 다만 동기화는 그 능력이 강력한 만큼 신중히 사용해야 한다. 필요없는 동기화가 남발되는 경우 속도도 느려지며, 심한 경우 스레드들이 공유 리소스의 한 파트씩을 점유한채 서로에게 다른 파트를 내놓기를 기다리며 정지해버리는 교착 상태(Dead Lock)에 빠질 수 있기 때문이다.
병렬 처리(Parallel Processing) API
닷넷은 TPL이란 작업 병렬 라이브러리를 제공한다. 이 파트는 내용이 너무 방대하기에 공식 문서의 링크를 남기고 여기에서는 그중에서도 자주 쓰일법하며 쉽게 사용 가능한 Parallel 클래스의 For(), ForEach() 메서드를 소개한다.
- Parallel.For() : For()의 병렬 버전
- System.Threading.Tasks 네임스페이스 선언, 람다식 활용
using System;
using System.Threading.Tasks;
class ParallelPractice
{
static void Main()
{
Parallel.For(0, 100000, (i) => { Console.WriteLine(i); });
}
}
- Parallel.ForEach() : ForEach()의 병렬 버전
- System.Threading.Tasks 네임스페이스 선언, 람다식 활용
using System;
using System.Threading.Tasks;
class ParallelPractice
{
public static int[] array = new int[10000];
static void Main()
{
Parallel.For(0, 10000, (index) => { array[index] = index; });
Parallel.ForEach(array, (index)=>{Console.WriteLine(array[index]);});
}
}
위의 예제는 Parallel.For()과 Parallel.ForEach()의 사용법을 최대한 직관적으로 나타낼 수 있는 코드이다. C# 기본 문법과 구조 자체는 거의 비슷하다고 보면 된다. 단, 좀 더 정확한 병렬 처리 관련 정보를 원한다면 공식 문제를 한번 둘러보는 것이 좋을 듯하다. 이후에 온전히 다룰 수 있다면 C# 병렬 처리 심화로 다시 다루겠다...
- 닷넷 병렬 처리 API(TPL) 공식 문서
https://learn.microsoft.com/ko-kr/dotnet/standard/parallel-programming/task-parallel-library-tpl
'C#' 카테고리의 다른 글
[C#] 인덱서(Indexer)와 반복기(iterator, yield 키워드), 지연된 연산 (0) | 2024.04.30 |
---|---|
[C#] 익명 형식(Anonymous Type)과 덕 타이핑(Duck Typing), 개체 이니셜라이저, nameof 연산자 (0) | 2024.04.30 |
[C#] 속성(Property)과 접근자(get, set, init), 읽기/쓰기 전용 속성 (0) | 2024.04.30 |
[C#] 생성자(Constructor)와 소멸자(Destructor) (0) | 2024.04.29 |
[C#] 람다 식(=>), 입력 매개 변수와 자연 형식 (0) | 2024.04.29 |
[C#] 이벤트(Event) (0) | 2024.04.26 |
[C#] Action, Func, Predicate 제네릭 대리자(델리게이트; Delegate)와 매개변수에 메서드 전달 (0) | 2024.04.26 |
[C#] 대리자(Delegate; 델리게이트)와 무명 메서드(+람다식 기초) (1) | 2024.04.26 |