본문 바로가기
ZAION/C++

[이것이 C#이다]Ch.19 스레드와 태스크 - 1

by 우기37 2024. 2. 1.

## 목차

1) 프로세스와 스레드

 1. 스레드 시작하기

 2. 스레드 임의로 종료하기

 3. 스레드의 일생과 상태 변화

 4. 인터럽트 : 스레드를 임의로 종료하는 다른 방법

 5. 스레드 간 동기화

 

 

 

#1 프로세스와 스레드

회사 프로그램의 소스 코드를 분석하는 중 스레드에 대한 개념이 빈약하여 소스 코드를 분석하는데 어려움을 느꼈습니다.

그래서 궁금증을 해소하기 위해 스레드에 대해서 먼저 개념을 익혀보겠습니다.

 

프로세스실행 파일이 실행되어 메모리에 적재된 인스턴스입니다. 가령 word.exe가 실행 파일이라면, 이 실행 파일에 담겨 있는 데이터와 코드가 메모리에 적재되어 동작하는 것이 프로세스입니다.

 

프로세스는 반드시 하나 이상의 스레드로 구성되는데, 스레드운영체제가 CPU 시간을 할당하는 기본 단위입니다.

 

이미지 출처 : https://velog.io/@gkqls813/C-%EC%8A%A4%EB%A0%88%EB%93%9C%EC%99%80-%ED%83%9C%EC%8A%A4%ED%81%AC

 

 

멀티스레드 장점

 

응답성을 높일 수 있다.

사용자 대화형 프로그램에서(콘솔 프로그램과 GUI 프로그램 모두) 멀티 스레드를 이용하면 응답성을 높일 수 있습니다.

 

ex) 단일 스레드 프로그램에서 파일을 복사하는데 30분이나 걸린다고 합니다. 취소를 하고 싶어도 프로그램은 사용자에게 반응하지 않아 취소가 불가능합니다. 이 프로그램에 사용자와의 대호를 위한 스레드를 하나 더 추가한다면 파일 복사를 하면서도 사용자로부터 명령을 입력받을 수 있게 됩니다.

 

자원 공유가 쉽다.

멀티 프로세스 방식에 비해 멀티 스레드 방식이 자원 공유가 쉽습니다.

 

멀티 프로세스 : GUI가 없는 웹 서버처럼 서버용 애플리케이션에서 많이 취하는 구조인데, 프로세스끼리 데이터를 교환하여면 소켓이나 공유 메모리 같은 IPC(Inter Process Communication)를 이용해야 합니다.

 

반면에, 훨씬 쉽게

 

멀티 스레드 : 스레드끼리 코드 내 변수를 같이 사용하는 것만으로도 데이터를 교환할 수 있습니다.

 

경제적이다.

프로세스를 띄우기 위해 메모리와 자원을 할당하는 작업은 비용이 비싼데, 스레드를 띄울 때는 이미 프로세스에 할당된 메모리와 자원을 그대로 사용하므로 메모리와 자원을 할당하는 비용을 지불하지 않아도 됩니다.

 

 

멀티스레드 단점

 

구현이 복잡하다.

멀티 스레드 구조의 소프트웨어는 구현하기가 매우 까다롭습니다. 테스트 역시 쉽지 않은데다 멀티 스레드 기반의 소프트웨어 디버깅은 개발자의 분노를 끌어올리는 최고의 촉매로 유명합니다.

 

소프트웨어 안정성을 악화시킬 수 있다.

멀티 프로세스 기반의 소프트웨어는 여러 자식 프로세스 중 하나에 문제가 생기면 그 자식 프로세스 하나가 죽는 것 이상의 영향이 확산되지는 않습니다.

 

반면에, 멀티 스레드 기반의 소프트웨어에서는 자식 스레드 중 하나에 문제가 생기면 전체 프로세스에 영향을 미칩니다.

 

과용하면 성능이 저하될 수 있다.

스레드를 너무 많이 사용하면 오히려 성능이 더 저하됩니다.

스레드가 CPU를 사용하기 위해서는 작업 간 전환을 해야하는데, 이에 상당한 비용이 소모됩니다. 애플리케이션이 실제로 일하는 시간에 비해 작업 간 전환에 사용하는 시간이 커지기 때문에 성능이 저하됩니다.

 

 

