【问题标题】:Two Tasks run on the same thread which invalidates lock两个任务在使锁无效的同一线程上运行
【发布时间】:2022-01-11 02:47:00
【问题描述】:

编辑

我发现Building Async Coordination Primitives, Part 1: AsyncManualResetEvent 可能与我的主题有关。

在 TaskCompletionSource 的情况下,这意味着同步延续可以作为调用 {Try}Set* 的一部分发生,这意味着在我们的 AsyncManualResetEvent 示例中,这些延续可以作为 Set 方法的一部分执行。根据您的需求(以及在所有同步延续执行时,Set 的调用者是否可以接受可能运行时间更长的 Set 调用),这可能是您想要的,也可能不是。

非常感谢所有的答案,感谢您的知识和耐心!


原始问题

我知道Task.Run 在线程池线程上运行,线程可以重新进入。但是我从来不知道当两个任务都活着时,它们可以在同一个线程上运行!

我的问题是:设计合理吗?这是否意味着异步方法中的lock 没有意义(或者说,如果我想要一个不允许重入的方法,lock 不能在异步方法块中被信任)?

代码:

namespace TaskHijacking
{
    class Program
    {
        static TaskCompletionSource<bool> tcs = new TaskCompletionSource<bool>();
        static object methodLock = new object();

        static void MethodNotAllowReetrance(string callerName)
        {
            lock(methodLock)
            {
                Console.WriteLine($"Enter MethodNotAllowReetrance, caller: {callerName}, on thread: {Thread.CurrentThread.ManagedThreadId}");
                if (callerName == "task1")
                {
                    tcs.SetException(new Exception("Terminate tcs"));
                }
                Thread.Sleep(1000);
                Console.WriteLine($"Exit MethodNotAllowReetrance, caller: {callerName}, on thread: {Thread.CurrentThread.ManagedThreadId}");
            }
        }

        static void Main(string[] args)
        {
            var task1 = Task.Run(async () =>
            {
                await Task.Delay(1000);
                MethodNotAllowReetrance("task1");
            });

            var task2 = Task.Run(async () =>
            {
                try
                {
                    await tcs.Task;  // await here until task SetException on tcs
                }
                catch
                {
                    // Omit the exception
                }
                MethodNotAllowReetrance("task2");
            });

            Task.WaitAll(task1, task2);

            Console.ReadKey();
        }
    }
}

输出:

Enter MethodNotAllowReetrance, caller: task1, on thread: 6
Enter MethodNotAllowReetrance, caller: task2, on thread: 6
Exit MethodNotAllowReetrance, caller: task2, on thread: 6
Exit MethodNotAllowReetrance, caller: task1, on thread: 6

线程6的控制流程如图:

【问题讨论】:

  • “我知道任务在线程上运行” -- 不,一般任务are not running on threads。如果您想要一个从头到尾在单个线程上运行的Task,它必须是delegate-based task,而不是从async 方法创建的promise 式任务。
  • @Tomingsun 好吧,Task.Run 确实 在 ThreadPool 线程上运行(the documentation 这么说),但你无法控制 which线程。
  • Tomingsun 异步编程的重点是通过不阻塞线程来有效利用线程。拥有ThreadPool 的全部意义在于重用线程,而不是为每个需要线程的微小操作启动一个新线程。将这两个概念结合在一起,是的,你会看到很多“劫持”发生。
  • Tomingsun 多线程很难。实际上,lock 语句的可重入性是您必须经常注意的陷阱之一,以便编写行为正确的多线程代码。

标签: c# asynchronous locking task


【解决方案1】:

您已经有几个解决方案。我只是想多描述一下问题。这里有几个因素在起作用,共同导致观察到的重入。

首先,lock 是可重入的。 lock 严格来说是threads 的互斥,这与code 的互斥是不一样的。我认为re-entrant locks are a bad idea 在 99% 的情况下(如我的博客所述),因为开发人员通常希望相互排斥 代码 而不是 线程。 SemaphoreSlim,由于不可重入,所以相互排斥code。 IMO 重入锁是几十年前的遗留物,当时它们是作为操作系统概念引入的,而操作系统只关心管理线程。

接下来,TaskCompletionSource&lt;T&gt; by default invokes task continuations synchronously。

另外,await will schedule its method continuation as a synchronous task continuation(如我的博客所述)。

Task continuations will sometimes run asynchronously even if scheduled synchronously,但在这种情况下它们将同步运行。 await捕获的上下文是线程池上下文,完成线程(调用TCS.TrySet*的那个)是线程池线程,在这种情况下,延续几乎总是同步运行。

因此,您最终会得到一个获取锁的线程,完成一个 TCS,从而执行该任务的延续,其中包括继续另一个方法,然后该方法能够获取相同的锁。

要在其他答案中重复现有的解决方案,要解决这个问题,您需要在某个时候打破该链条:

  • (OK) 使用不可重入锁。 SemaphoreSlim.WaitAsync 仍将在持有锁时执行延续(不是一个好主意),但由于 SemaphoreSlim 不是可重入的,因此方法延续将(异步)等待锁可用。
  • (最佳)使用TaskCompletionSource.RunContinuationsAsynchronously,它将强制任务延续到(不同的)线程池线程。这是一个更好的解决方案,因为您的代码在持有锁时不再调用任意代码(即任务继续)。

您还可以通过为方法awaiting TCS 使用非线程池上下文来中断链。例如,如果该方法必须在 UI 线程上恢复,则它不能从线程池线程同步运行。

从更广泛的角度来看,如果您混合使用锁和 TaskCompletionSource 实例,听起来您可能正在构建(或可能需要)异步协调原语。我有an open-source library that implements a bunch of them,如果有帮助的话。

