使锁定无效的同一线程上的两个任务 运行

Two Tasks run on the same thread which invalidates lock

编辑

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

In the case of TaskCompletionSource, that means that synchronous continuations can happen as part of a call to {Try}Set*, which means in our AsyncManualResetEvent example, those continuations could execute as part of the Set method. Depending on your needs (and whether callers of Set may be ok with a potentially longer-running Set call as all synchronous continuations execute), this may or may not be what you want.

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


原问题

我知道线程池线程上的 Task.Run 运行s 和线程可以重入。但是我从来不知道两个任务可以运行在同一个线程上,当它们都alive!

我的问题是:这样设计合理吗?这是否意味着异步方法中的 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的控制流程如图:

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

The SemaphoreSlim class doesn't enforce thread or task identity

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

// 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()方法,如果要异步等待进入信号量

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

My Question is: is that reasonable by design? Does that mean lock inside an async method is meaningless?

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

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

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

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

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

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

...并且 tcs.Task 附加了一个调用 MethodNotAllowReetrance 的延续。如果您的方法是这样的,那么会发生什么:

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

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

  1. 不要在按住 lock 的同时完成 TaskCompletionSource。推迟完成,直到您离开保护区:
static void MethodNotAllowReetrance()
{
    bool doComplete = false;
    lock (methodLock) doComplete = true;
    if (doComplete) tcs.SetResult();
}
  1. 配置 TaskCompletionSource 以便它通过传递 TaskCreationOptions.RunContinuationsAsynchronously in its constructor. This is an option that you don't have very often. For example when you cancel a CancellationTokenSource 异步调用其延续,您无法选择异步调用注册到其关联的回调CancellationToken.

  2. 以可以处理重入的方式重构 MethodNotAllowReetrance 方法。

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

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

接下来,TaskCompletionSource<T> 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,如果有帮助的话。