如何防止任务上的同步继续?


82

我有一些库(套接字网络)代码,Task基于提供了一个基于API的未决请求响应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();
    }
}

2
我尝试TaskCompletionSource使用自己的API打包以防止直接调用ContinueWith,因为TaskCompletionSourceTask都不适合从它们继承。
丹尼斯

1
@Dennis要清楚,实际上Task是暴露的,而不是TaskCompletionSource。从技术上讲,(公开一个不同的API)是一个选择,但是为此而做是一件非常极端的事……我不确定它是合理的
Marc Gravell

2
@MattH并不是真的-它只是改写了一个问题:您ThreadPool为此使用了(我已经提到过-这会导致问题),或者您有专用的“待处理的继续”线程,然后它们(具有ExecuteSynchronously指定的继续)可以劫持该对象一个消息-引起完全相同的问题,因为这意味着其他消息的继续可能会停止,这
又会

3
@Andrey(我的工作就像所有调用者都使用ContinueWith而不执行exec-sync一样)正是我想要实现的。问题是,如果我的库将某项任务交给某人,他们可能会做一些非常不可取的事情:他们可以通过(不可行地)使用exec-sync中断我的阅读器。这是非常危险的,这就是为什么我要防止它出现在库中
Marc Gravell

2
@Andrey,因为a:许多任务一开始就永远不会获得延续(尤其是在执行批处理工作时)-这将迫使每个任务都拥有一个任务,并且b:即使那些本来会延续的任务现在也要复杂得多,开销和工作人员的操作。这很重要。
Marc Gravell

Answers:


50

.NET 4.6的新增功能:

.NET 4.6包含一个新的TaskCreationOptionsRunContinuationsAsynchronously


由于您愿意使用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);

编辑:

建议您不要使用反射发射,而应使用表达式。这更具可读性,并且具有与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();

不使用反射:

如果有人感兴趣,我想出了一种在没有反射的情况下执行此操作的方法,但这也有点“肮脏”,并且当然会受到不容忽视的性能损失:

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

3
@MarcGravell使用它为TPL团队创建一些伪样本,并提出有关能够通过构造函数选项或其他方式进行更改的请求。
亚当·霍尔兹沃思

1
@Adam是的,如果你不得不称之为“它做什么”,而不是“是什么原因导致它”的标志,它会是这样的TaskCreationOptions.DoNotInline-甚至不会需要一个构造函数签名改变TaskCompletionSource
马克Gravell

2
@AdamHouldsworth,不用担心,我已经通过电子邮件发送给他们; p
Marc Gravell

1
为了您的利益,现在就是,通过优化ILGenerator等:github.com/StackExchange/StackExchange.Redis/blob/master/...
马克Gravell

1
@Noseratio是的,检查了他们-谢谢;他们都不错,IMO;我同意这是纯粹的解决方法,但是它具有正确的结果。
Marc Gravell

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

更新以解决评论:

我无法控制呼叫者-我无法让他们使用特定的continue-with变体:如果可以的话,问题就不会存在

我不知道您无法控制呼叫者。但是,如果您不控制它,则也可能不会将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);

这实际上awaitContinueWithfiddle)都有效,并且没有反射hack。


1
我无法控制呼叫者-我无法让他们使用特定的continue-with变体:如果可以的话,问题就不会摆在首位
Marc Gravell

@MarcGravell,我不知道您无法控制呼叫者。我发布了有关如何处理的最新信息。
noseratio 2014年

图书馆作者的困境; p请注意,有人找到了一种更简单,更直接的方法来达到预期的效果
Marc Gravell

4

那怎么办呢

var task = source.Task;

你这样做

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

因此,您总是添加一个将异步执行的连续,然后订阅者是否希望在同一上下文中继续就没关系。这有点繁琐,不是吗?


1
评论中提到了这一点(见安德烈);这个问题还有就是它迫使所有任务有一个延续的时候,他们不会有,否则,这是一件好事,这两个ContinueWithawait通常难以避免试图(通过检查已完成等) -和,因为这将迫使一切到工人,这实际上会使情况恶化。这是一个积极的主意,对此我非常感谢:但是在这种情况下,它无济于事。
Marc Gravell

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

该hack可能只是在下一版框架中停止工作。
noseratio 2014年

@Noseratio,是的,但现在可以正常工作,他们可能还会在下一个版本中实现正确的方法
Fredou 2014年

但是,如果您能够做到的话,为什么需要这个Task.Run(() => tcs.SetResult(result))呢?
noseratio 2014年

@Noseratio,我不知道,问这个问题给Marc :-),我只是在连接到TaskCompletionSource的所有任务上删除了TaskContinuationOptions.ExecuteSynchronously标志,以确保它们都使用线程

m_continuationObject hack实际上是我已经用来识别潜在问题任务的作弊手段-因此这并非没有考虑。有趣,谢谢。到目前为止,这是最有用的选项。
Marc Gravell

3

更新,我贴一个单独的答案来处理ContinueWith,而不是await(因为ContinueWith不关心当前同步上下文)。

你可以使用一个愚蠢的同步环境强加在异步延续触发调用SetResult/SetCancelled/SetExceptionTaskCompletionSource。我认为,当前的同步上下文(在处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比较的目标同步上下文和的目标同步上下文awaittcs.SetResult。如果同步上下文相同(或者两个位置都没有同步上下文),则直接同步地直接调用延续。否则,将使用SynchronizationContext.Post目标同步上下文(即正常await行为)进行排队。这种方法的作用总是强加SynchronizationContext.Post行为(如果没有目标同步上下文,则继续执行池线程)。

更新了,这不适用于task.ContinueWith,因为ContinueWith它不在乎当前的同步上下文。但是,它适用于await task小提琴)。它也适用于await task.ConfigureAwait(false)

OTOH,此方法适用于ContinueWith


诱人,但更改同步上下文几乎可以肯定会影响调用应用程序-例如,恰好正在使用我的库的Web或Windows应用程序不应发现每秒更改数百次的同步上下文。
Marc Gravell

@MarcGravell,我只在tcs.SetResult调用范围内更改它。它有点变成原子和线程安全的这种方式,因为延续本身上发生任何其他池中的线程或原同步。在捕获的上下文await tcs.Task。而且SynchronizationContext.SetSynchronizationContext它本身很便宜,比线程开关本身便宜得多。
noseratio 2014年

但是,这可能无法满足您的第二个要求:不使用ThreadPool。使用此解决方案,ThreadPool如果没有同步,TPL确实将使用。上下文(或它的基本默认值)await tcs.Task。但这是标准的TPL行为。
noseratio 2014年

嗯...因为同步上下文是每个线程的,所以这实际上是可行的-我不需要继续切换ctx-只需为工作线程设置一次即可。我需要玩
马克·格雷夫

1
@Noseration啊,对:不清楚关键是它们是否不同。将要看。谢谢。
Marc Gravell

3

中止模拟方法看起来很不错,但导致了TPL劫持线程在某些情况下

然后,我有一个类似于检查延续对象的实现,但是只是检查是否有任何延续,因为给定代码实际上有太多方案无法正常工作,但这意味着即使类似Task.Wait导致线程池查找。

最终,在检查了很多IL之后,唯一安全和有用的方案是SetOnInvokeMres方案(手动重置事件-精简连续)。还有许多其他方案:

  • 有些不安全,并导致线程劫持
  • 其余的没有用,因为它们最终会导致线程池

所以最后,我选择检查一个非null的延续对象。如果为null,则可以(不能继续);如果为非null,则特殊情况下检查SetOnInvokeMres-是否为:fine(可以安全调用);否则,让线程池执行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>));
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.