Всем давно известно, что в WinForms начиная с версии 2 появилась защита от многопоточного использования элементов управления. При попытке выполнить опасные операции библиотечный код выбрасывает InvalidOperationException
с текстом Cross-thread operation not valid: Control 'xxx' accessed from a thread other than the thread it was created on
. Дальше я объясню как выполняется эта проверка и о некоторых подводных камнях этого механизма.
Свойство Handle
класса Control
имеет нетривиальную логику, часть которой содержит проверку на безопасность использования даного кода из другого потока:
[
Browsable(false),
DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden),
DispId(NativeMethods.ActiveX.DISPID_HWND),
SRDescription(SR.ControlHandleDescr)
]
public IntPtr Handle {
get {
if (checkForIllegalCrossThreadCalls &&
!inCrossThreadSafeCall &&
InvokeRequired) {
throw new InvalidOperationException(SR.GetString(SR.IllegalCrossThreadCall,
Name));
}
if (!IsHandleCreated)
{
CreateHandle();
}
return HandleInternal;
}
}
Основная часть условия - свойство InvokeRequired
:
[
Browsable(false), EditorBrowsable(EditorBrowsableState.Advanced),
DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden),
SRDescription(SR.ControlInvokeRequiredDescr)
]
public bool InvokeRequired {
get {
using (new MultithreadSafeCallScope())
{
HandleRef hwnd;
if (IsHandleCreated) {
hwnd = new HandleRef(this, Handle);
}
else {
Control marshalingControl = FindMarshalingControl();
if (!marshalingControl.IsHandleCreated) {
return false;
}
hwnd = new HandleRef(marshalingControl, marshalingControl.Handle);
}
int pid;
int hwndThread = SafeNativeMethods.GetWindowThreadProcessId(hwnd, out pid);
int currentThread = SafeNativeMethods.GetCurrentThreadId();
return(hwndThread != currentThread);
}
}
}
Что здесь происходит? В локальную переменную hwnd
записывается дескриптор текущего окна (если оно создано), иначе - дескриптор первого созданного окна в иерархии child-parent (метод FindMarshalingControl
). Если ни один родитель не создан (нет дескриптора), метод InvokeRequired
возвращает false
. Далее используются функции GetWindowThreadProcessId
и GetCurrentThreadId
чтобы определить принадлежность созданного окна текущему потому. ОС Windows запоминает идентификаторы потоков в контексте которых произошли вызовы CreateWindow
для создания окон.
Из этого можно сделать следующие выводы:
- WinForms не изобретает колесо - лишь использует доступную информация для выполнения нужных проверок
- Если окно не было создано (нет дексриптора), а также не были созданы все его родители -
InvokeRequired
возвращает false
, что весьма логично - объект CLR может быть создан в любом потоке, но получит привязку к конкретному потоку лишь после создания
В результате следующий код содержит потенциальную проблему:
void handleNotificationFromOtherThread(...)
{
if (someControl.InvokeRequired)
{
someControl.BeginInvoke(handleNotificationFromOtherThread, ...);
}
// Thread safe code here
...
}
Если уведомления прийдут до того, как хоть одно окно из иерархии будет создано, InvokeRequired
вернет false
и код выполнится в неправильном контексте. И здесь даже механизм защиты WinForms не поможет. В результате получаем многопоточный доступ к ресурсам без блокировок.
Как избежать подобного сценария? Создавая окно специально для целей синхронизации доступа:
Control sync = new Control();
sync.CreateControl();
_syncInvoke = (ISynchronizeInvoke)sync;
...
void handleNotificationFromOtherThread(...)
{
if (_syncInvoke.InvokeRequired)
{
_syncInvoke.BeginInvoke(handleNotificationFromOtherThread, ...);
}
// Thread safe code here
...
}
Код выше принудительно создает окно в нужном контексте и использует его интерфейс ISynchronizeInvoke
.
Но есть и более изящное решение - сохранить System.Threading.SynchronizationContext.Current
как член класса и использовать для синхронизации. WinForms сам создаст по одному окну специально для маршаллинга вызовов в каждом потоке. Этот подход лучше всего подходит для простых сценариев, когда нужно отмаршаллить все выховы в один главный поток.