1. 스레드 시작하기

.NET은 스레드를 제어하는 클래스로 System.Threading.Thread를 제공합니다.

방법은 다음 순서와 같습니다.

 

  1. Thread의 인스턴스를 생성한다. 이때 생성자의 인수로 스레드가 실행할 메소드를 넘깁니다.
  2. Thread.Start() 메소드를 호출하여 스레드를 시작합니다.
  3. Thread.Join() 메소드를 호출하여 스레드가 끝날 때까지 기다립니다.

[실습]

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

namespace ThisIsCSharpExam.Ch._19.ThreadExam
{
    class BasicThread
    {
        public void Main()
        {
            Thread t1 = new Thread(new ThreadStart(DoSomething));	// 1.Thread의 인스턴스 생성

            Console.WriteLine("Starting thread...");
            t1.Start();	// 2.스레드 시작

	// t1 스레드의 DoSomething() 메소드가 실행되는 동시에 메인 스레드의 이 반복문도 실행됩니다.
            for (int i = 0; i < 5; i++)
            {
                Console.WriteLine($"Main : {i}");
                // Sleep() 메소드를 만나면 인수(10)만큼 CPU 사용을 멈춥니다. 이 때 인수 단위는 밀리초입니다.
                Thread.Sleep(10);
            }
            Console.WriteLine("Waiting until thread stops...");
            t1.Join();	// 3.스레드의 종료 대기

            Console.WriteLine("Finished");
        }
        static void DoSomething()	// 스레드가 실행할 메소드
        {
            for (int i = 0; i < 5;i++)
            {
                Console.WriteLine($"DoSomething : {i}");
                Thread.Sleep(10);
            }
        }
    }
}

 

기초를 중심으로 블로그를 작성하는 것이어서 코드 분석 또한 자세히 해보겠습니다.

 

이 코드에서 실제 스레드가 메모리에 적재되는 시점은 t1.Start() 메소드를 호출(2) 했을 때 입니다.

Thread 클래스의 인스턴스는 '준비'만 해둘 뿐입니다.

 

t1.Start() 메소드가 호출되고 나면, CLR은 스레드를 실제로 생성하여 DoSomething() 메소드를 호출합니다.

t1.Join() 메소드는 블록되어 있다가 DoSomething() 메소드의 실행이 끝나면, 다시 말해 t1스레드의 실행이 끝나면 반환되어 다음 코드를 실행할 수 있게 합니다.

 

이미지 출처 :&nbsp;https://velog.io/@gkqls813/C-%EC%8A%A4%EB%A0%88%EB%93%9C%EC%99%80-%ED%83%9C%EC%8A%A4%ED%81%AC

 

 

2. 스레드 임의로 종료하기

이어서 설명할 Thread.Abort() 메소드는 .NET 프레임워크에서만 지원됩니다.

 

사용자가 작업 관리자 등을 이용해서 프로세스를 임의로 죽일 수 있습니다. 아쉽게도 프로세스 안에서 동작하는 각 스레드는 그런 식으로 죽일 수 없습니다.

살아 있는 스레드를 죽이려면 그 스레드를 나타내는 Thread 객체의 Abort() 메소드를 호출해줘야 합니다.

 

Abort() 메소드를 사용할 때는 고려해야 할 사항이 있습니다. Abort() 메소드가 호출과 동시에 스레드를 즉시 종료하지 않는다는 점입니다.

Thread 객체에 Abort() 메소드를 호출하면 CLR은 해당 스레드가 실행 중이던 코드에 ThreadAbortException을 던집니다. 이 때 이 예외를 catch하는 코드가 있으면 이 예외 처리한 다음, finally 블록까지 실행한 후에야 해당 스레드는 완전히 종료됩니다.

 

그래서 Abort() 메소드를 호출할 때는 이 처리 시간을 반드시 염에 둬야 하는 것입니다.

 

이미지 출처 :&nbsp;https://velog.io/@gkqls813/C-%EC%8A%A4%EB%A0%88%EB%93%9C%EC%99%80-%ED%83%9C%EC%8A%A4%ED%81%AC

 

