【问题标题】:CLR via C# 4th Ed. - Confused about waiting for Task deadlockCLR 通过 C# 第 4 版。 - 对等待任务死锁感到困惑
【发布时间】:2017-08-07 19:27:50
【问题描述】:

Jeffrey Richter 在他的“CLR via C#”一书中指出了我不理解的可能死锁示例(第 702 页,带边框的段落)。

示例是一个线程,它运行 Task 并为此 Task 调用 Wait()。如果任务未启动,则 Wait() 调用应该没有阻塞,而是正在运行未启动的任务。如果在 Wait() 调用之前输入了锁,并且 Task 也尝试输入此锁,则可能导致死锁。

但是锁是在同一个线程中输入的,这是否会导致死锁场景?

以下代码产生预期的输出。

class Program
{
    static object lockObj = new object();

    static void Main(string[] args)
    {
        Task.Run(() =>
        {
            Console.WriteLine("Program starts running on thread {0}",
                Thread.CurrentThread.ManagedThreadId);
            var taskToRun = new Task(() =>
            {
                lock (lockObj)
                {
                    for (int i = 0; i < 10; i++)
                        Console.WriteLine("{0} from Thread {1}", 
                            i, Thread.CurrentThread.ManagedThreadId);
                }
            });

            taskToRun.Start();
            lock (lockObj)
            {
                taskToRun.Wait();
            }

        }).Wait() ;
    }
}

/* Console output
Program starts running on thread 3
0 from Thread 3
1 from Thread 3
2 from Thread 3
3 from Thread 3
4 from Thread 3
5 from Thread 3
6 from Thread 3
7 from Thread 3
8 from Thread 3
9 from Thread 3
*/

没有发生死锁。

J。 Richter 在他的书“CLR via C#”第 4 版第 702 页上写道:

当线程调用Wait方法时,系统会检查线程等待的Task是否已经开始执行。如果有,则调用 Wait 的线程将阻塞,直到 Task 完成运行。但是如果Task还没有开始执行,那么系统可能(取决于TaskScheduler)使用调用Wait的线程来执行Trask。如果发生这种情况,那么调用 Wait 的线程不会阻塞;它执行任务并立即返回。这很好,因为没有线程被阻塞,从而减少了资源使用(通过不创建线程来替换阻塞的线程),同时提高了性能(没有时间来创建线程并且没有上下文切换)。但是如果,例如,线程在调用 Wait 之前已经获取了线程同步锁,并且任务尝试获取相同的锁,从而导致线程死锁,这也可能会很糟糕!

如果我理解正确的话,上面的代码必须以死锁结束!?

【问题讨论】:

  • @Noseratio:这不一样!关键是引用的段落描述了当一个线程重入锁时不应发生的死锁。在所描述的引用场景中是否可能出现死锁?
  • 嗯,您似乎对Task 的工作原理不感兴趣,而是对如何通过尝试多次“锁定”资源来使单个线程自身死锁感兴趣。也许您问题的这一部分可以拆分为一个根本不引用Task/TPL 的新问题? ;-)

标签: c# .net task-parallel-library clr deadlock


【解决方案1】:

你把我对“锁”这个词的用法从字面上理解了。 C#“锁定”语句(我的书不鼓励使用)在内部利用 Monitor.Enter/Exit。 Monitor 锁是一种支持线程所有权和递归的锁。因此,单个线程可以多次成功获取这种锁。但是,如果您使用不同类型的锁,例如 Semaphore(Slim)、AutoResetEvent(Slim) 或 ReaderWriterLockSlim(没有递归),那么当单个线程多次尝试获取这些锁中的任何一个时,就会发生死锁。

【讨论】:

  • 这确实有道理。感谢您的澄清,杰弗里。
  • 这就是我在等待的答案。现在我明白了你在写什么:-)。非常感谢您的帖子。
  • 我正要搜索完全相同的问题,呵呵。感谢您的回答!
【解决方案2】:

在此示例中,您正在处理任务内联,这是 TPL 的默认任务调度程序的一种不那么罕见的行为。它导致任务在已经使用Task.Wait() 等待它的同一线程上执行,而不是在随机池线程上执行。在这种情况下,没有死锁。

