본문 바로가기

C#

Task와 Thread 차이

728x90

프로세스(Process)와 쓰레드(Thread)

프로세스는 실행 파일이 실행되어 메모리에 적재된 인스턴스입니다. 운영체제는 여러가지 프로세스를 동시에 실행할 수 있는 능력을 갖추고 있습니다. 즉 컴퓨터로 Youtube에서 노래를 들으면서 코딩을 할 수 있습니다.

그런데, 프로세스도 한 번에 여러가지 작업을 수행할 수 있습니다. 쓰레드는 운영체제가 CPU 시간을 할당하는 기본 단위인데, 프로세스는 하나 이상의 쓰레드로 구성됩니다.

 

쓰레드의 장점

- 사용자 대화형 프로그램에서 응답성을 높일 수 있다.(프로그램이 무슨 일을 하고 있을 때 대기 할 필요없이 다른 일을 진행할 수 있다)

- 멀티 프로세스 방식에 비해 멀티 스레드 방식이 자원 공유가 쉽다.(프로세스끼리 데이터를 교환할 때 IPC(Inter Process Communication)을 이용해야 하지만, 쓰레드는 코드 내의 변수를 같이 사용하기만 하면 된다.)

- 쓰레드를 사용하면 이미 프로세스에 할당된 메모리와 자원을 그대로 사용한다. ( 멀티 프로세스는 프로세스를 띄우기 위해 메모리와 자원을 할당하는 작업을 진행해야 한다.)

 

쓰레드의 단점

- 멀티 스레드에서 자식 스레드가 문제가 생기면 전체 프로세스가 영향을 받게 된다. (멀티 프로세스는 자식이 문제가 생기면 해당 프로세스만 죽습니다.)

- 멀티 스레드 구조의 소프트웨어는 구현하기가 까다롭다. (테스트가 어렵고 디버깅 또한 쉽지 않습니다.)

- 스레드가 CPU를 사용하기 위해서는 작업간 전환(Context Switching)을 해야 한다. (자주 작업 간 전환을 하게 되면 성능이 저하된다.)

 

스레드의 상태

 상태   설명 
 Unstarted  쓰레드 객체를 생성한 후 Thread.Start() 메소드가 호출 되기 전의 상태입니다.
 Running  쓰레드가 시작하여 동작 중인 상태입니다.
 Unstarted 상태의 쓰레드를 Thread.Start() 메소드를 통해 이 상태로 만들 수 있습니다.
 Suspended  쓰레드의 일시 중단 상태입니다.
 쓰레드를 Thread.Suspend() 메소드를 통해 이 상태로 만들 수 있으며, Suspended 상태인 쓰레드는
 Thread.Resume() 메소드를 통해 다시 Running 상태로 만들 수 있습니다.
 WaitSleepJoin  쓰레드가 블록(Block)된 상태입니다.
 쓰레드에 대해 Monitor.Enter(), Thread.Sleep(), Thread.Join() 메소드를 호출하면 이 상태가 됩니다.
 Aborted  쓰레드가 취소된 상태입니다.
 Thread.Abort() 메소드를 호출하면 이 상태가 됩니다. Aborted 상태가 된 쓰레드는 다시 Stopped 상태로 전
 환되어 완전히 중지됩니다.
 Stopped  중지된 쓰레드의 상태입니다.
 Thread.Abort() 메소드를 호출하거나 쓰레드가 실행 중인 메소드가 종료되면 이 상태가 됩니다.
 Background  쓰레드가 백그라운드로 동작되고 있음을 나타냅니다. Foreground 쓰레드는 하나라도 살아 있는 한 프로세스
 가 죽지 않지만, Background는 여러개가 살아 있어도 프로세스가 죽고 사는 것에는 영향을 미치지 않습니다
 하지만 프로세스가 죽으면 Background 쓰레드는 모두 죽습니다. Thread.IsBackground 속성에 true 값을
 입력하면 쓰레드를 이 상태로 바꿀 수 있습니다.

 

쓰레드 종료하기

