Microsoft MVP성태의 닷넷 이야기
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일
 
(연관된 글이 4개 있습니다.)
(시리즈 글이 9개 있습니다.)
.NET Framework: 612. UWP(유니버설 윈도우 플랫폼) 앱에서 콜백 함수 내에서의 UI 요소 접근 방법
; https://www.sysnet.pe.kr/2/0/11071

.NET Framework: 680. C# - 작업자(Worker) 스레드와 UI 스레드
; https://www.sysnet.pe.kr/2/0/11287

.NET Framework: 777. UI 요소의 접근은 반드시 그 UI를 만든 스레드에서!
; https://www.sysnet.pe.kr/2/0/11561

.NET Framework: 805. 두 개의 윈도우를 각각 실행하는 방법(Windows Forms, WPF)
; https://www.sysnet.pe.kr/2/0/11802

.NET Framework: 886. C# - Console 응용 프로그램에서 UI 스레드 구현 방법
; https://www.sysnet.pe.kr/2/0/12139

.NET Framework: 911. Console/Service Application을 위한 SynchronizationContext - AsyncContext
; https://www.sysnet.pe.kr/2/0/12231

.NET Framework: 1022. UI 요소의 접근은 반드시 그 UI를 만든 스레드에서! - 두 번째 이야기
; https://www.sysnet.pe.kr/2/0/12537

.NET Framework: 2076. C# - SynchronizationContext 기본 사용법
; https://www.sysnet.pe.kr/2/0/13190

.NET Framework: 2077. C# - 직접 만들어 보는 SynchronizationContext
; https://www.sysnet.pe.kr/2/0/13191




UI 요소의 접근은 반드시 그 UI를 만든 스레드에서! - 두 번째 이야기

아래와 같은 질문이 있군요. ^^

Winform에서 invoke없이 UI요소에 접근이 가능한가요?
; https://forum.dotnetdev.kr/t/winform-invoke-ui/411

질문을 정리하면 아래와 같은 코드를 Visual Studio에서 디버그 모드로 실행했을 때,

using System;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace UIElementAccessNoneInvoke
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();
        }

        private async void button1_Click(object sender, EventArgs e)
        {
            await Task.Run(() =>
            {
               textBox1.Text = "test"; // InvalidOperationException 예외 발생
            });
        }
    }
}

2차 스레드에서 textBox1 컨트롤의 요소를 접근하면 예외가 발생한다는 점입니다.

System.InvalidOperationException
  HResult=0x80131509
  Message=Cross-thread operation not valid: Control 'textBox1' accessed from a thread other than the thread it was created on.
  Source=System.Windows.Forms
  StackTrace:
   at System.Windows.Forms.Control.get_Handle()

  This exception was originally thrown at this call stack:
    System.Windows.Forms.Control.Handle.get()

반면, 그냥 실행하면 예외가 발생하지 않습니다. 그 이유를 알아볼까요? 이에 대해서는 Control 타입의 소스 코드를 보면 금방 알 수 있습니다.

static Control()
{
    // ... [생략]...
    checkForIllegalCrossThreadCalls = Debugger.IsAttached;
    // ... [생략]...
}

public static bool CheckForIllegalCrossThreadCalls
{
    get
    {
        return checkForIllegalCrossThreadCalls;
    }
    set
    {
        checkForIllegalCrossThreadCalls = value;
    }
}

[Browsable(false)]
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
[DispId(-515)]
[SRDescription("ControlHandleDescr")]
public IntPtr Handle
{
    get
    {
        if (checkForIllegalCrossThreadCalls && !inCrossThreadSafeCall && InvokeRequired)
        {
            throw new InvalidOperationException(SR.GetString("IllegalCrossThreadCall", Name));
        }

        if (!IsHandleCreated)
        {
            CreateHandle();
        }

        return HandleInternal;
    }
}

보는 바와 같이 checkForIllegalCrossThreadCalls 필드의 값이 Control 정적 생성자가 초기화될 때 Debugger.IsAttached 값에 영향을 받기 때문입니다.

따라서, 만약 디버거가 붙어 있지 않은 상황에서도 - 가령 파일 탐색기에서 EXE 파일을 두 번 클릭해 실행하는 경우에도 InvalidOperationException 예외가 발생하도록 만들고 싶다면 다음과 같이 한 줄만 추가해 주면 됩니다.

