[c#] 크로스-스레드 예외 해결하기

lock

제일 간단한 방법이다. 그러나 디버그 빌드로 실행하면 오류로 처리되고 릴리스 빌드를 한 때에만 제대로 작동한다. 이는 Control.CheckForIllegalCrossThreadCalls 프라퍼티 때문인데 이게 디버그 빌드를 할 때에는 true가 기본 값이고 릴리스 빌드를 할 때에는 false가 기본 값이다. 실행이 되는지 여부가 중요한 게 아니라 제대로 된 결과가 나오느냐 하는 게 문제다. 비록 아래 예제는 간단하여 의도한 대로 실행이 되지만 lock을 이용한 다른 복잡한 상황들에서 언제나 정상 작동을 기대할 수는 없다. 마이크로소프트는 예측할 수 없는 결과가 나올 수 있다고 분명히 경고하고 있다. 릴리스 빌드를 할 때 저 프라퍼티가 false를 기본 값으로 갖는 건 크로스-스레드를 확인하는 과정에서 성능이 느려지는 걸 막으려 하는 거 같다.

이 방법은 추천하지 않는다.

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

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

델리게이트를 이용하기

복잡하지만 기본이 되는 방법이다.

delegate void LabelTextDelegate(string _String);

LabelTextDelegate LabelTextDelegate1;

private void button1_Click(object sender, EventArgs e)
{
    LabelTextDelegate1 = new(LabelText);

    ThreadPool.QueueUserWorkItem(Method1);
}

void Method1(object object1)
{
    label1.BeginInvoke(LabelTextDelegate1, "a");
}

void LabelText(string string1)
{
    label1.Text = string1;
}

멀티스레드 자체도 그 개념이 어려운데 관련된 용어들마저 난해하기 짝이 없다. 일상에서는 잘 쓰지 않는 온갖 추상적 개념들이 동원된다. delegate는 representative 즉 ‘대표자’라는 뜻이다. 그러나 이렇게 이해하면 멀티스레드를 이용하는 데에는 별 도움이 되지 않는다. 메서드에 대한 포인터라 이해하는 게 좋다. 추가된 스레드에서 메인 스레드에 있는 메서드를 바로 실행하는 것이 아니라 중간에 한 다리 건너 실행하는 거라 보면 된다.

델리게이트를 이용하면 메서드를 타입처럼 다룰 수 있다. 클래스를 선언한 뒤 클래스 인스턴스를 만들어서 이용하듯 델리게이트를 선언하고 그 인스턴스를 만들어서 다른 메서드에서 호출할 수 있다. 이렇게 델리게이트를 거쳐 실행하는 걸 invoke라 한다. 이거 역시 무척 추상적이고 어려운 단어인데 ‘법 따위를 들먹인다’는 뜻이지만 프로그래밍에서는 ‘~로 하여금 ~을 시킨다’는 의미다. 델리게이트로 하여금 원래의 메서드를 실행하게 하는 거다. 관련된 예제들을 찾아보면 많이들 복잡한데 Control.InvokeRequired, 콜백 함수, 람다를 써서 그렇다. 모두 2차적인 것들이고 본질은 위의 예제에 나온 대로다.

InvokeRequired를 이용하면 현재 메서드를 호출한 것이 크로스-스레드인지 여부를 확인할 수 있다. 예를 들어 메인 스레드에서 호출할 때와 메인 스레드 아닌 스레드에서 호출할 때를 구별하여 각각에 맞는 처리를 해야 할 때 쓴다. 위 예제에서는 크로스-스레드 상황을 전제하므로 필요하지 않다.

델리게이트를 생략하기

최근의 c# 버전으로는 델리게이트 없이 아래와 같이 간단하게 할 수 있다.

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

void Method1(object object1)
{
    label1.BeginInvoke(LabelText, "a");
}

void LabelText(string string1)
{
    label1.Text = string1;
}

델리게이트는 보이지 않지만 invoke 안에서 내부적으로 델리게이트로 변환되어 처리되므로 달라지는 건 없다. 여기에서 나오는 게 action delegate라는 거다. 마이크로소프트는 Control.Invoke가 델리게이트 말고도 action도 아규먼트로 받는다고 명시한다. 액션이라는 건 메서드를 캡슐화encapsulate하여 델리게이트 역할을 하는 거다.

마이크로소프트는 Invoke(Delegate, Object[])의 형태로 오브젝트 배열을 아규먼트로 넣어야 한다고 명시하지만 아규먼트가 하나일 땐 위의 예제처럼 그냥 값을 넣으면 되고 아규먼트를 두 개 이상 넘길 때에는 오브젝트 배열을 이용해야 한다. 이때 호출되는 델리게이트는 오브젝트 배열로 받으면 안 된다. 아래와 같이 실제 타입으로 받아야 한다. 마이크로소프트가 이 부분을 기술하지 않고 있어서 헤매기 쉽다.

void Method1(object object1)
{
    label1.BeginInvoke(LabelText, new object[] { "a", 1 });
}

void LabelText(string string1, int int1)
{
    label1.Text = string1;

    int1 = 2;
}

네스티드 메서드

아래와 같이 invoke되는 메서드를 호출되는 메서드 안에 넣어도 된다. 이걸 nested methoed라 한다. 이렇게 하면 해당 클래스 안에 있는 메서드의 개수를 줄일 수 있어서 좋다.

void Method1(object object1)
{
    label1.BeginInvoke(labelText, new object[] { "a", 1 });

    void labelText(string string1, int int1)
    {
        label1.Text = string1;

        int1 = 2;
    }
}