Як я можу запобігти синхронному продовженню завдання?


83

У мене є якийсь бібліотечний (мережевий) код, який надає TaskAPI на основі очікуваних відповідей на запити на основі TaskCompletionSource<T>. Однак у TPL є роздратування, оскільки, здається, неможливо запобігти синхронним продовженням. Що б я хотів , щоб бути в змозі зробити це або:

  • скажіть a, TaskCompletionSource<T>який не повинен дозволяти абонентам приєднуватися до TaskContinuationOptions.ExecuteSynchronously, або
  • встановіть результат ( SetResult/ TrySetResult) таким чином, щоб вказати, що TaskContinuationOptions.ExecuteSynchronouslyслід ігнорувати, використовуючи замість цього пул

Зокрема, у мене проблема полягає в тому, що вхідні дані обробляє спеціальний зчитувач, і якщо абонент може приєднатись, TaskContinuationOptions.ExecuteSynchronouslyвін може зупинити зчитування (що стосується не лише їх). Раніше я працював над цим шляхом деяких хакерів, які виявляють, чи є якісь продовження, і якщо вони є, то вони штовхають завершення на ThreadPool, однак це має значний вплив, якщо абонент наситив свою чергу роботи, оскільки завершення не буде оброблено своєчасно. Якщо вони використовують Task.Wait()(або подібні), вони, по суті, самі себе заткнуться. Так само, саме тому читач перебуває на спеціальній темі, а не використовує робочих.

Так; перед тим, як я спробую і дошкуляти команді TPL: я втрачаю варіант?

Ключові моменти:

  • Я не хочу, щоб зовнішні абоненти могли перехопити мій потік
  • Я не можу використовувати ThreadPoolяк реалізацію, оскільки він повинен працювати, коли пул насичений

Наведений нижче приклад дає результат (замовлення може змінюватися залежно від часу):

Continuation on: Main thread
Press [return]
Continuation on: Thread pool

Проблема полягає в тому, що випадковому абонентові вдалося отримати продовження в "Основній темі". У реальному коді це може перервати основний читач; погані речі!

Код:

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

static class Program
{
    static void Identify()
    {
        var thread = Thread.CurrentThread;
        string name = thread.IsThreadPoolThread
            ? "Thread pool" : thread.Name;
        if (string.IsNullOrEmpty(name))
            name = "#" + thread.ManagedThreadId;
        Console.WriteLine("Continuation on: " + name);
    }
    static void Main()
    {
        Thread.CurrentThread.Name = "Main thread";
        var source = new TaskCompletionSource<int>();
        var task = source.Task;
        task.ContinueWith(delegate {
            Identify();
        });
        task.ContinueWith(delegate {
            Identify();
        }, TaskContinuationOptions.ExecuteSynchronously);
        source.TrySetResult(123);
        Console.WriteLine("Press [return]");
        Console.ReadLine();
    }
}

2
Я спробував би обернутися TaskCompletionSourceвласним API, щоб запобігти прямому виклику ContinueWith, оскільки ні те TaskCompletionSource, ні Taskінше не підходить для успадкування від них.
Денніс

1
@ Денніс, щоб бути зрозумілим, це насправді Taskвикрито, а не TaskCompletionSource. Те (викриття іншого API) технічно є варіантом, але це досить екстремальна річ, щоб зробити саме для цього ... Я не впевнений, що це виправдовує
Марк Гравелл

2
@MattH не дуже - він просто переформулює питання: або ви використовуєте ThreadPoolдля цього (про що я вже згадував - це спричиняє проблеми), або у вас є спеціальний потік "очікування продовження", і тоді вони (продовження з ExecuteSynchronouslyзазначеними) можуть викрасти це один замість цього - що спричиняє ту саму проблему, оскільки це означає, що продовження інших повідомлень може бути зупинено, що знову впливає на кількох абонентів
Марк Гравелл

3
@Andrey, що (це працює так, ніби всі абоненти використовували ContinueWith без exec-sync) - це саме те, чого я хочу досягти. Проблема полягає в тому, що якщо моя бібліотека передає комусь Завдання, вони можуть зробити щось дуже небажане: вони можуть перервати мого читача, (небажано), використовуючи exec-sync. Це надзвичайно небезпечно, тому я хотів би запобігти цьому потрапляння всередину бібліотеки .
Марк Гравелл