private async void button1_Click(object sender, EventArgs e)
{
    Control.CheckForIllegalCrossThreadCalls = true;
            
    await Task.Run(() =>
    {
        textBox1.Text = "test"; // InvalidOperationException 예외 발생
    });
}




그런데, 실행해 보면 알겠지만 예제와 같은 경우 딱히 컨트롤을 만들지 않은 다른 스레드에서 Text 설정을 하는 것이 아무런 문제가 없습니다. 그럼, 왜? 이렇게 강제로 막아버린 것일까요?

우선 윈도우 응용 프로그램의 원시적인 형태인 Win32 API를 사용하던 시절로 가볼까요? (WinForms/WPF도 마찬가지지만) 모든 윈도우 운영체제상의 프로그램은 Message Loop를 가지게 됩니다.

About Messages and Message Queues
; https://learn.microsoft.com/en-us/windows/win32/winmsg/about-messages-and-message-queues

저 당시에는 사실 개발자의 역량에 따라 적절하게 윈도우 메시지를 사용하면 UI 스레드와 worker 스레드 간의 협업이 문제될 것이 없었습니다. 엄밀히 윈도우 메시지의 개념 자체는 Message Queue로 인해 직렬화가 되기 때문에 다중 스레드에서도 안전합니다. 실제로 COM 같은 경우에는 STA 개체의 사용을 thread-safe하게 접근할 수 있도록 함수 호출을 윈도우의 Message Queue로 해결하고 있습니다.

그런데 문제는, 윈도우 메시지 운영이 단순하지 않다는 것입니다.

  • Send와 Post의 차이(SendMessage 와 PostMessage 함수에 대하여...)
  • 다른 프로세스/스레드인 경우 Send 역시 Message Queue를 이용
  • 다른 프로세스인 경우 WM_USER 미만의 메시지는 시스템이 마샬링 담당
  • 같은 프로세스라고 해도 WM_USER 미만의 메시지는 포인터 인자를 사용해 PostMessage로 전달하면 실패 반환
  • 메시지 큐 최대 크기: 기본값 10,000 (HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Windows, "USERPostMessageLimit") 지정 가능한 최솟값은 4,000
  • ...

저런 식의 규칙들은 윈도우상의 응용 프로그램 개발 환경이 어렵다는 인식을 만들기에 부족하지 않습니다. 이후 마이크로소프트는 MFC에서 그런 복잡성을 숨겨 윈도우 메시지보다는 가능한 해당 컨트롤 타입의 함수 호출로 래핑을 했습니다. C++ 클래스 기반으로 만든 MFC는 성능을 위해 멤버 함수들의 thread-safe을 보장하지 않았고, 이후 UI 스레드에 대한 중요성이 부각됩니다. (MFC는 다중 스레드에서의 접근을 일부러 막기 위해 TLS까지 사용합니다.)

Multithreading: MFC Programming Tips - Windows Handle Maps
; https://learn.microsoft.com/en-us/cpp/parallel/multithreading-programming-tips#_core_windows_handle_maps

  • MFC objects are not thread-safe by themselves.
  • As a general rule, a thread can access only MFC objects that it created.

이와 유사하게, .NET Framework의 Windows Forms에서도 해당 타입들이 thread-safe하지 않습니다.

How to: Make thread-safe calls to Windows Forms controls
; https://learn.microsoft.com/en-us/dotnet/desktop/winforms/controls/how-to-make-thread-safe-calls-to-windows-forms-controls

Multithreading can improve the performance of Windows Forms apps, but access to Windows Forms controls isn't inherently thread-safe.


아울러, COM과 엮이면 문제가 더 복잡해집니다.

WTF Reentrancy
; https://blog.impulse101.org/posts/WTF_Reentrancy/

What exactly is a reentrant function?
; https://stackoverflow.com/questions/2799023/what-exactly-is-a-reentrant-function

하지만, thread-safe할 수 있는 동작임에도 그 모든 것을 일일이 구분 지을 수 없기 때문에 보수적으로 전체를 다 thread-safe하지 않은 걸로 단정 지은 면이 있습니다. 일례로, 이 글의 예제에서 다룬 "textBox1.Text" 속성은,

