Исходники.Ру - Программирование
Исходники
Статьи
Книги и учебники
Скрипты
Новости RSS
Магазин программиста

Главная » Статьи по программированию » .NET - Windows Forms »

Обсудить на форуме Обсудить на форуме

Простая и безопасная реализация многопоточности в Windows Forms. Часть 2
Рассматривается применение многопоточности для отделения пользовательского интерфейса (UI) от длительно выполняемых операций с передачей рабочему потоку пользовательского ввода для управления его функционированием. Это позволяет создать схему передачи сообщений для надежной и корректной многопоточной обработки.

Аннотация

Рассматривается применение многопоточности для отделения пользовательского интерфейса (UI) от длительно выполняемых операций с передачей рабочему потоку пользовательского ввода для управления его функционированием. Это позволяет создать схему передачи сообщений для надежной и корректной многопоточной обработки.

Скачать файл-пример asynchcaclpi.exe.

Как вы, наверное, помните из статьи Safe, Simple Multithreading in Windows Forms, Part 1, Windows Forms в сочетании с многопоточностью позволяет добиться неплохих результатов, если соблюдать необходимую осторожность. Создание дополнительных потоков - неплохой способ выполнения длительных операций вроде вычисления pi с высокой точностью, как показано на рис. 1.

Рис. 1. Приложение Digits of Pi

Windows Forms и фоновая обработка

В прошлой статье мы начали с явного запуска фонового рабочего потока, но остановились на применении асинхронных делегатов. Удобство асинхронных делегатов - в синтаксисе передачи параметров и улучшенной масштабируемости за счет использования потоков из общепроцессного пула, управляемого общеязыковой исполняющей средой (common language runtime, CLR). Мы столкнулись только с одной настоящей проблемой: как быть, когда рабочему потоку нужно уведомить пользователя о ходе операции. В нашем случае такому потоку нельзя работать с UI-элементами напрямую (давний запрет в Win32®). Вместо этого рабочий поток должен посылать синхронные или асинхронные сообщения UI-потоку, используя Control.Invoke или Control.BeginInvoke, чтобы код выполнялся в потоке - владельце элемента управления. В результате у нас получился такой код:

// Делегат, начинающий асинхронное вычисление pi
delegate void CalcPiDelegate(int digits);
void _calcButton_Click(object sender, EventArgs e) {
  // Запустить асинхронное вычисление pi
  CalcPiDelegate calcPi = new CalcPiDelegate(CalcPi);
  calcPi.BeginInvoke((int)_digits.Value, null, null);
}

void CalcPi(int digits) {
  StringBuilder pi = new StringBuilder("3", digits + 2);

  // Отобразить ход выполнения
  ShowProgress(pi.ToString(), digits, 0);

  if( digits > 0 ) {
    pi.Append(".");

    for( int i = 0; i < digits; i += 9 ) {
      ...
      // Отобразить ход выполнения
      ShowProgress(pi.ToString(), digits, i + digitCount);
    }
  }
}

// Делегат, информирующий UI-поток о прогрессе рабочего потока
delegate
void ShowProgressDelegate(string pi, int totalDigits, int digitsSoFar);

void ShowProgress(string pi, int totalDigits, int digitsSoFar) {
  // Убедиться, что мы в правильном потоке
  if( _pi.InvokeRequired == false ) {
    _pi.Text = pi;
    _piProgress.Maximum = totalDigits;
    _piProgress.Value = digitsSoFar;
  }
  else {
    // Отражать ход выполнения синхронно
    ShowProgressDelegate showProgress =
      new ShowProgressDelegate(ShowProgress);
    this.BeginInvoke(showProgress,
      new object[] { pi, totalDigits, digitsSoFar });
  }
}

Заметьте, что у нас два делегата. Первый, CalcPiDelegate, используется для упаковки аргументов, передаваемых CalcPi в рабочем потоке, выделенном из пула. Экземпляр этого делегата создается в обработчике события, когда пользователь решает вычислить pi. Вызов BeginInvoke ставит задачу в очередь к пулу потоков. Первый делегат на самом деле нужен для передачи сообщения от UI-потока рабочему.