[실습]

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

namespace ThisIsCSharpExam.Ch._19.ThreadExam
{
    class AbortingThread
    {
        public void Main()
        {
            SideTask task = new SideTask(100);
            Thread t1 = new Thread(new ThreadStart(task.KeepAlive));
            t1.IsBackground = false;

            Console.WriteLine("Starting thread...");
            t1.Start();

            Thread.Sleep(100);

            Console.WriteLine("Aborting thread...");
            t1.Abort();

            Console.WriteLine("Wating until thread stops...");
            t1.Join();

            Console.WriteLine("Finished...");
        }
        class SideTask
        {
            int count;
            public SideTask(int count)
            {
                this.count = count;
            }
            public void KeepAlive()
            {
                try
                {
                    while (count > 0)
                    {
                        Console.WriteLine($"{count--} left");
                        Thread.Sleep(10);
                    }
                    Console.WriteLine("Count : 0");
                }
                catch (ThreadAbortException e)
                {
                    Console.WriteLine(e);
                    Thread.ResetAbort();
                }
                finally
                {
                    Console.WriteLine("Clearing resource...");
                }
            }
        }
    }
}

 

 

코드를 자세히 살펴보겠습니다.

 

1. Main() 메서드가 호출되면 스레드의 실행이 시작됩니다.
2. SideTask 클래스의 인스턴스가 생성됩니다. 이 때, 생성자에 정수 값 100이 전달됩니다.
3. KeepAlive() 메서드를 스레드로 실행하기 위해 Thread 클래스의 인스턴스인 t1이 생성됩니다.
4. IsBackground 속성이 false로 설정되어 t1 스레드가 백그라운드 스레드가 아닌 주 스레드로 설정됩니다.
5. t1.Start()를 호출하여 KeepAlive() 메서드가 실행되기 시작합니다.
6. KeepAlive() 메서드는 count 변수의 값이 100부터 1까지를 출력하고 10밀리초 간격으로 대기합니다.
7. Thread.Sleep(100)을 호출하여 메인 스레드가 100밀리초 동안 대기합니다.
8. 100밀리초가 지난 후에 메인 스레드는 t1.Abort()를 호출하여 t1 스레드를 중단시킵니다.
9. t1.Abort() 메서드 호출로 인해 KeepAlive() 메서드에서 ThreadAbortException이 발생합니다.
10. catch (ThreadAbortException e) 블록이 실행되어 예외를 처리하고 Thread.ResetAbort()를 호출하여 스레드의 중단 요청을 해제합니다.
11. finally 블록이 실행되어 "Clearing resource..."를 출력합니다.
12. t1.Join()을 호출하여 메인 스레드가 t1 스레드의 종료를 대기합니다.
13. t1 스레드가 종료되면 메인 스레드는 "Finished..."를 출력하고 종료됩니다.

 

 

3. 스레드의 일생과 상태 변화

스레드는 삶의 대부분을 일하는 데 보내지만, 마냥 기다려야 할 때도 있으며 갑작스럽게 해고당해 일을 그만두기도합니다. 스레드는 그렇게 살다가 죽습니다.

 

.NET은 스레드의 살태를 ThreadState 열거형에 정의해뒀습니다.

 

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

 

이들 ThreadState 열거형의 멤버에 대해 한가지 더 알아야 할 점은, ThreadState가 Flags 애트리뷰트를 갖고 있다는 점입니다. Flags는 자신이 수식하는 열거형을 비트 필드, 즉 플래그 집합으로 처리할 수 있음을 나타냅니다.

 

*비트 필드(Bit Field) : 원래 C 언어 등에서 구조체를 선언할 때 바이트 단위가 아닌 비트 단위로 선언한 필드를 말하는데, 주로 비트 단위의 플래그(Bit Flag)를 표현하기 위해 사용했습니다.

 

스레드는 동시에 둘 이상의 상태일 수 있습니다. 가령 Suspended 상태이면서 WaitSleepJoin 상태일수도 있고, Background 상태이면서 Stopped 상태일 수도 있습니다. 그래서 둘 이상의 상태를 동시에 표현하고자 ThreadState에  Flags 애트리뷰트가 있는 것입니다.

 