2
@Andrey, оскільки a: багато завдань ніколи не отримують продовження в першу чергу (особливо при пакетній роботі) - це змусить кожне завдання мати його, і b: навіть ті, які мали б продовження, тепер мають набагато більшу складність, накладні та робочі операції. Це має значення.
Марк Гравелл

Відповіді:


50

Нове у .NET 4.6:

.NET 4.6 містить новий TaskCreationOptions: RunContinuationsAsynchronously.


Оскільки ви готові використовувати Reflection для доступу до приватних полів ...

Ви можете позначити Завдання TCS TASK_STATE_THREAD_WAS_ABORTEDпрапором, що призведе до того, що всі продовження не будуть вбудовані.

const int TASK_STATE_THREAD_WAS_ABORTED = 134217728;

var stateField = typeof(Task).GetField("m_stateFlags", BindingFlags.NonPublic | BindingFlags.Instance);
stateField.SetValue(task, (int) stateField.GetValue(task) | TASK_STATE_THREAD_WAS_ABORTED);

Редагувати:

Замість використання Reflection emit я пропоную використовувати вирази. Це набагато читабельніше і має перевагу у тому, що воно сумісне з PCL:

var taskParameter = Expression.Parameter(typeof (Task));
const string stateFlagsFieldName = "m_stateFlags";
var setter =
    Expression.Lambda<Action<Task>>(
        Expression.Assign(Expression.Field(taskParameter, stateFlagsFieldName),
            Expression.Or(Expression.Field(taskParameter, stateFlagsFieldName),
                Expression.Constant(TASK_STATE_THREAD_WAS_ABORTED))), taskParameter).Compile();

Без використання Reflection:

Якщо комусь цікаво, я придумав спосіб зробити це без Рефлексії, але він теж трохи "брудний", і, звичайно, несе незначний штраф:

try
{
    Thread.CurrentThread.Abort();
}
catch (ThreadAbortException)
{
    source.TrySetResult(123);
    Thread.ResetAbort();
}

3
@MarcGravell Використовуйте це, щоб створити якийсь псевдо-зразок для команди TPL і зробити запит на зміну щодо можливості зробити це за допомогою параметрів конструктора або чогось іншого.
Адам Голдсворт

1
@ Адаме, так, якби вам довелося називати цей прапор "що він робить", а не "що це спричиняє", це було б щось на зразок TaskCreationOptions.DoNotInline- і навіть не потрібно було б змінювати підпис TaskCompletionSource
ктора

2
@AdamHouldsworth і не хвилюйся, я їм уже
надсилаю

1
Для ваших інтересів: ось він, оптимізований через ILGeneratoretc: github.com/StackExchange/StackExchange.Redis/blob/master/…
Marc Gravell

1
@Noseratio так, перевірив їх - дякую; всі вони в порядку з ІМО; Я погоджуюсь, що це чисте рішення, але воно має точно правильні результати.
Марк Гравелл

9

Я не думаю, що в TPL є щось, що забезпечує явний контроль API над TaskCompletionSource.SetResultпродовженнями. Я вирішив зберегти свою початкову відповідь для контролю такої поведінки для async/awaitсценаріїв.

Ось ще одне рішення, яке накладає асинхронне ContinueWith, якщо tcs.SetResultпродовження, яке запускається, відбувається в тому ж потоці, до якого SetResultбув викликаний:

public static class TaskExt
{
    static readonly ConcurrentDictionary<Task, Thread> s_tcsTasks =
        new ConcurrentDictionary<Task, Thread>();

    // SetResultAsync
    static public void SetResultAsync<TResult>(
        this TaskCompletionSource<TResult> @this,
        TResult result)
    {
        s_tcsTasks.TryAdd(@this.Task, Thread.CurrentThread);
        try
        {
            @this.SetResult(result);
        }
        finally
        {
            Thread thread;
            s_tcsTasks.TryRemove(@this.Task, out thread);
        }
    }

