본문으로 바로가기

[C#] 스레딩(threading)

category 개발자과정준비/C# 2021. 10. 21. 16:43
반응형

스레딩(threading)

스레드(thread)는 명령어를 실행하기 위한 스케줄링 단위이며, 프로세스 내부에서 생성할 수 있다. 이는 운영체제에서 멀티 스레딩을 지원한다면 하나의 프로세스가 여러 개의 스레드 자원을 가질 수 있음을 의미한다.

 

윈도우 사용자는 'Ctrl + Shift + ESC' 키를 눌러 직접 작업관리자를 통해 스레드를 볼 수 있다. 

 

윈도우는 멀티 스레딩을 지원하는 운영체제이므로 다음과 같이 프로세스당 여러 개의 스레드가 생성되있는 것을 확인할 수 있다.

작업관리자 -> 세부정보 를 통해 스레드를 볼 수 있다

 

 

윈도우는 프로세스를 생성할 때 기본적으로 한 개의 스레드를 함께 생성하며, 이를 주 스레드(main thread, primary thread)라고 한다.

 

스레드는 CPU의 명령어 실행과 관련된 정보를 보관하고 있는데, 이를 스레드 문맥(thread context)이라 한다.

운영체제의 스케줄러는 실행돼야 할 적절한 스레드를 골라서 CPU로 하여금 실행되게 만드는데, 이때 2가지 동작을 수행한다. CPU는 현재 실행 중인 스레드를 다음에 다시 이어서 실행할 수 있게 CPU의 환경 정보를 스레드 문맥에 보관한다. 그리고 운영체제로부터 할당받은 스레드의 문맥 정보를 다시 CPU 내부로 로드해서 마치 해당 스레드가 실행되고 있었던 상태인 것처럼 복원한 다음, 일정 시간 동안 실행을 계속한다.

스레드의 문맥 교환

이때 CPU가 기존에 실행하던 스레드가 text.exe에 속해 있다고 하고, 새롭게 할당된 스레드도 test.exe에 속했다고 가정해보자. 그런 경우 운영체제는 프로세스가 바뀐 것은 아니므로 프로세스의 문맥 정보를 바꾸지는 않는다. 하지만 다른 프로세스에 속한 스레드로 실행이 변경되면 프로세스의 문맥 정보까지 바뀌게 되고 이 작업은 더 많은 CPU 작업을 소모한다.

 

C#은 다중 스레드 응용 프로그램을 만들 수 있고, 멀티 CPU/코어의 기능을 활용할 수 있게 병렬 라이브러리를 지원한다. 이번에는 스레딩과 관련된 타입을 알아보자.

 

 

 

System.Threading.Thread

프로그램이 실행되면 주 스레드가 하나 기본적으로 생성된다. 주 스레드는 컴파일된 C# 코드를 순차적으로 실행해 나간다. 즉, 지금까지 실습한 모든 예제는 주 스레드가 하나 생성되어 실행된 것이라 볼 수 있다.

그럼 그 스레드의 존재를 확인하는 방법을 알아보자.

Thread 타입에는 현재 명령어를 실행 중인 스레드 자원에 접근할 수 있는 정적 속성을 제공한다. 이를 활용하면 다음의 코드처럼 프로그램을 실행하고 있는 스레드의 상태를 알 수 있다.

Thread thread = Thread.CurrentThread;
Console.WriteLine(thread.ThreadState); // 출력 결과 Running

 

자주 사용되는 Thread의 정적 메서드로는 Sleep 메서드가 있다. 이를 이용하면 현재 Running 상태인 스레드의 실행을 지정된 밀리초만큼 ThreadStste.WaitSleepJoin 상태로 변경할 수 있다. 쉽게 말해서 실행이 중단되는 것이다. 

중단된 스레드는 지정된 시간이 지난 후 다시 Running 상태로 돌아온다.

 

Console.WriteLine(DateTime.Now);  // 출력결과 : 오늘날짜 및 현재시간
Thread.Sleep(1000);  // 1초 동안 스레드 중지
Console.WriteLine(DateTime.Now);  // 출력결과 : 오늘날짜 및 현재시간(윗줄에서 +1초되어서 출력된다)

 

이제 새로운 스레드를 하나 생성해보자. 스레드는 실행된 명령어가 필요하므로 명령어의 묶음인 메서드를 Thread 생성자에 전달해야 한다. 일단 스레드 개체가 생성되면 Start 메서드를 호출하는 것으로 스레드를 시작할 수 있다.

 

using System;
using System.Threading;

namespace ThreadTest001_del
{
    class Program
    {
        static void Main(string[] args)
        {
            Thread t = new Thread(threadFunc);
        }

        private static void threadFunc()
        {
            Console.WriteLine("threadFunc run!");
        }
    }
}

이 동작을 그림으로 설명하면 다음 이미지와 같다.

스레드 생성

 

새롭게 생성된 스레드는 별도로 명령어를 실행해 나간다. 최근 다중 코어 CPU에서는 실제로 주 스레드와 t 스레드의 코드를 동시에 실행할 수 있다고 한다.

 

스레드의 종료는 결국 프로그램의 종료에 해당한다. 기본적으로 프로그램은 생성된 모든 스레드가 실행을 종료해야만 프로그램도 종료할 수 있다. 따라서 다음과 같이 새롭게 생성된 스레드에서 실행을 계속하고 있으면 EXE 프로세스는 해당 스레드가 끝날 때 까지 종료하지 않는다.

 

using System;
using System.Threading;

namespace ThreadTest001_del
{
    class Program
    {
        static void Main(string[] args)
        {
            Thread t = new Thread(threadFunc);
            t.Start();
            // 더는 주 스레드가 실행할 명령어가 없으므로 주 스레드는 제거됨.
        }

        private static void threadFunc()
        {
            Console.WriteLine("5초 후에 프로그램 종료");
            Thread.Sleep(1000 * 5);  // 5초 동안 실행 중지
                                     // 현재 주 스레드는 종료됐어도 t 스레드는 존속한다.
            Console.WriteLine("스레드 종료!");
        }
    }
}

실행화면 (5초후에 메세지가 출력된다)

 

이처럼 프로그램의 실행 종료에 영향을 미치는 스레드를 가리켜 전경 스레드(foreground thread)라 한다. 

이름에서 유추할 수 있듯이 배경 스레드(background thread)도 있으며, 이 유형은 실행 종료에 영향을 미치지 않는다. 

Thread 타입의 IsBackground 속성을 true로 바꿔 전경 스레드 동작을 배경 스레드로 바꿀 수 있다.

 

Thread t = new Thread(threadFunc);
t.IsBackgound = true;
t.Start();

 

IsBackground를 방금 했던 예제 코드에 적용해보자.

그럼 화면에 아무것도 출력되지 않고 종료되거나 아주 낮은 확률로 threadFunc() 메서드의 첫번째 Console.WriteLine 메서드가 실행되는 것을 볼 수 있다.

왜냐하면 새롭게 생성된 스레드의 종료 여부에 상관없이 Main 메서드를 실행하는 스레드가 완료되면 프로세스 자체가 종료되기 때문이다.

 

여기서 한 가지 의문이 생길 수 있는데, 주 스레드에서 분명히 스레드 객체의 Start 메서드를 실행했는데 어째서 threadFunc()에 있는 단 한줄의 코드조차도 실행되지 않는 것이냐는 의문이다.

 

그 이유는 스레드가 CPU에 의해 선택되어 실행 될 수 있는 단계까지 시간이 걸리기 때문이다. 즉, threadFunc을 실행해야할 스레드가 운영체제의 스케줄러에의해 선택되기도 전에 Main 메서드를 실행하는 스레드가 종료됐으므로 그런 현상이 발생한 것이다.

 

때로는 다른 스레드의 실행이 종료되기까지 기다려야 할 수도 있다. 이를 위해 Thread 타입의 Join 메서드를 사용할 수 있다. 다음 예제는 새로운 스레드(t)가 배경 스레드임에도 주 스레드가 Join 메서드를 호출해 t 스레드의 실행이 종료될 때까지 기다린다.

 

// Join 메서드 사용 예제
using System;
using System.Threading;

namespace ThreadTest001_del
{
    class Program
    {
        static void Main(string[] args)
        {
            Thread t = new Thread(threadFunc);
            t.IsBackground = true;
            t.Start();
            t.Join();  // t 스레드가 종료할 때까지 현재 스레드를 무한 대기
            Console.WriteLine("주 스레드 종료!");
        }

        private static void threadFunc()
        {
            Console.WriteLine("5초 후에 프로그램 종료");
            Thread.Sleep(1000 * 5);  // 3초 동안 실행 중지
                                     // 현재 주 스레드는 종료됐어도 t 스레드는 존속한다.
            Console.WriteLine("스레드 종료!");
        }
    }
}

실행화면

이를 그림으로 표현하면 아래 이미지와 같다.

Thread.Join 메서드의 역할

 

스레드를 시작하는 측에서 인자를 전달하는 것도 가능하다. 이를 위해 object 타입의 인자를 하나 전달받는 스레드 메서드를 준비하고 Thread.Start 메서드에 직접 값을 넣으면 된다.

 static void Main(string[] args)
{
       Thread t = new Thread(threadFunc);
       t.Start(10);
}

private static void threadFunc(object initialValue)
{
       int intValue = (int)initialValue;
       Console.WriteLine(intValue);    // 출력 결과 : 10
}

위 예제는 하나의 값만 전달하고 있는데, 만약 여러 개의 값일때는 어떻게 할까?

threadFunc 메서드의 인자 타입이 object인 것을 감안하면 전달할 값의 수만큼 필드를 포함한 클래스를 만들어 그 객체를 전달하면 된다.

 

using System;
using System.Threading;

namespace ThreadTest001_del
{
    class ThreadParam
    {
        public int Value1;
        public int Value2;
    }
    class Program
    {
        static void Main(string[] args)
        {
            Thread t = new Thread(threadFunc);
            
            ThreadParam param = new ThreadParam();
            param.Value1 = 10;
            param.Value2 = 20;
            t.Start(param);
        }

        static void threadFunc(object initialValue)
        {
            ThreadParam value = (ThreadParam)initialValue;
            Console.WriteLine("{0}, {1}", value.Value1, value.Value2);  // 출력 결과 : 10, 20
        }
    }
}

실행화면

 

마지막으로 스레드 사용의 이점을 경험할 수 있는 예제를 작성해보자. 간단하게 사용자가 입력한 수까지 루프를 돌면서 소수의 개수를 세는 프로그램을 만들텐데, 우선 스레드를 사용하지 않는 예제부터 살펴보자.

// 스레드를 사용하지 않는 소수 개수 계산 프로그램
using System;
using System.Threading;

namespace ThreadTest001_del
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("입력한 숫자까지의 소수 개수 출력 (종료 : 'x' + Encter)");

            while(true)
            {
                Console.Write("숫자를 입력하세요 : ");
                string userNumber = Console.ReadLine();

                if(userNumber.Equals("x", StringComparison.OrdinalIgnoreCase) == true)
                {
                    Console.WriteLine("프로그램 종료");
                    break;
                }
                CountPrimeNumbers(userNumber);
            }
        }

        private static void CountPrimeNumbers(object initialValue)
        {
            string value = (string)initialValue;
            int primeCandidate = int.Parse(value);
            int totalPrimes = 0;
            for(int i = 2; i<primeCandidate; i++)
            {
                if(IsPrime(i) == true)
                {
                    totalPrimes++;
                }
            }
            Console.WriteLine("숫자 {0}까지의 소수 개수 : {1}", value, totalPrimes);
        }


        // 소수 판정 메서드
        static bool IsPrime(int candidate)
        {
            if((candidate & 1) == 0)
            {
                return candidate == 2;
            }

            for(int i = 3; (i * i) <= candidate; i+=2)
            {
                if ((candidate % i) == 0) return false;
            }

            return candidate != 1;
        }
    }
}

