포스트

Thread에 대해서

Process 프로세스

  • OS안에서 실행되는 프로그램
  • 실행 파일에 담겨있는 데이터 및 코드가 메모리에 적재되어 동작하는 것

프로세스는 반드시 하나 이상의 스레드로 구성된다. 여러 스레드로 구성될 수도 있다.

Thread 스레드

  • OS가 CPU에 시간을 할당하는 기본단위
  • OS가 명령어를 실행하기 위한 스케줄링 단위

멀티 스레드의 장점

  1. 동시에 여러 작업을 수행할 수 있다.
  2. 데이터 공유가 쉽다.
    • 어차피 데이터까지 프로세스 안에 들어가기 때문
  3. 메모리를 절약할 수 있다.
    • 하나의 프로세스를 새로 만드는 것보다, 스레드를 만드는게 메모리 사용 측면에서 더 효율적.

멀티 스레드의 단점

  1. 구현이 복잡하다.
  2. SW 안전성이 낮아진다.
    • 프로세스는 종료될 때 해당 프로세스만 종료되고, 별다른 문제가 없지만
    • 스레드는 종료될 때 문제가 생기면, 이를 담고있는 프로세스가 뻑난다.
  3. 성능이 저하될 수 있다.
    • Context Switching의 과정에서 오히려 성능이 저하될 수 있기 때문에, 본래 멀티 스레드를 사용하는 그 의미가 퇴색될 수 있으므로 적절히 사용할 것.

멀티 스레드의 문제

멀티스레드의 문제 1. Race Conditions

레이스 컨디션 (Race Condition)

  • 두 개 이상의 스레드가 공유된 자원에 동시에 접근하려고 할 때 발생하는 문제
  • 싱글 스레드에서는 이 문제가 발생할 여지가 없다.

멀티스레드의 문제 2. Synchronization Issues

동기화 이슈 (Synchronization Issues)

  • 레이스 컨디션을 방지하기 위해 lock을 사용해서 공유 자원을 동기화하는 방식.

멀티스레드의 문제 3. Dead Lock

데드락 (Dead Lock)

  • 스레드가 다른 스레드가 가진 락을 무한히 기다려야 할 때 발생한다.

유니티와 스레드

유니티에는 우리가 접근 가능한 하나의 큰 스레드가 존재한다. 이는 메인 스레드로, 유니티의 모든 주요 API는 이 메인 스레드에서만 호출할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
public class TestThread : MonoBehaviour
{
    void Start()
    {
        Thread thread = new Thread(PositionCheck);
        thread.Start();
    }

    void PositionCheck()
    {
        Debug.Log(transform.position);
    }
}

Untitled (13)

이렇게 스레드를 직접 만들어서 동작시키면, 위와 같이 유니티의 기능은 main 스레드에서만 사용 가능함을 알린다.

이 경고는 유니티 엔진의 대부분의 API가 메인 스레드에서만 안전하게 호출될 수 있음을 의미한다.

따라서 유니티에서 병렬 작업이 필요할 때는 코루틴을 사용하는 것이 일반적이다.

코루틴은 메인 스레드에서 실행되며, 여러 작업을 비동기적으로 수행할 수 있게 해준다.

이렇게 함으로써 멀티스레드의 복잡성이나 문제를 피하면서도 동시성을 구현할 수 있다.


멀티 스레드 Multi Thread

유니티에서 스레드를 다루기 - 기초

