Індикація ходу виконання тривалих операцій, Windows Forms, ASP, статті

Хоча в більшості програм немає чого обчислювати pi, багато хто з них виконують тривалі операції, наприклад друк, виклик Web-сервісу або підрахунок процентних доходів за якимсь багатомільйонного вкладу в банку Pacific Northwest. Зазвичай користувачі готові почекати завершення такого роду операцій, часто займаючись в цей час чимось іншим, якщо можуть спостерігати за ходом виконання операції. Тому навіть в моєму маленькому додатку є індикатор прогресу (progress bar). Мій алгоритм обчислює 9 знаків числа pi за один прохід. Як тільки з’являється новий набір цифр, програма оновлює текст і змінює індикатор прогресу. Наприклад, рис. 2 ілюструє хід обчислення 1000 знаків pi (21 знак – добре, а 1000 знаків – краще).

Рис. 2. Обчислення pi з точністю до 1000 знаків

Нижче наведено код, оновлюючий користувальницький інтерфейс (UI) по мірі обчислення знаків pi.

void ShowProgress(string pi, int totalDigits, int digitsSoFar) {
  _pi.Text = pi;
  _piProgress.Maximum = totalDigits;
  _piProgress.Value = digitsSoFar;
}
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 ) {
      int nineDigits = NineDigitsOfPi.StartingAt(i+1);
      int digitCount = Math.Min(digits - i, 9);
      string ds = string.Format("{0:D9}", nineDigits);
      pi.Append(ds.Substring(0, digitCount));
 / / Відобразити хід виконання
      ShowProgress(pi.ToString(), digits, i + digitCount);
    }
  }
}

Все йшло чудово, поки в середині обчислення pi з точністю до 1000 знаків я не переключився в інший додаток, а потім повернувся назад. Те, що я побачив, показано на рис. 3.

Рис. 3. Подія paint пропало!

Звичайно, проблема в тому, що моє додаток – однопоточному, тому поки обчислюється pi, нічого не малюється. Раніше я з цим не стикався, так як при установці властивостей TextBox.Text і ProgressBar.Value відповідні елементи управління перемальовується в процесі запису властивостей (хоча я помітив, що це краще вдається індикатору прогресу, ніж текстовому полю). Однак, після того як я переклав додаток у фоновий режим, а потім знову зробив його активним, мені потрібно було відмалювати всю клієнтську область, для чого служить подія форми Paint. Оскільки ніякі інші події не обробляються, поки не закінчиться обробка поточного (тобто події Click кнопки Calc), нам не судилося спостерігати за виконанням обчислень. Значить, насправді потрібно звільнити UI-поток від виконання тривалої операції і реалізувати її як асинхронну. А для цього потрібен ще один потік.

Асинхронні операції

На той момент обробник події Click виглядав так:

void _calcButton_Click(object sender, EventArgs e) {
  CalcPi((int)_digits.Value);
}

Не забудьте, проблема в тому, що до тих пір, поки CalcPi не поверне управління, потік не вийде з обробника Click, а значить, форма не зможе обробляти подія Paint (чи будь-яке інше). Вирішити цю проблему можна, наприклад, запустивши інший потік:

using System.Threading;
…
int _digitsToCalc = 0;
void CalcPiThreadStart() {
  CalcPi(_digitsToCalc);
}
void _calcButton_Click(object sender, EventArgs e) {
  _digitsToCalc = (int)_digits.Value;
  Thread piThread = new Thread(new ThreadStart(CalcPiThreadStart));            
  piThread.Start();
}

Тепер, замість того щоб чекати завершення CalcPi, я створюю і запускаю новий потік. Метод Thread.Start налаштує новий потік як готовий до запуску і негайно поверне управління, що дозволить UI-потоку повернутися до своєї роботи. Тоді, якщо користувач захоче втрутитися в роботу додатки (перевести його у фоновий режим, знову зробити активним, змінити розмір його вікна або навіть закрити), UI-потік зможе вільно обробляти всі ці події, а робочий потік – незалежно обчислювати pi. На рис. 4 показані два потоки, які виконують свої завдання.

Window message queue – Черга віконних повідомлень
Dequeue – Витяг з черги
Owning thread – Потік-власник
Update – Оновлення
Window controls – Віконні елементи управління
Other thread – інший потік
Window with controls – Вікно з елементами управління

Рис. 4. Примітивна багатопоточність