像下面这样更改你的代码,你就会有一个解锁:

taskToRun.Start();
lock (lockObj)
{
    //taskToRun.Wait();

    ((IAsyncResult)taskToRun).AsyncWaitHandle.WaitOne();
}

任务内联是不确定的,它可能会发生也可能不会发生。你不应该做任何假设。查看 Stephen Toub 的Task.Wait and “Inlining” 了解更多详情。

更新,锁影响这里的任务内联。如果您将taskToRun.Start() 移动到锁内,您的代码仍会在没有死锁的情况下运行:

lock (lockObj)
{
    taskToRun.Start();
    taskToRun.Wait();
}

导致这里内联的作用是主线程在taskToRun.Start() 之后调用taskToRun.Wait() 的情况。以下是幕后发生的事情:

  1. taskToRun.Start() 将任务排入队列以供任务调度程序执行,但尚未为其分配池线程。
  2. 在同一个线程上,taskToRun.Wait() 中的 TPL 代码检查任务是否已经分配了一个池线程(它没有)并在主线程上内联执行它。在这种情况下,可以两次获取同一个锁而不会出现死锁。
  3. 还有一个 TPL 任务计划程序线程。如果在主线程上调用taskToRun.Wait() 之前该线程有机会执行,则不会发生内联并且您会遇到死锁。在Task.Wait() 之前添加Thread.Sleep(100) 将对此场景进行建模。如果您不使用 Task.Wait() 而是使用上面的 AsyncWaitHandle.WaitOne() 之类的东西,也不会发生内联。

至于您在问题中添加的引用,这取决于您如何阅读它。有一件事是肯定的:当任务被内联时,来自主线程的 same 可以进入任务内部,而不会出现死锁。你不能做出任何假设它被内联。

【讨论】:

  • 这是绝对正确的。我不确定 Richter 的任务内联段落是否正确。恕我直言,当线程始终相同时,不会发生死锁,这就是所有人都在这里写的。
  • @embee,关键是,它可能不一定是 same 线程,所以 Richter 的代码示例在技术上是正确的。你真的不应该依赖这种内联行为。
  • 代码示例并非来自书中,我写它是为了说明Richter描述的代码。我只是对 Richter 所写的内容感到困惑,即这段代码应该以死锁告终。我不知道在这里引用该段是否合法。如果它是合法的,我可以引用原始段落....
【解决方案3】:

在您的示例中,不会发生死锁,因为调度任务的线程和执行任务的线程恰好是相同的。如果您要修改代码以使您的任务在不同的线程上运行,您会看到死锁发生,因为两个线程将争夺同一个对象上的锁。

您的示例,修改为创建死锁:

class Program {
    static object lockObj = new object();

    static void Main(string[] args) {
        Console.WriteLine("Program starts running on thread {0}",
            Thread.CurrentThread.ManagedThreadId);
        var taskToRun = new Task(() => {
            lock (lockObj) {
                for (int i = 0; i < 10; i++)
                    Console.WriteLine("{0} from Thread {1}",
                        i, Thread.CurrentThread.ManagedThreadId);
            }
        });

        lock (lockObj) {
            taskToRun.Start();
            taskToRun.Wait();
        }
    }
}

【讨论】:

  • 这是绝对正确的,但是 Jeffrey 在他的书中描述了一个场景,比如我的代码示例。你有书吗?
  • @embee cokeman19 是对的。 Richter 的代码可能导致死锁,但前提是这两个任务在不同的线程上运行。它们是否在不同的线程上运行,是一个机会问题。你不需要像 cokeman19 那样“彻底”修改你的代码来证明这一点。只需在两个锁之前引入Thread.Sleep(1000)。同样,里希特的例子在理论上是可能的,但在实践中是不可能的。
  • 我在实际代码中没有这个问题。今天下午我读了第四版的线程部分,我一直在读这部分。 Richter 描述了未启动的任务可以在发生 Wait() 调用的同一线程中执行。输出显示此行为,但不存在死锁。
  • 我找到了您所指的那本书的副本,Richter 似乎确实暗示在同一个线程中多次锁定同一个对象可能会导致死锁。然而,锁在设计上是可重入的,所以我不知道这怎么可能,除非像 dcastro 建议的那样,他严格来说是理论上的。
