내용 보기

작성자

관리자 (IP : 172.17.0.1)

날짜

2020-07-29 02:05

제목

[C#] 왜 Application 의 GUI Component 는 여러 쓰레드에서 동시 접근이 안될까?


JAVA 로 Android Application 을 개발하거나 C# 이나 MFC로 Windows Application 을 개발하면서...

예전 부터 궁금한 게 있었다. 


"왜 GUI Component 는 여러 쓰레드에서 동시 접근이 안될까?" 였다.


이에 대한 궁금증을 해결하고자 여러 자료들을 찾아 보았고 그 중 가장 타당하다고 생각하는 근거를 찾을 수 있었다.

질문에 대한 답을 바로 구하기에 앞서서 정확히 문제의 현상과 원인을 파악하기 위해서는 Application 의 구동 메카니

즘에 대해서 이해를 하여야 한다. (정확히 말하면 Event driven 방식의 Application의 구동 메카니즘.)


현재 대부분의 GUI Application(Event driven 방식) 구조는  다음과 같다.


그림에서 보다시피 Application 은 전형적으로 Main Loop와 Message Queue 를 가지며,

메인 쓰레드가 이 Main Loop 를 돌면서 Message Queue 로부터 처리해야할 Message를 가져온 후

해당 Message를 처리하는 Handler 함수를 실행 시키는 구조로 되어 있다. 이는 Android Application 과 Windows 

Application 모두 동일한 개념이다.


JAVA 로 구현하는 Android Application 이나 C# 으로 구현하는 Windows Application 은 개발의 편의를 돕고자 

위 구조가 개발자에게 숨겨져 있는데 Win32 API 를 사용해서 Windows Application 을 개발했던 분이라면 다음과 같

은 Message Loop 를 포함해야지만 Application 이 제대로 실행 된다는 것을 기억하고 있을 것이다.

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE, LPSTR lpCmdLine, int nCmdShow)
{
    MSG msg;
 
    while(GetMessage(&msg, NULL00> 0)
    {
        TranslateMessage(&msg);
        DispatchMessage(&msg);
    }
 
    return msg.wParam;
}
cs

위 Message Loop 방식에 대하여 간단히 설명하자면...

1. Application 은 Main Entry 인 WinMain 으로 진입하며 While 문 안의 GetMessage 함수를 통해 Message Queue 로부터 Message 를 가져온다. 

2. TranslateMessage 를 함수를 실행하여 Virtual Key message 를 Character message 로 변환 한다.(이건 중요하지 않다.)

3. DispatchMessage 를 호출하여 실제 해당 Message 를 처리할 Handler 함수를 호출한다.


예를 들어서 Click 이벤트가 발생했을 때는 Click 되었다는 메시지가 Framework 단에 의하여 Message Queue 안에 

들어갈 것이고 Main Thread 가 While 문을 돌면서 GetMessage 함수를 통해 Click 이벤트 메시지를 가져온 후,

DispatchMessage 함수를 호출하여 실제 Click 이벤트를 처리하는 함수를 실행하게 된다.


참고로 이러한 방식의 프로그래밍에서 주의할 점은 오랜 시간이 걸리는 일은 Main Thread 에서 실행이 되면 안된다는 것이다.

그 이유는 사용자의 입력(터치/키 입력 등)을 처리하거나 GUI Component 를 사용자에게 보여 주기 위해서 Draw 하

는 부분이 Main Thread 에서 실행되기 때문이다.


만약 Main Thread 가 Message Queue 로부터 Message 를 가져와서 실제 처리를 담당하는 Handler 

를 호출했는데(Call back) 그 Handler 에서 시간을 오랫동안 잡아 먹는 다면 Message Queue 에 있는 

다른 Message 들은 그만큼 처리가 지연될 것이다.

이로 인해 Handler 가 리턴할  때까지 화면이 그려지지 않는 다던지 사용자가 입력을 해도 반응이 없다던지 하는 현상

이 발생하게 된다. 실제로 Android 에서는 이런 경우 아래처럼 ANR(Android Not Responding 다이얼로그를 띄운 후 

해당 Activity 를 강제 종료한다.



안드로이드 개발 문서에서는 ANR 이 발생하는 경우에 대해서 아래와 같이 설명한다.

What Triggers ANR?

Generally, the system displays an ANR if an application cannot respond to user input. For example, if an application blocks on some I/O operation (frequently a network access) on the UI thread so the system can't process incoming user input events. Or perhaps the app spends too much time building an elaborate in-memory structure or computing the next move in a game on the UI thread. It's always important to make sure these computations are efficient, but even the most efficient code still takes time to run.

In any situation in which your app performs a potentially lengthy operation, you should not perform the work on the UI thread, but instead create a worker thread and do most of the work there. This keeps the UI thread (which drives the user interface event loop) running and prevents the system from concluding that your code has frozen. 

In Android, application responsiveness is monitored by the Activity Manager and Window Manager system services. Android will display the ANR dialog for a particular application when it detects one of the following conditions:

  • No response to an input event (such as key press or screen touch events) within 5 seconds.
  • BroadcastReceiver hasn't finished executing within 10 seconds.

간단히 요약하면 UI Thread 에서 user input event 가 처리 되므로 UI Thread 상에서는 시간이 오래 걸리는 일을

하지 말것이며 시간이 오래 걸리는 일은 따로 Thread 로 분리하여 처리하라는 것이다.


그렇다면 위 말처럼 시간이 오래 걸리는 일은 새로운 쓰레드로 분리하여 처리한 후 일이 마무리 되면 결과를 보여 주

기 위해 그 쓰레드 상에서 화면 갱신을 하면 모든 것이 해결될까?

결론 부터 말하면 이 글의 주제에서 말하듯이 안된다는 것이다. 아니 정확히 말하면 시간이 오래 걸리는 일은 새로운 

쓰레드로 분리하여 처리하는 것이 맞지만 화면 갱신을 위한 UI 컴포넌트 접근은 새로 만든 쓰레드에서 하면 안된다.


다시 본론으로 돌아와서,

정말로 Main Thread(UI Thread)외의 다른 Thread에서 GUI Component 에 접근하는 것이 불가능한지 확인해 보자.

이를 증명하기 위해 새로운 쓰레드를 생성한 후 생성된 쓰레드 안에서 TextView 에 접근하는 예제를 만들어 보았다.

다음은 Android Application 의 예이다.

package com.example.activiyexample;
 
import android.app.Activity;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.TextView;
 
public class MainActivity extends Activity implements View.OnClickListener {
 
      
    TextView mTxtView;
     
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
         
        //TextView 는  Main Thread 에  의해 생성된다.  
        mTxtView = (TextView)findViewById(R.id.txtView);
        Button btn = (Button)findViewById(R.id.btn);
        btn.setOnClickListener(this);      
    }
 
    @Override
    public void onClick(View v) {
         
        if(v.getId() == R.id.btn) {
            // 새로운 Thread 를 실행시킨 후 그 안에서 TextView 의 글자를 바꾼다.
            new Thread(new ClickHandleThread()).start();
        }      
    }
     
    public class ClickHandleThread implements Runnable {
         
        @Override
        public void run() {
             
            /*
             *  새로운 Thread 안에서 Main Thread 에 속해 있는 UI Component(TextView)에
             *  접근하여 글자를 바꾼다.
             */        
            mTxtView.setText("Button Clicked");
        }      
    }
}
cs



위 예제는 사용자가 버튼을 누르면 새로운 쓰레드를 생성 후 그 쓰레드에서 TextView 에 "Button Clicked" 라는 글자

를 출력하는 간단한 예이다. 과연 잘 동작할까?

사용자가 버튼을 누르면 어떻게 될까? 불행히도 아래처럼 예외가 발생하고 Activity 가 종료되어 버린다.


W/dalvikvm(27214): threadid=12: thread exiting with uncaught exception (group=0x40f2f1f8)

E/AndroidRuntime(27214): FATAL EXCEPTION: Thread-6616

E/AndroidRuntime(27214): android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.

E/AndroidRuntime(27214):  at android.view.ViewRootImpl.checkThread(ViewRootImpl.java:4069)

E/AndroidRuntime(27214):  at android.view.ViewRootImpl.requestLayout(ViewRootImpl.java:725)

E/AndroidRuntime(27214):  at android.view.View.requestLayout(View.java:12782)

E/AndroidRuntime(27214):  at android.view.View.requestLayout(View.java:12782)

E/AndroidRuntime(27214):  at android.view.View.requestLayout(View.java:12782)

E/AndroidRuntime(27214):  at android.view.View.requestLayout(View.java:12782)

E/AndroidRuntime(27214):  at android.view.View.requestLayout(View.java:12782)

E/AndroidRuntime(27214):  at android.widget.TextView.checkForRelayout(TextView.java:7053)

E/AndroidRuntime(27214):  at android.widget.TextView.setText(TextView.java:3417)

E/AndroidRuntime(27214):  at android.widget.TextView.setText(TextView.java:3273)

E/AndroidRuntime(27214):  at android.widget.TextView.setText(TextView.java:3248)

E/AndroidRuntime(27214):  at com.example.activiyexample.MainActivity$ClickHandleThread.run(MainActivity.java:43)

E/AndroidRuntime(27214):  at java.lang.Thread.run(Thread.java:856)


위 로그 메시지 중 "Only the original thread that created a view hierarchy can touch its views." 에 
주목하자.

에러 메시지가 말해 주듯 View 는  오직 해당 View (UI Component)를 생성한 Thread 에서만 View 접근이 가능하다.
(대부분 UI Component 는 Main Thread 에서 생성을 하므로 UI Component 를 생성한 Thread 인 Main Thread 에서만 UI Component 접근이 가능하다.)

그렇다면 C# 을 사용하여 Windows Application 을 개발할 경우는 어떨까?
Windows 8의 Windows Store Application Guide 에는 다음과 같은 설명이 있다.

Windows Store apps using C++, C#, or Visual Basic are event-driven and all UI components share the same thread. Event-driven means that your code executes when an event occurs. After your app completes the work that is in response to an event, it sits idle, waiting for the next event to occur. The framework code for UI (running layout, processing input, firing events, and so on) and an app’s code for UI all are executed on the same thread. Only one piece of code can execute at a time, so if your app code takes too long to run, the framework can’t run layout or notify an app of other events that fired. In other words, the responsiveness of your app is directly related to the availability of the UI thread to process work. The less work you are doing on the UI thread, the more responsive your program will be to user input. 


Use asynchronous APIs

To help keep your app responsive, the platform provides asynchronous versions of many of its APIs. An asynchronous API ensures that your active execution thread never blocks for a significant amount of time. When you call an API from the UI thread, use the asynchronous version if it's available. For more info about programming with async patterns, seeAsynchronous programming.

Android 와 마찬가지로 Windows Store application 역시 UI Thread 에서 사용자 입력 처리와 Layout 그리는 것을 

처리하므로 UI Thread(Main Thread) 상에서 시간이 오래 걸리는 일을 하지 않도록 asynchronous API 를 활용하라

는 것이다. (참고로 Windows 8의 C# 에서는 Thread 를 직접 생성하는 API 가 없다.)

C# 의 경우도 마찬가지로 UI Component 를 UI Thread(Main Thread) 가 아닌 다른 Thread 에서 접근이 불가능 한지 

확인 해보기 위해서 간단한 예제를 만들어 보았다. Android 예제와 마찬가지로 Button 을 만들고 Button 을 클릭하면 

내부적으로 새로운 Thread 생성 후 에서 TextBlock UI Component 의 글자를 바꾸는 예제이다.

// The Blank Page item template is documented at http://go.microsoft.com/fwlink/?LinkId=234238
 
namespace UIThreadTest
{
    using System;
    using System.Collections.Generic;
    using System.IO;
    using System.Linq;
    using System.Threading.Tasks;
    using Windows.Foundation;
    using Windows.Foundation.Collections;
    using Windows.UI.Xaml;
    using Windows.UI.Xaml.Controls;
    using Windows.UI.Xaml.Controls.Primitives;
    using Windows.UI.Xaml.Data;
    using Windows.UI.Xaml.Input;
    using Windows.UI.Xaml.Media;
    using Windows.UI.Xaml.Navigation;
 
    /// <summary>
    /// An empty page that can be used on its own or navigated to within a Frame.
    /// </summary>
    public sealed partial class MainPage : Page
    {
        public MainPage()
        {
            this.InitializeComponent();
        }
 
        /// <summary>
        /// Invoked when this page is about to be displayed in a Frame.
        /// </summary>
        /// <param name="e">Event data that describes how this page was reached.  The Parameter
        /// property is typically used to configure the page.
        protected override void OnNavigatedTo(NavigationEventArgs e)
        {
        }
 
        /*
         * 사용자가 Button 을 클릭했을 때 들어오는 Event Handler
         */
        private void btn_Clicked(object sender, RoutedEventArgs e)
        {
            ClickHandlerAsync();
        }
 
        public async void ClickHandlerAsync()
        {
            /*
             * async / await 를 사용하여 내부적으로 새로운 Thread 생성 후
             * TextBlock 을 접근하여 글자를 출력하도록 하였다.
             */
            await Task.Run(() => { txtBlock.Text = "Clicked"; });
        }
 
    }
}
cs

결과는 어떨까? 당연한 것이겠지만... C# 의 경우 다음과 같은 예외를 발생시킨다.

C# 도 마찬가지로 오직 UI Thread 에서만 UI Component 접근이 가능하다.

자 이제 문제의 현상에 대해서 모두 파악이 되었다.

그렇다면 결론으로 돌아와서

왜 Main Thread(UI Thread)가 아닌 다른 Thread 에서는 UI Component 접근을 막아 놓았을까?

답은 생각외로 간단하다.

가능은 하지만 불가능에 가까울 만큼 구현이 어려워 지기 때문이다.

당신이 일반 Application 개발자가 아닌 UI Framework 를 만드는 개발자라고 생각해보자. 

당신이 만드는 Button 이나 Text 상자를 일반 개발자들이 가져다 쓰면서 Multithread 방식으로 접근한다고 가정해 보

자. 다음과 같은 시나리오가 있을 것이다.


당신은 Microsoft 나 Google 처럼 Platform 을 개발하는 회사의 직원이다.

당신의 업무는 GUI Framework 를 개발하는 일이고 그 업무 중 하로 'MyTextView' 이라는 간단히 글자를 

보여주는 UI Component 를 만들어서 이를 SDK 에 담아 배포하였다.


스마트폰 Application 개발자 A씨는 당신의 회사에서 배포한 SDK 를 사용하여 스톱와치 Application을 개

발 하기 시작 하였다. 그리고 UI 구성을 하면서 당신이 만든 'MyTextView' 이라는 UI Component 가 마음에 들

어 Application 메인 화면에 에 넣은 후 시간을 보여 주는 용도로 사용하기로 하였다.

개발자 A씨가 만든 Application은 시작 된 후 메인 화면이 보여지면서 당신의 'MyTextView' UI Component 가 나타날 것이다. (Main Thread 에서 'MyTextView' 객체 생성)


그리고 개발자 A씨는 스톱와치의 기능을 구현하기 위해 시작 버튼을 누르면 Thread 를 생성 하도록 하였고 그 Thread 안에서 1초 마다 현재 시간을 'MyTextView' 에 출력하도록 하였다.


프로그램은 잘 돌아가는 듯 싶었다.

그러던 어느날 개발자 A씨는 우연치 않게 아래와 같은 버그를 발견하였다.

1. 스톱워치의 시작 버튼을 눌러서 1초마다 시간이 갱신되도록 하였다.

2. 화면에 시간이 갱신되는 도중에 세로로 된 화면을 가로로 돌렸다.

3. 그리고 그때 잠시 동안 'MyTextView' 객체에 숫자가 제대로 출력 되지 않는 현상을 발견하였다.


위 시나리오 상에서 무엇이 잘못 되었길래 'MyTextView' 객체에 숫자가 제대로 출력되지 않았을까?

문제의 원인은 'MyTextView' 객체를 서로 다른 쓰레드가 동시에 접근할 때 발생한다.

- 스톱와치에서 생성한 Thread

스톱와치에서 생성한 쓰레드가 1초마다 'MyTextView' 객체에 접근 하여 보여 줄 시간 문자열을 지정하면 이를 

실제로 화면에 보여주기 위해 onDraw() 함수가 호출 될 것이다.

- 메인 Thread

그리고 그때 사용자가 화면을 돌린다면 회전된 화면에 맞게 'MyTextView' 객체를 다시 그려주기 위해서 GUI 

Framework 단에서 Message Queue 에 Redraw 하라는 메세지를 넣을 것이고 메인 쓰레드가 Message loop 를 돌

면서 Redraw 메세지를 가져와 onDraw() 함수를 호출 할 것이다.


즉 이때 서로 다른 두 개의 쓰레드가 'MyTextView' 객체의 onDraw() 함수를 호출 함으로 Race condition 이 유발되

고  onDraw() 함수의 결과는 예측할수 없게 된다.


그렇다면 해결책으로 Thread Safe 한 GUI Control 를 만들어 주기 위해서 GUI Control 내부에서 Lock / Unlock 같

Locking 메카니즘을 사용하면 어떨까 하는 생각이 들것이다. 물론 완벽하게 Thread Safe 한 GUI Control 을 만든다

면 가능할지도 모르겠다. 그러나 이미 이런 시도는 과거에도 있었고 그 결과 Deadlock 이나 Race condition 문제 등

으로 인하여 결국 현재처럼 Single Thread 에서만 UI Component 에 접근하는 구조가 만들어졌다고 한다.

(참조 : http://codeidol.com/java/java-concurrency/GUI-Applications/Why-are-GUIs-Single-threaded/)


그리고 문제는 하나 더 있다.

만약 표준 GUI Component가 Multithread 로 접근 가능 하다고 하더라도 사용자가 표준 GUI Control 을 상속받아서 

Custom GUI Control 을 만든다고 하면 사용자는 새로 생성하거나 Override하는 함수를 Thread Safe 하도록 작성해 

주어야 한다. 일반 개발자에게 이는 큰 burden 이 아닐 수 없다. 

예를 들어 단순히 네모 모양의 Button 하나를 상속받아서 동그란 모양을 가진 Custom Button 을 구현한다고 가정하면

기존 부모 클래스의 Thread Safe 한 onDraw() 함수를 자식 클래스에서 override 해야 할 것이고 이때 자식 클래스에

서 onDraw() 함수에 대하여 Lock Unlock 을 용하여 Thread Safe 를 다시 보장해 주어야 한다. 

결국 개발자가 신경 써야 할 부분이 너무 많아지게 된다.


결론은,

Multithread 로 접근 가능한 GUI Component 를 만들 수는 있지만 이를 개발하는 GUI Component 

개발자나 이를 사용하는 일반 개발자나 신경 써야할 부분이 너무 많아지고 프로그래밍이 복잡해지므로 

는 것 보다 잃는 것이 많다고 판단하여 현재와 같이 Single thread 로만 접근이 가능하도록 된 것이다.


사실 개발하다 보면 GUI Component 를 Main Thread 가 아닌 다른 Thread 에서 접근했을 때 발생하는 위 에러메시지

를 아무렇지 않게 당연시 여기고 지나칠수 있다.

하지만 왜 그런 것일까? 왜 다른 Thread 에서는 UI Component 접근이 안되는 것일까? 라는 호기심을 가져보면

그 안에는 그렇게 만들어진 과정이 숨겨져 있는 것이다.

출처1

https://everysw.tistory.com/entry/%EC%99%9C-%EB%8C%80%EB%B6%80%EB%B6%84%EC%9D%98-GUI-Framework-%EC%97%90%EC%84%9C%EB%8A%94-%ED%95%9C-%EC%93%B0%EB%A0%88%EB%93%9C%EB%A7%8C%EC%9D%B4-GUI-Component-%EC%97%90-%EC%A0%91%EA%B7%BC%EC%9D%B4-%EA%B0%80%EB%8A%A5%ED%95%A0%EA%B9%8C

출처2