C# 코딩 연습 - 크로스 스레드와 Control.Invoke
C# 코딩 연습 2009. 5. 7. 14:05명작 도서 More Effective C#: 50 Specific Ways to Improve Your C#를 읽다 보니 Control.Invoke를 캡슐화 한 ControlExtensions 이라는 클래스에 대한 이야기가 나왔습니다.
원서에는 멀티 스레딩이나 크로스 스레드 등에 대한 배경 설명 없이 달랑 몇 줄의 코드 정도만 제시되어 있는데 (이것이 바로 이 책의 매력 –반어적 의미 아님- 입니다.),
여기에 앞 뒤 설명을 붙이고, 간단한 예제를 만들어 보았습니다.
매 1초 마다 현재 시각을 표시하는 간단한 시계를 만든다고 합시다.
보통은 System.Timers.Timer 나 System.Threading.Timer, 혹은 System.Windows.Forms.Timer 를 사용하겠지만, 여기서는 Thread.Sleep 메서드를 이용하여 매 1초 마다 시간을 표시하는 방법을 사용해 보겠습니다.
실행하면, 시작하자 마자 바로 화면이 먹통이 되어버리는 걸 알 수 있습니다.
Thread.Sleep 메서드가 현재 메서드(UI가 생성된 메서드)를 잡고 있는 이른바 UI 블로킹이 일어난 것인데요. 이를 해결하기 위해서는 이 부분을 별도의 스레드에서 실행하여야 합니다.
1초를 기다린 후 라벨에 시각을 표시하는 로직이 별도의 메서드(StartNewThread)로 빠졌고, 이 메서드는 이제 메인 스레드(UI 스레드)와는 별개의 스레드에서 실행됩니다.
실행을 해 봅시다. 디버깅하지 않고 시작(CTRL + F5)을 실행하면 (운이 좋다면) 문제 없이 시계가 동작하는 걸 볼 수 있지만, 디버깅 시작(F5)을 실행하면 label1.Text = DateTime.Now.ToString();
에서 InvalidOperationException가 발생합니다.
일명 크로스 스레드 예외라고 하는데요.
label1.Text = DateTime.Now.ToString(); 에서 라벨 컨트롤에 접근을 하려고 하는데, 문제는 이 코드가 실행되는 스레드가 라벨 컨트롤이 생성된 스레드(메인 스레드, UI 스레드)가 아니라는 것입니다.
Control.Invoke를 호출하는 방법과 BackgroundWorker를 사용하는 두 가지 방법이 있을텐데, 대부분의 경우에는 BackgroundWorker 가 좋은 선택이 될 것입니다.
스레드 간에 상태를 공유하고, 진행상황을 보고하고, 작업을 중지하는 등 스레드와 관련한 대부분의 작업이 이미 구현되어 있기 때문에 편리하게 사용할 수 있습니다.
다만 BackgroundWorker에는옥의 티랄까, 한 가지 알려진 버그가 있습니다.
이 포스트의 주제가 BackgroundWorker가 아니고, 또 BackgroundWorker에는 곁다리로 슬쩍 이야기할 수 있는 수준 이상의 논점이 많으니까, 이 포스트에서는 Control.Invoke에 대해서만 이야기하도록 하겠습니다.
Control.Invoke를 호출하여 크로스 스레드 문제를 해결하는 코드는 다음과 같습니다.
먼저 라벨의 InvokeRequired 를 체크하여 Invoke가 필요한지를, 즉 컨트롤에 접근하는 스레드와 컨트롤이 생성된 스레드가 다른 스레드인지를 체크합니다.
굳이 Invoke가 필요하지 않다면 (비록 미미하더라도) 비용이 드는 Invoke를 호출할 필요가 없을 것입니다.
Control.Invoke의 시그니처는 다음과 같습니다.
첫번째 매개변수가 추상 클래스인 Delegate입니다.
따라서 label1.Invoke(new Delegate(DisplayDateTime)); 과 같이 대리자 인스턴스를 생성할 수가 없습니다.
대신 DisplayDateTimeHandler 라는 대리자 형식을 정의한 후 이 인스턴스를 전달하여야 합니다.
이제 실행(디버깅)을 하여 보면 크로스 스레드 문제 없이 잘 동작합니다.
크로스 스레드 문제가 해결되었으니 여기서 포스트가 끝나야 할 것 같지만, 이 포스트를 서야겠다고 생각한 이유는 사실은 지금 부터 시작 합니다.
Control.Invoke를 호출하는 위 코드를 Action 대리자와 확장 메서드를 사용하여 필드에서 사용할 만한 라이브러리로 만들어 봅시다.
닷넷 프레임웍 2.0에 추가된 두 가지 제네릭 대리자를 사용하면 대부분의 경우에는 대리자를 작성할 필요가 없습니다.
Func과 Action 대리자가 그것인데요. Func은 반환값이 있지만 Action은 반환값이 없다는(void) 점 외에는 동일하며, 두 대리자 모두 매개변수가 0개 ~ 4개인 오버로드가 각각 준비되어 있습니다.
(정확하게 이야기하자면, 매개변수가 0개인 Action 대리자의 형은 void Action() 이므로 제네릭 대리자는 아닙니다.)
그래서 매개 변수가 4개가 넘지 않는 시그니처를 가지는 대리자는 이 두 대리자로 표현할 수가 있는 것입니다.
위 코드에서도 DisplayDateTimeHandler 대리자를 따로 정의하지 않고 제네릭 대리자를 사용할 수 있습니다.
반환값이 없고 매개변수도 없으니까, 제네릭이 아닌 Action 대리자를 사용하면 되겠습니다.
이번에는 확장 메서드를 사용하여 위 코드를 캡슐화 해봅시다.
ControlExtensions라는 static 클래스를 만들고 아래와 같은 확장 메서드를 추가합니다.
(여기서는 매개변수가 0개 ~ 1개인 오버로드만 보이는데, 2개 ~ 4개인 오버로드도 정의해두면 편리합니다.)
이제 StartNewThread 메서드는 아래와 같이 간단해 집니다.
람다식이나 익명 메서드를 이용하면 DisplayDateTime 메서드도 따로 정의할 필요가 없어 코드가 좀 더 간단해 집니다.
연습 삼아 코드를 약간 고쳐 봅시다.
DisplayDateTime 메서드를 매개 변수를 가지는 형태로 다음과 같이 수정합니다.
그렇다면 이제 라벨의 Invoke 메서드를 호출할 때 현재 시각을 매개 변수로 넘겨야 합니다.
ControlExtensions.InvokeIfNeeded 오버로드 중,
가 호출되는데, 이는 다시 control.Invoke(action, arg); 를 호출합니다.
Control.Invoke의 두 번째 매개변수는 params object[] 이기 때문에, action 대리자의 매개변수의 갯수가 형에 상관없이 호출이 가능합니다.
보너스 : 제가 알렸드렸다고 소문 내지는 마시고, More Effective C# 책이 보고 싶으신 분은 이 링크를 눌러 보세요.