Можливо, ви звернули увагу, що в CalcPiThreadStart – вхідні точку робочого потоку – ніякі аргументи не передаються. Замість цього я записую число знаків у поле _digitsToCalc і викликаю вхідну точку потоку, яка в свою чергу викликає CalcPi. Це не дуже зручно і є однією з причин, по якій я віддаю перевагу для асинхронних обчислень використовувати делегати. Делегати підтримують передачу аргументів, що позбавляє мене від метушні з гаком тимчасовим полем і проміжної функцією між моїми двома функціями.

На випадок, якщо ви не знайомі з делегатами, повідомлю, що це просто об’єкти, що викликають статичні функції, або функції екземпляра. В C # вони оголошуються по синтаксису оголошення функцій. Скажімо, делегат, викликає CalcPi, виглядає так:

delegate void CalcPiDelegate(int digits);

Тепер, коли у мене є делегат, я можу створити екземпляр, синхронно викликає функцію CalcPi:

void _calcButton_Click(object sender, EventArgs e) {
  CalcPiDelegate  calcPi = new CalcPiDelegate(CalcPi);
  calcPi((int)_digits.Value);
}

Звичайно, мені не потрібен синхронний виклик CalcPi; я хочу викликати її асинхронно. Однак до цього нам доведеться глибше розібратися в роботі делегатів. Наведена вище рядок оголошення делегата насправді оголошує новий клас, похідний від MultiCastDelegate, з трьома функціями – Invoke, BeginInvoke і EndInvoke, як показано тут:

class CalcPiDelegate : MulticastDelegate {
  public void Invoke(int digits);
  public void BeginInvoke(int digits, AsyncCallback callback,
                          object asyncState);
  public void EndInvoke(IAsyncResult result);
}

Коли раніше я створював примірник CalcPiDelegate і викликав його як функцію, я насправді викликав синхронну функцію Invoke, в свою чергу викликала мою функцію CalcPi. А BeginInvoke і EndInvoke дозволяють асинхронно викликати функцію і отримувати результати її роботи. Тому, щоб викликати CalcPi в іншому потоці потрібно викликати BeginInvoke так:

void _calcButton_Click(object sender, EventArgs e) {
  CalcPiDelegate  calcPi = new CalcPiDelegate(CalcPi);
  calcPi.BeginInvoke((int)_digits.Value, null, null);
}

Зауважте: в якості двох останніх аргументів 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 відразу виявляє помилку в моєму підході:

using System.Diagnostics;
void ShowProgress(string pi, int totalDigits, int digitsSoFar) { / / Перевіримо, чи в тому потоці ми знаходимося
  Debug.Assert(_pi.InvokeRequired == false);
  ...
}

У документації. NET з цього питання все досить чітко. У ній говориться: “Є чотири методи елемента керування, які можна безпечно викликати з будь-якого потоку: Invoke, BeginInvoke, EndInvoke і CreateGraphics. Щоб викликати будь-які інші методи, використовуйте invoke-методи, що передають виклики в потік елемента управління “. Значить, при завданні властивостей елемента керування я порушую це правило. А виходячи з імен перших трьох функцій (Invoke, BeginInvoke і EndInvoke), які дозволено викликати, стає зрозумілим, що мені потрібен ще один делегат – він буде виконуватися в UI-потоці. Якби я був стурбований блокуванням робочого потоку (як у випадку з UI-потоком), мені б довелося скористатися асинхронними методами BeginInvoke і EndInvoke. Але, оскільки робочий потік всього лише обслуговує UI-потік, ми обійдемося простішим синхронним методом Invoke, що визначений так:

public object Invoke(Delegate method);
public object Invoke(Delegate method, object[] args);

Перша перевантажена версія Invoke приймає примірник делегата, містить метод, який потрібно викликати в UI-потоці. Ніяких аргументів вона не передбачає. Проте функція, що викликається для поновлення UI (ShowProgress), приймає три аргументи, тому нам знадобиться другий перевантажена версія. Щоб аргументи передавалися коректно, нам знадобиться ще один делегат для методу ShowProgress. Застосування методу Invoke гарантує, що виклики ShowProgress та звернення до вікна будуть відбуватися в коректному потоці (не забудьте замінити обидва виклику ShowProgress в CalcPi):

delegate
void ShowProgressDelegate(string pi, int totalDigits, int digitsSoFar);
void CalcPi(int digits) {
  StringBuilder pi = new StringBuilder("3", digits + 2);
 / / Готуємося до асинхронному відображенню індикатора прогресу
  ShowProgressDelegate showProgress =
    new ShowProgressDelegate(ShowProgress);
 / / Відобразити хід виконання
  this.Invoke(showProgress, new object[] { pi.ToString(), digits, 0});
  if( digits > 0 ) {
    pi.Append(".");
    for( int i = 0; i < digits; i += 9 ) {
      ... / / Відобразити хід виконання
      this.Invoke(showProgress,
        new object[] { pi.ToString(), digits, i + digitCount});
    }
  }
}