스레드를 사용하지않은 소수 계산 프로그램

 

위 예제를 실행하고 숫자를 입력하면 0부터 입력한 숫자까지 소수의 개수가 출력된다.

이때 숫자를 점점 크게 입력하면 조금씩 느려지는 것을 확인할 수 있다.

숫자가 100000000 정도되면 CPU가 한참 동안 일을 한 다음에야 5761455라는 답을 내게 될 것이다.

스레드를 사용하지 않았기 때문에 주 스레드가 계산 작업에 매달리는 동안 사용자는 다른 어떤 키도 입력할 수 없다.

심지어 결과를 기다리지 않고 프로그램을 종료하기 위한 'x+Enter' 키를 입력하는 작업조차 할 수 없이 기다려야한다.

 

이 프로그램을 스레드를 사용하는 버전으로 바꿔보자.

간단하게 Main 메서드의 일부만 변경하면 된다.

// 스레드를 사용한 소수 개수 계산 프로그램(메인 메서드만)
static void Main(string[] args)
{
       Console.WriteLine("입력한 숫자까지의 소수 개수 출력 (종료 : 'x' + Encter)");

       while(true)
       {
             Console.Write("숫자를 입력하세요 : ");
             string userNumber = Console.ReadLine();

             if(userNumber.Equals("x", StringComparison.OrdinalIgnoreCase) == true)
             {
                 Console.WriteLine("프로그램 종료");
                 break;
             }
             Thread t = new Thread(CountPrimeNumbers);
             t.IsBackground = true;
             t.Start(userNumber);
       }
}

 

 

 

