Підрозділ 16.2
Вкладені завдання та масиви завдань. Task<T>
16.2. Вкладені завдання та масиви завдань. Task<T Розглянемо можливості TPL, що дозволяють будувати складніші структури завдань: вкладені завдання, масиви завдань з колективним очікуванням і типізовані завдання
16.2. Вкладені завдання та масиви завдань. Task
Розглянемо можливості TPL, що дозволяють будувати складніші структури завдань: вкладені завдання, масиви завдань з колективним очікуванням і типізовані завдання, що повертають значення.
Вкладені завдання
Завдання може запускати інше завдання зсередини свого тіла — таке завдання називається вкладеним (nested task). За замовчуванням вкладене завдання є незалежним від батьківського: батьківське завдання може завершитись раніше, ніж вкладене. Це може здивувати — інтуїтивно здається, що завдання повинно чекати на всі запущені ним задачі. Але TPL так не поводиться без явної вказівки.
Поведінка «не чекати вкладеного» є навмисним рішенням дизайну: в більшості сценаріїв завдання запускають допоміжні підзавдання, за якими не потрібно стежити. Але якщо потрібно прив'язати вкладене завдання до батьківського, використовується TaskCreationOptions.AttachedToParent.
AttachedToParent: дочірнє завдання
TaskCreationOptions.AttachedToParent перетворює вкладене завдання на дочірнє (child task). Батьківське завдання не може завершитись, доки не завершаться всі його дочірні завдання: виконавши власний код, воно переходить у стан WaitingForChildrenToComplete. Якщо дочірнє завдання кине виняток, він буде перекинутий через батьківське при виклику Wait() — причому як AggregateException усередині AggregateException; розгорнути вкладені обгортки допомагає метод e.Flatten().
Важливе обмеження: батьківське завдання має бути створене через Task.Factory.StartNew або new Task(...). Task.Run навмисно створює задачі з опцією DenyChildAttach, тому прив'язати до них дочірнє завдання неможливо — AttachedToParent всередині Task.Run(...) просто ігнорується, і вкладене завдання поводиться як незалежне. Саме тому приклад нижче використовує Task.Factory.StartNew.


Практичне правило: якщо вкладені завдання є логічною частиною батьківської операції і їх результат потрібен для підтвердження завершення батька — використовуйте AttachedToParent. Якщо вкладені завдання є допоміжними, їх стан не має значення для батька — залишайте їх незалежними.
Масиви завдань
Коли потрібно запустити кілька завдань і відстежити їх стан, зручно зібрати їх у масив або список. Це дозволяє застосовувати Task.WaitAll і Task.WaitAny до довільної кількості завдань без перерахування кожного вручну.
WaitAll vs WaitAny: коли що обирати
Task.WaitAll — для сценарію «потрібні всі результати, щоб рухатись далі». Наприклад, перш ніж видати загальний висновок, лікар чекає на всі аналізи.
Task.WaitAny — для сценарію «реагуй на перший готовий результат». Наприклад, система моніторингу обробляє показники від першого датчика, що надіслав дані, не чекаючи решти:
Task: завдання з результатом
До цього моменту ми розглядали завдання, що нічого не повертають (Task — аналог void). Але часто завдання обчислює щось і результат потрібен у головному потоці. Для цього існує типізована версія — Task<T>, де T — тип значення, що повертається.
Результат отримується через властивість Result. Звернення до Result блокує поточний потік до завершення завдання — аналогічно Wait(). Тому Result є одночасно і очікуванням, і отриманням результату.
Task з класом результату
У реальних клінічних задачах результат — це, як правило, не просте число, а об'єкт із кількома полями:
Кілька Task з WaitAll
Task.WaitAll коректно працює і з масивами Task<T>. Після WaitAll до всіх результатів можна звертатись через Result без додаткового блокування — результати вже готові:
Важливий момент: звернення до Result після WaitAll не блокує — всі завдання вже завершились. Але якщо завдання завершилось з винятком, Result кине AggregateException при зверненні. Завжди обробляйте можливі винятки через try/catch, якщо завдання може завершитись з помилкою.