Чи гарантовано Task.Factory.StartNew () використовувати інший потік, ніж викличний потік?


75

Я починаю нове завдання з функції, але я не хотів би, щоб воно працювало в тому ж потоці. Мені байдуже, на якому потоці він працює, поки він інший (тому інформація, наведена в цьому питанні , не допомагає).

Чи гарантую я, що наведений нижче код завжди вийде, TestLockперш ніж дозволити Task tйого вводити знову? Якщо ні, який рекомендований шаблон дизайну для запобігання повторному вступу?

object TestLock = new object();

public void Test(bool stop = false) {
    Task t;
    lock (this.TestLock) {
        if (stop) return;
        t = Task.Factory.StartNew(() => { this.Test(stop: true); });
    }
    t.Wait();
}

Редагувати: На основі наведеної нижче відповіді Джона Скіта та Стівена Туба, простим способом детермінованого запобігання повторному вступу буде передача CancellationToken, як показано в цьому методі розширення:

public static Task StartNewOnDifferentThread(this TaskFactory taskFactory, Action action) 
 {
    return taskFactory.StartNew(action: action, cancellationToken: new CancellationToken());
}

Я сумніваюся, що ви можете гарантувати, що під час дзвінка буде створений новий потік StartNew. Завдання визначається як асинхронна операція, яка не обов'язково передбачає новий потік. Може десь також використовувати існуючий потік або інший спосіб зробити асинхронізацію.
Tony The Lion

Якщо ви використовуєте C # 5, спробуйте замінити t.Wait()на await t. Waitнасправді не відповідає філософії TPL.
CodesInChaos

2
Так, це правда, і я б не проти в цій конкретній справі. Але я віддаю перевагу детермінованій поведінці.
Erwin Mayer

2
Якщо ви використовуєте планувальник, який виконує завдання лише в певному потоці, тоді ні, завдання не може бути запущено в іншому потоці. Досить часто використовується a, SynchronizationContextщоб переконатися, що завдання виконуються в потоці інтерфейсу користувача. Якщо ви запустили код, який викликав StartNewпотік інтерфейсу користувача таким чином, тоді вони обидва працювали б в одному потоці. Єдина гарантія полягає в тому, що завдання буде виконуватися асинхронно від StartNewдзвінка (принаймні, якщо ви не надаєте RunSynchronouslyпрапор.
Пітер Річі,

7
Якщо ви хочете "примусити" створити новий потік, використовуйте прапор 'TaskCreationOptions.LongRunning'. наприклад: Task.Factory.StartNew(() => { this.Test(stop: true); }, TaskCreationOptions.LongRunning); Це гарна ідея, якщо ваші замки можуть перевести нитку в стан очікування протягом тривалого періоду часу.
Пітер Річі,

Відповіді:


83

З цим питанням я надіслав Стівену Тубу, члену команди PFX . Він повернувся до мене дуже швидко, з великою кількістю деталей - тому я просто скопіюю та вставлю сюди його текст. Я не все цитував, оскільки читання великої кількості цитованого тексту в кінцевому підсумку стає менш комфортним, ніж ванільний чорно-білий, але насправді, це Стівен - я не знаю так багато речей :) Я зробив ця вікі-відповідь спільноти відповідей на те, що все добро нижче не є справді моїм вмістом:

Якщо ви зателефонуєте Wait()до завдання, яке виконано, блокування не буде (воно просто видасть виняток, якщо завдання виконане зі статусом TaskStatus, відмінним від RanToCompletion, або іншим чином повернеться як nop ). Якщо ви зателефонуєте Wait()до завдання, яке вже виконується, воно повинно заблокувати, оскільки нічого іншого він розумно не може зробити (коли я кажу, що блокувати, я включаю як справжнє очікування, так і обертання на основі ядра, оскільки воно зазвичай робить суміш обох ). Подібним чином, якщо ви викликаєте Wait()Завдання, яке має статус Createdабо WaitingForActivation, воно буде блокувати, поки завдання не буде виконане. Жодна з цих ситуацій не обговорюється цікавою справою.