[실습]

using System;
using System.Threading;

namespace ThisIsCSharpExam.Ch._19.ThreadExam
{
    class UsingThreadState
    {
        private static void PrintThreadState(ThreadState state)
        {
            Console.WriteLine("{0, -16} : {1}", state, (int)state);
        }
        public void Main() {

        PrintThreadState(ThreadState.Running);

        PrintThreadState(ThreadState.StopRequested);

        PrintThreadState(ThreadState.SuspendRequested);

        PrintThreadState(ThreadState.Background);

        PrintThreadState(ThreadState.Unstarted);

        PrintThreadState(ThreadState.Stopped);

        PrintThreadState(ThreadState.WaitSleepJoin);

        PrintThreadState(ThreadState.Suspended);

        PrintThreadState(ThreadState.AbortRequested);

        PrintThreadState(ThreadState.Aborted);

        PrintThreadState(ThreadState.Aborted | ThreadState.Stopped);
        }
    }
}

 

 

2의 제곱수를 2진수로 바꾸면 다음 표에 나타나 있는 것처럼 항상 딱 한 자리만 1이고 나머지는 0으로 채워집니다. 이렇게 해두면 비트 연산을 통해 ThreadState 가 어떤 상태에 있는지 쉽게 알아낼 수 있습니다.

 

상태 10진수 2진수
Running 0 000000000
StopRequested 1 000000001
SuspendRequested 2 000000010
Background 4 000000100
Unstarted 8 000001000
Stopped 16 000010000
WaitSleepJoin 32 000100000
Suspended 64 001000000
AbortRequested 128 010000000
Aborted 256 100000000

 

Thread 객체의 ThreadState 필드를 통해 상태를 확인할 때는 반드시 비트 연산을 이용해야합니다. 지금까지 이야기해온 것처럼 ThreadState 열거형이 여러 상태를 동시에 나타낼 수 있도록 만들어져 있기 때문입니다.

 

 

4. 인터럽트 : 스레드를 임의로 종료하는 다른 방법

수명이 다해 스레드가 스스로 종료하는 것이 가장 좋지만, 불가피하게 스레드를 상제로 종료해야 하는 경우가 있습니다.

Abort() 메소드를 사용할 때는 도중에 강제로 중단된다 해도 프로세스 자신이나 시스템에 영향을 받지 않는 작업에 한해 사용하는 것이 좋습니다.

 

Thread.Interrupt() 메소드는 스레드가 한참 동작 중인 상태(Running상태)를 피해서 WaitJoinSleep 상태에 들어갔을 때 ThreadInterruptedException 예외를 던져 스레드를 중지시킵니다. 둘 다 비슷하지만, Thread.Interrupt() 메소드가 조금 더 신사적이라고 할 수 있습니다.

 

또한, Thread.Interrupt() 메소드는 스레드가 이미 WaitSleepJoin 상태에 있을 때는 즉시 중단시키지만, 다른 상태일 때는 스레드를 지켜보고 있다가 WaitSleepJoin 상태가 되면 그제서야 스레드를 중단시킵니다. 이런 특징 때문에 프로그래머는 최소한 코드가 '절대로 중단되면 안 되는' 작업을 하고 있을 때는 중단되지 않는다는 보장을 받을 수 있습니다.

 

이미지 출처 : https://velog.io/@gkqls813/C-%EC%8A%A4%EB%A0%88%EB%93%9C%EC%99%80-%ED%83%9C%EC%8A%A4%ED%81%AC

 

[실습]

using System;
using System.Threading;