【解决方案4】:

此示例代码有 两个 标准线程问题。要理解它,您首先必须了解线程竞赛。当您启动一个线程时,您可以永远假设它会立即开始运行。你也不能假设线程内的代码在特定的时刻到达特定的语句。

这里重要的是任务是否在主线程之前到达 lock 语句。换句话说,它是否领先于主线程中的代码。将此建模为赛马,获得锁的线程就是获胜的马。

如果是获胜的任务,在具有多个处理器内核的现代机器或没有任何其他线程处于活动状态的简单程序(并且可能在您测试代码时)上很常见,那么一切都不会出错。它获取锁并阻止主线程在稍后到达锁语句时执行相同的操作。所以你会看到控制台输出,任务完成,主线程现在获取锁,Wait() 调用很快完成。

但是如果线程池已经忙于其他线程,或者机器忙于执行其他程序中的线程,或者你运气不好,在任​​务开始运行时收到了一封电子邮件,那么任务中的代码不会' t 立即开始运行,它是首先获得锁的主线程。该任务现在不能再进入锁定语句,因此无法完成。并且主线程无法完成,Wait() 永远不会返回。一个致命的拥抱,叫做死锁。

Deadlock 相对容易调试,您可以随时附加调试器并查看活动线程以了解它们被阻塞的原因。线程竞争错误非常难以调试,它们发生的频率太低,而且很难通过导致它们的排序问题进行推理。诊断线程竞争的常用方法是向程序添加跟踪,以便您查看顺序。这会改变时间并可以使错误消失。许多程序都带有跟踪功能,因为它们无法诊断问题:)

【讨论】:

  • 你是对的,但 Richter 描述了一个场景,其中只有“一个”线程处理主线程和任务。调用 Wait() 时,任务不会开始执行(在可能的另一个线程上)。 Wait() 和 TaskScheduler 决定使用当前线程运行 Task。在这种情况下你不能陷入僵局!?
  • 当您调用 Start() 方法时,该任务有资格开始运行。它是否立即是你无法假设的。您也不能假设 TaskScheduler 会强制它运行,这是您不能指望的优化。它肯定不在我的机器上。这些是导致线程错误的假设类型,这也是 Richter 警告您的原因。
  • 这不是程序在我的机器上所做的,它反复死锁。您的“但如果”毫无意义,您必须担心“但如果不是”的情况。
  • 这不是主线程和任务线程之间的竞争。相反,这是主线程和 TPL 任务调度程序线程之间的竞争,它会影响任务内联。将taskToRun.Start() 移动到lock 中很好地说明了这一点:仍然没有死锁。
  • 这又是一个错误的假设。该任务可以在开始运行后被操作系统的线程调度程序抢占。确切地推理在哪里比赛将发生没有意义,唯一重要的是可能有比赛。
【解决方案5】:

感谢@jeffrey-richter 指出,@embee 在某些情况下,当我们使用 Monitor 以外的锁而不是单个线程多次尝试获取这些锁中的任何一个时,就会发生死锁。看看下面的例子

以下代码会产生预期的死锁。不需要嵌套任务也可以发生死锁而不嵌套

class Program
{
    static AutoResetEvent signalEvent = new AutoResetEvent(false);

    static void Main(string[] args)
    {
        Task.Run(() =>
        {
            Console.WriteLine("Program starts running on thread {0}",
                 Thread.CurrentThread.ManagedThreadId);
            var taskToRun = new Task(() =>
            {
                signalEvent.WaitOne();
                for (int i = 0; i < 10; i++)
                    Console.WriteLine("{0} from Thread {1}", 
                        i, Thread.CurrentThread.ManagedThreadId);
            });

            taskToRun.Start();
            signalEvent.Set();
            taskToRun.Wait();

        }).Wait() ;
    }
}

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多