C#

[C#] 태스크 (Task)

sunlight-dby 2025. 5. 26. 15:08

 

'이것이 C#이다 개정판'의 chapter 19를 정리한 글입니다.

 


 

태스크 (Task)

Task는 비동기 작업의 단위를 나타내며, 비동기 작업을 실행하고 결과를 기다리거나 처리할 수 있게 해주는 클래스입니다.

C#의 Task는 비동기 프로그래밍에서 핵심적으로 사용되는 클래스이며, .NET의 System.Treading.Tasks 네임스페이스에 정의되어 있습니다.

 

Task는 주로 시간이 오래 걸리는 작업(예 : 파일 IO, 네트워크 요청 등)을 별도의 스레드에서 실행하고, 메인 스레드를 블로킹하지 않도록 도와줍니다.


 

비동기 프로그래밍 (Asynchronous Programming)

비동기 프로그래밍은 프로그램이 작업을 기다리지 않고 다른 일을 계속할 수 있또록 만드는 프로그래밍 방식입니다.

시간이 오래 걸리는 작업을 실행하면서도 메인 흐름을 차단하지 않고 다음 작업을 계속할 수 있도록 만드는 방식입니다.

 

예를 들어 작업 A를 시작한 후 A의 결과가 나올 때까지 마냥 대기하는 대신 곧이어 다른 작업 B, C, D⋯를 수행하다가 작업 A가 끝나면 그 때 결과를 받아내는 처리를 하게 됩니다.


Task 클래스의 생성자

Task 클래스는 여러 가지 생성자 오버로드를 제공하며, 다양한 델리게이트를 인자로 받을 수 있습니다.

  • Task(Action)
    • 반환값이 없는 작업을 실행할 때 사용합니다.
Task t1 = new Task(() => Console.WriteLine("Hello Task!"));
  • Task(Action<object>, object state)
    • 파라미터를 전달하는 작업을 실행할 때 사용합니다.
Task t2 = new Task((obj) => Console.WriteLine($"Hello, {obj}!"), "World");
  • Task<TResult>(Func<TResult>)
    • 반환값이 있는 작업을 실행할 때 사용합니다.
Task<int> t3 = new Task<int>(() => 27);
  • Task<TResult>(Func<object, TResult>, object state)
    • 매개변수가 있고 반환값도 있는 작업을 실행할 때 사용합니다.
Task<string> t4 = new Task<string>((obj) => $"Hi, {obj}", "Task");

Task 인스턴스 실행

Task 인스턴스를 실행할 때는 Start() 메서드, Task.Run() 메서드, TaskFactory.StartNew() 메서드를 사용할 수 있습니다.

대표적으로는 Start() 메서드와 Task.Run() 메서드가 사용되며, 각각의 사용 방법에 있어 목적과 용도가 차이가 존재합니다.

 

Start() 메서드

Task myTask = new Task(() => Console.WriteLine("Start Task"));
myTask.Start();
  • Start() 메서드는 직접 생성한 Task 인스턴스를 수동으로 실행할 때 사용합니다.
  • 아직 실행되지 않는 Task만 Start() 할 수 있으며, 한 번 실행한 Task는 재사용(재시작)이 불가합니다.

Task.Run() 메서드

var myTask = Task.Run(()=> Console.WriteLine("Run Task"));
  • Task.Run()은 Task 인스턴스를 만들고 즉시 실행합니다.
  • 내부적으로 Task를 만들고 Start()를 호출하는 것과 유사하지만, Task.Run() 메서드는 ThreadPool에 자동으로 작업을 큐에 넣습니다.

TaskFactory.StartNew() 메서드

Task myTask = Task.Factory.StartNew(()=> Console.WriteLine("Start New TaskFactory"));
  • TaskFactory는 고급 옵션을 사용하거나 Task를 좀 더 세밀하게 제어할 때 사용합니다.
  • StartNew()는 Run()과 비슷하지만, 스케줄링 동작을 더 많이 제어할 수 있습니다.

[예제]

using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;

namespace UsingTask
{
    class MainApp
    {
        static void Main(string[] args)
        {
            string srcFile = args[0];

            // 파라미터를 전달하는 Action 대리자를 사용
            Action<object> FileCopyAction = (object state) =>
            {
                string[] paths = (String[])state;
                File.Copy(paths[0], paths[1]);

                Console.WriteLine("TaskID : {0}, ThreadId : {1}, {2} was copied to {3}",
                    Task.CurrentId, Thread.CurrentThread.ManagedThreadId, paths[0], paths[1]);
            };

            // 비동기로 파일 복사를 수행하는 Task
            Task t1 = new Task(
                FileCopyAction, new string[] { srcFile, srcFile + ".copy1" });

            // 비동기로 파일 복사를 수행하는 Task
            Task t2 = Task.Run(() =>
            {
                FileCopyAction(new string[] { srcFile, srcFile + ".copy2" });
            });

            t1.Start();

            // 동기로 파일 복사를 수행하는 Task
            Task t3 = new Task(
                FileCopyAction, new string[] { srcFile, srcFile + ".copy3" });

            // 동기 실행을 위한 RunSynchronously() 메서드
            t3.RunSynchronously();

            t1.Wait();
            t2.Wait();
            t3.Wait();
        }
    }
}

실행 결과
실제 생성된 결과

 

RunSynchronously() 메서드

RunSynchronously() 메서드는 Task를 현재 스레드에서 동기적으로 실행하는 메서드입니다.

즉, Task가 다른 스레드(TrheadPool 등)에서 실행되는 것이 아니라, 현재 호출한 스레드에서 직접 실행됩니다.

 

Task가 아직 실행되지 않은 상태에서만 동작이 가능하며, 이미 시작한 Task에 대해 사용한다면 InvalidOperationException 예외가 발생합니다.


Task<TResult> 클래스

Task<TResult>는 제네릭 Task 클래스로, 비동기적으로 실행된 작업의 반환 결과를 TResult 타입으로 가져올 수 있습니다.

또한 결과값을 반환하는 비동기 작업이므로 Func<TResult> 대리자를 사용합니다.


선언 방식

var myTask = Task<List<int>>.Run()
    () =>
    {
        Thread.Sleep(1000);
        
        List<int> list = new List<int>();
        list.Add(3);
        list.Add(4);
        list.ADd(5);
        
        return list;    // Task<TResult>의 myTask의 반환값
    }
};

