C# 스레드와 태스크

2025. 3. 19. 23:54개발/C#

이 글은 박상현 님의 '이것이 C#이다 개정판'을 참고하여 공부한 내용입니다.


스레드는 컴퓨터의 실행 단위로 사용되기도 하지만 어원은 아마로 만든 실에서 나왔다고 합니다. 그래서 연결된 무언가를 뜻하기도 한다고 합니다. 저는 컴퓨터 관련 용어를 공부할 때 어원을 궁금해서 찾아보곤 합니다. 그 어원을 알게 되면 그 개념의 본질에 대해 더 이해되는 것 같습니다.

 

예를 들어 바이트 오더를 뜻하는 엔디안의 경우 걸리버의 여행기에서 소인국 편에서 나오는 사람들을 뜻합니다. 여기 사람들은 계란을 깨는 것을 가지고 편을 갈라 격론을 펼칩니다. 계란을 깰 때 가장 두꺼운 끝 부분을 깨야한다는 사람들과 가장 얇은 끝 부분을 깨야하다는 사람들로 말이죠. 그래서 계란의 어느 끝 부분을 깨는 사람을 End에 접미사 an을 붙여 Endian으로 부르게 된 거죠.

 

여러분은 새로운 용어를 접할 때 어떻게 하시나요?

 

Thread

C#에서는 당연하게도 객체를 통해 Thread를 관리합니다. System.Threading 네임스페이스를 통해 사용할 수 있으며, 사용법도 간단합니다.

 

using System;
using System.Threading;

class Program
{
    static void PrintNumbers()
    {
        for (int i = 0; i < 5; i++)
        {
            Console.WriteLine($"Thread Running: {i}");
            Thread.Sleep(500);
        }
    }

    static void Main()
    {
        Thread thread = new Thread(PrintNumbers);
        thread.Start();

        for (int i = 0; i < 5; i++)
        {
            Console.WriteLine($"Main Thread Running: {i}");
            Thread.Sleep(500);
        }

        thread.Join();
    }
}

 

Main Thread Running: 0
Thread Running: 0
Thread Running: 1
Main Thread Running: 1
Thread Running: 2
Main Thread Running: 2
Main Thread Running: 3
Thread Running: 3
Thread Running: 4
Main Thread Running: 4

 

위 예제는 Thread를 사용하여 메서드를 동시에 처리하는 코드입니다. Thread는 생성되며 실행할 메서드를 인자로 받습니다. 그리고 Start 메서드를 호출하여 Thread를 실행합니다. 그리고 Join 메서드를 통해 해당 Thread가 실행이 모두 될 때까지 대기합니다. 객체를 통해 Thread를 사용하기에 간단히 코드를 작성할 수 있습니다.

 

하지만 Thread는 무분별하게 사용해서는 안됩니다. 프로세스의 Context Switch보다는 낫지만, Thread의 Context Switch도 적지 않은 비용을 소모합니다. 그리고 Thread는 자원을 공유하기 때문에 하나의 Thread에서 문제가 생기면 전체 Thread에 영향을 줍니다. 그렇기에 설계와 구현이 복잡해지며 안정성이 낮아질 수 있습니다. 항상 Thread를 사용하기 전에는 검토를 하여 해당 방법이 적합한지 확인해야 합니다.

 

ThreadState Enum

Thread는 현재 상태를 나타내는 ThreadState 프로퍼티를 가지고 있습니다. 이 ThreadState는 다양한 상태를 비트마스킹을 통해 관리하고 있습니다.

Name Value Description
Running 0 Thread가 현재 실행중이며, 아직 stopped 상태가 되기 전 입니다.
StopRequested 1 Thread가 중지 요청을 받은 상태입니다. 이 상태는 내부전용입니다.
SuspendRequested 2 Thread가 일시 중단 요청을 받은 상태입니다.
Background 4 Thread가background에서 실행되는 상태입니다. IsBackground 속성을 통해 제어합니다.
Unstarted 8 Thread에서 Start 메서드가 호출되지 않은 상태입니다.
Stopped 16 Thread가 중지되었습니다.
WaitSleepJoin 32 Thread가 blocked 상태입니다. Sleep, Join, 동기화 등의 대기 상태인 경우입니다.
Suspended 64 Thread가 일시 중단되었습니다.
AbortRequested 128 Thread가 강제 중단을 요청 받은 상태입니다. 아직 ThreadAbortException이 던지지 않은 상태입니다. 
Aborted 256 Thread가 강제 중단된 상태입니다. 그리고 아직 stopped 상태가 되기 전 입니다.

 