스레드를 동작시킬 때 데이터를 인자로 넘기기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class TestThread : MonoBehaviour
{
    // 스레드에 여러개의 인자를 넘기기 위한 클래스
    public class Param
    {
        public int v1;
        public int v2;
    }
    
    private Thread thread;
    
    void Start()
    {
        // 새로운 스레드 만들기 (스레드를 만듦과 동시에 실행할 함수를 연결 => delegate) 
        thread = new Thread(Temp);
        
        // 인자로 들어갈 객체를 만들고
        Param param = new Param();
        
        // 변수 지정
        param.v1 = 10000;
        param.v2 = 20000;
        
        // 스레드 시작
        thread.Start(param);
    }

    void Temp(object input1)
    {
        Debug.Log("스레드 시작");
        Debug.Log(thread.ThreadState);

        Thread.Sleep(2000); // 2초 동안 스레드 중지
        
        // 매개변수 타입 캐스팅
        Param param = (Param)input1;
        Debug.Log(param.v1 + "|" + param.v2);
        Debug.Log("스레드 종료");
    }
}

Thread의 Join으로 스레드 동기화 제어

유니티에서 스레드를 사용하는 과정에서 join을 이용해 스레드 동기화를 제어할 수 있다.

Thread.Join Method (System.Threading)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class TestThread : MonoBehaviour
{
    private Thread thread1, thread2;
    
    void Start()
    {
        thread1 = new Thread(Thread1Function);
        thread1.Start();
    }

    // 
    void Thread1Function()
    {
        Debug.Log("Thread 1 시작");
        thread2 = new Thread(Thread2Function);
        thread2.Start();
        thread2.Join();     // thread1은 여기서 멈춘다. thread2가 끝날 때까지
        Debug.Log("Join 완료");
    }

    private void Thread2Function()
    {
        Debug.Log("Thread 2 시작");
        Thread.Sleep(2000);     // 2초 동안 스레드 대기
        Debug.Log("Thread 2 끝");
    }
}

// 실행 결과 : 
// Thread 1 시작
// Thread 2 시작
// - 2초 -
// Thread 2 끝
// Join 완료
  • Join()함수 : 호출된 스레드가 완료될 때까지, 호출한 스레드를 멈추게 하는 동기화 방법.
  • 위 코드에서는 thread2.Join(); 를 호출해 thread2가 끝날 때까지 thread1이 멈춰있는다.

Thread의 강제 종료 Abort & 방해 Interrupt

AbortInterrupt는 스레드를 제어하는 두 가지 중요한 메서드.

그러나 Abort는 위험할 수 있으며, 이를 사용하는 것은 권장되지 않는다.

Thread.Abort Method (System.Threading)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class TestThread : MonoBehaviour
{
    private Thread thread1;
    
    void Start()
    {
        thread1 = new Thread(ThreadFunction);
        thread1.Start();
    }
    
    void ThreadFunction()
    {
        try
        {
            Debug.Log("스레드 시작");
            Thread.Sleep(5000);     // 5초 대기
            Debug.Log("스레드 종료");

        }
        catch (ThreadAbortException)
        {
            Debug.Log("스레드 강제 종료");
        }
        catch (ThreadInterruptedException)
        {
            Debug.Log("스레드 WaitSleepJoin");
        }
    }

    private void Update()
    {
        // 일정 시간이 지난 후 스레드를 강제 종료
        if (Time.timeSinceLevelLoad > 2f)   // 예시로 2초 대기
        {
            thread1.Abort();
        }
    }
}

// 실행 결과 : 
// "스레드 시작"
// - 2초 - 
// "스레드 강제 종료"
  • Abort() 함수 : ThreadAbortException 예외를 발생시키고 스레드를 종료시킨다. 스레드의 즉시 종료에 가까워 위험할 수 있다.
  • 대신 Interrupt() 함수를 사용하는 것이 권장된다. thread1.Abort(); 대신 thread1.Interrupt();를 사용하면 출력은 다음과 같이 바뀐다.
1
2
3
4
// 실행 결과 : 
// "스레드 시작"
// - 2초 - 
// "스레드 WaitSleepJoin"

이 방식은 스레드가 WaitSleepJoin 상태가 될 때 종료되도록 합니다.

Background Thread

