c# 멀티뜨레드 문제들, 뜨레드 안전성, 크로스-뜨레드, 데이터 레이스

멀티뜨레드는 동시에 이루어지는 작업들이다. 하나의 객체에 담겨 있는 데이터를 여러 뜨레드들에서 동시에 읽는 거는 문제될 게 없는데 동시에 쓰려고 하면 문제가 될 수 있다. 한 뜨레드에서 데이터를 쓰는 작업을 ‘마치기’ 전에 다른 뜨레드에서 기록을 한다면 이들은 서로 충돌할 수 있기 때문이다. 이런 문제는 세 가지 형태로 나타난다.

뜨레드 안전성

아래의 예제는 하나의 DataTable 셀에 객체를 넣는 건데 다섯 개의 뜨레드들에서 같은 작업을 동시에 시도한다. 간혹 제대로 되기도 하지만 대개는 오류가 생겨서 멈춘다. 이때 DataTable은 쓰기에 thread-safe하지 않다고 형용사구로 표현한다. 뜨레드 안전이 보장되지 않는 경우 unsafe하다고 하지는 않고 not safe하다고 한다. c#에서 unsafe라는 모디파이어는 포인터를 직접 제어할 때 쓴다. modify는 ‘수정하다’나 ‘한정한다’는 뜻이라서 modifier를 ‘한정자’라고 번역하기도 하는데 이 말이 원래 명칭보다 딱히 더 쉽지도 않고 그 의미가 잘 와닿지도 않아서 나는 그냥 모디파이어라고 한다.

DataTable DataTable1 = new();

private void Form1_Load(object sender, EventArgs e)
{
    DataTable1.Columns.Add();
    DataTable1.Rows.Add();

    dataGridView1.DataSource = DataTable1;
}

private void button1_Click(object sender, EventArgs e)
{
    ThreadPool.QueueUserWorkItem(Method1, 1);
    ThreadPool.QueueUserWorkItem(Method1, 2);
    ThreadPool.QueueUserWorkItem(Method1, 3);
    ThreadPool.QueueUserWorkItem(Method1, 4);
    ThreadPool.QueueUserWorkItem(Method1, 5);
}

void Method1(object object1)
{
    DataTable1.Rows[0][0] = object1;
}

아래와 같이 해결할 수 있다. lock 구문을 통해 특정 객체에 대한 접근을 동시에는 못 하게 하는 거다. 예를 들어 여러 뜨레드들에서 독립적으로 복잡한 연산을 수행하고 마지막에 출력만 하나의 객체에 하는 경우 마지막 단계에서만 잠시 멀티뜨레드를 중단시키는 것이다. 이때 lock의 대상은 레퍼런스 타입의 객체이어야 한다.

void Method1(object object1)
{
    lock (DataTable1)
    {
        DataTable1.Rows[0][0] = object1;
    }
}

크로스-뜨레드

아래 예제는 메인 뜨레드에서 작동하는 label에 메인 뜨레드 아닌 뜨레드에서 데이터를 쓰는 거다. corss-thread 예외가 발생하는 전형적인 경우다. 이 문제는 대부분의 경우 비주얼 컨트롤을 대상으로 할 때 일어나지만 꼭 그런 건 아니다. 예를 들어 이베스트투자증권의 api는 멀티뜨레드를 지원하지 않는데 멀티뜨레드에서 이 api에 들어있는 메떠드를 실행하면 크로스-뜨레드 예외로 처리된다. c# 레퍼런스에 따르면 윈도우즈 폼즈 컨트롤에 메인 뜨레드 아닌 뜨레드에서 데이터를 쓰려고 할 때 일어난다. 역시 읽기만 할 때에는 문제될 게 없다. 크로스-뜨레드에 대한 자세한 내용은 c# 멀티뜨레드 크로스-뜨레드 예외 해결 방법으로 자세히 다뤘다.

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

void Method1(object object1)
{
    label1.Text = "a";
}

아래와 같이 delegate를 써서 invoke를 해야 한다. 자세한 내용은 복잡하지만 아래와 같이 간단하게 구현할 수 있다. 이거도 lock을 거는 거처럼 잠시 멀티뜨레드를 멈추고 순차적으로 처리하는 거기 때문에 일시적으로 성능 저하는 피할 수 없다.

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

void Method1(object object1)
{
    label1.Invoke(invokeLabel);

    void invokeLabel()
    {
        label1.Text = "a";
    }
}

데이터 레이스

위의 경우들과 달리 윈도우즈 폼즈 컨트롤이 아닌 객체에 여러 뜨레드들이 데이터를 쓰는 때에는 예외로 처리되지 않는다. 프로세스가 멈추지 않고 진행하기 때문에 문제가 생겨도 모르고 지나칠 수 있으므로 주의해야 한다. 아래의 예제는 정수 변수에 멀티뜨레드로 천 번 1을 더하는 거다. 결과는 1,000이 나오지 않고 더 적은 값이 나온다. 위에 설명한 거처럼 변수에 값을 저장하는 작업이 끝나기 전에 다른 뜨레드에서 1을 더하기 때문이다. 예를 들어 변수가 1인 때 뜨레드 a에서 1을 더하여 2를 막 저장하려고 하던 차에 뜨레드 b에서 해당 변수의 값을 읽고 작업을 먼저 끝내면 두 뜨레드 모두 2를 저장하게 된다. 이런 현상을 data race라고 한다.

int Int1;

private void button1_Click(object sender, EventArgs e)
{
    for (int i = 0; i < 1000; i++)
    {
        ThreadPool.QueueUserWorkItem(Method1);
    }
}

void Method1(object object1)
{
    Int1++;
}

private void button6_Click(object sender, EventArgs e)
{
    label1.Text = Int1.ToString();
}

이 경우 int는 레퍼런스 객체가 아니므로 lock을 걸 수가 없다. 위의 경우라면 아래와 같이 레퍼런스 객체인 클래스를 만들어서 그 안에 담은 뒤 클래스에다가 lock을 걸면 된다.

class Class1
{
    public int Int1;
}

Class1 Class1_1 = new();

private void button1_Click(object sender, EventArgs e)
{

    for (int i = 0; i < 1000; i++)
    {
        ThreadPool.QueueUserWorkItem(Method1);
    }
}

void Method1(object object1)
{
    lock (Class1_1)
    {
        Class1_1.Int1++;
    }
}