// TextBox
public override string Text
{
    get
    {
        return base.Text;
    }
    set
    {
        base.Text = value;
        selectionSet = false;
    }
}

// TextBoxBase
public override string Text
{
    get
    {
        return base.Text;
    }
    set
    {
        if (value != base.Text)
        {
            base.Text = value;
            if (base.IsHandleCreated)
            {
                SendMessage(185, 0, 0);
            }
        }
    }
}

// Control
public virtual string Text
{
    get
    {
        // ...[생략]...
    }
    set
    {
        if (value == null)
        {
            value = "";
        }

        if (value == Text)
        {
            return;
        }

        if (CacheTextInternal)
        {
            text = value;
        }

        WindowText = value;
        OnTextChanged(EventArgs.Empty);
        if (!IsMnemonicsListenerAxSourced)
        {
            return;
        }

        for (Control control = this; control != null; control = control.ParentInternal)
        {
            ActiveXImpl activeXImpl = (ActiveXImpl)control.Properties.GetObject(PropActiveXImpl);
            if (activeXImpl != null)
            {
                activeXImpl.UpdateAccelTable();
                break;
            }
        }
    }
}

internal virtual string WindowText
{
    get
    {
        // ...[생략]...
    }
    set
    {
        if (value == null)
        {
            value = "";
        }

        if (!WindowText.Equals(value))
        {
            if (IsHandleCreated)
            {
                UnsafeNativeMethods.SetWindowText(new HandleRef(window, Handle), value);
            }
            else if (value.Length == 0)
            {
                text = null;
            }
            else
            {
                text = value;
            }
        }
    }
}

연관된 코드들 없이, UnsafeNativeMethods.SetWindowText 코드만 동작하는 수준이라면 thread-safe하다고 할 수 있지만 그 이외의 코드에 대한 제어를 일일이 개발자에게 맡길 수 없으므로 아예 Text 속성 자체를 thread-safe하지 않은 걸로 제약을 걸어둔 것입니다.

그리고 그 제약을 걸기 가장 좋은 곳이, UI를 가진 Window 프로그램이라면 항상 접근하게 될 HWND 값이므로 Control.Handle 속성에서 checkForIllegalCrossThreadCalls 필드를 이용해 체크하는 방법을 마이크로소프트는 선택합니다.

참고로, Windows Forms에서의 이러한 정책은 thread-safe하지 않다는 것 이외에도 주요하게는 dead-lock 문제를 막기 위한 것이 더 큽니다.




[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]

[연관 글]






[최초 등록일: ]
[최종 수정일: 6/5/2023]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer at outlook.com

비밀번호

댓글 작성자
 



2023-09-25 04시22분
선생님 안녕하세요!

[질문 ①] 위의 글을 간단히 요약하면 다음과 같을까요?
디버깅 시작(F5)으로 실행 시,
스레드 풀의 스레드가 TextBox 컨트롤의 요소를 접근하면 예외가 발생합니다.
하지만 디버그 하지 않고 시작(Ctrl + F5)으로 실행 시,
스레드 풀의 스레드가 TextBox 컨트롤의 요소를 접근하면 예외가 발생하지 않습니다.
이런 차이가 발생하는 이유는 핸들(Handle) 반환(Return) 전에
디버거가 프로세스에 연결되어 있는지 여부를 확인하기 때문입니다.
그러면 왜 핸들 반환 전으로 기준을 잡았까요?
UI를 가진 Window 프로그램이라면 항상 접근하게 될 HWND 값이므로
Control.Handle 속성에서 checkForIllegalCrossThreadCalls 필드를 이용해
체크하는 방법을 마이크로소프트는 선택했습니다.


[질문 ②] Text 속성 자체를 thread-safe 하지 않은 걸로 단정 짓고,
Text 속성 자체에 제약을 걸어둔다는 것이
Control.Handle 속성에서 checkForIllegalCrossThreadCalls 필드를 이용해 체크하는 것인데
이런 체크와 thread-safe와 무슨 관련이 있길래 thread-safe 해지는지 이유가 궁금합니다.