아래는 전체 코드이다.

// 스레드를 사용한 소수 개수 계산 프로그램(전체 코드)
using System;
using System.Threading;

namespace ThreadTest001_del
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("입력한 숫자까지의 소수 개수 출력 (종료 : 'x' + Encter)");

            while(true)
            {
                Console.Write("숫자를 입력하세요 : ");
                string userNumber = Console.ReadLine();

                if(userNumber.Equals("x", StringComparison.OrdinalIgnoreCase) == true)
                {
                    Console.WriteLine("프로그램 종료");
                    break;
                }
                Thread t = new Thread(CountPrimeNumbers);
                t.IsBackground = true;
                t.Start(userNumber);
            }
        }

        private static void CountPrimeNumbers(object initialValue)
        {
            string value = (string)initialValue;
            int primeCandidate = int.Parse(value);
            int totalPrimes = 0;
            for(int i = 2; i<primeCandidate; i++)
            {
                if(IsPrime(i) == true)
                {
                    totalPrimes++;
                }
            }
            Console.WriteLine("숫자 {0}까지의 소수 개수 : {1}", value, totalPrimes);
        }


        // 소수 판정 메서드
        static bool IsPrime(int candidate)
        {
            if((candidate & 1) == 0)
            {
                return candidate == 2;
            }

            for(int i = 3; (i * i) <= candidate; i+=2)
            {
                if ((candidate % i) == 0) return false;
            }

            return candidate != 1;
        }
    }
}