백그라운드 스레드는 메인 스레드와 달리 프로그램의 실행과 종료에 영향을 미치지 않는 스레드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Program
{
   static private Thread subThread;

   static void Main(string[] args)
   {
      subThread = new Thread(SubThreadFunction);
      Console.WriteLine("메인 스레드 시작");
      subThread.IsBackground = true;
      subThread.Start();
      Console.WriteLine("메인 스레드 종료");
   }
   
   static void SubThreadFunction()
   {
      Console.WriteLine("서브 스레드 시작");
      Thread.Sleep(5000);     // 5초 대기
      Console.WriteLine("서브 스레드 종료");
      Thread.Sleep(3000);
   }
}
  • 프로그램의 실행과 종료에 영향을 미치지 않는 스레드를 백그라운드 스레드라고 한다.
    • 이와 반대로, 기본 스레드는 모두 포그라운드 스레드로 프로그램 실행 종료에 영향을 미친다.
  • 위 코드는 하나의 스레드를 만들지만, 이 스레드가 백그라운드 스레드가 되었기 때문에 바로 프로그램은 종료됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Program
{
   static private Thread subThread;

   static void Main(string[] args)
   {
      subThread = new Thread(SubThreadFunction);
      Console.WriteLine("메인 스레드 시작");
      subThread.IsBackground = true;
      subThread.Start();
      subThread.Join();
      Console.WriteLine("메인 스레드 종료");
      Thread.Sleep(2000);
   }
   
   static void SubThreadFunction()
   {
      Console.WriteLine("서브 스레드 시작");
      Thread.Sleep(5000);     // 5초 대기
      Console.WriteLine("서브 스레드 종료");
      Thread.Sleep(3000);
   }
}
  • 백그라운드 스레드에 join을 사용하면 메인 스레드가 멈추며, 백그라운드 스레드가 끝날 때까지 기다린다.
    • 메인 스레드가 멈추면 프로세스도 멈추므로, 이전과 달리 바로 종료되지 않는다.

스레드의 실행순서

다음의 코드를 유니티에서 실행해보자 (반복문으로 0~9 까지 10개의 스레드를 생성하고 실행시키는 코드)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class AboutThread : MonoBehaviour
{
    private void Start()
    {
        for (int i = 0; i < 10; i++)
        {
            Thread thread = new Thread(TestThread);
            thread.Start(i);
        }
    }

    void TestThread(object num)
    {
        Debug.Log($"{num}번째 스레드");
    }
}

실행 결과는 다음과 같다.

Untitled (14)

그러면 위와 같이, 순서가 뒤죽박죽이 된다. 위와 같이, 스레드의 실행 순서는 예측할 수 없다. 이는 OS가 스레드의 우선순위에 따라 스케줄링을 하기 때문


스레드 상태 Thread State

ThreadState Enum (System.Threading)

ThreadState는 그 이름에서 알 수 있듯, Thread 객체의 상태를 나타내는 정보다.

이는 다음과 같이 System.Threading namespace안에서 enum으로 정의되어있으며, 그 종류는 다음과 같다.

Untitled (15)

  • Running: 스레드가 실행 중인 상태
  • StopRequested: 스레드 중지를 요청받은 상태
  • SuspendRequested: 스레드 일시중단을 요청받은 상태
  • Background: 백그라운드 스레드 상태
  • Unstarted: 스레드가 시작되지 않은 상태
  • Stopped: 스레드가 완료된 상태
  • WaitSleepJoin: 스레드가 일시중단된 상태 (특정 조건을 기다림)
  • Suspended: 스레드가 일시중단된 상태
  • AbortRequested: 스레드 중지를 요청받은 상태
  • Aborted: 스레드가 중지된 상태

ThreadState의 종류

Untitled (16)

Unstarted

  • 스레드가 시작하기 전 상태로, 스레드의 Start() 메서드가 호출되지 않은 상태.

Running

  • 스레드가 동작하고 있는 상태로, 스레드가 시작했고 멈추기 전의 상태.

Untitled (17)

Suspended

  • 스레드가 일시 중단된 상태로, Resume() 함수가 호출될 때까지 대기.