namespace ThisIsCSharpExam.Ch._19.ThreadExam
{
    class InterruptingThread
    {
            public void Main()
            {
                SideTask task = new SideTask(100);
                Thread t1 = new Thread(new ThreadStart(task.KeepAlive));
                t1.IsBackground = false;

                Console.WriteLine("Starting thread...");
                t1.Start();

                Thread.Sleep(100);

                Console.WriteLine("Interrupting thread...");
                t1.Interrupt();

                Console.WriteLine("Wating until thread stops...");
                t1.Join();

                Console.WriteLine("Finished...");
            }
            class SideTask
            {
                int count;
                public SideTask(int count)
                {
                    this.count = count;
                }
                public void KeepAlive()
                {
                    try
                    {
                    Console.WriteLine("Running thread isn't gonna be interrupted");
                    Thread.Sleep(100);

                    while (count > 0)
                        {
                            Console.WriteLine($"{count--} left");

                        Console.WriteLine("Entering into WaitJoinSleep State...");
                        Thread.Sleep(10);
                        }
                        Console.WriteLine("Count : 0");
                    }
                    catch (ThreadInterruptedException e)
                    {
                        Console.WriteLine(e);
                    }
                    finally
                    {
                        Console.WriteLine("Clearing resource...");
                    }
                }
            }
        }
    }

 

 

 

5. 스레드 간 동기화

프로그래머에게는 여러 자원을 공유하고 제멋대로 사용해버리는 스레드들이 정연하게 자원을 사용할 수 있도록 질서를 잡아야 할 책임이 있습니다. 스레드들이 순서를 갖춰 자원을 사용하게 하는 것을 일컬어 '동기화'라고 하는데, 이것을 제대로 하는 것이야말로 멀티 스레드 프로그래밍을 완벽하게 하는 길이라고 할 수 있습니다.

 

스레드 동기화에서 가장 중요한 것은 '자원을 한 번에 하나의 스레드가 사용하도록 보장' 라는 것입니다.

 

.NET이 제공하는 대표적인 도구로 lock 키워드와 Monitor 클래스가 있습니다.

lock 키워드는 사용하기가 쉽고, Movitor 클래스는 더 섬세한 동기화 제어 기능을 제공하고 있습니다.

 

lock 키워드로 동기화하기

크리티컬 섹션은 한 번에 한 스레드만 사용할 수 있는 코드 영역을 말하는데, C#에서는 lock 키워드로 감싸주기만 해도 평범한 코드를 크리티컴 섹션으로 바꿀 수 있습니다.

 

class Counter
{
	public int count = 0;
    public void Increase()
    {
    	count = count + 1;
    }
}

//...
CounterClass obj = new CounterClass();
Thread t1 = new Thread(new ThreadStart(obj.Increase));
Thread t2 = new Thread(new ThreadStart(obj.Increase));
Thread t3 = new Thread(new ThreadStart(obj.Increase));

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

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

Console.WriteLine(obj, count);

 

이 코드에서 출력하는 결과값은 3일 수도 있고 아닐 수도 있습니다.

원인은 count = count + 1; 코드에 있습니다. 더 이상 쪼갤 수 없는 원자처럼 보이지만 count + 1만 해도 내부적으로 여러 단계의 하위 연산으로 나눠지는 코드입니다. CPU 입장에서는 꽤 긴 코드입니다.

 

이 코드에서 t1, t2, t3 스레드가 항상 사이 좋게 동작하리라 기대하지만 현실은 그렇지 않습니다.

t1스레드가 Increase() 메소드를 한참 실행하다 미쳐 연산을 마치기 전에 t2 스레드가 같은 코드를 실행하고, t2가 아직 연산을 마치지 않았는데 t3도 같은 코드를 실행하면 obj.count는 0인 채로 연산을 당하다가 세 개 스레드가 작업을 마쳤는데도 값은 1에 불과한 결과를 맞게 됩니다. 더 심각한 문제는 이 코들르 실행할 때마다 결과가 1,2,3 중 어떤 것이 나올지 모른다는 점입니다.

 

이 문제를 해결하기 위해서는 count = count + 1 코드를 한 스레드가 실행하고 있을 때 다른 스레드는 실행하지 못하도록 하는 장치가 필요합니다. 그 장치가 바로 크리티컬 섹션이고 lock 키워드를 이용해서 만들 수 있습니다.

 

하지만, lock 키워드를 사용할 때 따라오는 고민도 있습니다.

 