실행화면

 

프로그램을 실행하고 숫자 100000000를 입력해보자.

이번에는 출력값을 기다리지않고 "x + Enter" 키를 입력할 수 있어 프로그램이 자연스럽게 종료되는 것을 확인할 수 있다.

 

스레드를 사용하지 않았을 때는 프로그램의 명령을 실행할 수 있는 유일한 주 스레드가 계산작업을 하느라 바빴다.

스레드는 순차적으로만 작업을 처리할 수 있기 때문에 계산 작업이 완료되기 전에는 그다음 명령어를 실행할 수 없다.

위의 소수 개수 계산 프로그램은 CountPrimeNumbers 메서드의 코드를 주 스레드가 마치기 전에는 Console.ReadLine 명령어를 실행할 수 없으므로 사용자 입력을 전혀 받을 수 없었던 것이다.

 

반면 계산 작업을 별도의 스레드에 맡겼을 때는 상황이 달라진다. 계산 작업을 할 필요가 없는 주 스레드는 Thread.Start 메서드를 실행한 후 곧바로 다음 명령어를 실행한다. 결국 Console.ReadLine이 실행되기 때문에 사용자는 프로그램에 값을 입력할 수 있게 되는 것이다.

반응형

'개발자과정준비 > C#' 카테고리의 다른 글

[C#] 비동기 호출  (0) 2021.11.02
[C#] 문자열(String) 처리 - 2  (0) 2020.09.27
[C#] 문자열(String) 처리 - 1  (0) 2020.09.26
[C#] 시간(DateTime, TimeSpan, Diagnostics.Stopwatch)  (0) 2020.09.19
[C#] LiNQ  (0) 2020.08.29