    // ContinueWithAsync, TODO: more overrides
    static public Task ContinueWithAsync<TResult>(
        this Task<TResult> @this,
        Action<Task<TResult>> action,
        TaskContinuationOptions continuationOptions = TaskContinuationOptions.None)
    {
        return @this.ContinueWith((Func<Task<TResult>, Task>)(t =>
        {
            Thread thread = null;
            s_tcsTasks.TryGetValue(t, out thread);
            if (Thread.CurrentThread == thread)
            {
                // same thread which called SetResultAsync, avoid potential deadlocks

                // using thread pool
                return Task.Run(() => action(t));

                // not using thread pool (TaskCreationOptions.LongRunning creates a normal thread)
                // return Task.Factory.StartNew(() => action(t), TaskCreationOptions.LongRunning);
            }
            else
            {
                // continue on the same thread
                var task = new Task(() => action(t));
                task.RunSynchronously();
                return Task.FromResult(task);
            }
        }), continuationOptions).Unwrap();
    }
}

Оновлено для звернення до коментаря:

Я не контролюю абонента - я не можу змусити їх використовувати певний варіант продовження з: якби я міг, проблема б не існувала спочатку

Я не знав, що ти не контролюєш абонента. Тим не менше, якщо ви не керуєте цим, ви, ймовірно, не передаєте TaskCompletionSourceоб'єкт безпосередньо абоненту. Логічно, ви передавали б токен його частини, тобто tcs.Task. У цьому випадку рішення може бути ще простішим, додавши до вищезазначеного ще один метод розширення:

// ImposeAsync, TODO: more overrides
static public Task<TResult> ImposeAsync<TResult>(this Task<TResult> @this)
{
    return @this.ContinueWith(new Func<Task<TResult>, Task<TResult>>(antecedent =>
    {
        Thread thread = null;
        s_tcsTasks.TryGetValue(antecedent, out thread);
        if (Thread.CurrentThread == thread)
        {
            // continue on a pool thread
            return antecedent.ContinueWith(t => t, 
                TaskContinuationOptions.None).Unwrap();
        }
        else
        {
            return antecedent;
        }
    }), TaskContinuationOptions.ExecuteSynchronously).Unwrap();
}

Використання:

// library code
var source = new TaskCompletionSource<int>();
var task = source.Task.ImposeAsync();
// ... 

// client code
task.ContinueWith(delegate
{
    Identify();
}, TaskContinuationOptions.ExecuteSynchronously);

// ...
// library code
source.SetResultAsync(123);

Це на самому ділі працює як awaitіContinueWith ( скрипка ) і вільна від відображення писак.


1
Я не контролюю абонента - я не можу змусити їх використовувати певний варіант продовження з: якби я міг, проблема б не існувала спочатку
Марк Гравелл

@MarcGravell, я не знав, що ти не можеш контролювати абонента. Я опублікував оновлення про те, як я з цим впораюся.
noseratio

дилема автора бібліотеки; p Зверніть увагу, що хтось знайшов набагато простіший і
пряміший

4

Як щодо того, а не робити

var task = source.Task;

ви робите це замість цього

var task = source.Task.ContinueWith<Int32>( x => x.Result );

Таким чином, ви завжди додаєте одне продовження, яке буде виконуватися асинхронно, і тоді не має значення, чи хочуть абоненти продовження в тому ж контексті. Це свого роду каррінг завдання, чи не так?


1
Це висловилося в коментарях (див. Андрій); проблема є в тому , що вона змушує всі завдання мати продовження , коли вони не мали б, що це те , що як ContinueWithі awaitзазвичай намагаються уникати (шляхом перевірки вже завершені і т.д.) - і так як це змусить все на робітників, це фактично посилило б ситуацію. Це позитивна ідея, і я дякую вам за це, але в цьому сценарії це не допоможе.
Марк Гравелл

3

якщо ви можете і готові використовувати рефлексію, це слід зробити;

