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

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

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

Еще раз о многопоточности в Windows Forms
Рассматривается, как выполнять в рабочем потоке (worker thread) длительные операции, запускаемые из потока пользовательского интерфейса (UI). И при этом поддерживать связь с рабочим потоком, управляя его работой, что позволяет реализовать схему передачи сообщений между потоками для надежной и корректной обработки данных в нескольких потоках.
Файл примера asynchcaclpi.exe можно скачать из MSDN Code Center.

Аннотация

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

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

Рис. 1. Приложение, вычисляющее заданное количество разрядов числа pi

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

В прошлой статье мы сначала напрямую запускали поток для фоновой обработки, но потом перешли на запуск рабочего потока через асинхронные делегаты. У асинхронных делегатов удобный синтаксис для передачи аргументов, и они обеспечивают более высокую масштабируемость, так как при этом потоки берутся из пула, существующего в рамках процесса и управляемого общеязыковой исполняющей средой (CLR). Единственная серьезная проблема, с которой мы столкнулись, возникала, когда рабочему потоку нужно было уведомлять пользователя о ходе операции. Она заключалась в том, что нельзя напрямую работать с UI-элементами из рабочего потока (это давнее ограничение Win32®). Из-за этого рабочий поток должен синхронно (send) или асинхронно (post) посылать сообщения 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) {
  // Проверяем, не вызывается ли метод из UI-потока
  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 отправляет в UI-поток сообщение для самого себя через Control.BeginInvoke и передает ему экземпляр ShowProgressDelegate. Control.BeginInvoke ставит операцию в очередь на асинхронное выполнение в UI-потоке и возвращает управление, не дожидаясь ее завершения.

Отмена длительной операции

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

Рис. 2. Пользователь может отменить длительную операцию

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

Если пользователь решил отменить операцию, этот факт нужно отразить в какой-то переменной-члене. Кроме того, необходимо отключить UI на короткий промежуток времени - с момента, когда 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 (а также меняется надпись на кнопке), и, как и раньше, запускается асинхронное вычисление. Если при нажатии кнопки Calc/Cancel состояние было Calculating, оно меняется на Canceled, и UI отключается до тех пор, пока информация о том, что вычисление отменяется, не поступит в рабочий поток. Когда информация об отмене операции попадет в рабочий поток, мы снова активизируем UI и вернем состояние в Pending, разрешая пользователю запуск новой операции. Чтобы сообщить рабочему потоку о том, что он должен завершиться, добавим в метод ShowProgress out-параметр:

void ShowProgress(..., out bool cancel)

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

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

        // Показываем информацию о ходе выполнения
        // (проверяя, не отменяется ли операция)
        ShowProgress(..., out cancel);
        if( cancel ) break;
    }
}

Может показаться соблазнительным сделать признак отмены логическим возвращаемым значением метода ShowProgress. Но лично мне было бы трудно запомнить, что означает true: что нужно отменить операцию или что все в порядке и можно продолжать выполнение операции. Чтобы код был более понятным, я решил использовать out-параметр.

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

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

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

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

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

object _stateLock = new object();

void ShowProgress(..., out bool cancel) {
  // Так тоже не делайте!
  lock( _stateLock ) { // отслеживаем блокировку
    if( _state == CalcState.Cancel ) {
      _state = CalcState.Pending;
      cancel = true;
    }
    ...
  }
}

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

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

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

Взаимодействие через параметры методов

Мы уже добавили out-параметр в метод ShowProgress. Ничто не мешает проверять в методе 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 { ... }
}

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

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

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

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

Для получения параметра cancel было бы идеально сразу передать переменную типа Boolean методу Control.Invoke, но тут возникает проблема: bool - это тип значения (value type), тогда как Invoke принимает в качестве параметра массив объектов, а объекты относятся к ссылочным типам (reference types). В справочных материалах (см. ниже) перечислены книги, где рассматриваются различия между этими типами. Суть в том, что при передаче переменной типа bool как объекта ее значение копируется, а реальное значение остается неизменным. Из-за этого мы никогда не узнаем об отмене операции. Чтобы избежать такой ситуации, приходится создавать объект (inoutCancel) и передавать его вместо bool - тогда копирования не происходит. После синхронного вызова Invoke переменная типа object приводится к типу bool, и можно определить, отменена ли операция.

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

Заключение

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

Справочные материалы


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


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

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

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

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