추가로 Suspend와 Abort 메서드는 현재 .NET 5 이상의 버전에서는 더 이상 사용되지 않습니다. 만약 호출하게 되면 PlatformNotSupportedException을 발생시킵니다. 사용이 중지된 이유는 두 메서드, 모두 안전한 종료 패턴이 아니기 때문입니다. 중단 혹은 종료될 때 동기화 객체를 점유하여 데드락이 발생할 수 있으며, 데이터의 처리 중 중단 혹은 종료될 경우 데이터가 손상될 수 있기 때문입니다. 

 

Thread States

 

Thread의 상태는 비트 마스킹으로 표현되기 때문에 여러 상태가 겹쳐 나타날 수 있습니다. 예를 들어 아직 실행하지 않은 Thread에 IsBackground에 True를 대입하여 ThreadState를 조회하면 Background, Unstarted 두 가지 상태를 가지고 있는 것을 확인할 수 있습니다.

 

Interrupt

Abort 메서드를 대신하여 Thread를 종료하는 메서드입니다. 차이점은 Abort와 달리 Interrupt는 해당 Thread가 바로 종료되지 않고 WaitJoinSleep 상태에 들어가게 되면 ThreadInterruptException을 발생시키고 종료된다는 것입니다. 그래서 Interrupt는 WaitSleepJoin 상태에 들어가기 전 코드를 모두 실행시켜 안전하게 Thread를 종료할 수 있습니다.

 

using System;
using System.Threading;

class Program
{
    static void PrintNumbers()
    {
        try
        {
            for (int i = 0; i < 10; i++)
            {
                Console.WriteLine($"Thread Running {i}");
                Thread.Sleep(1000);
            }
        }
        catch (ThreadInterruptedException)
        {
            Console.WriteLine("Thread is intrrupted");
        }
    }

    static void Main()
    {
        Thread thread = new Thread(PrintNumbers);
        thread.Start();
        Thread.Sleep(3000);
        thread.Interrupt();
        thread.Join();
    }
}

 

Thread Running 0
Thread Running 1
Thread Running 2
Thread is intrrupted

 

위 예시는 Interrupt 메서드를 사용하여 예외를 발생시키는 코드입니다. Interrupt를 호출하면 PrintNumbers 메서드의 어느 부분을 실행하고 있든 Thread.Sleep을 통해 WaitSleepJoin 상태에 들어가야 ThreadInterruptedException을 발생시킵니다.

 

Synchronization

멀티 Thread 환경에서는 공유 자원에 접근하는 Thread 끼리 충돌이 발생할 수 있습니다. 충돌을 예방하기 위해서는 동기화를 사용하여 Thread들이 공유 자원에 순차적으로 접근할 수 있게 해야 합니다. C#에서는 간단한 동기화 방법으로 lock 키워드가 있습니다.

 

lock

using System;
using System.Threading;

class Program
{
    private static object lockObj = new object();

    static void PrintNumbersASC()
    {
        lock (lockObj)
        {
            for (int i = 0; i < 10; i++)
            {
                Console.WriteLine($"ASC Thread Running {i}");
                Thread.Sleep(500);
            }
        }
    }

    static void PrintNumbersDESC()
    {
        lock (lockObj)
        {
            for (int i = 10; i >= 0; i--)
            {
                Console.WriteLine($"DESC Thread Running {i}");
                Thread.Sleep(500);
            }
        }
    }