public static class MakeItAsync
{
    static public void TrySetAsync<T>(this TaskCompletionSource<T> source, T result)
    {
        var continuation = typeof(Task).GetField("m_continuationObject", BindingFlags.NonPublic | BindingFlags.GetField | BindingFlags.Instance);
        var continuations = (List<object>)continuation.GetValue(source.Task);

        foreach (object c in continuations)
        {
            var option = c.GetType().GetField("m_options", BindingFlags.NonPublic | BindingFlags.GetField | BindingFlags.Instance);
            var options = (TaskContinuationOptions)option.GetValue(c);

            options &= ~TaskContinuationOptions.ExecuteSynchronously;
            option.SetValue(c, options);
        }

        source.TrySetResult(result);
    }        
}

Цей злом може просто перестати працювати в наступній версії Framework.
noseratio 23.03.14

@Noseratio, правда, але це працює зараз, і вони також можуть застосувати належний спосіб зробити це в наступній версії
Фреду,

Але навіщо вам це потрібно, якщо ви просто можете це зробити Task.Run(() => tcs.SetResult(result))?
noseratio 23.03.14

@Noseratio, я не знаю, задай це запитання Марку :-), я просто видаляю прапор TaskContinuationOptions.ExecuteSynchronously для всіх завдань, підключених до TaskCompletionSource, які переконуються, що всі вони використовують пул потоків замість основного потоку
Fredou

Взлом m_continuationObject - це насправді обман, який я вже використовую для виявлення потенційно проблемних завдань - так що це не піддається розгляду. Цікаво, дякую. Наразі це найбільш корисний на вигляд варіант.
Марк Гравелл

3

Оновлений , я опублікував окрему відповідь , з якою маю справу, на ContinueWithвідміну від нього await(оскільки ContinueWithвін не піклується про поточний контекст синхронізації).

Ви можете використовувати тупий контекст синхронізації ввести асинхронність на продовженні ініціюється виклик SetResult/SetCancelled/SetExceptionна TaskCompletionSource. Я вважаю, що поточний контекст синхронізації (в точці await tcs.Task) є критеріями, за допомогою яких TPL вирішує, робити таке продовження синхронним чи асинхронним.

Для мене працює наступне:

if (notifyAsync)
{
    tcs.SetResultAsync(null);
}
else
{
    tcs.SetResult(null);
}

SetResultAsync реалізується так:

public static class TaskExt
{
    static public void SetResultAsync<T>(this TaskCompletionSource<T> tcs, T result)
    {
        FakeSynchronizationContext.Execute(() => tcs.SetResult(result));
    }

    // FakeSynchronizationContext
    class FakeSynchronizationContext : SynchronizationContext
    {
        private static readonly ThreadLocal<FakeSynchronizationContext> s_context =
            new ThreadLocal<FakeSynchronizationContext>(() => new FakeSynchronizationContext());

        private FakeSynchronizationContext() { }

        public static FakeSynchronizationContext Instance { get { return s_context.Value; } }

        public static void Execute(Action action)
        {
            var savedContext = SynchronizationContext.Current;
            SynchronizationContext.SetSynchronizationContext(FakeSynchronizationContext.Instance);
            try
            {
                action();
            }
            finally
            {
                SynchronizationContext.SetSynchronizationContext(savedContext);
            }
        }

        // SynchronizationContext methods

        public override SynchronizationContext CreateCopy()
        {
            return this;
        }

        public override void OperationStarted()
        {
            throw new NotImplementedException("OperationStarted");
        }

        public override void OperationCompleted()
        {
            throw new NotImplementedException("OperationCompleted");
        }

        public override void Post(SendOrPostCallback d, object state)
        {
            throw new NotImplementedException("Post");
        }

        public override void Send(SendOrPostCallback d, object state)
        {
            throw new NotImplementedException("Send");
        }
    }
}

SynchronizationContext.SetSynchronizationContext дуже дешево з точки зору накладних витрат, які він додає. Насправді, дуже схожий підхід застосовується при впровадженні WPFDispatcher.BeginInvoke .

TPL порівнює цільовий контекст синхронізації в точці з awaitточкою в точці tcs.SetResult. Якщо контекст синхронізації однаковий (або контексту синхронізації немає в обох місцях), продовження викликається безпосередньо, синхронно. В іншому випадку вона потрапляє в чергу за SynchronizationContext.Postдопомогою цільового контексту синхронізації, тобто нормальної awaitповедінки. Цей підхід завжди нав'язує SynchronizationContext.Postповедінку (або продовження потоку пулу, якщо немає цільового контексту синхронізації).

