멀티스레드

[멀티스레드] Lock구현

wnstjd 2024. 7. 17. 22:43

lock은 세 가지 방법으로 구현할 수 있다. 

  1. 변수를 점유할 때 까지 쉬지않고 계속 점유를 시도하는 방법
  2. 점유에 실패하면 일정 시간을 대기하고 점유를 시도하는 방법
  3. 점유 종료 시점에 발행되는 이벤트를 사용하여 점유를 시도하는 방법

세 가지의 lock을 구현하고 아래의 코드로 테스트 할 것이다.

class Program
{
    static Lock _lock = new Lock();
    static int number = 0;

    static void Thread_1()
    {
        for(int i = 0; i < 10000; i++)
        {
            _lock.Enter();
            number++;
            _lock.Exit();
        }
    }

    static void Thread_2()
    {
        for (int i = 0; i < 10000; i++)
        {
            _lock.Enter();
            number--;
            _lock.Exit();
        }
    }

    static void Main(string[] args)
    {
        Task t1 = new Task(Thread_1);
        Task t2 = new Task(Thread_2);
        t1.Start();
        t2.Start();

        Task.WaitAll(t1, t2);

        Console.WriteLine(number);
    }
}

 

1번(SpinLock)

첫 번째 방법으로 구현하는 lock이다.

    public class Lock
    {
        //여러 스레드에서 사용되므로 volatile로 가시성 확보
        // 1 = 점유 가능, 0 = 점유 불가능
        private volatile int available = 1;

        public void Enter()
        {
            while(true)
            {
                int expected = 1;
                int desired = 0;

                if (Interlocked.CompareExchange(ref available, desired, expected) == expected)
                    break;
                /* CompareExchange의 작업
                if(available == expected)
                {
                    int origin = available;
                    available = desired;
                    return origin;
                }
                */
            }
        }

        public void Exit()
        {
            Interlocked.Exchange(ref available, 1);
        }
    }

결과를 보면 Lock이 잘 작동하는 것을 확인할 수 있다.

 

여기서 주의해야 할 곳은 CompareExchange()이다.

if (Interlocked.CompareExchange(ref available, desired, expected) == expected)
	break;

 

점유 가능한 상태인지 확인하고 가능하면 점유하는 부분이다. 이 부분을 일반적으로 구현하면 아래처럼 간단하게 구현 가능하다.

if(available == 1)
	available = 0;

Interlocked로 구현한 이유는 원자성이 보장되어야 하는 부분이기 때문이다. 

  1. 점유 가능한지 확인
  2. 가능하면 점유

두 가지의 작업으로 나누어져 있기 때문에 경합 조건으로 점유 작업이 꼬일 수 있다.

원자성만 보장되면 되기 때문에 꼭 Interlocked로 구현할 필요는 없지만 간단한 작업이기 때문에 Interlocked를 사용하지 않을 이유도 없다.

 

만약 점유에 실패하면 while(true)이기 때문에 점유에 성공할 때 까지 계속해서 시도하게 된다. 

 

이 방법은 대기 상태가 길어질수록 비효율적인 방법이다. 하지만 유일하게 Context Switcing이 발생하지 않는다는 특징이 있다.

 

2번

두 번째 방법은 첫 번째 방법에서 잠시 대기하는 작업만 추가하면 된다.

대기하는 방법에도 세 가지가 있다.

  • 지정한 밀리세컨드만큼 대기
  • 자신보다 우선순위가 같거나 높은 스레드에게 CPU양도
  • 모든 스레드에게 CPU양도
    public class Lock
    {
        //여러 스레드에서 사용되므로 volatile로 가시성 확보
        // 1 = 점유 가능, 0 = 점유 불가능
        private volatile int available = 1;

        public void Enter()
        {
            while(true)
            {
                int expected = 1;
                int desired = 0;

                if (Interlocked.CompareExchange(ref available, desired, expected) == expected)
                    break;
                /* CompareExchange의 작업
                if(available == expected)
                {
                    int origin = available;
                    available = desired;
                    return origin;
                }
                */
                
                //Thread.Sleep(10); // 10ms 대기
                //Thread.Sleep(0); // 자신보다 우선순위가 같거나 높은 스레드 중 하나에게 양도
                Thread.Yield() // 모든 스레드 중 하나에게 양도
            }
        }

        public void Exit()
        {
            Interlocked.Exchange(ref available, 1);
        }
    }

 

대기 상태동안 다른 스레드에게 무조건 CPU를 양도하기 때문에 Context Switching이 발생한다.

 

3번

3번 방법은 AutoResetEvent라는 것을 사용하여 구현할 수 있다.

AutoResetEvent는 점유 가능한 상태가 되면 이벤트를 발행한다. 스레드는 점유를 시도하였을 때 실패하게 되면 대기 상태에 들어가고 이벤트가 발행되는 시점에 점유를 다시 시도한다.

public class Lock
{
    private AutoResetEvent available = new AutoResetEvent(true);

    public void Enter()
    {
        available.WaitOne();
    }

    public void Exit()
    {
        available.Set();
    }
}

 

Context Switching이 발생하기 때문에 실행 시간이 크다는 것을 확인할 수 있다.