2023.03.27 - [C# 프로그래밍] - [C#] Task (1)
[C#] Task (1)
칩 제조사들은 cpu의 클럭 스피드를 올리는 것으로는 더 이상 물리적 한계에 부딪히게 되어 코어를 증가시키는 쪽으로 성능을 올렸습니다. 그렇기 때문에 성능 좋은 큰 프로그램을 만들려면 여
onesside-world.tistory.com
*동기와 비동기
task 클래스는 우리가 비동기 코드를 쉽게 작성할 수 있도록 돕습니다.
그런데 비동기 코드가 뭔지를 아직 설명을 안 적었네요.
일단, 비동기 코드를 설명하기 전에 동기 코드를 먼저 설명하겠습니다.
동기 코드는 검사가 검으로 공격할 때처럼 동작합니다.
검사가 검으로 상대를 찌른 뒤에 다시 뽑아야 검을 쓸 수 있는 것처럼,
동기 코드는 메소드를 호출한 뒤에 이 메소드의 실행이 완전히 종료되어야만 다음 메소드를 호출할 수 있습니다.
예를 들어 아래 코드처럼 Slash() 메소드가 동기로 동작하고 실행 시간이 1초라고 하면, 다음 코드로 넘어가는데 모두 3초의 시간이 소요됩니다.
Swordman obj = new Swordman();
for (int i = 0; i < 3; i++)
{
object.Slash();
}
//다음 코드
반면에 비동기 코드는 총사가 총을 쏠 때처럼 동작합니다.
총사는 총을 쏘고 나면 바로 다음 총을 쏠 준비를 합니다.
그리고 이미 발사한 총알은 그저 처음 의도한 경로대로 나아갈 뿐입니다.
총사는 발사한 총알은 더 이상 생각하지 않습니다. 그저 다음 총을 발사할 뿐입니다.
비동기 코드는 메소드를 호출한 뒤에 이 메소드의 종료를 기다리지 않고 바로 다음 코드를 실행합니다.
예를 들어 아래 코드처럼 Shoot() 메소드가 비동기로 동작한다면 해당 메소드가 언제 종료되든 관계 없이 CLR은 단숨에 다음 코드까지 실행을 합니다.
Musketeer obj = new Musketeer();
for (int i = 0; i < 3; i++)
{
object.Shoot();
}
//다음 코드
1. Task 클래스 사용하기
Task 클래스가 기본적으로 하는 일은 Action 대지자를 실행하는 겁니다.
Task 클래스는 인스턴스를 생성할 때 Action 대리자를 넘겨받습니다.
즉, 반환형을 갖지 않는 메소드와 익명 메소드, 무명 함수 등을 넘겨받습니다.
예제 코드를 보면서 설명하겠습니다.
using System;
using System.Threading.Tasks;
class Program
{
static void Main()
{
Action taskAction1 = () => {
int num = 1;
Console.WriteLine("Task 1 number: " + num);
};
Action taskAction2 = () => {
int num = 2;
Console.WriteLine("Task 2 number: " + num);
};
Action taskAction3 = () => {
int num = 3;
Console.WriteLine("Task 3 number: " + num);
};
Task t1 = new Task(taskAction1);
Task t2 = new Task(taskAction2);
Task t3 = new Task(taskAction3);
// Task 객체를 비동기로 실행
t1.Start();
t2.Start();
t3.Start();
// Task 결과를 비동기 호출이 완료될 때까지 대기
t1.Wait();
t2.Wait();
t3.Wait();
}
}


Task 클래스의 생성자와 Task.Start() 메소드를 따로 호출하는 예제 코드를 보여드렸지만, Task 클래스를 사용하는 조금 더 일반적인 방법은 Task.Factory.StartNew() 메소드 또는 Task.Run() 메소드를 이용하는 것입니다.
using System;
using System.Threading.Tasks;
class Program
{
static void Main()
{
//Task의 객체 생성과 Action 대리자 비동기 실행을 동시에
Task t1 = Task.Run(() => {
int num = 1;
Console.WriteLine("Task 1 number: " + num);
});
Task t2 = Task.Run(() => {
int num = 2;
Console.WriteLine("Task 2 number: " + num);
});
Task t3 = Task.Run(() => {
int num = 3;
Console.WriteLine("Task 3 number: " + num);
});
// Task 결과를 비동기 호출이 완료될 때까지 대기
t1.Wait();
t2.Wait();
t3.Wait();
}
}
2. Task<TResult> 클래스 사용하기
Task<TResult> 클래스는 코드의 비동기 실행 결과를 돌려줍니다.
인스턴스를 생성하고 실행하는 과정 자체는 Task 클래스의 사용법과 별로 다르지 않습니다.
다만 비동기로 수행할 코드를 Action 대리자로 받는 대신 Func 대리자로 받는 다는 점과 결과를 반환받을 수 있는 점을 생각하고 사용해야합니다.
그러면 예제 코드를 보겠습니다.
Task<int> myTask = Task.Run(() =>
{
int sum = 0;
for (int i = 0; i < 100; i++)
{
sum += i;
}
return sum;
});
myTask.Wait();
int result = myTask.Result;
Console.WriteLine($"The sum of numbers from 0 to 99 is {result}");
위 코드에서 myTask.Result 프로퍼티는 비동기 작업이 끝나야 반환되므로 myTask.Wait()는 호출하지 않아도 되지만, Task 클래스를 사용할때 Wait()를 호출하지 않는 습관이 들 수 있어 하는게 좋습니다.
다음 예제는 많이 복잡한 예제입니다.
범위 안의 소수를 찾는 프로그램으로 숫자 두 개를 입력 받고 task 갯수를 적으면 두 숫자의 범위를 task 갯수만큼 나눠서 작업하고 결과를 반환합니다.
작업 시간이 task 갯수만큼 나눠지게 돼지만 구현하기 힘든 단점을 보여주기 위해 소개하겠습니다.
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace TaskResult
{
class MainApp
{
static bool IsPrime(long number)
{
//2보다 작으면 소수가 아니므로 'false'를 반환합니다.
if (number < 2)
return false;
//숫자가 2이거나 2로 나누어 떨어지는 경우 소수가 아니므로 메서드는 'false'를 반환합니다.
if (number % 2 == 0 && number != 2)
return false;
//2보다 큰 다른 모든 홀수의 경우 메서드는 [2, number-1] 범위의 정수로 나눌 수 있는지 확인합니다.
//이러한 정수로 나눌 수 있는 경우 소수가 아니므로 이 메서드는 'false'를 반환합니다.
for (long i = 2; i < number; i++)
{
if (number % i == 0)
return false;
}
//모든 테스트를 통과한 숫자는 소수이며 메서드는 'true'를 반환합니다.
return true;
}
static void Main(string[] args)
{
Console.WriteLine("This program calculates all prime numbers between two numbers:");
Console.WriteLine("Please enter the first number: ");
//소수를 구할 첫번째 범위
long from;
//정수가 아니면 다시 정수로 적어달라고 부탁하고 반복합니다.
//정수라던 다음 단계로 넘어갑니다.
while (!long.TryParse(Console.ReadLine(), out from))
{
Console.WriteLine("Please enter a valid integer for the first number: ");
}
Console.WriteLine("Please enter the second number: ");
//소수를 구할 두번째 범위
long to;
while (!long.TryParse(Console.ReadLine(), out to))
{
Console.WriteLine("Please enter a valid integer for the second number: ");
}
Console.WriteLine("Please tell me how many tasks to create: ");
//범위를 분담해서 작업할 task 갯 수
int taskCount;
while (!int.TryParse(Console.ReadLine(), out taskCount))
{
Console.WriteLine("Please enter a valid integer for the task count: ");
}
Func<object, List<long>> FindPrimeFunc =
(objRange) =>
{
long[] range = (long[])objRange;
List<long> found = new List<long>();
//range[0]: currentFrom
//range[1]: currentTo
for (long i = range[0]; i < range[1]; i++)
{
if (IsPrime(i))
found.Add(i);
}
return found;
};
//지정된 범위 내에서 소수를 찾기 위해 생성될 개별 작업을 저장하는 데 사용되는 Task 객체의 배열을 생성합니다.
Task<List<long>>[] tasks = new Task<List<long>>[taskCount];
//currentFrom의 초기값은 범위의 시작 번호인 from으로 설정
long currentFrom = from;
//currentTo의 초기값은 분할된 범위의 크기를 나타내는 to/tasks.Length로 설정
long currentTo = to / tasks.Length;
for (int i = 0; i < tasks.Length; i++)
{
//각 작업이 담당할 숫자 범위를 출력하는 데 사용
Console.WriteLine("Task[{0}] : {1} ~ {2}",
i, currentFrom, currentTo);
//FindPrimeFunc 함수와 작업이 담당할 숫자 범위를
//포함하는 배열을 전달하여 새로운 'Task' 객체가 생성
tasks[i] = new Task<List<long>>(FindPrimeFunc,
new long[2] { currentFrom, currentTo });
//currentFrom 변수는 이전 작업의 범위 끝 +1로 업데이트되어
//서로 다른 작업 범위 사이에 겹치는 부분이 없도록 합니다.
currentFrom = currentTo + 1;
//현재 작업이 마지막 작업에서 두 번째인 경우 currentTo 변수는
//to로 설정되어 최종 작업이 범위의 나머지 숫자를 포함하도록 합니다.
if (i == tasks.Length - 2)
currentTo = to;
else
//마지막 작업이 아니라면, currentTo 변수는 업데이트되어 범위의
//크기가 작업 간에 균등하게 나누어지도록 합니다.
currentTo = currentTo + (to / tasks.Length);
}
//이제 작업이 시작합니다.
Console.WriteLine("Started...");
//작업이 얼마가 걸리는지 확인하기위한 시간 변수
//작업 실행이 시작되기 전 현재 시간을 기록합니다.
DateTime startTime = DateTime.Now;
foreach (Task<List<long>> task in tasks)
task.Start();
List<long> total = new List<long>();
//몇 번째 task 작업물인지 확인하기 위한 변수
int count = 0;
//모든 작업을 마무리할때까지 기다리고 작업 결과를 total 목록에 추가합니다.
foreach (Task<List<long>> task in tasks)
{
task.Wait();
total.AddRange(task.Result.ToArray());
count++;
//task의 결과 값들을 보여줌
Console.WriteLine("\nPrime numbers found by {0} task: {1}\n", count , string.Join(", ", total));
}
//모든 작업이 완료된 후 현재 시간을 기록합니다.
DateTime endTime = DateTime.Now;
//시작 시간과 종료 시간 사이에 경과된 시간을 계산합니다.
TimeSpan ellapsed = endTime - startTime;
Console.WriteLine("Prime number count between {0} and {1} : {2}",
from, to, total.Count);
Console.WriteLine("Elapsed time : {0}", ellapsed);
}
}
}

3. Parallel 클래스 사용하기
바로 위의 예제 프로그램은 특정 범위 안에 있는 모든 소수를 찾기 위해 여러 개의 Task 인스턴스를 생성하여 각 인스턴스에게 작업할 범위를 할당한 후, foreach 반복문을 이용하여 시동했습니다.
이렇게 시동이 걸린 각 Task 객체는 동시에 작업을 수행한 뒤 작업 결과를 반환했습니다.
Task 때문에 시간은 획기적으로 줄어들었지만 구현하기가 조금 빡셉니다;; ≧ ﹏ ≦
C# 개발사는 Parallel 클래스를 제공하여, Task<TResult>를 이용해서 구현한 병렬 처리를 더 쉽게 구현할 수 있게 해 줍니다.
다음은 예제 코드입니다.
using System;
using System.Threading.Tasks;
class Program
{
static void Main()
{
int[] arr = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
Parallel.For(0, arr.Length, i => {
lock(arr)
{
arr[i] = arr[i] * arr[i];
Console.WriteLine("Square of {0} is {1}", i, arr[i]);
}
});
Console.WriteLine("All squares computed.");
}
}
Parallel.For() 메소드는 0부터 arr.Length 값 사이의 정수를 메소드의 매개 변수로 넘깁니다.
함수를 병렬로 호출할 때 몇 개의 스레드를 사용할지는 Parallel 클래스가 내부적으로 판단하여 최적화합니다.
주의 할점으로 Parallel 클래스는 내부적으로 멀티 스레드를 사용합니다.
그러므로 스레드간에 공유되는 자원 대해서는 반드시 동기화를 해줘야 합니다.
그렇기 때문에 arr객체를 보호하기 위해 lock 키워드를 이용해서 크리티컬 섹션을 만들었습니다.
그러면 소수 찾기 프로그램 코드를 Parallel 클래스를 사용하여 얼마나 단순해지고 성능은 얼마나 나아졌는지 보겠습니다.
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)
{
Console.WriteLine("This program calculates all prime numbers between two numbers:");
Console.WriteLine("Please enter the first number: ");
long from;
while (!long.TryParse(Console.ReadLine(), out from))
{
Console.WriteLine("Please enter a valid integer for the first number: ");
}
Console.WriteLine("Please enter the second number: ");
long to;
while (!long.TryParse(Console.ReadLine(), out to))
{
Console.WriteLine("Please enter a valid integer for the second number: ");
}
Console.WriteLine("Started...");
DateTime startTime = DateTime.Now;
List<long> total = new List<long>();
//Parallel 클래스 사용
Parallel.For(from, to, (long i) =>
{
if (IsPrime(i))
//크리티컬 섹션 안하면 오류남
lock (total)
{
total.Add(i);
}
});
DateTime endTime = DateTime.Now;
TimeSpan ellapsed = endTime - startTime;
Console.WriteLine("Prime number count between {0} and {1} : {2}",
from, to, total.Count);
Console.WriteLine("Ellapsed time : {0}", ellapsed);
}
}
}

2023.03.28 - [C# 프로그래밍] - [C#] async 한정자와 await 연산자
[C#] async 한정자와 await 연산자
async 한정자 async 한정자는 메소드, 이벤트 처리기, 태스크, 람다식 등을 수식합니다. C# 컴파일러가 async 한전자로 수식한 코드의 호출자를 만날 때 호출 결과를 기다리지 않고 바로 다음 코드로
onesside-world.tistory.com
'C# 프로그래밍' 카테고리의 다른 글
[C#] TCP/IP 네트워크 (1) (1) | 2023.03.28 |
---|---|
[C#] async 한정자와 await 연산자 (0) | 2023.03.28 |
[C#] Task (1) (1) | 2023.03.27 |
[C#] Action 대리자와 Func 대리자 (0) | 2023.03.16 |
[C#] 스레드 사용법(2) (0) | 2023.03.14 |