【讨论】:

    【解决方案2】:

    任务是对某些工作量的抽象。通常这意味着工作被分成几部分,执行可以在部分之间暂停和恢复。恢复时它很可能在另一个线程上运行。但是暂停/恢复只能在await 语句中完成。值得注意的是,当任务“暂停”时,例如因为它正在等待 IO,它根本不消耗任何线程,它只会在实际运行时使用一个线程。

    我的问题是:设计合理吗?这是否意味着异步方法中的锁没有意义?

    异步方法中的锁远非毫无意义,因为它允许您确保一段代码一次只能由一个线程运行。

    在您的第一个示例中,一次只能有一个线程拥有锁。当锁被持有时,任务不能被暂停/恢复,因为await 在锁体中是不合法的。因此,单个线程将执行整个锁体,并且该线程在完成锁体之前不能做任何其他事情。所以没有重入的风险,除非你调用一些可以回调相同方法的代码。

    在您更新的示例中,由于TaskCompletionSource.SetException 而出现问题,允许重用当前线程以立即运行任务的任何延续。为避免这种情况以及许多其他问题,请确保您仅在运行 有限 数量的代码时持有锁。任何可能运行任意代码的方法调用都有导致死锁、重入和许多其他问题的风险。

    您可以通过使用 ManualResetEvent(Slim) 在线程之间执行信号而不是使用 TaskCompletionSource 来解决特定问题。

    【讨论】:

    • and that thread cannot do anything else until it completes the lock body. So there is no risk of re-entrancy,这与我更新的代码和插图相反。在我的代码中,线程只是离开锁体,并转移到另一个任务,进入相同的方法,所以在我的代码中有 可重入。您能分享一下您对此的看法吗?
    • @Tomingsun 问题是SetException 可以立即运行延续。这与调用委托或引发事件没有根本区别。所以不要在持有锁的情况下调用可能运行任意代码的代码。
    • 您对continuation的更新回答对我来说很有意义,为您的回答点赞。
    【解决方案3】:

    所以你的方法基本上是这样的:

    static void MethodNotAllowReetrance()
    {
        lock (methodLock) tcs.SetResult();
    }
    

    ...tcs.Task 附加了一个调用MethodNotAllowReetrance 的延续。如果你的方法是这样的,那么会发生同样的事情:

    static void MethodNotAllowReetrance()
    {
        lock (methodLock) MethodNotAllowReetrance();
    }
    

    道德教训是,每次调用lock-protected 区域内的任何方法时都必须非常小心。在这种特殊情况下,您有几个选择:

    1. 不要在拿着lock的同时完成TaskCompletionSource。将其推迟到您退出受保护区域后完成:
    static void MethodNotAllowReetrance()
    {
        bool doComplete = false;
        lock (methodLock) doComplete = true;
        if (doComplete) tcs.SetResult();
    }
    
    1. 通过在其构造函数中传递TaskCreationOptions.RunContinuationsAsynchronously,配置TaskCompletionSource,使其异步调用其延续。这是您不经常使用的选项。例如,当您取消 CancellationTokenSource 时,您无法选择异步调用注册到其关联的 CancellationToken 的回调。

    2. 重构MethodNotAllowReetrance 方法,使其能够处理重入。

    【讨论】:

    • 我会仔细考虑选项 2,这对我来说似乎很有趣,可能是锁定或信号量的替代方案。真的很感激!
    【解决方案4】:

    使用SemaphoreSlim 而不是lock,因为正如文档所述:

    SemaphoreSlim 类不强制执行线程或任务标识

    在你的情况下,它看起来像这样:

    // Semaphore only allows one request to enter at a time
    private static readonly SemaphoreSlim _semaphoreSlim = new SemaphoreSlim(1, 1);
    
    void SyncMethod() {
      _semaphoreSlim.Wait();
      try {
        // Do some sync work
      } finally {
        _semaphoreSlim.Release();
      }
    }
    

    try/finally 块是可选的,但它确保即使在代码中的某处引发异常也释放信号量。

    注意SemaphoreSlim还有一个WaitAsync()方法,如果你想异步等待进入信号量。

    【讨论】:

    • 感谢您的好建议,实际上在发布此问题之前,我已经求助于 SemaphoreSlim。这是线程劫持让我感到恐慌。那么线程可以从一个任务劫持到另一个任务是正常的吗?
    • @Tomingsun 这很正常。一项任务!= 一个线程。异步任务的目的是,当一个任务正在等待某事时,线程可以被释放来处理另一个任务,而不是只是闲置。
    • Gabriel 如果你有重入问题并尝试通过从lock 切换到SemaphoreSlim(1, 1) 来解决它,很可能你最终会遇到死锁问题。这就像调用_semaphoreSlim.Wait() 两次,两者之间没有Release。第二个Wait(或await WaitAsync)永远不会完成!
    • @TheodorZoulias 是的,我实际上使用WaitAsync 来使其工作,所以我想我应该将我的方法名称更改为MethodNotAllowReentrancy 而不是SyncMethod,如果异步方法是称为 Sync :-P。我想在新答案中更新我的解决方案。但真的很感谢你们两位带领我走出黑暗。
    • @TheodorZoulias 我猜应该是new SemaphoreSlim(0,1)?我已经有一段时间没有使用它了,文档的解释很混乱。
    猜你喜欢
    • 1970-01-01
    • 2020-12-03
    • 2013-04-12
    • 2012-08-24
    • 1970-01-01
    • 2020-10-15
    • 2011-12-27
    • 2017-04-27
    • 2017-10-27
    相关资源
    最近更新 更多