스레드는 스스로 할 일을 마치고 종료하는 것이 가장 좋겠지만, 스레드를 종료시켜야 할 경우가 있습니다.

Thread.Abort() 메소드로 가능하지만, 이는 스레드를 강제로 종료시켜버립니다. 즉, 도중에 작업이 강제로 종료되도 프로세스 자신이나 시스템에 전혀 영향이 없는 작업에 한해 사용하는 것이 좋습니다. 만약, 수행중인 작업이 시스템에 영향이 있을 거라 판단된다면 다음과 같이 스레드를 종료시켜야 합니다.

 

Thread.Interrupt() 메소드는 스레드가 Running State를 피해서 WaitJoinSleep State에 들어갔을 때 TreadInterruptedException 예외를 던져 스레드를 중지시킵니다. 따라서 절대로 중단되면 안 되는 작업을 할 때 이렇게 안정성이 보장된 방법을 사용해야 합니다.

 

쓰레드 간의 동기화하기

각 스레드들은 여러가지 자원을 공유하는 경우가 많습니다. 스레드가 어떤 자원을 사용하고 있는데, 도중에 다른 스레드가 이 자원을 사용한다면 문제가 발생할 수 있습니다.

예를 들면 은행에서 돈을 인출해주려고 할 때, ATM 기기에서, 휴대폰에서, 인터넷 뱅킹으로, 각각 비슷한 시간에 전재산을 인출해달라고 요청한다면 은행이 3번 모두 전재산을 인출시킨다면 문제가 있겠지요

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
 
using System.Threading;
 
namespace CsharpStudy
{
    class Program
    {
        class Account
        {
            public int money = 1000;
 
            public void withdraw()
            {
                if (money <= 0)
                {
                    Console.WriteLine("잔액이 모자랍니다.");
                }
                else
                {
                    money -= 1000;
                }
            }
        }
 
        static void Main(string[] args)
        {
            Account account = new Account();
            Thread ATM = new Thread(new ThreadStart
(account.withdraw));
            Thread Phone = new Thread(new ThreadStart(account.withdraw));
            Thread Internet = new Thread(new ThreadStart(account.withdraw));
 
            Console.WriteLine("ATM");
            ATM.Start();
            Console.WriteLine("Phone");
            Phone.Start();
            Console.WriteLine("Internet");
            Internet.Start();
        }
    }
}

위의 코드 결과가 잔액이 모자랍니다가 나올 수도 있고 안 나올수도 있습니다. 동시에 진행되어 3번 무사히 출금이 이루어 질 수도 있는 것입니다.

따라서, 스레드들이 순서를 갖춰 자원을 사용할 수 있도록 동기화(Synchronization)를 해주어야 합니다. 자원을 한 번에 하나의 스레드만 사용할 수 있도록 보장해야 합니다.

C# 에서는 스레드 간에 동기화하는 도구로 lock 키워드와 Monitor 클래스를 제공합니다.

 

lock 키워드로 동기화하기

한번에 한 스레드만 사용할 수 있는 크리티컬 섹션(Critical Section)인 코드 영역을 만들어 주어야 합니다.

C#에서는 lock 키워드로 감싸주기만 하면 크리티컬 섹션으로 바꿀 수 있습니다.

private readonly object thisLock= new object();
public void withdraw()
{
	lock(thisLock) // 크리티컬 섹션영역이 됩니다. 한 쓰레드가 이 코드를 실행하면서
    {			   // lock 블록이 끝나기 전까지 다른 쓰레드는 이 코드를 실행할 수 없습니다.
    	if (money <= 0)
        {
        	Console.WriteLine("잔액이 모자랍니다.");
        }
        else
        {
        	money -= 1000;
        }
    }
}

lock 키워드는 사용하는 것 자체는 쉽습니다. 하지만, 스레드들이 lock 키워드를 만나 크리티컬 섹션을 생성하려고 할 때 이미 하나의 스레드가 사용 중이면 락을 얻을 수가 없습니다. 즉, 계속 대기하는 상황이 벌어집니다. 이렇게, 소프트웨어의 성능이 크게 떨어집니다. 따라서 스레드의 동기화를 설계할 때 크리티컬 섹션을 반드시 필요한 곳에만 사용하는 것이 중요합니다.