Untitled (18)

WaitSleepJoin

  • 스레드가 특정 조건에서 일시 중단된 상태. 대기 중이며, Wait, Sleep, 또는 Join 메서드에 의해 중단된다.

Untitled (19)

Aborted

  • 스레드가 취소된 상태. ThreadAbortException 예외를 발생시키며, Abort() 함수가 호출되면 스레드가 Aborted 상태가 된다. 이 상태에서 Stopped 상태로 전환된다.

Untitled (20)

Background

  • 백그라운드 스레드로 실행되고 있는 상태입니다. 프로세스의 수명에 영향을 주지 않는다.

~Requested

  • 해당 상태로 전이되기 위해 요청을 받은 상태. 특정 상태로 전이시키는 함수가 호출된 직후의 상태라고 이해할 수 있다.

Interrupt() 함수의 영향

Untitled (21)

  • Interrupt() 함수는 호출 즉시 스레드를 중단시키지 않는다. Abort() 함수와 달리, Interrupt() 함수는 스레드가 WaitSleepJoin 상태에 들어갈 때 중단된다.

Thread Pool 스레드 풀

스레드 풀은 재사용 가능한 스레드의 집합을 의미한다. 필요할 때마다 스레드를 생성하는 대신, 기존의 스레드를 재사용하여 성능을 최적화할 수 있다.

스레드의 동작방식은 크게 두 가지로 나뉜다.

  1. 상시 실행 스레드
    • 스레드가 비교적 오래 생성되어 있는 방식으로, 주로 무한 루프를 사용한다.
  2. 일회성 임시 실행 스레드
    • 특정 연산만을 수행하고 바로 종료되는 스레드입니다. 스레드가 많이 필요해질 경우 성능을 위해 스레드 풀의 사용이 요구된다.

기초적인 스레드의 시작 방식

1
ThreadPool.QueueUserWorkItem(/*스레드로 시작시킬 함수*/);

매개변수가 있는 경우 스레드의 시작 방식

1
ThreadPool.QueueUserWorkItem(/*스레드로 시작시킬 함수*/, /*함수의 매개변수*/);

매개변수가 여러개 있는 경우 스레드의 시작 방식

여러 값을 저장하는 또 다른 객체 (클래스 혹은 구조체)를 만들어 인스턴스를 인자로 넘길 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
struct MultiParams
{
    public int param1;
    public int param2;
    public int param3;
}

public class AboutThread : MonoBehaviour
{
    private void Start()
    {
        MultiParams params1 = new MultiParams()
        {
            param1 = 1, param2 = 2, param3 = 3
        };
        ThreadPool.QueueUserWorkItem(TestThreadPool, params1);
    }

    void TestThreadPool(object value)
    {
        MultiParams params1 = (MultiParams)value;
        
        Debug.Log($"첫번째 인자 : {params1.param1}");
        Debug.Log($"두번째 인자 : {params1.param2}");
        Debug.Log($"세번째 인자 : {params1.param3}");
    }
}

Untitled (22)


스레드 락 Thread Lock

스레드 동기화

스레드 동기화는 여러 스레드가 동시에 공유 자원에 접근할 때 발생하는 문제를 해결하기 위한 방법. C#에서는 lock 키워드를 통해 동기화를 구현할 수 있다.

C#의 Lock

lock 키워드는 다른 스레드가 특정 코드 블록에 접근하는 것을 막아서 자원을 한 번에 하나의 스레드만 사용하도록 보장한다. 이는 크리티컬 섹션 (Critical Section)을 선언해 특정 코드 블록에 대한 동시 접근을 제한하는 방식으로 동기화를 실행한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class AboutThread : MonoBehaviour
{
    private Thread t1, t2;
    private int num;

    private void Start()
    {
        num = 0;
        t1 = new Thread(TestFunction);
        t2 = new Thread(TestFunction);

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

        t1.Join();
        t2.Join();
        Debug.Log(num);
    }

    void TestFunction()
    {
        for (int i = 0; i < 1000000; i++)
        {
            num++;
        }
    }
}