[질문 ③]
F5 실행 시에는 스레드 풀의 스레드가 컨트롤에 접근하면 예외가 발생하고
Ctrl + F5 실행 시에는 스레드 풀의 스레드가 컨트롤에 접근하면 예외가 발생하지 않는 이유가
핸들 반환(Return) 전에 디버거가 프로세스에 연결되어 있는지를 확인하기 때문이라는 것은 알겠습니다.
그런데 Ctrl + F5 실행 시에는 무슨 이유 때문에 예외를 발생하지 않도록 했는지 궁금합니다.
다시 말씀드리면 "UI 요소의 접근은 반드시 그 UI를 만든 스레드에서"라는 원칙을
Ctrl + F5 실행 시에는 예외로 둔 이유가 궁금합니다.


[질문 ④]
"UnsafeNativeMethods.SetWindowText 코드만 동작하는 수준이라면
thread-safe 하다고 할 수 있다고 말할 수 있는" 이유는
SetWindowText가 공유 자원이 아닌 Win32 API 함수를 호출하는 것이기 때문인가요?

[질문 ⑤]
"UnsafeNativeMethods.SetWindowText 코드만 동작하는 수준이라면 thread-safe하다고 할 수 있지만
그 이외의 코드에 대한 제어를 일일이 개발자에게 맡길 수 없으므로
아예 Text 속성 자체를 thread-safe하지 않은 걸로 제약을 걸어둔 것입니다."
이는 TextBoxd Text의 Set 함수(공유 자원)에 여러 스레드가 접근할 수 있기 때문에
아래와 같은 문제가 발생한다는 뜻이죠?

닷넷이 나오기 전 C/C++ 프로그래머들이 개발했던 윈도우 프로그램에서는 그런 제약이 없었다는 점입니다.
사실 제약이 없었다기 보다는 문제는 있었지만 표면화되지 않았을 뿐입니다.
(C/C++에서 다중 스레드를 이용해 ListBox에 아이템을 채우면 설령 이상하게 채워질지언정 못 채우는 것은 아닙니다.)
[참고] https://www.sysnet.pe.kr/2/0/1541
한예지
2023-09-26 12시23분
[답변 1] 맞습니다.

[답변 2] TextBox.Text 속성으로는 사실 thread-safe 한 것과 크게 관련은 없습니다. 단지, 가능한 thread-safe를 보장하기 위해 Handle 값 접근 시에 일괄적으로 체크를 하도록 제약을 둔 것입니다. 일반적으로 윈도우 핸들을 이용한 Win32 메시지 처리는 모두 Window procedure라는 특별한 함수에서 처리됩니다. 몇몇 상황에서는 Window Message를 이용해 해당 함수의 호출이 직렬화되지만 경우에 따라서는 (예를 들어 같은 프로세스 내의 SendMessage) 직접 호출이 되기도 합니다. 그렇기 때문에 다중 스레드에서 Window procedure를 호출하게 되면 내부적으로 동기화 처리를 하지 않은 동작들은 모두 문제가 발생할 수 있습니다.

[답변 3] Ctrl+F5 실행은 결국 사용자가 실행하는 것과 같습니다. 디버깅 시에는 개발자에게 경고성으로 알려줘 문제가 없는 코드를 만들도록 유도하지만, 일단 배포돼 실행이 되는 환경에서도 그런 이유로 예외가 발생해 프로세스가 비정상 종료하는 것은 바람직하지는 않을 것입니다.

[답변 4] 제가 쓴 글의 마지막 코드를 보시면, "Window = value" 코드 이후에 OnTextChanged와 ActiveXImpl 관련 코드들이 나옵니다. 일례로, OnTextChanged는 사용자가 작성할 수 있는 이벤트 핸들러가 엮일 텐데요, 그 메서드들 내부의 코드가 thread-safe 하게 작성되었음을 보증할 수는 없을 것입니다.

[답변 5] 결국 답변 2, 4와 겹치는 듯합니다. 단순히 SetWindowText 정도라면 문제가 되지 않을 테지만, 마이크로소프트 입장에서는 모든 코드를 뒤져 일일이 thread-safe 유무를 가려 if 문을 추가하는 것이 부담스러웠을 거라는... 지극히 개인적인 ^^ 의견입니다.
정성태

