본문 바로가기
공부/게임서버

경합 조건 (Race Condition), Interlocked

by samosa 2024. 11. 11.

이 코드의 결과가 0이 아니라는 놀라운 사실.

namespace ServerCore;

internal class Program
{
    private static int number;

    private static void Thread1()
    {
        for (var i = 0; i < 1000000; i++)
            number++;
    }

    private static void Thread2()
    {
        for (var i = 0; i < 1000000; i++)
            number--;
    }

    private static void Main(string[] args)
    {
        var t1 = new Task(Thread1);
        var t2 = new Task(Thread2);

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

        Task.WaitAll(t1, t2);

        Console.WriteLine(number);
    }
}

결과는 -294605 ??? 왜일까? 10만 번 더하고 10만 번 빼면 0이어야 하는 것 아닌가?


이 코드에는 경합 조건(race condition)이 발생할 가능성이 있다. 경합 조건은 여러 스레드가 동시에 공유 자원에 접근하여 예상치 못한 동작이 일어나는 상황을 말한다. 이 코드의 경우, number 변수에 대한 접근이 Thread1Thread2에서 동시에 이루어지기 때문에, 두 스레드가 각각 number++number-- 연산을 수행할 때 서로 영향을 주어 결과값이 의도하지 않은 값이 될 수 있다.

number++number-- 연산은 비원자적 연산이다. 즉, 이 연산들은 여러 단계로 이루어져 있어 실행 중에 다른 스레드가 개입하면 정확한 결과를 보장할 수 없다. 각 연산은 메모리에서 값을 읽고, 연산을 수행한 후 다시 메모리에 쓰는 과정으로 이루어지는데, 그 사이에 다른 스레드가 값을 변경하면 예기치 못한 동작이 발생할 수 있다.

 

number++와 같은 증감 연산은 C#에서는 단순한 명령어처럼 보이지만, 실제로는 어셈블리 수준에서 여러 단계로 수행된다. 이 과정은 temp 변수와 같은 중간 저장소가 있는 것처럼 동작한다. 일반적인 증감 연산의 단계는 다음과 같다:

  1. 읽기: 메모리에서 number 값을 레지스터로 가져온다.
  2. 증감: 레지스터에 있는 값을 1 증가하거나 감소한다.
  3. 쓰기: 변경된 값을 다시 메모리에 저장한다.

이 과정은 원자적이지 않기 때문에, 여러 스레드가 동시에 number에 접근할 경우 경합 조건이 발생할 수 있다. 예를 들어:

  • 스레드 A가 number 값을 읽어 temp에 저장한다.
  • 스레드 B도 동시에 number 값을 읽어 temp에 저장한다.
  • 스레드 A가 값을 증가시키고 number에 기록한다.
  • 스레드 B가 값을 감소시키고 number에 기록한다.

이처럼 두 스레드가 동시에 작업하면 예상하지 못한 최종 값이 남게 될 수 있다. 이를 방지하기 위해 lock 문이나 Interlocked 클래스 같은 동기화 기법을 사용해 원자성을 보장해야 한다.

 

해결 방법

이 문제를 해결하려면 스레드 간의 동기화를 보장해야 한다. 다음은 몇 가지 해결 방법이다:

 

lock 문 사용:
lock 키워드를 사용하여 임계 구역(critical section)을 설정하여 한 번에 하나의 스레드만 number에 접근하도록 한다.

   private static readonly object _lock = new object();

    private static void Thread1()
    {
        for (var i = 0; i < 1000000; i++)
        {
            lock (_lock)
            {
                number++;
            }
        }
    }

    private static void Thread2()
    {
        for (var i = 0; i < 1000000; i++)
        {
            lock (_lock)
            {
                number--;
            }
        }
    }

 

Interlocked 클래스 사용:

  private static void Thread1()
    {
        for (var i = 0; i < 1000000; i++)
        {
            Interlocked.Increment(ref number);
        }
    }

    private static void Thread2()
    {
        for (var i = 0; i < 1000000; i++)
        {
            Interlocked.Decrement(ref number);
        }
    }


System.Threading.Interlocked 클래스의 메서드를 사용하면 안전하게 증감 연산을 수행할 수 있다.

Interlocked 클래스는 성능 측면에서도 효율적이며 간단하게 경합 조건을 해결할 수 있는 방법이다.

이 두 방법 중 하나를 사용하여 경합 조건을 해결하면 코드의 동기화 문제가 해소되어 예상한 결과를 보장할 수 있다.