스레드들이 lock 키워드를 만나 크리티컬 섹션을 생성하려고 할 때 다른 스레드들이 자기들도 크리티컬 섹션을 만들어야하니 lock을 달라고 대기하는 상황 입니다. 이런 경우 소프트웨어의 성능이 크게 떨어집니다. 따라서 스레드의 동기화를 설계할 때는 크리티컬 섹션을 반드시 필요한 곳에만 사용하는 것이 중요합니다.

 

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

 

  • this
  • Type 형식
  • string 형식

이들은 컴파일 검사를 통과하지만, 다른 자원에 대해 동기화해야 하는 스레드도 예기치 않게 대기하는 상황을 만들기 때문입니다.

 

[실습]

using System;
using System.Threading;

namespace ThisIsCSharpExam.Ch._19.ThreadExam
{
    class Synchronize
    {
        public void Main()
        {
            Counter counter = new Counter();

            Thread incThread = new Thread(
                new ThreadStart(counter.Increase));
            Thread decThread = new Thread(
                new ThreadStart(counter.Decrease));

            incThread.Start();
            decThread.Start();

            incThread.Join();
            decThread.Join();

            Console.WriteLine(counter.Count);
        }
    }
    class Counter
    {
        const int LOOP_COUNT = 1000;

        readonly object thisLock;

        private int count;
        public int Count
        {
            get { return count; }
        }
        public Counter()
        {
            thisLock = new object();
            count = 0;
        }
        public void Increase()
        {
            int loopCount = LOOP_COUNT;

            while (loopCount-- > 0)
            {
                lock (thisLock)
                {
                    count++;
                }
                Thread.Sleep(1);
            }
        }
        public void Decrease()
        {
            int loopCount = LOOP_COUNT;
            while (loopCount-- > 0)
            {
                lock (thisLock)
                {
                    count--;
                }
                Thread.Sleep(1);
            }
        }
    }
}

 

 

Monitor 클래스로 동기화하기

Monitor 클래스는 스레드 동기화에 사용하는 몇 가지 정적 메소드를 제공합니다. 먼저 만나볼 메소드는 Monitor.Enter()와 Monitor.Exit() 입니다.

 

  • Monitor.Enter() : 크리티컬 섹션 생성
  • Monitor.Exit() : 크리티컬 섹션 제거

[실습]

(이전 예제에서 lock 키워드를 Monitor.Enter()와 Monitor.Exit() 메소드로 바꾼 코드입니다.)

using System;
using System.Threading;

namespace ThisIsCSharpExam.Ch._19.ThreadExam
{
    class UsingMonitor
    {
        public void Main()
        {
            Counter counter = new Counter();

            Thread incThread = new Thread(
                new ThreadStart(counter.Increase));
            Thread decThread = new Thread(
                new ThreadStart(counter.Decrease));

            incThread.Start();
            decThread.Start();

            incThread.Join();
            decThread.Join();

            Console.WriteLine(counter.Count);
        }
    }
    class Counter
    {
        const int LOOP_COUNT = 1000;

        readonly object thisLock;

        private int count;
        public int Count
        {
            get { return count; }
        }
        public Counter()
        {
            thisLock = new object();
            count = 0;
        }
        public void Increase()
        {
            int loopCount = LOOP_COUNT;

            while (loopCount-- > 0)
            {
                Monitor.Enter(thisLock);
                try
                {
                    count++;
                }
                finally
                {
                    Monitor.Exit(thisLock);
                }
                Thread.Sleep(1);
            }
        }
        public void Decrease()
        {
            int loopCount = LOOP_COUNT;
            while (loopCount-- > 0)
            {
                Monitor.Enter(thisLock);
                try
                {
                    count--;
                }
                finally
                {
                    Monitor.Exit(thisLock);
                }

                Thread.Sleep(1);
            }
        }
    }
}

 

 

Monitor.Wait()와 Monitor.Pulse()로 하는 저수준 동기화

Monitor.Wait() 와 Monitor.Pulse() 메소드는 단순히 lock 키워드만 사용할 때보다 더 섬세하게 멀티 스레드 간 동기화를 가능하게 해줍니다.

 

두 메소드는 반드시 lock 블록 안에서 호출해야 합니다.

lock을 걸어 놓지 않은 상태에서 호출하면 CLR이 SynchronizationLockException 예외를 던지는 광경을 봐야 하기 때문입니다.

 