Цікавий випадок, коли ви викликаєте Wait()Завдання в WaitingToRunштаті, це означає, що воно раніше стояло в черзі до TaskScheduler, але що TaskScheduler ще не встиг фактично запустити делегат Завдання. У цьому випадку дзвінок до Waitзапитає планувальник, чи нормально запустити Завдання тоді і там на поточному потоці, через виклик TryExecuteTaskInlineметоду планувальника . Це називається вкладанням . Планувальник може вибрати або вбудувати завдання за допомогою виклику base.TryExecuteTask, або може повернути 'false', щоб вказати, що він не виконує завдання (часто це робиться з логікою, як ...

return SomeSchedulerSpecificCondition() ? false : TryExecuteTask(task);

Причиною TryExecuteTaskповернення логічного значення є те, що він обробляє синхронізацію, щоб забезпечити виконання даного Завдання лише один раз). Отже, якщо планувальник хоче повністю заборонити вбудовування Завдання під час Wait, його можна просто реалізувати як return false; Якщо планувальник хоче завжди дозволяти вбудовування, коли це можливо, його можна просто реалізувати як:

return TryExecuteTask(task);

У поточній реалізації (і .NET 4, і .NET 4.5, і я особисто не сподіваюся, що це зміниться) планувальник за замовчуванням, який націлений на ThreadPool, дозволяє вбудовувати, якщо поточний потік є потоком ThreadPool, і якщо цей потік був той, хто раніше поставив завдання в чергу.

Зверніть увагу, що тут немає довільного повернення, оскільки планувальник за замовчуванням не буде прокачувати довільні потоки під час очікування завдання ... він дозволить лише вбудовувати це завдання, і, звичайно, будь-яке вбудовування цього завдання, у свою чергу, вирішує зробити. Також зверніть увагу, що Waitнавіть не буде запитувати планувальник за певних умов, натомість воліє блокувати. Наприклад, якщо ви передаєте CancellationToken , що скасовується , або якщо ви передаєте нескінченний тайм-аут, він не намагатиметься вбудувати, оскільки це може зайняти довільно тривалий проміжок часу для встановлення виконання завдання, що є все або нічого , і це може призвести до значної затримки запиту на скасування або часу очікування. Загалом, TPL намагається знайти гідний баланс тут між марнотратством потоку, який робитьWaitзанадто багато використання та повторне використання цієї теми. Цей тип вкладеності дійсно важливий для рекурсивних проблем "поділи і владай" (наприклад, QuickSort ), коли ти створюєш кілька завдань, а потім чекаєш їх завершення. Якби це робилося без вкладання, ви дуже швидко зайшли б у глухий кут, вичерпуючи всі нитки в басейні та будь-які майбутні, які він вам хотів дати.

Окремо від Waitтого, також можливо (віддалено), що виклик Task.Factory.StartNew може в кінцевому підсумку виконати завдання тоді і там, якщо використовуваний планувальник вирішив виконати завдання синхронно як частину виклику QueueTask. Жоден із планувальників, вбудованих у .NET, ніколи цього не зробить, і я особисто вважаю, що це був би поганий дизайн для планувальника, але теоретично це можливо, наприклад:

protected override void QueueTask(Task task, bool wasPreviouslyQueued)
{
    return TryExecuteTask(task);
}

Перевантаження Task.Factory.StartNewцього не приймає a TaskSchedulerвикористовує планувальник від TaskFactory, що у випадку Task.Factoryцілей TaskScheduler.Current. Це означає, що якщо ви телефонуєте Task.Factory.StartNewзсередини Завдання, яке знаходиться в черзі до цього міфічного RunSynchronouslyTaskScheduler, воно також потрапляє в чергу RunSynchronouslyTaskScheduler, в результаті чого StartNewвиклик виконує Завдання синхронно. Якщо вас це взагалі турбує (наприклад, ви впроваджуєте бібліотеку і не знаєте, звідки вам буде викликано), ви можете явно перейти TaskScheduler.Defaultдо StartNewвиклику, використовуйте Task.Run(який завжди надходить TaskScheduler.Default), або використовуйте TaskFactoryстворене для націлювання TaskScheduler.Default.


EDIT: Гаразд, схоже, я був абсолютно помилковим, і нитку, яка зараз чекає на завдання, можна викрасти. Ось простіший приклад цього:

using System;
using System.Threading;
using System.Threading.Tasks;

namespace ConsoleApplication1 {
    class Program {
        static void Main() {
            for (int i = 0; i < 10; i++)
            {
                Task.Factory.StartNew(Launch).Wait();
            }
        }