Второй делегат, ShowProgressDelegate, требуется для того, чтобы рабочий поток мог передать сообщение обратно в UI-потоку (в нашем случае - информацию о ходе выполнения длительной операции). Чтобы скрыть детали безопасного в многопоточной среде взаимодействия между рабочим и UI-потоками, метод ShowProgress использует ShowProgressDelegate и отправляет сообщение самому себе в UI-потоке через метод Control.BeginInvoke. Последний асинхронно ставит задачу в очередь UI-потока и продолжает работу, не дожидаясь результатов.

Отмена операции

В этом примере мы можем посылать сообщения между потоками без всякой опаски. UI-потоку не нужно ждать завершения рабочего потока и даже получать уведомление о его завершении, так как рабочий поток посылает сообщения по мере выполнения операции. Точно так же рабочему потоку не требуется ждать, пока UI-поток отобразит ход выполнения, - достаточно того, что сообщения посылаются регулярно и пользователь должен быть счастлив. Однако отсутствие полного контроля над выполнением приложения все же не радует пользователя. Хотя UI реагирует на действия пользователя в процессе вычисления pi, пользователю все равно хочется иметь возможность отмены вычисления (например, если ему нужно число pi с точностью до 1 000 001 знака, а он ошибочно указал всего 1 000 000). Переработанный интерфейс CalcPi, позволяющий отменять вычисления, приведен на рис. 2.

Рис. 2. UI, позволяющий отменить длительную операцию

Отмена длительной операции - процесс многошаговый. Во-первых, потребуется соответствующий UI. В нашем случае кнопка Calc меняется на Cancel после начала вычислений. Другое популярное решение - диалоговое окно, отражающее ход выполнения; оно содержит кнопку Cancel и индикатор прогресса, показывающий процентное соотношение выполненной и оставшейся работы.

Если пользователь решил отменить операцию, это следует отметить в переменной-члене и блокировать UI на короткое время между моментом, когда UI-поток узнает, что рабочий поток нужно остановить, и моментом, когда рабочий поток узнает об отмене и сможет прекратить передачу уведомлений о ходе выполнения. Если проигнорировать этот период времени, пользователь сможет запустить новую операцию до того, как первый рабочий поток прекратит уведомления, что потребует от UI-потока определять, какие сообщения поступают от нового потока, а какие - от старого, который считается завершающимся. Можно, конечно, присвоить каждому рабочему потоку уникальный идентификатор (в случае нескольких одновременных длительных операций вам так и придется сделать), но зачастую проще приостановить UI-поток. С этой целью в нашей несложной программе, вычисляющей pi, достаточно ввести три состояния, на которые указывают соответствующие значения перечислимого типа:

enum CalcState {
    Pending,     // вычисление не выполняется и не отменяется
    Calculating, // вычисление выполняется
    Canceled,    // вычисление отменено в UI-потоке, но не в рабочем
}

CalcState _state = CalcState.Pending;

Теперь кнопка Calc обрабатывается по-разному - в зависимости от состояния программы:

void _calcButton_Click(...)  {
    // Кнопка Calc служит и кнопкой Cancel
    switch( _state ) {
        // Начать новое вычисление
        case CalcState.Pending:
            // Разрешить отмену
            _state = CalcState.Calculating;
            _calcButton.Text = "Cancel";

            // Метод асинхронного делегата
            CalcPiDelegate  calcPi = new CalcPiDelegate(CalcPi);
            calcPi.BeginInvoke((int)_digits.Value, null, null);
            break;

        // Отменить выполняемую операцию
        case CalcState.Calculating:
            _state = CalcState.Canceled;
            _calcButton.Enabled = false;
            break;

        // Запретить нажатие кнопки Calc в процессе отмены
        case CalcState.Canceled:
            Debug.Assert(false);
            break;
    }
}

Заметьте, что при нажатии кнопки Calc/Cancel в состоянии Pending, мы переходим в состояние Calculating (а также изменяем надпись на кнопке) и запускаем вычисление асинхронно, как и прежде. Если в момент нажатия текущее состояние - Calculating, мы переходим в состояние Canceled и блокируем интерфейс на время, необходимое для передачи уведомления об отмене в рабочий поток. После того как мы уведомили рабочий поток о необходимости отмены, UI разблокируется и состояние устанавливается обратно в Pending, так что пользователь может начать новую операцию. Чтобы уведомить рабочий поток о необходимости отмены, добавим в метод ShowProgress новый выходной параметр:

void ShowProgress(..., out bool cancel)

void CalcPi(int digits) {
    bool cancel = false;
    ...

    for( int i = 0; i < digits; i += 9 ) {
        ...

        // ShowProgress (проверка на Cancel)
        ShowProgress(..., out cancel);
        if( cancel ) break;
    }
}

Возможно, вы испытываете соблазн сделать индикатор отмены булевым значением, возвращаемым из ShowProgress, но я вот никогда не могу запомнить, что означает true - отмену или продолжение нормальной работы, поэтому использую более четкий выходной параметр.

Теперь, чтобы отслеживать запрос от пользователя на отмену вычислений и информировать об этом CalcPi, осталось лишь обновить метод ShowProgress - код, который на самом деле передает данные между рабочим и UI-потоками. Конкретный способ обмена данными между потоками зависит от наших предпочтений.

Взаимодействие через общие данные

Очевидный способ сообщить текущее состояние UI - позволить рабочему потоку напрямую обращаться к переменной-члену _state. Этого можно было бы добиться с помощью такого кода:

void ShowProgress(..., out bool cancel) {
  // Не делайте так!
  if( _state == CalcState.Cancel ) {
    _state = CalcState.Pending;
    cancel = true;
  }
  ...
}

Надеюсь, когда вы увидели этот код, у вас в голове зазвенел тревожный звоночек (и не только из-за предупреждающего комментария). Если вы собираетесь заниматься многопоточным программированием, то должны остерегаться вероятности одновременного доступа двух потоков к одним и тем же данным (в нашем случае, к переменной-члену _state). Совместный доступ к данным из двух потоков легко может привести к состоянию конкуренции (race conditions), когда один процесс считывает частично обновленные данные до того, как другой процесс заканчивает их обновление. Чтобы одновременный доступ к общим данным работал, нужно следить за обращением к ним: пока один поток не закончит работать с данными, остальные потоки должны терпеливо ждать своей очереди. Для этого в .NET предусмотрен класс Monitor, используемый в качестве общего объекта и действующий как блокировка неких данных. C# предоставляет удобную оболочку этого класса - блок lock:

object _stateLock = new object();

void ShowProgress(..., out bool cancel) {
  // Так тоже не делайте!
  lock( _stateLock ) { // Monitor the lock
    if( _state == CalcState.Cancel ) {
      _state = CalcState.Pending;
      cancel = true;
    }
    ...
  }
}

Теперь я заблокировал доступ к общим данным, но сделал это так, что появился риск возникновения другой распространенной при многопоточном программировании проблемы - взаимоблокировки (deadlock). Когда два потока взаимоблокированы, каждый из них ждет, пока другой закончит свою работу, и в итоге ни один из них не работает.

Если все эти разговоры о конкуренции и взаимоблокировке вселяют в вас озабоченность, так это даже хорошо. Многопоточное программирование с использованием общих (разделяемых) данных - штука чертовски трудная. До сих пор мы избегали этих проблем, так как передавали копии данных, которые принадлежали исключительно тем или иным потокам. Когда нет общих данных, нет и нужды в синхронизации. Если вам понадобится доступ к общим данным (например, издержки копирования слишком велики), тогда изучайте, как корректно организовать разделение данных между потоками (в разделе "Ссылки" приведен список моих любимых пособий по этой тематике).

Однако подавляющее большинство многопоточных программ, особенно связанных с многопоточным пользовательским интерфейсом, лучше работает по простой схеме передачи сообщений, до сих применяемой нами. Чаще всего вам не понадобится доступ из UI к данным, обрабатываемым в фоновом режиме (например, к печатаемому документу или к набору объектов, которые перечисляются в каком-то потоке). В таких случаях лучше избегать применения общих данных.

Передача данных через параметры методов

Мы уже добавили выходной параметр к методу ShowProgress. Почему бы ему не проверять состояние переменной _state, когда он выполняется в UI-потоке, например так:

void ShowProgress(..., out bool cancel) {
    // Убедиться, что мы в UI-потоке
    if( _pi.InvokeRequired == false ) {
        ...

        // Проверка на отмену
        cancel = (_state == CalcState.Canceled);

        // Проверка на окончание работы
        if( cancel || (digitsSoFar == totalDigits) ) {
            _state = CalcState.Pending;
            _calcButton.Text = "Calc";
            _calcButton.Enabled = true;

        }
    }
    // Передать управление UI-потоку
    else { ... }
}

Так как лишь UI-поток обращается к переменной-члену _state, синхронизация не нужна. Теперь задача сводится к передаче управления UI-потоку таким образом, чтобы получить выходной параметр cancel от ShowProgressDelegate. Увы, использование Control.BeginInvoke усложняет задачу. Проблема в том, что BeginInvoke не ждет результата вызова ShowProgress в UI-потоке, так что у нас два варианта. Первый вариант - передать BeginInvoke другой делегат, вызываемый по возвращении ShowProgress из UI-потока, но это произойдет в другом потоке из пула, и нам снова понадобится синхронизация - на этот раз между рабочим потоком и другим потоком из пула. Проще воспользоваться синхронным методом Control.Invoke и дождаться получения выходного параметра cancel. Однако для этого требуется довольно хитрый код:

void ShowProgress(..., out bool cancel) {
    if( _pi.InvokeRequired == false ) { ... }
    // Передать управление UI-потоку
    else {
        ShowProgressDelegate  showProgress =
            new ShowProgressDelegate(ShowProgress);

        // Во избежание упаковки (boxing) и потери возвращаемого значения
        object inoutCancel = false;

        // Синхронно отображать ход выполнения
        // (чтобы можно было делать проверку на отмену)
        Invoke(showProgress, new object[] { ..., inoutCancel});
        cancel = (bool)inoutCancel;
    }
}

Хотя было бы идеально для получения возвращаемого значения просто передать булеву переменную непосредственно Control.Invoke, здесь есть проблема. Она в том, что bool - тип значения, но Invoke принимает в виде параметров массив объектов, а объекты - ссылочные типы. В чем разница между этими типами, см. в книгах, перечисленных в разделе "Ссылки", но, если коротко, то bool, переданный в виде объекта, будет скопирован, а значение исходной переменной останется неизменным, т. е. мы ничего не узнаем об отмене операции. Чтобы избежать этого, мы создаем собственную объектную переменную (inoutCancel) и передаем ее, а не копию. После синхронного вызова Invoke, мы приводим переменную типа object к bool и получаем требуемые данные.

О разнице между значимыми и ссылочными типами следует всегда помнить при любом вызове Control.Invoke (или Control.BeginInvoke) с out- или ref-параметрами, которые относятся к типам значений вроде элементарных int или bool либо перечислимых или структур. Однако, если вы передаете более сложные данные, скажем пользовательский ссылочный тип (класс), от вас ничего не потребуется. Но даже такое неудобство обработки типов значений в Invoke/BeginInvoke - ничто в сравнении с обращением многопоточного кода к общим данным с учетом вероятности конкуренции и взаимоблокировки. И, по-моему, это небольшая цена.

Заключение

И вновь на очень простом примере мы исследовали ряд весьма сложных вопросов. Мы не только отделили UI от длительной операции, но и реализовали передачу пользовательского ввода в рабочий поток, управляя его поведением. Хотя можно было бы прибегнуть к разделению данных и избавиться от сложных проблем синхронизации (которые возникают, только когда босс пробует запустить ваш код), мы остались верны своей схеме на основе передачи сообщений, которая обеспечивает надежную и корректную многопоточную обработку.

Ссылки


Может пригодится:


Автор: Крис Селлз
Прочитано: 10939
Рейтинг:
Оценить: 1 2 3 4 5

Комментарии: (1)

Прислал: Rotar
Пример создания многопоточного приложения в Delphi числа Пі http://depositfiles.com/files/8850784

Добавить комментарий
Ваше имя*:
Ваш email:
URL Вашего сайта:
Ваш комментарий*:
Код безопастности*:

Рассылка новостей
Рейтинги
© 2007, Программирование Исходники.Ру