Windows Forms і фонова обробка, Windows Forms, ASP, статті

У минулій статті ми почали з явного запуску фонового робочого потоку, але зупинилися на застосуванні асинхронних делегатів. Зручність асинхронних делегатів – в синтаксисі передачі параметрів і поліпшеною масштабованості за рахунок використання потоків з общепроцессного пулу, керованого загальномовних виконуючою середовищем (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 знака, а він помилково вказав всього 1000000). Перероблений інтерфейс 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>

*

*