        static void Launch()
        {
            Console.WriteLine("Launch thread: {0}", 
                              Thread.CurrentThread.ManagedThreadId);
            Task.Factory.StartNew(Nested).Wait();
        }

        static void Nested()
        {
            Console.WriteLine("Nested thread: {0}", 
                              Thread.CurrentThread.ManagedThreadId);
        }
    }
}

Вихідні дані:

Launch thread: 3
Nested thread: 3
Launch thread: 3
Nested thread: 3
Launch thread: 3
Nested thread: 3
Launch thread: 3
Nested thread: 3
Launch thread: 4
Nested thread: 4
Launch thread: 4
Nested thread: 4
Launch thread: 4
Nested thread: 4
Launch thread: 4
Nested thread: 4
Launch thread: 4
Nested thread: 4
Launch thread: 4
Nested thread: 4

Як бачите, є багато випадків, коли потік очікування використовується повторно для виконання нового завдання. Це може статися, навіть якщо нитка придбала замок. Неприємне повторне входження. Я в повній мірі вражений і стурбований :(


1
У випадку одного доступного потоку його код зайшов у глухий кут, оскільки "після того, як код, що використовує цей потік, уже закінчився з ним", ніколи не відбувається.
CodesInChaos

1
наприклад, якщо ви "примусите" створити новий потік (тобто не потік пулу потоків), тоді ви не побачите жодної вставки від Wait:Task.Factory.StartNew(Launch, TaskCreationOptions.LongRunning).Wait()
Пітера Річі

1
@svick: Я думаю про такі речі, як дзвінки, Waitпоки ти маєш замок. Блокування повторно входять, тому якщо завдання, яке ви очікуєте, спробує також отримати замок, воно буде успішним - оскільки воно вже є власником блокування, хоча це логічно в іншому завданні. Це повинно зайти в глухий кут, але це не стане ... коли він знову вступить. В основному повторне залучення мене нервує загалом; відчувається, що це порушує всілякі припущення.
Jon Skeet

1
Мені шкода, що я не використовую F #, щоб цей проект був змушений писати код, що сприяє повторному вступу;)
Ервін Майер,

1
@ErwinMayer: Ви можете передати маркер, який можна скасувати, і просто ніколи не скасовувати його. Схоже, це зробить трюк. Або пропустіть тайм-аут, який становить кілька років :)
Джон Скіт,

4

Чому б просто не розробити його, а не нахилятись назад, щоб цього не сталося?

TPL - це червоний оселедець, повторне входження може відбутися в будь-якому коді за умови, що ви можете створити цикл, і ви точно не знаєте, що відбудеться на південь від вашого кадру стека. Тут найкращим результатом є синхронне повернення - принаймні, ви не можете самостійно зайти в глухий кут (так легко).

Замки управляють синхронізацією поперечних потоків. Вони ортогональні управлінню повторним входом. Якщо ви не захищаєте справжній одноразовий ресурс (можливо, фізичний пристрій, і в цьому випадку вам слід скористатися чергою), чому б не просто переконатись, що стан вашого екземпляра є послідовним, щоб повторне входження могло «просто працювати».

(Побічна думка: чи повертаються семафори без зменшення?)


0

Ви можете легко перевірити це, написавши швидкий додаток, який розділяє сокетне з'єднання між потоками / завданнями.

Завдання отримає блокування перед тим, як відправити повідомлення в сокет і чекати відповіді. Як тільки це блокується і стає простоєм (IOBlock), поставте інше завдання в тому ж блоці зробити те саме. Він повинен заблокувати отримання блокування, якщо цього не станеться, а друге завдання дозволено передати блокування, оскільки воно виконується тим самим потоком, тоді у вас проблема.


0

Рішення, new CancellationToken()запропоноване Ервіном, для мене не спрацювало, все одно траплялося вбудовування.

Тож я в підсумку використав іншу умову, яку порадили Джон та Стівен ( ... or if you pass in a non-infinite timeout ...):

  Task<TResult> task = Task.Run(func);
  task.Wait(TimeSpan.FromHours(1)); // Whatever is enough for task to start
  return task.Result;

Примітка: Якщо для простоти опустити обробку винятків тощо тощо, вам слід зважати на те, що є у виробничому коді.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.