또, lock 키워드의 매개변수로 사용하는 객체는 참조형이면 어느 것이든 쓸 수 있지만, public 키워드 등을 통해 외부 코드에서도 접근할 수 있는 다음 세가지는 절대 사용하지 않기를 권합니다.

- this : 클래스의 인스턴스는 클래스 내부뿐만 아니라 외부에서도 자주 사용됩니다. lock (this)는 좋지 않습니다.

- Type 형식 : typeof 연산자나 object 클래스로부터 물려받은 GetType() 메소드는 코드 어느 곳에서나 특정 형식에 대한 Type 객체를 얻을 수 있습니다. lock(typeof(SomeClass)), lock(obj.GetType())은 좋지 않습니다.

- string 형식 : 절대 string 객체로 lock 하지마시기 바랍니다. lock("abc") 는 좋지 않습니다.

 

Monitor 클래스로 동기화하기

public void withDraw()
{
	lock(thisLock)
    {
    	if(money <= 0)
        {
        	Console.WriteLine("잔액이 모자랍니다.");
        }
        else
        {
        	money -= 1000;
        }
    }
}
public void withDraw()
{
	Monitor.Enter(thisLock);
    
	try
    {
    	if(money <= 0)
        {
        	Console.WriteLine("잔액이 모자랍니다.");
        }
        else
        {
        	money -= 1000;
        }
    }
    finally
    {
    	Monitor.Exit(thisLock);
    }
}

위 두가지 방식은 같은 방법입니다. lock 키워드는 Monitor 클래스의 Enter()와 Exit() 메소드를 바탕으로 구현되어 있습니다. 그럼에도 불구하고 Monitor 클래스 방식을 적는 이유는 Monitor.Wait() 메소드와 Monitor.Pulse() 메소드로 더욱 섬세하게 멀티 스레드간의 동기화를 가능하게 해줄 수 있습니다.

Wait()과 Pulse() 메소드는 반드시 lock 블록 안에서 호출해야 합니다. (그렇지 않으면 CLR이 SynchronizationLockException을 던집니다.)

스레드가 WaitSleepJoin 상태가 되면, 동기화를 위해 갖고 있던 lock을 놓고 Waiting Queue에 입력되고, 다른 스레드가 lock을 얻어 작업을 수행하게 됩니다.

Thread.Sleep() 메소드도 스레드를 WaitSleepJoin State가 될 수 있지만, Monitor.Pulse() 메소드에 의해 깨어날 수 없습니다. 다시 Running State 가 되려면 매개 변수로 입력된 시간이 경과되거나 interrupt() 메소드 호출에 의해 깨어날 수 있습니다.

반면에 Monitor.Wait() 메소드는 Monitor.Pulse() 메소드가 호출되면 바로 깨어날 수 있습니다. 따라서 멀티 스레드 프로그램의 성능 향상을 위해서 Monitor.Wait() 와 Monitor.Pulse() 를 사용합니다.

 

사용방법은 다음과 같습니다.

1. 클래스 안에 동기화 객체 필드를 선언합니다.
2. 스레드를 WaitSleepJoin State로 바꿔 블록키리 조건 (Wait()를 호출할 조건)을 결정할 필드를 선언합니다.
3. 스레드를 블록시키고 싶은 곳에서는 lock 블록안에서 2번 과정에서 선언한 필드를 검사하여 Monitor.Wait()을 호출합니다.
4. 3번 과정에서 선언한 코드는 lockedCount가 true면 해당 스레드를 블록시킵니다. 블록된 스레드가 깨어나면 lockedCount를 true로 변경합니다. 다른 스레드가 이 코드에 접근하면 3번 과정에서 선언했던 블로킹 코드에 걸려 같은 코드를 실행할 수 없습니다.
작업을 마치면 lockedCount의 값을 다시 false로 바꾼 뒤 Monitor.Pulse()를 호출합니다. 그럼 Waiting Queue에 대기하고 있던 다른 스레드가 깨어나서 false로 바뀐 lockedCount를 보고 작업을 수행합니다.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
 