Untitled (23)

위 코드를 실행한 결과, 콘솔창에는 예상치 못한 값이 출력된다.

이는 Race Condition으로 인해 발생한다.

레이스 컨디션 Race Condition

Race Condition은 두 개 이상의 스레드가 공유된 자원에 동시에 접근하려고 할 때 발생하는 문제.

위 코드에서는 두 스레드 t1, t2가 공유 자원인 num에 접근을 시도하는 과정에서 Race Condition이 발생한다.

두 스레드가 각각 반복문을 통해 변수 num의 증감 연산을 1000000회 시도하지만, 이 연산은 원자적 연산이 아니기 때문에 예상치 못한 결과가 발생할 수 있다. 결과값은 2000000이 될 것처럼 보이지만, 실제로는 그렇지 않다.

Untitled (24)

이를 그림으로 표현하면 위와 같다.

그러면 RaceCondition 이 발생하지 않도록, 위에서 언급한 C#의 Lock을 사용해보자.

먼저 object 타입의 변수를 선언하고, 이를 매개변수로 lock 키워드와 함께 사용한 크리티컬 섹션 (Critical Section) 안에서 증감연산을 수행하도록 해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class AboutThreadLock : MonoBehaviour
{
    private Thread t1, t2;
    private int num;

    // 동일한 잠금 객체
    private readonly object _lock = new object();
    
    private void Start()
    {
        num = 0;
        t1 = new Thread(TestFunction);
        t2 = new Thread(TestFunction);

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

        t1.Join();
        t2.Join();
        Debug.Log(num);
    }

    void TestFunction()
    {
        for (int i = 0; i < 1000000; i++)
        {
            lock (_lock)
            {
                num++;
            }
        }
    }
}

Untitled (26)

이 코드는 매번 실행해도 동일한 결과인 2000000을 출력한다.

이는 lock 키워드를 통해 Race Condition을 방지했기 때문이다.

크리티컬 섹션 (Critical Section)

크리티컬 섹션은 한 번에 한 스레드만 사용할 수 있는 코드 영역을 의미한다.

lock 키워드의 인자는 일반적으로 object 타입을 많이 사용한다. 이는 동일한 인스턴스를 사용해 동일한 락을 보장하기 위함이다.

this, Type, string 형식을 lock 인자로 사용하는 것은 권장되지 않는다. 이들은 외부에서 접근할 수 있는 위험성이 있어, 동기화 문제를 일으킬 수 있기 때문이다.

부가 설명

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class MyClass1
{
    public void MyMethod1() { lock (this) { Debug.Log("MyMethod1"); } }

    public void MyMethod2() { lock (this) { Debug.Log("MyMethod2"); } }

    public MyClass1 GetInstance() { return this; } 
}

public class AboutThreadLock : MonoBehaviour
{
    private Thread t1, t2;
    private MyClass1 _myClass = new MyClass1();

