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 з
точністю до 1000001 знака, а він помилково вказав всього 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 від тривалої операції, а й
реалізували передачу користувальницького введення в робочий потік, керуючи
його поведінкою. Хоча можна було б удатися до поділу даних і
позбавитися від складних проблем синхронізації (які виникають, тільки
коли бос пробує запустити ваш код), ми залишилися вірними своїй схемі на
основі передачі повідомлень, яка забезпечує надійну і коректну
багатопоточну обробку.

Посилання

Схожі статті:


Сподобалася стаття? Ви можете залишити відгук або підписатися на RSS , щоб автоматично отримувати інформацію про нові статтях.

Коментарів поки що немає.

Ваш отзыв

Поділ на параграфи відбувається автоматично, адреса електронної пошти ніколи не буде опублікований, допустимий HTML: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>

*

*