Wait() 메소드는 WaitSleepJoin 상태로 만듭니다. 이렇게 WaitSleepJoin 상태에 들어간 스레드는 동기화를 위해 갖고 있던 lock을 내려놓은 뒤 Waiting Queue라고 하는 큐(먼저 입력된 요소가 먼저 출력되는 자료구조)에 입력되고, 다른 스레드가 락을 얻어 작업을 수행합니다.

 

작업을 수행하던 스레드가 일을 마친 뒤 Pulse() 메소드를 호출하면 CLR은 Waiting Queue에서 첫 번재 위치에 있는 스레드를 꺼낸 뒤 Ready Queue에 입력합니다. 그리고 입력된 차례에 따라 락을 얻어 Running 상태에 들어갑니다. 즉, 다시 작업을 수행하게 됩니다.

 

이미지 출처 : https://velog.io/@gkqls813/C-%EC%8A%A4%EB%A0%88%EB%93%9C%EC%99%80-%ED%83%9C%EC%8A%A4%ED%81%AC

 

한편, Thread.Sleep() 메소드도 스레드를 WaitSleepJoin 상태로 만들기는 하지만 Monitor.Pulse() 메소드에 의해 깨어날 수는 없습니다.

 

다시 Running 상태로 돌아오려면 매개변수에 입력된 시간이 경과되거나 인터럽트 예외를 받아야 깨어납니다.

반면에, Monitor.Wait() 메소드는 Monitor.Pulse() 메소드가 호출되면 바로 깨어납니다.

 

이 때문에 멀티 스레드 애플리케이션의 성능 향상을 위해서 Monitor.Wait()와 Monitor,Pulse() 메소드를 사용하는 것입니다.

 

[실습]

(이전 예제에서 lock 키워드를 Monitor.Wait()와 Monitor.Pulse() 메소드로 바꾼 코드입니다.)

using System;
using System.Runtime.InteropServices;
using System.Threading;

namespace ThisIsCSharpExam.Ch._19.ThreadExam
{
    class WaitPulse
    {
        public void Main()
        {
            Counter counter = new Counter();

            Thread incThread = new Thread(
                new ThreadStart(counter.Increase));
            Thread decThread = new Thread(
                new ThreadStart(counter.Decrease));

            incThread.Start();
            decThread.Start();

            incThread.Join();
            decThread.Join();

            Console.WriteLine(counter.Count);
        }
    }
    class Counter
    {
        const int LOOP_COUNT = 1000;

        readonly object thisLock;
        // lockedCount와 count는 스레드가 블록될 조건을 검사하기 위해 사용합니다.
        // lockedCount는 count 변수를 다른 스레드가 사용하고 있는지를판별하기 위해,
        // count는 각 스레드가 너무 오랫동안 count 변수를 혼자 사용하는 것을 막기 위해 사용했습니다.
        bool lockedCount = false;

        private int count;
        public int Count
        {
            get { return count; }
        }
        public Counter()
        {
            thisLock = new object();
            count = 0;
        }
        public void Increase()
        {
            int loopCount = LOOP_COUNT;

            while (loopCount-- > 0)
            {
                lock (thisLock)
                {
                // count가 0보다 크거나 lockedCount가 다른 스레드에 의해 true로 바뀌어 있으면
                // 현재 스레드를 블록합니다.
                // 다른 스레드가 Pulse() 메소드를 호출해줄 때까지는 WaitSleepJoin 상태로 남습니다.
                    while (count > 0 || lockedCount == true)
                        Monitor.Wait(thisLock);

                    lockedCount = true;
                    count++;
                    lockedCount = false;

                    Monitor.Pulse(thisLock);
                }
            }
        }
        public void Decrease()
        {
            int loopCount = LOOP_COUNT;

            while (loopCount-- > 0)
            {
                lock (thisLock)
                {
                    while (count < 0 || lockedCount == true)
                        Monitor.Wait(thisLock);

                    lockedCount = true;
                    count--;
                    lockedCount = false;

                    Monitor.Pulse(thisLock);
                }
            }
        }
    }
}