    private void Start()
    {
        MyClass1 instance = _myClass.GetInstance();
        instance.MyMethod1();       // 외부에서 동일한 인스턴스에 접근이 가능해서 문제가 발생한다.

        t1 = new Thread(_myClass.MyMethod1);
        t2 = new Thread(_myClass.MyMethod2);

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

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

위와 같이 Lock의 인자로 this를 사용하면 외부에서도 해당 인스턴스에 접근할 수 있다. (?)

논리적으로 모호해진다. (?)

사실 이 말의 정확한 의미를 이해하지 못했다.

일단은 이러한 방식을 권장하지 않는다는 것만 이해해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class MyClass2
{
    public void MyMethod1() { lock (typeof(MyClass2)) { Debug.Log("MyMethod1"); } }

    public void MyMethod2() { lock (typeof(MyClass2)) { Debug.Log("MyMethod2"); } }
}

public class AboutThreadLock : MonoBehaviour
{
    private Thread t1, t2;
    private MyClass2 _myClass = new MyClass2();

    private void Start()
    {
        Type type = typeof(MyClass2);
        MyClass2 instance = (MyClass2)Activator.CreateInstance(type);
        instance.MyMethod1();       // 외부에서 동일한 인스턴스에 접근이 가능해서 문제가 발생한다.

        t1 = new Thread(_myClass.MyMethod1);
        t2 = new Thread(_myClass.MyMethod2);

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

        t1.Join();
        t2.Join();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// string을 사용하는 경우는 .NET 런타임이 문자열을 인턴(intern)하기 때문에 권장되지 않는다.
// 문자열 인턴(intern)이란 동일한 내용의 문자열이 메모리에서 동일한 객체를 참조하도록 하는 과정이다.
// 따라서 서로 다른 부분에서 같은 string을 lock으로 사용하게 되면 의도치 않은 동작이 나타날 수 있다.

public class MyClass3
{
    public void MyMethod1() { lock ("Key") { Debug.Log("MyMethod1"); } }

    public void MyMethod2() { lock ("Key") { Debug.Log("MyMethod2"); } }
}

public class MyClass4
{
    public void MyMethod3() { lock ("Key") { Debug.Log("MyMethod3"); } }
}

public class AboutThreadLock : MonoBehaviour
{
    private Thread t1, t2;
    private MyClass3 _myClass = new MyClass3();

    private void Start()
    {
        MyClass4 myClass4 = new MyClass4();
        myClass4.MyMethod3();

        t1 = new Thread(_myClass.MyMethod1);
        t2 = new Thread(_myClass.MyMethod2);

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

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

Type의 경우와 string을 사용하는 경우도 마찬가지.

아무튼 이렇게 사용하는 방식은 권장되지 않는다고 한다.

또 다른 방식의 스레드 동기화

Interlocked 클래스

Interlocked 클래스원자적 연산 (atomic operation) 방식으로 작동합니다. 이를 사용해 간단한 동기화를 구현할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class AboutThread : MonoBehaviour
{
    private Thread t1, t2;
    private int num;

    private void Start()
    {
        num = 0;
        t1 = new Thread(TestFunction);
        t2 = new Thread(TestFunction);

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

        t1.Join();
        t2.Join();
        Debug.Log(num);
    }

    void TestFunction()
    {
        for (int i = 0; i < 1000000; i++)
        {
            Interlocked.Increment(ref num);
        }
    }
}

동기화하고자 하는 스레드 간의 연산이 간단한 연산인 경우에는 Interlocked 클래스를, 그렇지 않은 경우에는 lock 키워드를 사용하자.

Monitor 클래스의 활용

lock 키워드는 내부적으로 Monitor 클래스를 활용하는 방식으로 변환되어 동작한다.

[코드 1]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class AboutThreadLock : MonoBehaviour
{
    private Thread t1, t2;
    private int num;

    // 동일한 잠금 객체
    private readonly object _lock = new object();
    
    private void Start()
    {
        num = 0;
        t1 = new Thread(TestFunction);
        t2 = new Thread(TestFunction);

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

        t1.Join();
        t2.Join();
        Debug.Log(num);
    }

    void TestFunction()
    {
        for (int i = 0; i < 1000000; i++)
        {
            lock (_lock)
            {
                num++;
            }
        }
    }
}

[코드 2]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class AboutThreadLock : MonoBehaviour
{
    private Thread t1, t2;
    private int num;

    // 동일한 잠금 객체
    private readonly object _lock = new object();
    
    private void Start()
    {
        num = 0;
        t1 = new Thread(TestFunction);
        t2 = new Thread(TestFunction);

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

        t1.Join();
        t2.Join();
        Debug.Log(num);
    }

    void TestFunction()
    {
        for (int i = 0; i < 1000000; i++)
        {
            Monitor.Enter(_lock);
            try
            {
                num++;
            }
            finally
            {
                Monitor.Exit(_lock);
            }
        }
    }
}

그래서 [코드 1] 과 같이 lock을 사용한 방식은 내부적으로 [코드 2]와 같이 구현되어 있다.

Monitor.Enter(_lock);로 객체를 활용해 크리티컬 섹션을 만들고,

Monitor.Exit(_lock);로 이를 해제한다.

Wait과 Pulse

Monitor 클래스를 사용해 스레드 간의 동기화를 더 세밀하게 다룰 수 있다. 이를 통해 스레드 간의 신호를 주고받을 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class AboutThreadLock : MonoBehaviour
{
    private readonly object locker = new object();

    private void Start()
    {
        Thread pingThread = new Thread(Ping);
        Thread pongThread = new Thread(Pong);

        pingThread.Start();
        pongThread.Start();
    }

    void Ping()
    {
        lock (locker)
        {
            for (int i = 0; i < 5; i++)
            {
                Monitor.Wait(locker);       // 대기 및 lock 해제
                Debug.Log("Ping");
                Monitor.Pulse(locker);      // 대기 중인 Pong 스레드에 신호 보내기
            }            
        }
    }

    void Pong()
    {
        lock (locker)
        {
            for (int i = 0; i < 5; i++)
            {
                Monitor.Wait(locker);       // 대기 및 lock 해제
                Debug.Log("Pong");
                Monitor.Pulse(locker);      // 대기 중인 Ping 스레드에 신호 보내기
            }
        }
    }
}
  • Monitor.Wait() 함수는 스레드를 WaitSleepJoin 상태로 변환시키며 대기하게 한다.
  • Monitor.Pulse() 함수는 대기 중인 스레드를 다시 깨운다.

하지만 위 코드는 실행 시 아무런 결과도 반환하지 않는다.

이는 데드락 (Deadlock) 상태에 빠졌기 때문.

데드락 Deadlock

데드락은 둘 이상의 프로세스나 스레드가 서로 상호작용을 기다리면서 무한히 기다리는 상태.

Wait과 Pulse의 동작

Untitled (25)

  • Monitor.Wait 함수를 실행하면, 해당 스레드는 WaitSleepJoin 상태로 변하면서 대기한다.
    • 그리고 스레드는 WaitingQueue에 들어간다.
  • Monitor.Pulse 함수는 WaitingQueue에서 대기중인 스레드 하나를 ReadyQueue 로 이동시킨다.
    • 이후에 OS에 따라서 Lock을 획득하고, 해당 스레드가 진행될 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public class AboutThreadLock : MonoBehaviour
{
    private readonly object locker = new object();
    private bool isPingTurn = true;
    
    private void Start()
    {
        Thread pingThread = new Thread(Ping);
        Thread pongThread = new Thread(Pong);

        pingThread.Start();
        pongThread.Start();
    }

    void Ping()
    {
        lock (locker)
        {
            for (int i = 0; i < 5; i++)
            {
                while (!isPingTurn)
                {
                    Monitor.Wait(locker);       // ping 차례가 아닐 때
                }
                Debug.Log("Ping");
                isPingTurn = false;         // Pong 차례로 변경
                Monitor.Pulse(locker);      // 대기 중인 Pong 스레드에 신호 보내기
            }            
        }
    }

    void Pong()
    {
        lock (locker)
        {
            for (int i = 0; i < 5; i++)
            {
                while (isPingTurn)
                {
                    Monitor.Wait(locker);       // pong 차례가 아닐 때
                }
                Debug.Log("Pong");
                isPingTurn = true;          // Ping 차례로 변경
                Monitor.Pulse(locker);      // 대기 중인 Ping 스레드에 신호 보내기
            }
        }
    }
}

Untitled (27)

위 코드의 결과로 두 스레드가 Ping-Pong을 서로 번갈아 수행한다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.