    static void Main()
    {
        Thread thread1 = new Thread(PrintNumbersASC);
        Thread thread2 = new Thread(PrintNumbersDESC);
        thread1.Start();
        Thread.Sleep(500);
        thread2.Start();
        thread1.Join();
        thread2.Join();
    }
}

 

ASC Thread Running 0
ASC Thread Running 1
ASC Thread Running 2
ASC Thread Running 3
ASC Thread Running 4
ASC Thread Running 5
ASC Thread Running 6
ASC Thread Running 7
ASC Thread Running 8
ASC Thread Running 9
DESC Thread Running 10
DESC Thread Running 9
DESC Thread Running 8
DESC Thread Running 7
DESC Thread Running 6
DESC Thread Running 5
DESC Thread Running 4
DESC Thread Running 3
DESC Thread Running 2
DESC Thread Running 1
DESC Thread Running 0

 

위 예시는 lock 키워드를 사용하여 동기화를 통해 오름차순 출력 Thread를 먼저 실행시키고 내림차순 출력 Thread를 실행시켜 순서를 갖게 하였습니다. Main의 Thread.Sleep은 오름차순 출력 Thread 가 먼저 실행될 수 있게 잠시 내림차순 출력  Thread Start를 지연시키기 위해 사용했습니다.

 

lock 키워드는 참조 오브젝트 점유를 통해 임계구간에 진입할 수 있습니다. 이때 다른 Thread 가 lock 키워드를 통해 해당 오브젝트를 점유하려면 준비 큐에 들어가 앞선 Thread 가 오브젝트를 반환하기를 기다립니다.

 

여기서 주의해야 할 점은 점유할 오브젝트입니다. 점유할 오브젝트가 잘못되면 데드락 혹은 예상치 못한 동작이 발생할 수 있기 때문입니다. 그래서  string, this, Type 등은 절대로 사용해서는 안됩니다. string 형식은 같은 문자열이어도 실제로는 다른 객체일 가능성이 있습니다. 그리고 this와 Type의 경우 클래스 외부에서도 접근할 수 있기에 데드락이 발생할 수 있습니다.

 

Monitor

lock 키워드 보다 좀 더 세밀한 제어가 가능한 Monitor 클래스가 있습니다. Monitor는 Pulse, PulseAll, Wait 메서드를 통해 Thread 간 통신이 가능합니다. lock 키워드 또한 내부적으로 Monitor의 Enter와 Exit 메서드를 통해 동작합니다. 예시를 통해 사용법을 알아보겠습니다.

 

using System;
using System.Threading;

class Program
{
    private static object lockObj = new object();
    private static bool isDataAvailable = false;

    static void Producer()
    {
        lock (lockObj)
        {
            Console.WriteLine("Creating data...");
            Thread.Sleep(2000);
            isDataAvailable = true;
            Console.WriteLine("Data creating completed!");
            Monitor.Pulse(lockObj);
        }
    }

    static void Consumer()
    {
        lock (lockObj)
        {
            while (!isDataAvailable)
            {
                Console.WriteLine("Waiting data...");
                Monitor.Wait(lockObj);
            }
            Console.WriteLine("Data acquisition completed!");
        }
    }

    static void Main()
    {
        Thread consumerThread = new Thread(Consumer);
        Thread producerThread = new Thread(Producer);

        consumerThread.Start();
        Thread.Sleep(500);
        producerThread.Start();

        consumerThread.Join();
        producerThread.Join();
    }
}

 

Waiting data...
Creating data...
Data creating completed!
Data acquisition completed!

 

Monitor

 

위 예시는 lock 키워드와 모니터 클래스를 사용한 동기화 코드입니다. 중요한 점은 Consumer Thread와 Producer Thread의 순서입니다. 우선 Consumer Thread가 lock 키워드를 통해 동기화 객체를 얻어 들어옵니다. 이후 Monitor.Wait 메서드를 사용하여 동기화 객체를 반환하고 대기 큐에 들어갑니다. 동시에 반환한 동기화 객체를 준비 큐에서 대기하던 Producer Thread 가 획득하며 임계구간에 진입합니다.

 