using System.Threading;
 
namespace CsharpStudy
{
    class Program
    {
        class Account
        {
            public int money = 1000;
             
            private readonly object thisLock = new object();
            private bool lockedCount= false; // 다른 쓰레드가 공유된 자원을 사용하고 있는지 판별하기 위해 사용됨
 
            public void withdraw()
            {
                lock (thisLock)
                {
                    while (lockedCount == true) // 다른 쓰레드에 의해 true로 바뀌어있으면 현재 쓰레드를 블록시킵니다.
                        Monitor.Wait(thisLock);    // 다른 쓰레드가 Pulse()를 호출해 줄때 까지는 WaitSleepJoin State 에 남습니다.
 
                    lockedCount = true;
 
                    if (money <= 0)
                    {
                        Console.WriteLine("잔액이 모자랍니다.");
                    }
                    else
                    {
                        money -= 1000;
                    }
 
                    lockedCount = false; // 다른 쓰레드를 꺠웁니다. 
                                              // 깨어난 쓰레드들은 while의 조건검사를 통해 Wait()를 호출할지 코드를 실행할지 결정합니다.
 
                    Monitor.Pulse(thisLock);
                }
            }
        }
 
        static void Main(string[] args)
        {
            Account account = new Account();
            Thread ATM = new Thread(new ThreadStart(account.withdraw));
            Thread Phone = new Thread(new ThreadStart(account.withdraw));
            Thread Internet = new Thread(new ThreadStart(account.withdraw));
 
            Console.WriteLine("ATM");
            ATM.Start();
            Console.WriteLine("Phone");
            Phone.Start();
            Console.WriteLine("Internet");
            Internet.Start();
        }
    }
}

 

테스크(Task)

CPU가 발전하면서 클럭을 높이는 방향에는 한계에 다다르자, 하나의 CPU안에 여러개의 코어를 집적하는 방향으로 제품을 향상시키기 시작했습니다. 이러한 하드웨어의 변화에 맞춰 소프트웨어도 변화를 최대로 활용할 수 있는 방법이 등장하고 있습니다. .Net Framework에는 System.Threading.Tasks에는 병행성 코드나 비동기 코드의 실행을 돕는 클래스들이 들어 있습니다. (Task 또한 내부적으로 Thread로 구현됩니다.)

Task 클래스를 이용하여 비동기(Asynchronous) 코드를 작성할 수 있습니다.

 

Task<TResult> 클래스는 코드의 비동기 실행 결과를 얻을 수 있습니다.

Task 클래스는 비동기로 수행할 코드를 Action 델리게이트로 주는 반면 Task<TResult>는 Func 델리게이트로 줍니다.

즉, Task<TResult> 비동기 작업이 끝나면 Task<>.Result 프로퍼티에 값을 반환하게 됩니다.

 


 

Task는 구현하고 싶은 작업 자체를 의미하며,

Thread는 구현하고 싶은 작업을 수행하는 수많은 작업자들 중 하나를 의미한다.

 

Task를 사용하면 어디에서 실제로 해당 작업을 수행했는지 신경 쓰지 않기 때문에 실행자를 제어할 수 없다.(전체 프로세스가 하나의 스레드에 의해 처리되는지 / 멀티 스레드로 처리되는지 모름)

 

결론 :

실행 중인 작업을 언제든지 중지할 수 있는 제어 수준 필요 : Thread 사용

실행 중인 작업이 완료된 후 중지되는 정도의 제어 수준 필요 : Task 사용

(반복문 내에서 Cancellation Token을 사용하면 중지할 수 있지만, 해당 반복문을 진행한 뒤 중지됨)

 

[출처]

https://nshj.tistory.com/entry/C-%EA%B8%B0%EC%B4%88%EB%AC%B8%EB%B2%95-11-%EC%93%B0%EB%A0%88%EB%93%9CThread%EC%99%80-%ED%85%8C%EC%8A%A4%ED%81%ACTask

https://hvyair.tistory.com/53

728x90