1  2  3  4  5  6  7  8  9  10  11  12  13  14  [15]  ...
NoWriterDateCnt.TitleFile(s)
13246정성태2/6/20234084개발 환경 구성: 664. Hyper-V에 설치한 리눅스 VM의 VHD 크기 늘리는 방법 - 두 번째 이야기
13245정성태2/6/20234624.NET Framework: 2093. C# - PEM 파일을 이용한 RSA 개인키/공개키 설정 방법파일 다운로드1
13244정성태2/5/20233983VS.NET IDE: 179. Visual Studio - External Tools에 Shell 내장 명령어 등록
13243정성태2/5/20234850디버깅 기술: 190. windbg - Win32 API 호출 시점에 BP 거는 방법 [1]
13242정성태2/4/20234291디버깅 기술: 189. ASP.NET Web Application (.NET Framework) 프로젝트의 숨겨진 예외 - System.UnauthorizedAccessException
13241정성태2/3/20233820디버깅 기술: 188. ASP.NET Web Application (.NET Framework) 프로젝트의 숨겨진 예외 - System.IO.FileNotFoundException
13240정성태2/1/20233981디버깅 기술: 187. ASP.NET Web Application (.NET Framework) 프로젝트의 숨겨진 예외 - System.Web.HttpException
13239정성태2/1/20233623디버깅 기술: 186. C# - CacheDependency의 숨겨진 예외 - System.Web.HttpException
13238정성태1/31/20235629.NET Framework: 2092. IIS 웹 사이트를 TLS 1.2 또는 TLS 1.3 프로토콜로만 운영하는 방법
13237정성태1/30/20235312.NET Framework: 2091. C# - 웹 사이트가 어떤 버전의 TLS/SSL을 지원하는지 확인하는 방법
13236정성태1/29/20234961개발 환경 구성: 663. openssl을 이용해 인트라넷 IIS 사이트의 SSL 인증서 생성
13235정성태1/29/20234503개발 환경 구성: 662. openssl - 윈도우 환경의 명령행에서 SAN 적용하는 방법
13234정성태1/28/20235540개발 환경 구성: 661. dnSpy를 이용해 소스 코드가 없는 .NET 어셈블리의 코드를 변경하는 방법 [1]
13233정성태1/28/20236893오류 유형: 840. C# - WebClient로 https 호출 시 "The request was aborted: Could not create SSL/TLS secure channel" 예외 발생
13232정성태1/27/20234697스크립트: 43. uwsgi의 --processes와 --threads 옵션
13231정성태1/27/20233611오류 유형: 839. python - TypeError: '...' object is not callable
13230정성태1/26/20234030개발 환경 구성: 660. WSL 2 내부로부터 호스트 측의 네트워크로 UDP 데이터가 1개의 패킷으로만 제한되는 문제
13229정성태1/25/20234966.NET Framework: 2090. C# - UDP Datagram의 최대 크기
13228정성태1/24/20235109.NET Framework: 2089. C# - WMI 논리 디스크가 속한 물리 디스크의 정보를 얻는 방법 [2]파일 다운로드1
13227정성태1/23/20234819개발 환경 구성: 659. Windows - IP MTU 값을 바꿀 수 있을까요? [1]
13226정성태1/23/20234490.NET Framework: 2088. .NET 5부터 지원하는 GetRawSocketOption 사용 시 주의할 점
13225정성태1/21/20233746개발 환경 구성: 658. Windows에서 실행 중인 소켓 서버를 다른 PC 또는 WSL에서 접속할 수 없는 경우
13224정성태1/21/20234089Windows: 221. Windows - Private/Public/Domain이 아닌 네트워크 어댑터 단위로 방화벽을 on/off하는 방법
13223정성태1/20/20234281오류 유형: 838. RDP 연결 오류 - The two computers couldn't connect in the amount of time allotted
13222정성태1/20/20233932개발 환경 구성: 657. WSL - DockerDesktop.vhdx 파일 위치를 옮기는 방법
13221정성태1/19/20234162Linux: 57. C# - 리눅스 프로세스 메모리 정보파일 다운로드1
1  2  3  4  5  6  7  8  9  10  11  12  13  14  [15]  ...