var myList = new List<int>();

myList.Add(0);
myList.Add(1);
myList.Add(2);

myTask.Wait();
myList.AddRange(myTask.Result.ToArray());  // myList의 요소 : 0, 1, 2, 3, 4, 5

 

반환 값

  • Task<TResult>의 결과는 .Result 속성을 통해 동기적으로(작업이 끝난 후) 가져올 수 있습니다.
  • 단, Result를 호출하면 해당 Task가 완료될 때까지 기다립니다. (즉, Task.Wait() 사용한 효과를 포함합니다.)

주요 속성과 메서드

  • Result
    • 작업 완료 후 결과 값을 동기적으로 반환합니다.
    • 예외가 발생했을 경우 예외를 던집니다.
  • IsCompleted
    • 작업 결과가 성공인지, 실패인지 무관하게 작업 완료 여부를 bool 타입으로 반환합니다.
  • IsFaulted
    • 작업 중 예외가 발생했는지를 bool 타입으로 반환합니다.
    • 내부적으로 Exception 속성에 예외 정보가 저장됩니다.
  • Exception
    • AggregateException 타입의 예외 객체입니다.
    • 작업에서 발생한 예외를 포함하며, IsFaulted가 true일 때 유효합니다.
  • ContinueWith()
    • 현재 작업이 완료된 후 실행될 후속 작업(continuation)을 정의합니다.
    • ContinueWith() 메서드는 명시적 콜백 방식이며, 가능하면 가독성 좋은 비동기 흐름 표현 방식인 async/await 사용을 권장합니다.
    • 병렬 플로우나 체이닝 작업에 유용합니다.
      • 체이닝 작업 : 작업의 결과를 다음 작업에 연결하여 순차적으로 처리하는 방식입니다.
                            각 작업은 이전 작업의 겨로가나 완료 상태에 따라 실행됩니다.

[예제]

