c# lock으로 멀티뜨레드 대이터 래이스 막기

대이터 래이스

아래의 예제는 일부러 data race가 일어나게 한 거다. 동시에 두 개의 뜨레드에서 하나의 리스트에 대이터를 쓴다. 그 결과 리스트 값들은 듬성듬성 빠져 있다. 하나의 뜨레드에서 쓰기 작업을 끝내기 전에 다른 뜨레드에서 덮어쓰기 때문이다.

List<int> Ints1 = new();

private void button1_Click(object sender, EventArgs e)
{
    ThreadPool.QueueUserWorkItem(Method1, Ints1);
    ThreadPool.QueueUserWorkItem(Method1, Ints1);
}

void Method1(object object1)
{
    List<int> ints = (List<int>)object1;

    for (int i = 0; i < 50; i++)
    {
        ints.Add(i);

        Thread.Sleep(100);
    }
}

private void Form1_Click(object sender, EventArgs e)
{
    for (int i = 0; i < Ints1.Count; i++)
    {
        textBox1.AppendText(Ints1[i].ToString() + "\r\n");
    }
}

lock으로 동기화

리스트에 쓰는 부분을 아래와 같이 lock으로 둘러싸면 대이터 래이스는 일어나지 않는다. 먼저 lock에 다다른 리스트에 대한 쓰기 작업이 완전히 끝날 때까지 다음 리스트 쓰기 작업은 기다려야 한다. 이 부분에서 성능 저하가 일어나지만 서로 다른 뜨레드 작업들은 순차적으로 처리된다. 멀티뜨레드를 쓰는 의미가 없다.

동기화는 영어로 synchronous나 serialized라고 하며 어떠한 일들이 순차적으로 처리되는 걸 말한다. ‘직렬화’라고도 한다.

void Method1(object object1)
{
    List<int> ints = (List<int>)object1;

    lock (ints)
    {
        for (int i = 0; i < 50; i++)
        {
            ints.Add(i);

            Thread.Sleep(100);
        }
    }
}

lock을 이용한 선택적 비동기화

그렇다고 멀티뜨레드를 이용할 때 lock이 아무 의미도 없는 건 아니다. 오히려 반대로 무척 중요한 역할을 하기도 한다. 아래의 예제는 위의 예제와 달리 서로 다른 두 개의 리스트들을 각각의 뜨레드에 태워 하나의 메떠드에서 작동하게 하는 거다. 같은 메떠드가 두 개의 뜨레드들에서 실행되는 건 위의 예제와 같지만 이 메떠드에 전달되는 아규먼트가 서로 다른 것들이다. 이때 lock은 자신에게 온 리스트가 서로 다른 거라는 걸 확인한 뒤 대이터 래이스가 일어나지 않을 것을 알게 된다. 그리고는 나중에 온 걸 기다리게 하지 않고 동시에 처리한다. 그 결과 두 개의 리스트들은 서로 간섭하지 않고 동시에 처리되는 진정한 멀티뜨레드가 구현된다. 잠궈지는 객체가 없으니 성능 저하도 일어나지 않는다.

비동기화는 asynchronous 또는 parallel이라 하며 여러 일들을 동시에 처리한다는 뜻이다. ‘병렬화’라고도 한다.

List<int> Ints1 = new();
List<int> Ints2 = new();

private void button1_Click(object sender, EventArgs e)
{
    ThreadPool.QueueUserWorkItem(Method1, Ints1);
    ThreadPool.QueueUserWorkItem(Method1, Ints2);
}

void Method1(object object1)
{
    List<int> ints = (List<int>)object1;

    lock (ints)
    {
        for (int i = 0; i < 50; i++)
        {
            ints.Add(i);

            Thread.Sleep(100);
        }
    }
}

private void Form1_Click(object sender, EventArgs e)
{
    for (int i = 0; i < Ints1.Count; i++)
    {
        textBox1.AppendText(Ints1[i].ToString() + "\r\n");
    }

    for (int i = 0; i < Ints2.Count; i++)
    {
        textBox2.AppendText(Ints2[i].ToString() + "\r\n");
    }
}

증권회사 api 프로그래밍에 응용하기

이런 방법은 증권회사 api를 이용한 주식 프로그래밍을 할 때 유용하다. 예를 들어 주식 자동 매매 애플리케이션을 만들 때 현재 기준으로 스팩, etf, 기타 상품 등을 제외한 주식 종목들은 코스피와 코스닥을 합해 2,500개 정도 된다. 이들에 대한 실시간 체결과 호가 이벤트를 수신하면 1초에 수십에서 수백 번까지도 이벤트가 실행될 수 있다. 메인 뜨레드에서 전부 처리하면 부하가 너무 커져서 느려진다. 그 정도가 더 심해지면 윈도우즈 메시지 처리 한계를 넘어서게 되며 수신된 패킷은 소실된다. 이러한 문제를 피하기 위해서는 메인 뜨레드에서 수신을 한 즉시 타입캐스팅과 같은 기본적인 작업만 하고 세부적인 종목별 연산 작업은 새로운 뜨레드를 만들어서 바로 태워 보내야 한다. 이때 하나의 종목에 대한 여러 이벤트들이 동시에 쏟아져 들어오는 경우들이 흔한데 이것들을 동시에 여러 뜨레드들로 만들어서 처리하면 같은 종목에 대한 대이터 쓰기 작업이 동시에 일어나 대이터 래이스가 일어나게 된다. 이 문제를 lock (symbol)의 간단한 한 줄로 피할 수 있다. 대이터 래이스가 일어나면 안 되는 한 종목에 대한 동시 처리 시도는 대기를 시켜 순차적으로 처리하되 lock이 판단할 때 같은 symbol이 아니라면 그냥 온 대로 동시에 처리를 하는 거다. 어차피 대이터 래이스는 일어날 여지가 없으니까.

lock이 걸리지 않는 경우

록은 레퍼런스 타입의 ‘expression’에만 가능하다고 레퍼런스에 나와 있는데 이 의미가 애매하다. 일단 록이 레퍼런스 타입에 대해서만 가능한 건 분명하다. 그런데 록이 걸리는 것이 참조하는 대상도 레퍼런스 타입이어야 한다. 아래 예제에서 메떠드로 넘겨지는 아규먼트가 밸류 타입인 정수일 때에는 메떠드에서 아규먼트를 레퍼런스 타입인 object로 받은 패러미터에 록을 걸어도 작동하지 않는다. 오류로 처리되지는 않지만 대이터 이스는 일어난다.

int TaskCount;

void Method1(object _Object)
{
    lock (_Object)
    {
        TaskCount++;
    }
}

private void button1_Click(object sender, EventArgs e)
{
    TaskCount = default;

    int int1 = 1;

    for (int i = 0; i < 10000; i++)
    {
        Task task = new(Method1, int1); // 9xxx

        task.Start();
    }
}

class Class1
{
    //
}

private void button2_Click(object sender, EventArgs e)
{
    TaskCount = default;

    Class1 class1 = new();

    for (int i = 0; i < 10000; i++)
    {
        Task task = new(Method1, class1); // 10000

        task.Start();
    }
}