이후 Producer Thread의 Monitor.Pulse 메서드에 의해 대기 큐에 있던 Consumer Thread가 준비 큐로 이동됩니다. 이후 실행을 모두 마친 Producer Thread가 동기화 객체를 반환하며 Consumer Thread가 동기화 객체를 획득하며 나머지 부분이 실행됩니다. 이렇게 Monitor를 사용하여 더 세부적인 동기화가 가능합니다.

 

Task

비동기 작업을 실행하고 관리할 수 있게 해주는 클래스입니다. System.Threading.Tasks 네임 스페이스에 정의되어 있으며, CLR에서 관리하는 Thread Pool을 활용하기 때문에 생성 비용과 성능이 Thread 보다 효율적입니다. 그래서 Thread는 특정 Thread를 제어할 때를 제외하면 잘 사용되지 않으며, 주로 Task를 사용합니다. 

 

using System;
using System.Threading.Tasks;

class Program
{
    static void Main()
    {
        Action TaskRunning = () =>
        {
            Console.WriteLine("Task Running...");
            Task.Delay(2000).Wait();

        };

        Task task = new Task(TaskRunning);
        task.Start();

        while (!task.IsCompleted)
        {
            Console.WriteLine("Waiting Task...");
            Task.Delay(500).Wait();
        }

        Console.WriteLine("Task Done");
    }
}

 

Waiting Task...
Task Running...
Waiting Task...
Waiting Task...
Waiting Task...
Task Done

 

위 예시는 Taks를 이용한 병렬처리 코드입니다. Task는 Action 대리자를 통해 지정된 작업을 실행할 수 있습니다. 그리고 중간에 있는 Task.Delay는 입력한 시간만큼 지연 후 완료될 Task를 반환합니다. 그리고 생성된 Task에 Wait을 호출하여 해당 Task가 작업이 모두 완료될 때까지 대기합니다. 즉, 입력된 시간만큼 대기한다는 뜻입니다.

 

Action 대리자를 사용하는 방법도 있지만 익명 함수를 사용하는 방법이 더 일반적입니다.

using System;
using System.Threading.Tasks;

class Program
{
    static void Main()
    {
        Task task = Task.Run(() =>
        {
            Console.WriteLine("Task Running...");
            Task.Delay(2000).Wait();
        });

        while (!task.IsCompleted)
        {
            Console.WriteLine("Waiting Task...");
            Task.Delay(500).Wait();
        }

        Console.WriteLine("Task Done");
    }
}

 

위 예시는 이전에 작성한 Task 병렬처리 코드를 익명 함수를 사용하여 다시 작성한 코드입니다. 위 방식을 사용하면 Action 대리자를 선언할 필요 없이 Run 메서드에 의해 즉시 실행됩니다. 

 

Task의 파생 클래스인 Task<TResult> 클래스는 Task와 다르게 값을 반환할 수 있는 Func 대리자를 사용하는 클래스입니다. 사용법은 Task와 동일하며 반환할 자료형을 명시해야 합니다.

 

using System;
using System.Threading.Tasks;

class Program
{
    static void Main()
    {
        Task<int> task = Task.Run(() =>
        {
            return 100;
        });

        task.Wait();
        Console.WriteLine($"Result: {task.Result}");
    }
}

 

Result: 100

 

위 예시는 Task<TResult>의 반환 값을 확인해 보는 코드입니다. 간단하게 비동기 작업을 Wait으로 대기한 후 Task.Result 프로퍼티를 사용하여 반환값을 출력하였습니다.

 

async / await

C# 뿐만 아니라 다른 모든 언어에서 볼 수 있는 키워드인 async와 await입니다. 이 두 키워드는 비동기 프로그래밍에서 중요한 역할을 합니다. async는 Task와 Task<TResult>를 사용하여 비동기 메서드를 간단하게 작성할 수 있게 해 줍니다.

Task DoWorkAsync()
{
    return Task.Run(() =>
    {
        Task.Delay(3000).Wait();
        Console.WriteLine("Task Done!");
    });
}

 

위와 같은 코드를 async와 await을 사용하면 아래와 같이 코드를 바꿀 수 있습니다.