using System;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace TaskResult
{
    class MainApp
    {
        static bool IsPrime(long number)
        {
            if (number < 2)
                return false;
            if (number % 2 == 0 && number != 2)
                return false;

            for (long i = 2; i < number; i++)
            {
                if (number % i == 0)
                    return false;
            }

            return true;
        }

        static void Main(string[] args)
        {
            long from = Convert.ToInt64(args[0]);
            long to = Convert.ToInt64(args[1]);
            int taskCount = Convert.ToInt32(args[2]);

            Func<object, List<long>> FindPrimeFunc =
                (objRange) =>
                {
                    long[] range = (long[])objRange;
                    List<long> found = new List<long>();

                    for (long i = range[0]; i < range[1]; i++)
                    {
                        if (IsPrime(i))
                            found.Add(i);
                    }

                    return found;
                };

            Task<List<long>>[] tasks = new Task<List<long>>[taskCount];
            long currentFrom = from;
            long currentTo = to / tasks.Length;

            for (int i = 0; i <tasks.Length; i++)
            {
                Console.WriteLine("Task[{0}] : {1} ~ {2}", i, currentFrom, currentTo);

                tasks[i] = new Task<List<long>>(FindPrimeFunc, new long[] {currentFrom, currentTo});

                if (i == tasks.Length - 2)
                    currentTo = to;
                else
                    currentTo = currentTo + (to / tasks.Length);
            }

            Console.WriteLine("Please press enter to start...");
            Console.ReadLine();
            Console.WriteLine("Started...");

            DateTime startTime = DateTime.Now;

            foreach (Task<List<long>> task in tasks)
                task.Start();

            List<long> total = new List<long>();

            foreach (Task<List<long>> task in tasks)
            {
                task.Wait();
                total.AddRange(task.Result.ToArray());
            }

            DateTime endTime = DateTime.Now;

            TimeSpan elapsed = endTime - startTime;

            Console.WriteLine("Prime number count between {0} and {1} : {2}", from, to, total.Count);
            Console.WriteLine("Elapsed time : {0}", elapsed);
        }
    }
}

0 1000000 1 입력 후 실행 결과
0 1000000 10 입력 후 실행 결과


Parallel 클래스

Parallel 클래스는 System.Threading.Tasks 네임스페이스에 포함되어 있으며, CPU 바운드 작업을 병렬로 수행하기에 적합합니다. 내부적으로 TreadPool을 이용하여 여러 작업을 동시에 처리합니다.


주요 메서드

Parallel.For

  • 일반 for 반복문을 병렬로 수행합니다.
  • 순서를 보장하지 않습니다.
Parallel.For(0, 10, i =>
{
    Console.WriteLine($"i = {i}, Thread = {Thread.CurrentTread.ManagedThreadId}");
});
  • 매개변수
    • 시작 인덱스
    • 종료 인덱스
    • 반복 실행할 Action 대리자

Parallel.ForEach

  • 컬렉션(IEnumerable)의 각 항목을 병렬로 처리합니다.
  • 순서를 보장하지 않습니다.
var list = new List<int> { 1, 2, 3, 4, 5 };

Parallel.ForEach(list, item =>
{
    Console.WriteLine($"item = {item}, Thread = {Thread.CurrentTread.ManagedThreadId}");
});
  • 매개변수
    • IEnumerable<TSource> : 반복할 컬렉션
    • 반복 실행할 Action 대리자

Parallel.Invoke

  • 서로 다른 여러 작업을 병렬로 동시에 수행하게 합니다.
Parallel.Invoke(
    () => Console.WriteLine("Work 1"),
    () => Console.WriteLine("Work 2"),
    () => Console.WriteLine("Work 3")
);
  • 매개변수
    • params Action[] : 병렬로 동시에 실행할 Action 대리자의 배열

특징

  • 간단한 병렬 처리
    • 루프나 여러 작업을 병렬화하는 데 매우 간단합니다.
  • 자동 스레드 분산
    • 내부적으로 스레드 풀을 관리하기 때문에 성능이 최적화되어 있습니다.
  • 동기적
    • Parallel 메서드는 호출이 완료될 때까지 블로킹 됩니다.
  • 예외 처리
    • 여러 작업 중 하나라도 예외가 발생하면 AggregateException이 발생합니다.

[예제]

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;

namespace ParallelLoop
{
    class MainApp
    {
        static bool IsPrime(long number)
        {
            if (number < 2)
                return false;
            if (number % 2 == 0 && number != 2)
                return false;

            for (long i = 2; i < number; i++)
            {
                if (number % i == 0)
                    return false;
            }

            return true;
        }

        static void Main(string[] args)
        {
            long from = Convert.ToInt64(args[0]);
            long to = Convert.ToInt64(args[1]);

            Console.WriteLine("Please press enter to start...");
            Console.ReadLine();
            Console.WriteLine("Started...");

            DateTime startTime = DateTime.Now;

            List<long> total = new List<long>();

            Parallel.For(from, to, (long i) =>
            {
                if (IsPrime(i))
                    lock (total)
                        total.Add(i);
            });

            DateTime endTime = DateTime.Now;

            TimeSpan elapsed = endTime - startTime;

            Console.WriteLine("Prime number count between {0} and {1} : {2}", from, to, total.Count);
            Console.WriteLine("Elapsed time : {0}", elapsed);
        }
    }
}

