[C#] 스레드와 태스크

Yijun Jeon·2023년 10월 10일
0

C#

목록 보기
7/7
post-thumbnail

[이것이 C#이다] 19장 스레드와 태스크

스레드(Thread)

👉 프로세스(Process)란 실행 파일이 실행되어 메모리에 적재된 인스턴스

  • 프로세스는 반드시 하나 이상의 스레드로 구성

👉 스레드(Thread)란 운영체제가 CPU 시간을 할당하는 기본 단위

👍 멀티 스레드 활용 시 장점

  • 응답성을 높일 수 있음
    • ex) 파일 복사를 하면서 사용자의 명령을 입력 받음
  • 자원 공유가 쉬움
    • 스레드끼리 코드 내 변수를 같이 사용하는 것만으로 데이터 교환 가능
    • 프로세스끼리 테이터 교환은 소켓이나 공유 메모리 같은 IPC 이용 필요
  • 경제적
    • 프로세스에 이미 할당된 메모리와 자원을 그대로 사용 가능

👎 멀티 스레드 활용 시 단점

  • 구현이 복잡
  • 소프트웨어 안전성을 악화시킬 수 있음
    • 자식 스레드의 문제가 전체 프로세스에 영향을 미칠 수 있음
  • 과용 시 성능 저하
    • 과도한 스레드 사용 시 작업 간 전환(Context Switching) 비용 증가

System.Threading.Thread

생성 및 실행

🔗 Thread 인스턴스 생성 및 실행 예

// 스레드 실행 대상 메소드
static void DoSomething()
{
	for(int i=0;i<5;i++)
    {
    	Console.WriteLine("DoSomething : {0}", i);
        // 인수(밀리초) 만큼 CPU 사용을 멈춤
        Thread.Sleep(10:
    }
}

static void Main(string[] args)
{
	// Thread의 인스턴스 생성
	Thread t1 = new Thread(new ThreadStart(DoSomething);
    
    // 스레드 시작
    t1.Start();
    
    // 스레드의 종료 대기
    t1.Join();
}

임의 종료

.NET 프레임워크에서만 지원하는 Thread.Abort() 메소드

💡 Abort() 메소드는 호출과 동시에 스레드를 즉시 종료하지는 않음

  • 해당 스레드가 실행 중이던 코드에 ThreadAbortException 발생
  • 예외 catch 코드가 있을 시 처리 후 finally 블록까지 실행한 뒤 스레드 완전히 종료
  • 추가 처리시간까지 고려 필요

👉 도중에 강제로 중단되더라도 프로세스 자신이나 시스템에 영향을 받지 않는 작업에 한해서만 사용하는 것이 좋음

🔗 Thread 임의 종료 예

// 스레드 실행 대상 메소드
static void DoSomething()
{
	try
    {
		for(int i=0;i<5;i++)
    	{
    		Console.WriteLine("DoSomething : {0}", i);
        	Thread.Sleep(10:
    	}
    }catch(ThreadAbortedException){ ... }
    finally{ ... }
}

static void Main(string[] args)
{
	Thread t1 = new Thread(new ThreadStart(DoSomething);
    t1.Start();
    
    // 스레드 취소(종료)
    t1.Abort();
    
    t1.Join();
}

스레드 상태

👉 ThreadState 열거형에 정의

  • Flag 애트리뷰트 : 자신이 수식하는 열거형을 비트 필드로 처리할 수 있음
  • 스레드가 동시에 둘 이상의 상태일 수 있기 때문에 이를 표현하기 위해 필요

상태 변화 규칙

🔗 Flag 활용 예시

[Flags]
enum MyEnum{
	Apple = 1 << 0, // 1(0001)
    Orange = 1 << 1, // 2(0010)
}

Console.WriteLine((MyEnum)1); // Apple
Console.WriteLine((MyEnum)2); // Orange
Console.WriteLine((MyEnum)(1 | 2)); // Apple, Orange (0011)

💡 ThreadState 필드를 통해 상태를 확인할 때는 반드시 비트 연산을 이용

if(t1.ThreadState & ThreadState.Aborted == ThreadState.Aborted)
	Console.WriteLine("스레드가 정지했습니다.");
else if(t1.ThreadState & ThreadState.Stopped == ThreadState.Stopped)
	Console.WriteLine("스레드가 취소됐습니다.");

인터럽트

스레드를 안전하게 임의로 종료하기 위한 Thread.Interrupt() 메소드

💡 Interrupt() 메소드는 스레드가 한참 동작 중인 상태를 피해서 스레드를 중지시킴

  • Running 상태를 피해 WaitJoinSleep 상태에 들어갔을 때
  • 이미 WaitJoinSleep 상태라면 즉시
  • ThreadInterruptedException 예외를 던짐

👉 절대로 중단되면 안 되는 작업의 안전성 보장 가능

🔗 인터럽트 임의 종료 예

static void Main(string[] args)
{
	Thread t1 = new Thread(new ThreadStart(DoSomething);
    t1.Start();
    
    // 스레드 취소(종료)
    t1.Interrupt();
    
    t1.Join();
}

스레드 간 동기화

동기화(Synchronization) 란 스레드들이 순서를 갖춰 자원을 사용하게 하여 질서를 정해주는 것

👉 자원을 한 번에 하나의 스레드가 사용하도록 보장하는 것이 사명

lock 키워드

임계 영역(critical section) : 한 번에 한 스레드만 사용할 수 있는 코드 영역

  • lock 키워드로 처리 가능
  • 한 스레드가 처리중이면 다른 스레드는 절대 해당 부분을 실행할 수 없음

🔗 lock 키워드 사용 예

class Counter
{
	private readonly object thisLock = new object();
	public void Increase()
    {
    	lock(thisLock)
        {
        	count = count + 1;
        }
	}
}

MyClass obj = new MyCLass();
Thread t1 = new Thread(new ThreadStart(obj.Increase);
Thread t2 = new Thread(new ThreadStart(obj.Increase);

t1.Start();
t2.Start();

t1.Join();
t2.Join();

// 2가 보장됨
Console.WriteLine(obj.count); 

⭐️ lock은 다른 스레드들이 대기하게 되므로, 스레드의 동기화를 설계할 때는 크리티컬 섹션을 반드시 필요한 곳에만 사용하여 성능 저하를 최소화해야함

💡 lock 키워드의 매개변수는 참조형이면 모두 가능
But 외부 코드에서도 접근할 수 있는 변수는 사용 금지

  • public
  • this
  • Type 형식
  • string 형식

Monitor 클래스

Monitor 클래스는 스레드 동기화에 사용하는 몇 가지 정적 메소드를 제공

💡 lock 키워드는 Monitor 클래스의 Enter() & Exit() 메소드를 바탕으로 구현

따라서 다음 두 코드는 동일한 역할

public void Increase()
{
	lock(thisLock)
	{
		count = count + 1;
	}
}

public void Increase()
{
	Monitor.Enter(thisLock);
    try
    {
    	count = count + 1;
    }
    finally
    {
    	Monitor.Exit(thisLock);
    }    
}

⭐️ Monitor.Wait() & Monitor.Pulse() 메소드를 활용한 섬세한 동기화 제어 (저수준 동기화)

  • Monitor.Wait() : 스레드를 WaitSleepJoin 상태로 만듦

    • 스레드는 lock을 내려놓고 Waiting Queue 에 들어감
  • Monitor.Pulse() : Wait() 메소드를 바로 깨워주는 역할

    • Waiting Queue 의 처음 스레드를 Ready Queue 에 입력
    • 입력된 차례에 따라 lock을 얻어 Running 상태로 들어감

멀티 스레드 애플리케션의 성능 향상에 사용됨

🔗 Wait & Pulse 사용 예

bool lockedCount = false;

lock(thisLock)
{
	// 다른 스레드가 작업중이므로 대기
	while(count > 0 || lockedCount == true)
    	Monitor.Wait(thisLock);
    
    lockedCount = true;
    count++;
    lockdCount = false;
    
    // 다른 Wait 스레드를 깨워줌
    Monitor.Pulse(thisLock);
}

태스크(Task)

  • 병렬 처리 : 하나의 작업을 여러 작업자가 나눠서 수행한 뒤 다시 하나의 결과로 만드는 것

  • 비동기 처리 : 작업 A를 시작하고 마냥 대기하는 대신 곧이어 다른 작업 B,C..를 수행하다가 A가 끝나면 결과를 받아내는 처리 방식

  • 동기 처리 : 하나의 작업이 끝날 때까지 대기

System.Threading.Tasks 네임스페이스는 병행성 코드나 비동기 코드 활용을 돕는 여러 클래스 제공

System.Threading.Tasks.Task 클래스

Task

Task 클래스 : 인스턴스를 생성할 때 Action 대리자를 넘겨 받아 비동기 호출

🔗 Task 인스턴스 생성 및 실행 예

Action someAction = () =>
{
	Thread.Sleep(1000);
    Console.WriteLine("Printed asynchronously.");
};

Task myTask = new Task(someAction);
// 생성자에서 넘겨받은 무명 함수 비동기 호출
myTast.Start();

...or...

// Task의 생성과 시작을 한꺼번에 처리
var myTask = Task.Run( () =>
	{
    	Thread.Sleep(1000);
        Console.WriteLine("Printed asynchronously.");
	}
};

Console.WriteLine("Printed synchronously.");

// 비동기 호출이 완료될 때까지 대기
myTask.Wait();

실행 결과

Printed synchronously.
Printed asynchronously.

👉 Task 클래스는 동기 실행을 위한RunSynchronously() 메소드도 제공

Task<TResult>

Task<TResult> : 인스턴스를 생성할 때 Func 대리자를 넘겨 받아 비동기 호출 후 결과 반환

  • Task.Result 로 반환

🔗 Task<TResult> 인스턴스 생성 및 실행 예

var myTask = Task<List<int>>.Run( () => 
	{
    	Thread.Sleep(1000);
        
        List<int> list = new List<int>();
        list.Add(2);
        list.Add(3);
        
        return list;
	}
);

var myList = new List<int>();
myList.Add(0);
myList.Add(1);

myTask.Wait();
myList.AddRange(myTask.Result.ToArray());
// 리스트 요소 - 0,1,2,3

Parallel 클래스

System.Threading.Tasks.Parallel : For(), Foreach() 등의 메소드를 제공하여 병렬 처리를 쉽게 도와줌

💡 병렬 처리에 몇 개의 스레드를 사용할 지는 내부적으로 판단하여 최적화함

🔗 사용 예

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

Parallel.ForEach(total, prime =>
{
	Console.WriteLine("{0} is prime",prime);
});

async 한정자 & await 연산자

👉 async 한정자 는 메소드, 이벤트 처리기, 태스크, 람다식 등을 수식하여 C# 컴파일러가 해당 호출 코드를 만날 때 결과를 기다리지 않고 바로 다음 코드로 이동하도록 처리

💡 반환 형식은 반드시 다음 세가지 중 하나

  • Task
  • Task<TResult>
  • void

⭐️ 기본 형식

public static async Task MyMethodAsync()
{
	...
}

async 한정await 연산자를 만나는 곳에서 호출자에게 제어를 돌려주며, await 연산자가 없는 경우 동기로 실행됨

다음 그림과 같이 a와 b의 흐름을 동시에 실행하게 됨

Task.Delay() 메소드 : Thread.Sleep()의 비동기 버전
👉 인수로 입력받은 시간이 지나면 Task 객체를 반환

  • 스레드를 블록시키지 않아서 Thread.Sleep()과 달리 해당 메소드의 반환 여부와 관계없이 UI 작동 가능

추가 비동기 API

.NET 클래스 라이브러리에서 ~Async() 양식의 메소드들

💡 async로 한정한 코드를 호출하는 코드도 역시 async로 한정되어 있어야 함

🔗 ReadAsync & WriteAsync 사용 예

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;
    }
}

long result = await CopyAsync(FromPath, ToPath);

0개의 댓글