async Task DoWorkAsync()
{
    await Task.Delay(3000);
    Console.WriteLine("Task Done!");
}

 

여기서 await은 무조건 대기를 하는 것이 아닌 현재 Thread가 비동기 작업을 점유하지 않고 다른 작업에 양보될 수 있습니다. 그래서 현재 Thread는 DoWorkAsync 작업을 호출한 메서드에게 제어권을 넘깁니다. 이후 Delay Task가 끝나면 새로운 Thread가 대신하여 "Task Done!"을 출력하며 메서드를 모두 실행합니다.

 

주의해야 할 점은 async로 선언한 메서드는 반환형을 Task 혹은 Task<TResult>로 해야 한다는 것입니다. 이유는 반환형이 다르면 await을 사용하지 못해  내부에서 발생한 예외를 처리할 수 없기 때문입니다.

Parallel

Task를 사용하여 데이터 병렬 반복작업을 자동으로 처리해 주는 클래스입니다. 말 그대로 자동으로 처리하기에 Task를 따로 생성할 필요 없이 알아서 주어진 반복작업에 대한 Task 기반의 최적화 멀티 스레딩 환경을 구성합니다. 사용방법은 3가지로 나뉘며 모두 반복작업에 대한 병렬처리를 자동으로 최적화 해줍니다.

 

using System;
using System.Threading.Tasks;

class Program
{
    static void Main()
    {
        Parallel.For(1, 11, i =>
        {
            Console.WriteLine($"Value: {i}, Thread: {Thread.CurrentThread.ManagedThreadId}");
        });

        Console.WriteLine();

        List<int> numbers = Enumerable.Range(1, 10).ToList();
        Parallel.ForEach(numbers, num =>
        {
            Console.WriteLine($"Number: {num}, Thread: {Thread.CurrentThread.ManagedThreadId}");
        });

        Console.WriteLine();

        Parallel.Invoke(
            () => Console.WriteLine($"Task1, Thread: {Thread.CurrentThread.ManagedThreadId}"),
            () => Console.WriteLine($"Task2, Thread: {Thread.CurrentThread.ManagedThreadId}"),
            () => Console.WriteLine($"Task3, Thread: {Thread.CurrentThread.ManagedThreadId}")
        );
    }
}

 

Value: 1, Thread: 1
Value: 7, Thread: 1
Value: 6, Thread: 10
Value: 9, Thread: 10
Value: 10, Thread: 10
Value: 3, Thread: 7
Value: 5, Thread: 9
Value: 8, Thread: 1
Value: 4, Thread: 8
Value: 2, Thread: 4

Number: 2, Thread: 4
Number: 1, Thread: 1
Number: 3, Thread: 8
Number: 9, Thread: 8
Number: 5, Thread: 7
Number: 6, Thread: 10
Number: 7, Thread: 4
Number: 8, Thread: 1
Number: 4, Thread: 9
Number: 10, Thread: 8

Task2, Thread: 8
Task1, Thread: 1
Task3, Thread: 9

 

위 예시는 Parallel 클래스를 사용하여 병렬 반복문을 자동으로 처리하는 코드입니다. For 메서드는 for 반복문과 다르게 순서에 상관없이 멀티 스레드를 사용하여 반복 작업을 실행합니다. 이것은 다른 메서드도 동일하며 순서를 보장하지 않습니다. Parallel.ForEach는 컬렉션을 병렬로 처리하기 위해 사용되며 for와 동일하게 멀티 스레드를 사용하여 실행 속도를 높이는 것을 확인할 수 있습니다. Parallel.Invoke는 여러 개의 독립적인 작업을 병렬로 처리하기 위해 사용되며 순서는 보장되지 않습니다.

'개발 > C#' 카테고리의 다른 글

C# Garbage Collection  (1) 2025.04.01
C# 네트워크 프로그래밍  (0) 2025.03.28
C# 입출력 작업  (0) 2025.03.09
C# dynamic  (0) 2025.03.05
C# 애트리뷰트  (0) 2025.02.25