0 1000000 입력 후 실행 결과


async 한정자와 await 연산자

async 한정자는 비동기 메서드를 정의할 때 사용하는 키워드입니다.

async 한정자는 메서드, 이벤트 처리기, 태스크, 람다식 등을 수식함으로써 C# 컴파일러가 이들을 호출하는 코드를 만날 때 호출 결과를 가디리지 않고 바로 다음 코드로 이동하도록 실행 코드를 생성하게 합니다.

 

async로 한정하는 메서드는 반환 형식이 Task나 Task<TResult> 또는 void여야 한다는 제약이 있습니다.

실행하고 잊어버릴 작업을 담고 있는 메서드라면 반환 형식을  void로 선언하고, 작업이 완료될 때까지 기다리는 메서드라면 Task, Task<TResult>로 선언하면 됩니다.

 

C# 컴파일러는 Task 또는 Task<TResult> 형식의 메서드를 async 한정자가 수식하는 경우, await 연산자가 해당 메서드 내부의 어디에 위치하는지 찾습니다. 그리고 await 연산자를 찾으면 그곳에서 호출자에게 제어를 돌려주도록 실행 파일을 만듭니다.

 

※ 만약 await 연산자가 존재하지 않으면, 호출자에게 제어를 돌려주지 않으므로 해당 메서드/태스크는 동기적으로 실행됩니다.

 

await 연산자는 비동기 작업을 기다리는 연산자로, async 메서드 내에서만 사용이 가능합니다.

await 연산자는 비동기 작업이 완료될 때까지 현재 실행을 잠시 멈추고 기다립니다. 대기하는 동안 현재 스레드는 차단(Blocking)되지 않고 다른 작업을 수행할 수 있습니다. 작업이 완료되면 결과를 반환하고, 나머지 코드를 실행합니다.

 

출처 : https://nomad-programmer.tistory.com/222

 

위 그림에서 Caller()의 실행이 시작되면, 1번의 흐름을 따라 문장 1이 실행되고, 이어서 2번을 따라 MyMethodAsync() 메서드의 실행으로 제어가 이동합니다.

MyMethodAsync()에서는 3번을 따라 문장 2가 실행되면 async 람다문을 피연산자로 하는 await 연산자를 만납니다.

여기서 CLR은 4번을 따라 제어를 호출자인 Caller()에게로 이동시키고, a와 b의 흐름을 동시에 실행합니다.


[예제]

using System;
using System.Threading.Tasks;

namespace Async
{
    class MainApp
    {
        async static private void MyMethodAsync(int count)
        {
            Console.WriteLine("C");
            Console.WriteLine("D");

            await Task.Run(async () =>
            {
                for (int i = 1; i <= count; i++)
                {
                    Console.WriteLine($"{i} / {count} ...");
                    await Task.Delay(100);
                }
            });

            Console.WriteLine("G");
            Console.WriteLine("H");
        }

        static void Caller()
        {
            Console.WriteLine("A");
            Console.WriteLine("B");

            MyMethodAsync(3);

            Console.WriteLine("E");
            Console.WriteLine("F");
        }

        static void Main(string[] args)
        {
            Caller();

            Console.ReadLine();  // 프로그램 종료 방지
        }
    }
}

실행 결과


Task.Delay()

Task.Delay()가 하는 일은 인수로 입력받은 시간이 지나면 Task 객체를 반환하는 것입니다.

Thread.Sleep()과 동일한 역할을 하지만 TaskDelay()는 ThreadSleep()과 다르게 스레드를 차단(Blocking)시키지 않습니다.


  .NET이 제공하는 비동기 API

MS에서 C# 언어가 비동기 프로그래밍 패러다임을 지원하도록 만드는 한편, .NET도 기존 API에 더불어 비동기 버전 API를 새롭게 재공하도록 업그레이드 했습니다.

 

.NET에서 제공하는 비동기 API들은 주로 Async 접미사가 붙은 메서드들을 통해 비동기 I/O 작업(파일 I/O, 네트워크 통신, DB 접근 등)을 효율적으로 처리할 수 있도록 도와줍니다.


System.IO.Stream 클래스가 제공하는 읽기/쓰기 메서드

동기 버전 메서드 비동기 버전 메서드 설명
Read ReadAsync 스트림에서 데이터를 읽습니다.
Write WriteAsync 스트림에서 데이터를 기록합니다.

 