Оновлено , це не спрацює task.ContinueWith, оскільки ContinueWithйого не хвилює поточний контекст синхронізації. Однак це працює для await task( скрипки ). Це також працює для await task.ConfigureAwait(false).

OTOH, цей підхід працює для ContinueWith.


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

@MarcGravell, я змінюю його лише для обсягу tcs.SetResultвиклику. Таким чином, він стає атомарним і безпечним для потоків, оскільки саме продовження відбуватиметься або в іншому потоці пулу, або при вихідній синхронізації. контекст, захоплений в await tcs.Task. І SynchronizationContext.SetSynchronizationContextсам по собі дуже дешевий, набагато дешевший, ніж сам нитковий перемикач.
noseratio

Однак це може не задовольнити вашу другу вимогу: не використовувати ThreadPool. З цим рішенням TPL справді використовуватиметься ThreadPool, якщо не було синхронізації. context (або це був основний стандартний) за адресою await tcs.Task. Але це стандартна поведінка TPL.
noseratio

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

1
@Noseration ах, правильно: не було ясно, що ключовим моментом було те, що вони різні . Подивиться. Дякую.
Марк Гравелл

3

Підхід імітації переривання виглядав дуже добре, але в деяких сценаріях призвів до викрадення потоків TPL .

Потім у мене була реалізація, подібна до перевірки об’єкта продовження , але просто перевірка будь-якого продовження, оскільки насправді існує занадто багато сценаріїв для даного коду, щоб він працював добре, але це означало, що навіть такі речі, як Task.Waitрезультат, призвели до пошуку пулу потоків.

Зрештою, після перевірки багато-багато ІЛ, єдиним безпечним та корисним сценарієм є SetOnInvokeMresсценарій (продовження вручну-скидання-події). Є безліч інших сценаріїв:

  • деякі небезпечні і призводять до викрадення ниток
  • решта не корисні, оскільки в підсумку ведуть до пулу потоків

Тож врешті-решт я вирішив перевірити наявність ненульового об’єкта продовження; якщо він нульовий, штраф (без продовжень); якщо він не є нульовим, у спеціальному випадку перевіряйте SetOnInvokeMres- якщо він такий: штраф (безпечно викликати); в іншому випадку нехай пул потоків виконує TrySetComplete, не кажучи завданню робити щось особливе, наприклад, підмінення аборту. Task.Waitвикористовує SetOnInvokeMresпідхід, який є конкретним сценарієм, який ми хочемо реально постаратися, щоб не зайти в глухий кут.

Type taskType = typeof(Task);
FieldInfo continuationField = taskType.GetField("m_continuationObject", BindingFlags.Instance | BindingFlags.NonPublic);
Type safeScenario = taskType.GetNestedType("SetOnInvokeMres", BindingFlags.NonPublic);
if (continuationField != null && continuationField.FieldType == typeof(object) && safeScenario != null)
{
    var method = new DynamicMethod("IsSyncSafe", typeof(bool), new[] { typeof(Task) }, typeof(Task), true);
    var il = method.GetILGenerator();
    var hasContinuation = il.DefineLabel();
    il.Emit(OpCodes.Ldarg_0);
    il.Emit(OpCodes.Ldfld, continuationField);
    Label nonNull = il.DefineLabel(), goodReturn = il.DefineLabel();
    // check if null
    il.Emit(OpCodes.Brtrue_S, nonNull);
    il.MarkLabel(goodReturn);
    il.Emit(OpCodes.Ldc_I4_1);
    il.Emit(OpCodes.Ret);

    // check if is a SetOnInvokeMres - if so, we're OK
    il.MarkLabel(nonNull);
    il.Emit(OpCodes.Ldarg_0);
    il.Emit(OpCodes.Ldfld, continuationField);
    il.Emit(OpCodes.Isinst, safeScenario);
    il.Emit(OpCodes.Brtrue_S, goodReturn);

    il.Emit(OpCodes.Ldc_I4_0);
    il.Emit(OpCodes.Ret);

    IsSyncSafe = (Func<Task, bool>)method.CreateDelegate(typeof(Func<Task, bool>));
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.