- Простая и безопасная реализация многопоточности в Windows Forms. Часть 1
- Введение
- Индикация хода выполнения длительных операций
- Асинхронные операции
- Безопасная многопоточность
- Упрощенная многопоточность
- Заключение
- Благодарности
- Пошаговое руководство. Осуществление потокобезопасных вызовов элементов управления Windows Forms How to: Make thread-safe calls to Windows Forms controls
- Ненадежные вызовы между потоками Unsafe cross-thread calls
- Надежные вызовы между потоками Safe cross-thread calls
- Пример. использование метода Invoke с делегатом Example: Use the Invoke method with a delegate
- Пример. использование обработчика событий BackgroundWorker Example: Use a BackgroundWorker event handler
Простая и безопасная реализация многопоточности в Windows Forms. Часть 1
Автор: Крис Селлз (Chris Sells)
Sells Brothers Consulting
Источник: GotDotNet.ru
Опубликовано: 05.06.2003
Исправлено: 13.03.2005
Версия текста: 1.0
Введение
Все началось вполне невинно. Мне впервые потребовалось вычислить площадь окружности в .NET. Для этого, естественно, нужно точное значение числа pi. В принципе константа System.Math.PI удобна, но в силу того что ее точность составляет 20 знаков, меня беспокоила точность моих расчетов (для полной уверенности мне хотелось получить точность в 21 знак). И я, как и любой настоящий программист, забыл о своей первоначальной задаче и написал программу для вычисления числа pi с любой точностью. Вот что у меня вышло (рис. 1).
Рис. 1. Приложение Digits of Pi
Индикация хода выполнения длительных операций
Хотя в большинстве приложений незачем вычислять pi, многие из них выполняют длительные операции, например печать, вызов Web-сервиса или подсчет процентных доходов по некоему многомиллионному вкладу в банке Pacific Northwest. Обычно пользователи готовы подождать завершения такого рода операций, часто занимаясь в это время чем-то другим, если могут наблюдать за ходом выполнения операции. Поэтому даже в моем маленьком приложении есть индикатор прогресса (progress bar). Мой алгоритм вычисляет 9 знаков числа pi за один проход. Как только появляется новый набор цифр, программа обновляет текст и изменяет индикатор прогресса. Например, рис. 2 иллюстрирует ход вычисления 1000 знаков pi (21 знак — хорошо, а 1000 знаков — лучше).
Рис. 2. Вычисление pi с точностью до 1000 знаков
Ниже приведен код, обновляющий пользовательский интерфейс (UI) по мере вычисления знаков pi.
Все шло замечательно, пока в середине вычисления pi с точностью до 1000 знаков я не переключился в другое приложение, а потом вернулся обратно. То, что я увидел, показано на рис. 3.
Рис. 3. Событие paint пропало!
Конечно, проблема в том, что мое приложение — однопоточное, поэтому пока вычисляется pi, ничего не рисуется. Раньше я с этим не сталкивался, так как при установке свойств TextBox.Text и ProgressBar.Value соответствующие элементы управления перерисовываются в процессе записи свойств (хотя я заметил, что это лучше удается индикатору прогресса, чем текстовому полю). Однако, после того как я перевел приложение в фоновый режим, а потом вновь сделал его активным, мне нужно было отрисовать всю клиентскую область, для чего служит событие формы Paint. Поскольку никакие другие события не обрабатываются, пока не закончится обработка текущего (т. е. события Click кнопки Calc), нам не суждено наблюдать за выполнением вычислений. Значит, на самом деле нужно освободить UI-поток от выполнения длительной операции и реализовать ее как асинхронную. А для этого нужен еще один поток.
Асинхронные операции
На тот момент обработчик события Click выглядел так:
Не забудьте, проблема в том, что до тех пор, пока CalcPi не вернет управление, поток не выйдет из обработчика Click, а значит, форма не сможет обрабатывать событие Paint (или любое другое). Решить эту проблему можно, например, запустив другой поток:
Теперь, вместо того чтобы ждать завершения CalcPi , я создаю и запускаю новый поток. Метод Thread.Start настроит новый поток как готовый к запуску и немедленно вернет управление, что позволит UI-потоку вернуться к своей работе. Тогда, если пользователь захочет вмешаться в работу приложения (перевести его в фоновый режим, вновь сделать активным, изменить размер его окна или даже закрыть), UI-поток сможет свободно обрабатывать все эти события, а рабочий поток — независимо вычислять pi. На рис. 4 показаны два потока, выполняющие свои задачи.
Рис. 4. Примитивная многопоточность
Window message queue — Очередь оконных сообщений
Dequeue — Извлечение из очереди
Owning thread — Поток-владелец
Update — Обновление
Window controls — Оконные элементы управления
Other thread — Другой поток
Window with controls — Окно с элементами управления
Возможно, вы обратили внимание, что в CalcPiThreadStart — входную точку рабочего потока — никакие аргументы не передаются. Вместо этого я записываю число знаков в поле _digitsToCalc и вызываю входную точку потока, которая в свою очередь вызывает CalcPi . Это не слишком удобно и является одной из причин, по которой я предпочитаю для асинхронных вычислений использовать делегаты. Делегаты поддерживают передачу аргументов, что избавляет меня от возни с лишним временным полем и промежуточной функцией между моими двумя функциями.
На случай, если вы не знакомы с делегатами, сообщу, что это просто объекты, вызывающие статические функции, или функции экземпляра. В C# они объявляются по синтаксису объявления функций. Скажем, делегат, вызывающий CalcPi , выглядит так:
Теперь, когда у меня есть делегат, я могу создать экземпляр, синхронно вызывающий функцию CalcPi:
Конечно, мне не нужен синхронный вызов CalcPi ; я хочу вызывать ее асинхронно. Однако до этого нам придется поглубже разобраться в работе делегатов. Приведенная выше строка объявления делегата на самом деле объявляет новый класс, производный от MultiCastDelegate, с тремя функциями — Invoke, BeginInvoke и EndInvoke, как показано здесь:
Когда ранее я создавал экземпляр CalcPiDelegate и вызывал его как функцию, я на самом деле вызывал синхронную функцию Invoke , в свою очередь вызывавшую мою функцию CalcPi . А BeginInvoke и EndInvoke позволяют асинхронно вызывать функцию и получать результаты ее работы. Поэтому, чтобы вызвать CalcPi в другом потоке нужно вызвать BeginInvoke так:
Заметьте: в качестве двух последних аргументов BeginInvoke я передаю null. Эти аргументы нужны, если вы хотите получить результат выполнения функции позже (функция EndInvoke предназначена еще и для этого). А поскольку CalcPi напрямую обновляет UI, эти аргументы нам не нужны, и я передаю в них null. Дополнительную информацию о синхронных и асинхронных делегатах см. в .NET Delegates: A C# Bedtime Story.
Теперь я должен был бы быть доволен. В моем приложении полностью интерактивный UI сообщал о ходе выполнения длительных вычислений. И я был доволен, пока не понял, что натворил.
Безопасная многопоточность
Как выяснилось, мне просто повезло (или не повезло — это как посмотреть). В Microsoft Windows XP нижележащая подсистема поддержки окон, на которой построена Windows Forms, очень надежна. Настолько надежна, что сумела справиться с нарушением первой заповеди программирования в Windows: «Не работай с окном из потока, его не создавшего». Увы, нет никаких гарантий, что другие, менее надежные реализации Windows будут так же великодушны к моим скверным манерам.
Конечно, я сам создал себе проблему. Помните, на рис. 4 два потока обращались к одному и тому же окну одновременно. Однако, поскольку длительные операции в Windows-приложениях — не редкость, у всех UI-классов в Windows Forms (т. е. у классов, производных от System.Windows.Forms.Control ) есть свойство, которое можно использовать из любого потока для безопасного обращения к окну. Это свойство называется InvokeRequired и возвращает true, если вызывающий поток должен передать управление потоку, создавшему объект, до вызова методов этого объекта. Простое выражение Assert в функции ShowProgress сразу выявляет ошибку в моем подходе:
В документации .NET по этому вопросу все достаточно четко. В ней говорится: «Есть четыре метода элемента управления, которые можно безопасно вызывать из любого потока: Invoke, BeginInvoke, EndInvoke и CreateGraphics. Чтобы вызывать любые другие методы, используйте invoke-методы, передающие вызовы в поток элемента управления». Значит, при задании свойств элемента управления я нарушаю это правило. А исходя из имен первых трех функций ( Invoke , BeginInvoke и EndInvoke ), которые разрешено вызывать, становится ясным, что мне нужен еще один делегат — он будет выполняться в UI-потоке. Если бы я был озабочен блокировкой рабочего потока (как в случае с UI-потоком), мне бы пришлось воспользоваться асинхронными методами BeginInvoke и EndInvoke. Но, поскольку рабочий поток всего лишь обслуживает UI-поток, мы обойдемся более простым синхронным методом Invoke, который определен так:
Первая перегруженная версия Invoke принимает экземпляр делегата, содержащего метод, который нужно вызвать в UI-потоке. Никаких аргументов она не предполагает. Однако функция, вызываемая для обновления UI (ShowProgress), принимает три аргумента, поэтому нам потребуется вторая перегруженная версия. Чтобы аргументы передавались корректно, нам понадобится еще один делегат для метода ShowProgress. Применение метода Invoke гарантирует, что вызовы ShowProgress и обращения к окну будут происходить в корректном потоке (не забудьте заменить оба вызова ShowProgress в CalcPi):
Метод Invoke наконец-то позволил мне безопасно использовать многопоточность в приложении Windows Forms. UI-поток порождает рабочий, который выполняет длительную операцию и возвращает управление UI-потоку, когда возникает необходимость в обновлении пользовательского интерфейса. Безопасная многопоточная архитектура показана на рис. 5.
Рис. 5. Безопасная многопоточность
Window message queue — Очередь оконных сообщений
Request update — Запрос на обновление
Dequeue — Извлечение из очереди
Owning thread — Поток-владелец
Update — Обновление
Window controls — Оконные элементы управления
Other thread — Другой поток
Window with controls — Окно с элементами управления
Упрощенная многопоточность
Вызов Invoke не слишком удобен, а поскольку он дважды встречается в функции CalcPi , мы можем облегчить себе жизнь и изменить ShowProgress, чтобы она сама выполняла асинхронный вызов. Если ShowProgress вызывается из корректного потока, она обновляет элементы управления, в ином случае она использует Invoke для вызова самой себя в нужном потоке. Вернемся к предыдущей, более простой версии CalcPi:
Так как вызов Invoke — синхронный и нам не нужно его возвращаемое значение (ведь ShowProgress не возвращает значение), здесь лучше использовать BeginInvoke , чтобы рабочий поток не завис:
BeginInvoke всегда предпочтительнее, если возвращаемое назначение не требуется, поскольку при использовании этого метода рабочий поток сразу же возвращается к своей работе, что исключает вероятность взаимной блокировки.
Заключение
В этом коротком примере я показал, как выполнять длительные операции так, чтобы можно было отображать ход их выполнения, а UI продолжал реагировать на действия пользователя. С этой целью я использовал асинхронный делегат для запуска рабочего потока и метод Invoke для главной формы. При этом еще один делегат выполнялся в UI-потоке.
Я следил, чтобы к данным не было одновременного доступа из рабочего и UI-потоков. Вместо этого я передавал каждому потоку копию нужных ему данных (в рабочий поток — число знаков, а в UI-поток — количество знаков, вычисленных на данный момент). В итоге я никогда не передавал ссылки на объекты, разделяемые двумя потоками, например на текущий StringBuilder. Если бы я передавал ссылки, мне пришлось бы задействовать .NET-средства синхронизации, чтобы исключить вероятность обращения двух потоков к одному объекту одновременно, а это потребовало бы дополнительных усилий. Мне и без того пришлось проделать массу работы, чтобы два потока могли вызывать друг друга.
Конечно, если вы имеете дело с большими наборами данных, вы вряд ли захотите их копировать. Однако везде, где это возможно, я советую для реализации длительных операций в приложениях Windows Forms сочетать асинхронные делегаты и передачу сообщений между рабочим и UI-потоками.
Благодарности
Хотел бы поблагодарить Саймона Робинсона (Simon Robinson) за его сообщение в списке рассылки DevelopMentor .NET, вдохновившее меня на написание этой статьи, Йена Гриффитса (Ian Griffiths) за его начальные наработки в этой области и Майка Вудринга (Mike Woodring) за знаменитые картинки со схемами поддержки нескольких потоков, которые я без зазрения совести стащил у него для своей статьи.
Пошаговое руководство. Осуществление потокобезопасных вызовов элементов управления Windows Forms How to: Make thread-safe calls to Windows Forms controls
Многопоточность может повысить производительность Windows Forms приложений, но доступ к элементам управления Windows Forms не является потокобезопасным. Multithreading can improve the performance of Windows Forms apps, but access to Windows Forms controls isn’t inherently thread-safe. Многопоточность может представлять код для очень серьезных и сложных ошибок. Multithreading can expose your code to very serious and complex bugs. Два или более потока, управляющих элементом управления, могут привести к нестабильному состоянию и вызвать условия гонки, взаимоблокировки, зависания или фиксации. Two or more threads manipulating a control can force the control into an inconsistent state and lead to race conditions, deadlocks, and freezes or hangs. При реализации многопоточности в приложении следует обязательно вызывать элементы управления между потоками потокобезопасным образом. If you implement multithreading in your app, be sure to call cross-thread controls in a thread-safe way. Дополнительные сведения см. в разделе рекомендации по управляемому потоку. For more information, see Managed threading best practices.
Существует два способа безопасного вызова элемента управления Windows Forms из потока, который не был создан этим элементом управления. There are two ways to safely call a Windows Forms control from a thread that didn’t create that control. Метод можно использовать System.Windows.Forms.Control.Invoke для вызова делегата, созданного в основном потоке, который, в свою очередь, вызывает элемент управления. You can use the System.Windows.Forms.Control.Invoke method to call a delegate created in the main thread, which in turn calls the control. Или можно реализовать System.ComponentModel.BackgroundWorker , который использует модель, управляемую событиями, для разделения работы, выполненной в фоновом потоке, от создания отчетов о результатах. Or, you can implement a System.ComponentModel.BackgroundWorker, which uses an event-driven model to separate work done in the background thread from reporting on the results.
Ненадежные вызовы между потоками Unsafe cross-thread calls
Вызвать элемент управления напрямую из потока, который не создал его, неважно. It’s unsafe to call a control directly from a thread that didn’t create it. В следующем фрагменте кода показан незащищенный вызов System.Windows.Forms.TextBox элемента управления. The following code snippet illustrates an unsafe call to the System.Windows.Forms.TextBox control. Button1_Click Обработчик событий создает новый WriteTextUnsafe поток, который устанавливает свойство основного потока TextBox.Text напрямую. The Button1_Click event handler creates a new WriteTextUnsafe thread, which sets the main thread’s TextBox.Text property directly.
Отладчик Visual Studio обнаруживает эти ненадежные вызовы потоков путем вызова исключения InvalidOperationException с сообщением, недопустимой операцией между потоками. Доступ к элементу управления «» осуществляется из потока, отличного от потока, в котором он был создан. The Visual Studio debugger detects these unsafe thread calls by raising an InvalidOperationException with the message, Cross-thread operation not valid. Control «» accessed from a thread other than the thread it was created on. InvalidOperationExceptionВсегда происходит для ненадежных межпотоковых вызовов во время отладки Visual Studio и может возникнуть во время выполнения приложения. The InvalidOperationException always occurs for unsafe cross-thread calls during Visual Studio debugging, and may occur at app runtime. Проблему следует устранить, но можно отключить исключение, задав Control.CheckForIllegalCrossThreadCalls для свойства значение false . You should fix the issue, but you can disable the exception by setting the Control.CheckForIllegalCrossThreadCalls property to false .
Надежные вызовы между потоками Safe cross-thread calls
В следующих примерах кода демонстрируются два способа безопасного вызова элемента управления Windows Forms из потока, который не создал его. The following code examples demonstrate two ways to safely call a Windows Forms control from a thread that didn’t create it:
- System.Windows.Forms.Control.InvokeМетод, который вызывает делегат из основного потока для вызова элемента управления. The System.Windows.Forms.Control.Invoke method, which calls a delegate from the main thread to call the control.
- System.ComponentModel.BackgroundWorkerКомпонент, который предоставляет модель, управляемую событиями. A System.ComponentModel.BackgroundWorker component, which offers an event-driven model.
В обоих примерах фоновый поток заждет одну секунду для имитации работы, выполняемой в этом потоке. In both examples, the background thread sleeps for one second to simulate work being done in that thread.
Вы можете собрать и запустить эти примеры как .NET Framework приложения из командной строки C# или Visual Basic. You can build and run these examples as .NET Framework apps from the C# or Visual Basic command line. Дополнительные сведения см. в разделе Построение из командной строки с помощью csc.exe или Сборка из командной строки (Visual Basic). For more information, see Command-line building with csc.exe or Build from the command line (Visual Basic).
Начиная с .NET Core 3,0, можно также создавать и запускать примеры как приложения Windows .NET Core из папки с файлом проекта .NET Core Windows Forms . csproj . Starting with .NET Core 3.0, you can also build and run the examples as Windows .NET Core apps from a folder that has a .NET Core Windows Forms .csproj project file.
Пример. использование метода Invoke с делегатом Example: Use the Invoke method with a delegate
В следующем примере показан шаблон для обеспечения потокобезопасных вызовов элемента управления Windows Forms. The following example demonstrates a pattern for ensuring thread-safe calls to a Windows Forms control. Он запрашивает System.Windows.Forms.Control.InvokeRequired свойство, которое СРАВНИВАЕТ идентификатор потока создаваемого элемента управления с идентификатором вызывающего потока. It queries the System.Windows.Forms.Control.InvokeRequired property, which compares the control’s creating thread ID to the calling thread ID. Если идентификаторы потоков совпадают, он вызывает элемент управления напрямую. If the thread IDs are the same, it calls the control directly. Если идентификаторы потоков отличаются, он вызывает Control.Invoke метод с делегатом из основного потока, который выполняет фактический вызов элемента управления. If the thread IDs are different, it calls the Control.Invoke method with a delegate from the main thread, which makes the actual call to the control.
SafeCallDelegate Позволяет задать TextBox свойство элемента управления Text . The SafeCallDelegate enables setting the TextBox control’s Text property. WriteTextSafe Метод запрашивает InvokeRequired . The WriteTextSafe method queries InvokeRequired. Если InvokeRequired возвращает true , WriteTextSafe передает в SafeCallDelegate метод, Invoke чтобы выполнить фактический вызов элемента управления. If InvokeRequired returns true , WriteTextSafe passes the SafeCallDelegate to the Invoke method to make the actual call to the control. Если InvokeRequired возвращает false , WriteTextSafe задает TextBox.Text непосредственно. If InvokeRequired returns false , WriteTextSafe sets the TextBox.Text directly. Button1_Click Обработчик событий создает новый поток и выполняет WriteTextSafe метод. The Button1_Click event handler creates the new thread and runs the WriteTextSafe method.
Пример. использование обработчика событий BackgroundWorker Example: Use a BackgroundWorker event handler
Простой способ реализации многопоточности заключается в использовании System.ComponentModel.BackgroundWorker компонента, использующего модель, управляемую событиями. An easy way to implement multithreading is with the System.ComponentModel.BackgroundWorker component, which uses an event-driven model. Фоновый поток запускает BackgroundWorker.DoWork событие, которое не взаимодействует с основным потоком. The background thread runs the BackgroundWorker.DoWork event, which doesn’t interact with the main thread. Главный поток запускает BackgroundWorker.ProgressChanged BackgroundWorker.RunWorkerCompleted обработчики событий и, которые могут вызывать элементы управления основного потока. The main thread runs the BackgroundWorker.ProgressChanged and BackgroundWorker.RunWorkerCompleted event handlers, which can call the main thread’s controls.
Чтобы сделать потокобезопасный вызов с помощью BackgroundWorker , создайте метод в фоновом потоке, чтобы выполнить работу, и привяжите его к DoWork событию. To make a thread-safe call by using BackgroundWorker, create a method in the background thread to do the work, and bind it to the DoWork event. Создайте другой метод в основном потоке, чтобы сообщить результаты фоновой работы и привязать его к ProgressChanged RunWorkerCompleted событию или. Create another method in the main thread to report the results of the background work, and bind it to the ProgressChanged or RunWorkerCompleted event. Чтобы запустить фоновый поток, вызовите BackgroundWorker.RunWorkerAsync . To start the background thread, call BackgroundWorker.RunWorkerAsync.
В примере с помощью RunWorkerCompleted обработчика событий задается TextBox свойство элемента управления Text . The example uses the RunWorkerCompleted event handler to set the TextBox control’s Text property. Пример использования ProgressChanged события см. в разделе BackgroundWorker . For an example using the ProgressChanged event, see BackgroundWorker.