ReadAsync 메서드

public virtual Task<int> ReadAsync(
    byte[] buffer, int offset, int count, CancellationToken cancellationToken = default);
  • buffer : 데이터를 읽어들일 바이트 배열
  • offset : 데이터를 저장할 배열의 시작 인덱스
  • count : 최대 몇 바이트까지 읽을지 지정
  • 반환값 : int형 Task로, 실제로 읽은 바이트 수

WriteAsync 메서드

public virtual Task WriteAsync(
    byte[] buffer, int offset, int count, CancellationToken cancellationToken = default);
  • buffer : 쓸 데이터를 담고 있는 바이트 배열
  • offset : 배열에서 데이터를 가져올 시작 인덱스
  • count : 몇 바이트까지 쓸지 지정
  • 반환값 : 반환값은 존재하지 않으며, 내부적으로 데이터 쓰기가 끝날 때까지 기다린 후 Task를 반환합니다.

CancellationToken

  • .NET에서 비동기 또는 장시간 수행되는 작업을 취소할 수 있게 해주는 구조체이며, 해당 토큰을 사용하는 작업에 중단 요청을 보낼 수 있습니다.
  • 취소 요청으로 작업이 중단되면, OperationCanceledException 예외가 발생합니다.

 

동기 버전 파일 읽기/쓰기 코드

static long CopySync(string FromPath, string ToPath)
{
    using (var fromStream = new FileStream(FromPath, FileMode.Open))
    {
        long totalCopied = 0;
        
        using(var toStream = new FileStream(ToPath, FileMode.Create))
        {
            byte[] buffer = new byte[1024];
            int nRead = 0;
            
            while ((nRead = fromStream.Read(buffer, 0, buffer.Length)) != 0)
            {
                toStream.Write(buffer, 0, nRead);
                totalCopied += nRead;
            }
        }
        
        return totalCopied;
    }
}

 

비동기 버전 파일 읽기/쓰기 코드

async Task<long> CopyAsync(string FromPath, string ToPath)
{
    using (var fromStream = new FileStream(FromPath, FileMode.Open))
    {
        long totalCopied = 0;
        
        using(var toStream = new FileStream(ToPath, FileMode.Create))
        {
            byte[] buffer = new byte[1024];
            int nRead = 0;
            
            while ((nRead = await fromStream.ReadAsync(buffer, 0, buffer.Length)) != 0)
            {
                await toStream.WriteAsync(buffer, 0, nRead);
                totalCopied += nRead;
            }
        }
        
        return totalCopied;
    }
}

 

async로 한정한 코드를 호출하는 코드도 역시 async로 한정되어 있어야 합니다. 반환 형식은 Task 또는 void 형이어야 합니다.

ReadAsync()와 WriteAsync() 메서드 역시 async로 한정되어 있으며, 이들을 호출하려면 await 연산자가 필요합니다.


[예제]

using System;
using System.IO;
using System.Threading.Tasks;

namespace AsyncFileIO
{
    class MainApp
    {
        static async Task<long> CopyAsync(string FromPath, string ToPath)
        {
            using (var fromStream = new FileStream(FromPath, FileMode.Open))
            {
                long totalCopied = 0;

                using (var toStream = new FileStream(ToPath, FileMode.Create))
                {
                    byte[] buffer = new byte[1024];
                    int nRead = 0;

                    while ((nRead = await fromStream.ReadAsync(buffer, 0, buffer.Length)) != 0)
                    {
                        await toStream.WriteAsync(buffer, 0, nRead);
                        totalCopied += nRead;
                    }
                }

                return totalCopied;
            }
        }

        static async void DoCopy(string FromPath, string ToPath)
        {
            long totalCopied = await CopyAsync(FromPath, ToPath);
            Console.WriteLine($"Copied Total {totalCopied} Bytes.");
        }

        static void Main(string[] args)
        {
            if (args.Length < 2)
            {
                Console.WriteLine("Usage : AsyncFileIO <Source> <Destination>");
                
                return;
            }

            DoCopy(args[0], args[1]);

            Console.ReadLine();
        }
    }
}

실행 결과
실행 결과

 

'C#' 카테고리의 다른 글

[C#] 스레드 (Thread)  (0) 2025.05.23
[C#] 파일과 디렉터리 다루기, 스트림, 객체 직렬화  (0) 2025.05.23
[C#] dynamic  (0) 2025.05.16
[C#] 리플렉션과 애트리뷰트  (0) 2025.05.13
[C#] LINQ  (0) 2025.05.12