为啥任务取消发生在调用者线程上?

Posted

技术标签:

【中文标题】为啥任务取消发生在调用者线程上?【英文标题】:Why the task cancellation happens on the caller thread?为什么任务取消发生在调用者线程上? 【发布时间】:2014-02-27 00:49:56 【问题描述】:

我在任务取消模式上发现了一个问题,我想了解为什么应该以这种方式工作。

考虑这个小程序,其中辅助线程执行异步“长”任务。同时,主线程通知取消。

这个程序是一个更大的程序的一个非常简化的版本,它可以有许多并发线程执行“长任务”。当用户要求取消时,所有正在运行的任务都应该被取消,因此 CancellationTokenSource 集合。

class Program

    static MyClass c = new MyClass();

    static void Main(string[] args)
    
        Console.WriteLine("program=" + Thread.CurrentThread.ManagedThreadId);
        var t = new Thread(Worker);
        t.Start();
        Thread.Sleep(500);
        c.Abort();

        Console.WriteLine("Press any key...");
        Console.ReadKey();
    

    static void Worker()
    
        Console.WriteLine("begin worker=" + Thread.CurrentThread.ManagedThreadId);

        try
        
            bool result = c.Invoker().Result;
            Console.WriteLine("end worker=" + result);
        
        catch (AggregateException)
        
            Console.WriteLine("canceled=" + Thread.CurrentThread.ManagedThreadId);
        
    


    class MyClass
    
        private List<CancellationTokenSource> collection = new List<CancellationTokenSource>();

        public async Task<bool> Invoker()
        
            Console.WriteLine("begin invoker=" + Thread.CurrentThread.ManagedThreadId);

            var cts = new CancellationTokenSource();
            c.collection.Add(cts);

            try
            
                bool result = await c.MyTask(cts.Token);
                return result;
            
            finally
            
                lock (c.collection)
                
                    Console.WriteLine("removing=" + Thread.CurrentThread.ManagedThreadId);
                    c.collection.RemoveAt(0);
                
                Console.WriteLine("end invoker");
            
        

        private async Task<bool> MyTask(CancellationToken token)
        
            Console.WriteLine("begin task=" + Thread.CurrentThread.ManagedThreadId);
            await Task.Delay(2000, token);
            Console.WriteLine("end task");
            return true;
        

        public void Abort()
        
            lock (this.collection)
            
                Console.WriteLine("canceling=" + Thread.CurrentThread.ManagedThreadId);
                foreach (var cts in collection) //exception here!
                
                    cts.Cancel();
                
                //collection[0].Cancel();
            ;
        

    

尽管锁定了集合访问,但访问它的线程与请求取消的线程相同。也就是说,在迭代期间修改了集合,并引发了异常。

为了更清楚,您可以注释掉整个“foreach”并取消注释最后一条指令,如下所示:

        public void Abort()
        
            lock (this.collection)
            
                Console.WriteLine("canceling=" + Thread.CurrentThread.ManagedThreadId);
                //foreach (var cts in collection) //exception here!
                //
                //    cts.Cancel();
                //
                collection[0].Cancel();
            ;
        

这样做,没有例外,程序优雅地终止。但是,有趣的是看到所涉及的线程的 ID:

program=10
begin worker=11
begin invoker=11
begin task=11
canceling=10
removing=10
end invoker
Press any key...
canceled=11

显然,“finally”主体是在调用者线程上运行的,但一旦离开“Invoker”,该线程就是次要的。

为什么“finally”块不在辅助线程中执行?

【问题讨论】:

【参考方案1】:

任务在哪个线程上运行是一个实现细节。如果您使用知道如何在特定线程上运行代码的任务调度程序,您只能确定一个。像 TaskScheduler.FromCurrentSynchronizationContext()。这永远不会在控制台模式应用程序中工作,因为它没有。

因此,由 Task 类实现来确定要使用的线程。并且它会寻找机会不需要需要线程上下文切换,这些都是昂贵的。如果它可以在启动线程池线程执行代码并等待它完成与直接执行代码之间进行选择,那么它总是会选择最后一个选择,它是优越的。

它在您的代码中找到了一个,您在主线程上调用了 Abort() 方法。其中,通过任务类管道中的许多层(查看调用堆栈窗口),找出了如何在同一线程上调用 finally 块。这当然是一件好事。您应该期望的是,您的线程没有其他任何事情可做,因此它也可以用于执行任务代码。

与使用 CancelAfter() 相比,现在您的线程适合执行 finally 块,您将看到 finally 块在 TP 线程上执行。

【讨论】:

另外有趣的是注意取消是如何从 await Task.Delay(3000, token); 传播的:在 MyTask 中捕获并重新抛出 Exception,也在 Invoker 中捕获并重新抛出 Exception - 你会看到它是 @ MyTask 中的 987654324@(我们调用 Cancel() 的线程),Invoker 中的 TaskCanceledException(也是我们调用 Cancel() 的线程),最后它变成 AggregateException 但在线程上,等待 Result TaskScheduler.FromCurrentSynchronizationContext(). Which will never work in a console mode app since it doesn't have one. 不是“从不”。您可以手动创建同步上下文并将其设置为控制台应用程序中的当前上下文。 “通常”在这里可能更合适,而不是“从不”。【参考方案2】:

似乎一旦你在第一个子线程上调用Cancel()await 延续就不能再在这个线程上恢复,而是在调用者/父线程上执行。如果在调用后立即添加catch 以生成第二个子线程,则可以看到父线程在TaskCancelationException 之后执行的代码,

try

    bool result = await c.MyTask(cts.Token);
    return result;

catch (Exception exception)

    Console.WriteLine("catch invoker exception=" + exception.GetType());
    Console.WriteLine("catch invoker=" + Thread.CurrentThread.ManagedThreadId);
    return true;

哪个产生,

program=10
begin worker=11
begin invoker=11
begin task=11
canceling=10
catch invoker exception=TaskCanceledException
catch invoker=10      <-- parent thread resuming on child cancellation
removing=10

它在父线程上执行的原因可能是由于产生新线程以恢复执行的性能原因(Hans Passant 解释的);同样,如果子线程从未被取消(注释掉c.Abort();),await 的执行将在这两种情况下在子线程而不是父线程上恢复,

program=10
begin worker=11   <-- first child thread
begin invoker=11
begin task=11
Press any key...
end task=12       <-- second child thread resuming on 'await Task.Delay'
removing=12       <-- second child thread resuming on 'await c.MyTask(cts.Token)'
end invoker=12
end worker=True    
end worker=11     <-- back to the first child thread

其中thread 11,已经返回到它的调用者方法(返回Worker),可能证明切换线程上下文以在MyTask 恢复更昂贵,而thread 12(假定的第二个孩子),刚刚变得可用于继续,但只能到 Invoker 方法的末尾,其中 thread 11 位于它最初暂停的确切位置。

【讨论】:

以上是关于为啥任务取消发生在调用者线程上?的主要内容,如果未能解决你的问题,请参考以下文章

Dart:让流中抛出的异常传播并在调用者中捕获它

如何在新线程上运行任务并立即返回给调用者?

我可以在调用者处将函数参数默认为 __FILE__ 的值吗?

Delphi DLL 和 Delphi EXE 之间的回调功能

JAVA多线程模式-Future

在 C 中通过引用传递时会发生啥?