Как предотвратить синхронное продолжение задачи?

У меня есть некоторый код библиотеки (socket networking), который предоставляет API Task для ожидающих ответов на запросы на основе TaskCompletionSource<T>. Однако в TPL есть досада, в которой невозможно предотвратить синхронные продолжения. То, что я хотел бы сделать, это либо:

  • сообщите 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();
    }
}

Ответ 1

Новое в .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);

Edit:

Вместо использования 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:

Если кому-то интересно, я выяснил способ сделать это без Reflection, но он немного "грязный", и, конечно же, он несет несущественную штрафную пенальти:

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

Ответ 2

Я не думаю, что в 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 (fiddle) и не имеет отражений.

Ответ 3

А что, вместо того, чтобы делать

var task = source.Task;

сделайте это вместо

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

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

Ответ 4

Обновлено. Я отправил отдельный ответ, чтобы иметь дело с ContinueWith в отличие от await (потому что ContinueWith doesn ' t заботиться о текущем контексте синхронизации).

Вы можете использовать немой контекст синхронизации, чтобы наложить асинхронность при продолжении, вызванном вызовом SetResult/SetCancelled/SetException on 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 очень дешево с точки зрения накладных расходов, которые он добавляет. На самом деле очень похожий подход осуществляется с помощью реализации WPF Dispatcher.BeginInvoke.

TPL сравнивает целевой контекст синхронизации в точке await с целевой точкой tcs.SetResult. Если контекст синхронизации один и тот же (или нет контекста синхронизации в обоих местах), продолжение вызывается напрямую, синхронно. В противном случае он ставит в очередь с помощью SynchronizationContext.Post в целевом контексте синхронизации, то есть в обычном режиме await. То, что делает этот подход, всегда налагает поведение SynchronizationContext.Post (или продолжение потока пула, если нет целевого контекста синхронизации).

Обновлено, это не будет работать для task.ContinueWith, потому что ContinueWith не заботится о текущем контексте синхронизации. Однако он работает для await task (fiddle). Он также работает для await task.ConfigureAwait(false).

OTOH, этот подход работает для ContinueWith.

Ответ 5

если вы можете и готовы использовать отражение, это должно сделать это;

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);
    }        
}

Ответ 6

Подход simulate abort выглядел действительно неплохо, но в некоторых сценариях он приводил к захвату потоков .

Тогда у меня была реализация, аналогичная проверке объекта продолжения, а просто проверка на продолжение, поскольку на самом деле существует слишком много сценариев для работы данного кода но это означало, что даже такие вещи, как Task.Wait, привели к просмотру пула потоков.

В конечном счете, после проверки много и много ИЛ, единственным безопасным и полезным сценарием является сценарий SetOnInvokeMres (ручной reset -event-slim продолжение). Существует множество других сценариев:

  • некоторые из них небезопасны и приводят к захвату нитей.
  • остальные не полезны, так как они в конечном итоге приводят к пулу потоков

Итак, в конце концов, я решил проверить ненулевой объект-продолжение; если он равен нулю, штраф (без продолжений); если он не является нулевым, проверка специального случая для 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>));