Метод Invoke нарешті дозволив мені безпечно використовувати багатопоточність в додатку Windows Forms. UI-потік породжує робітник, який виконує тривалу операцію і повертає керування UI-потоку, коли виникає необхідність в оновленні користувальницького інтерфейсу. Безпечна багатопотокова архітектура показана на рис. 5.

Window message queue – Черга віконних повідомлень
Request update – Запит на оновлення
Dequeue – Витяг з черги
Owning thread – Потік-власник
Update – Оновлення
Window controls – Віконні елементи управління
Other thread – інший потік
Window with controls – Вікно з елементами управління
Рис. 5. Безпечна багатопоточність

Спрощена багатопоточність

Виклик Invoke не дуже зручний, а оскільки він двічі зустрічається в функції CalcPi, ми можемо полегшити собі життя і змінити ShowProgress, щоб вона сама виконувала асинхронний виклик. Якщо ShowProgress викликається з коректного потоку, вона оновлює елементи управління, в іншому випадку вона використовує Invoke для виклику самої себе в потрібному потоці. Повернемося до попередньої, більш простої версії CalcPi:

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.Invoke(showProgress,
      new object[] { pi, totalDigits, digitsSoFar});
  }
}
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);
    }
  }
}

Так як виклик Invoke – синхронний і нам не потрібно його повертається значення (адже ShowProgress не повертає значення), тут краще використовувати BeginInvoke, щоб робочий потік не завис:

BeginInvoke(showProgress, new object[] { pi, totalDigits, digitsSoFar});

BeginInvoke завжди краще, якщо повертається призначення не потрібно, оскільки при використанні цього методу робочий потік відразу ж повертається до своєї роботи, що виключає ймовірність взаємної блокування.

Висновок

У цьому короткому прикладі я показав, як виконувати тривалі операції так, щоб можна було відображати хід їх виконання, а UI продовжував реагувати на дії користувача. З цією метою я використав асинхронний делегат для запуску робочого потоку і метод Invoke для головної форми. При цьому ще один делегат виконувався в UI-потоці.

Я стежив, щоб до даних не було одночасного доступу з робочого і UI-потоків. Замість цього я передавав кожному потоку копію потрібних йому даних (в робочий потік – число знаків, а в UI-потік – кількість знаків, обчислених на даний момент). У підсумку я ніколи не передавав посилання на об’єкти, що розділяються двома потоками, наприклад на поточний StringBuilder. Якби я передавав посилання, мені довелося б задіяти . NET-засоби синхронізації, щоб виключити ймовірність звернення двох потоків до одного об’єкту одночасно, а це вимагало б додаткових зусиль. Мені й без того довелося проробити масу роботи, щоб два потоки могли викликати один одного.

Звичайно, якщо ви маєте справу з великими наборами даних, ви навряд чи захочете їх копіювати. Проте скрізь, де це можливо, я раджу для реалізації тривалих операцій у додатках Windows Forms поєднувати асинхронні делегати і передачу повідомлень між робітникам і UI-потоками.

Подяки

Хотів би подякувати Саймона Робінсона (Simon Robinson) за його повідомлення в списку розсилки DevelopMentor. NET, надихнуло мене на написання цієї статті, Йена Гріффітса (Ian Griffiths) за його початкові напрацювання в цій галузі і Майка Вудрінг (Mike Woodring) за знамениті картинки зі схемами підтримки декількох потоків, які я без докорів совісті поцупив у нього для своєї статті.

Посилання

Кріс Селлз (Chris Sells) – Незалежний консультант і викладач в DevelopMentor. Спеціалізується на розподілених додатках в. NET і COM. Автор кількох книг, у тому числі “ATL Internals”, яка в даний час переробляється для урахування змін, що з’явилися в ATL7. Крім того, працює над книгами “Essential Windows Forms” (Addison-Wesley) і “Mastering Visual Studio. NET” (O? Reilly). У вільний час Кріс підтримує Web-сервіси DevCon і керує Genghis – проектом з відкритим вихідним кодом. Більш детальну інформацію про нього і про його численних проектах см. на сайті
www.sellsbrothers.com.

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


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

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

Ваш отзыв

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

*

*