【问题标题】:Thread.Join method does not always return the same value when the thread has already terminated (.NET 5 / Core)当线程已经终止(.NET 5 / Core)时,Thread.Join 方法并不总是返回相同的值
【发布时间】:2021-02-22 12:08:15
【问题描述】:

Thread.Join 方法具有三个重载:Join()Join(Int32)Join(TimeSpan)。对于这三个重载中的每一个,都有以下语句in the Microsoft doc

如果调用Join时线程已经终止,则该方法立即返回。

虽然此语句对 Join() 重载有意义,但它没有指定为 Join(Int32)Join(TimeSpan) 返回哪个值,因此我在两个不同的环境中测试了 Int32 重载:

  1. Windows 10:返回 true
  2. Linux/Docker:返回 false(在 Docker 桌面上使用 mcr.microsoft.com/dotnet/runtime:5.0

请注意,如果在调用 Join 时线程仍在运行并且在 称呼。如果线程在调用之前 终止,它只会返回 false

在我看来,Join 无论在什么平台上都应该始终返回 true,那么如何解释这种不一致的行为呢?我错过了什么还是 .NET 5 错误?

更新

根据@txtechhelp 的建议,here is a .NET Fiddle 提供我正在测试的确切代码。

如果我在 Windows 10(或 .NET Fiddle)上运行此代码,我会得到以下结果:

Starting..
Sleeping 1200..expect T1 end before join
In T1
Leaving T1
Join(100)..expect success
Join(100) success!
Done..

然后,如果我在 Docker Desktop (v. 3.1.0) 上使用 mcr.microsoft.com/dotnet/runtime:5.0 运行此代码,则会得到以下结果:

Starting..
Sleeping 1200..expect T1 end before join
In T1
Leaving T1
Join(100)..expect success
Join(100) failed
Done..

更新 2

实际上,经过进一步测试后,我意识到只有在 Docker 应用程序正在卸载时调用Join(即接收到AssemblyLoadContext.Default.Unloading 事件后,即 Docker 发送的通知它的信号),上述测试才会失败将关闭应用程序)。

所以here is the exact test 甚至在.NET Fiddle 上都失败了:

public class Program
{
    public static void Main()
    {
        System.Runtime.Loader.AssemblyLoadContext.Default.Unloading += (arg) => { OnStopSignalReceived("application unloading"); };
    }

    public static void T1()
    {
        System.Console.WriteLine("In T1");
        System.Threading.Thread.Sleep(1000);
        System.Console.WriteLine("Leaving T1");
    }

    private static void OnStopSignalReceived(string stopSignalSource)
    {
        System.Threading.Thread t1 = new System.Threading.Thread(T1);
        System.Console.WriteLine("Starting..");
        t1.Start();
        System.Console.WriteLine("Sleeping 1200..expect T1 end before join");
        System.Threading.Thread.Sleep(1200);
        System.Console.WriteLine("Join(100)..expect success");
        if (t1.Join(100))
        {
            System.Console.WriteLine("Join(100) success!");
        }
        else
        {
            System.Console.WriteLine("Join(100) failed");
        }
        t1.Join();
        System.Console.WriteLine("Done..");
    }
}

【问题讨论】:

  • 也许它返回null?
  • @jdweng 我的问题确实是关于 int 或 TimeSpan 重载,它们返回一个不可为空的布尔值。我将编辑我的问题以进行澄清。
  • 您链接的文档明确指出:如果线程终止,则返回 true;如果在 timeout 参数指定的时间量过后线程尚未终止,则返回 false。因此,如果线程终止,如果实现返回 false,我建议提交 Bug。
  • @Rem 建议在 GitHub 上的 dotnet/runtime repo 上提出这个问题,微软肯定会告诉你这是正确的还是错误的。
  • 你能发布一些麻烦的代码吗?在多个平台(包括 Docker)上进行快速测试会产生预期的结果。这是我用来测试您声称的内容的 C# 代码:dotnetfiddle;如您所见,我专门尝试调用您所说的行为,其中线程退出 然后 Yield(Int32) 被调用,而在 Linux 中您将获得 false 值(意思是线程尚未退出并且指定的时间跨度已通过)。相关代码将有助于确定问题所在,因为它似乎不可重现。

标签: c# multithreading .net-core


【解决方案1】:

这个问题确实似乎是AssemblyLoadContext class 的底层 .NET 5 代码中的错误,或者可能是文档尚未指定的一些未定义行为;也就是说,AssemblyLoadContext.Unloading event 的文档仅说明:

在卸载 AssemblyLoadContext 时发生。

鉴于您遇到的问题,这是唯一的句子,并没有提供太多上下文。

话虽如此,经过一番挖掘,我编写了您提供的代码的 2 个版本,并发现了一些处理 AssemblyLoadContext.Unloading 和线程的有趣行为。

这段代码重现了你提到的错误:

buggy.cs

using System;
using System.Diagnostics;
using System.Threading;
using System.Runtime.Loader;

public class Program
{
        static Thread t1 = new Thread(ThreadFn);
        static Stopwatch sw = new Stopwatch();
    
        public static void Main()
        {
            AssemblyLoadContext.Default.Unloading += ContextUnloading;
            sw.Start();
            Console.WriteLine("{0}ms: leaving main", sw.ElapsedMilliseconds);
        }
    
        public static void ThreadFn()
        {
            Console.WriteLine("{0}ms: in ThreadFn, sleeping 1s", sw.ElapsedMilliseconds);
            Thread.Sleep(1000);
            Console.WriteLine("{0}ms: leaving ThreadFn", sw.ElapsedMilliseconds);
        }

        private static void ContextUnloading(AssemblyLoadContext context)
        {
            Console.WriteLine("{0}ms: unloading '{1}', thread state '{2}'", sw.ElapsedMilliseconds, context, t1.ThreadState);
            
            // possible bug/UB with t1.Start() in this function
            Console.WriteLine("{0}ms: starting thread", sw.ElapsedMilliseconds);
            t1.Start();

            Console.WriteLine("{0}ms: calling Sleep(1200); expect thread in state '{1}' to end before join called", sw.ElapsedMilliseconds, t1.ThreadState);
            Thread.Sleep(1200);
            Console.WriteLine("{0}ms: calling Join(100) on thread in state '{1}'; expect 'succeeded!'", sw.ElapsedMilliseconds, t1.ThreadState);
            Console.WriteLine("Join(100) {0}", (t1.Join(100) ? "succeeded!" : "failed"));
            Console.WriteLine("{0}ms: done", sw.ElapsedMilliseconds);
        }
}

Run it in dotnetfiddle

运行该代码会给我以下结果:

0ms: leaving main
12ms: unloading '"Default" System.Runtime.Loader.DefaultAssemblyLoadContext #0', thread state 'Unstarted'
13ms: starting thread
14ms: calling Sleep(1200); expect thread in state 'Running' to end before join called
14ms: in ThreadFn, sleeping 1s
1014ms: leaving ThreadFn
1214ms: calling Join(100) on thread in state 'Stopped'; expect 'succeeded!'
Join(100) failed
1214ms: done

您会注意到,在这个有缺陷的版本中,每个点的线程状态和时间间隔都与呈现的代码相匹配。该错误发生在调用Join(Int32) 方法时;即使文档声明返回值是Boolean,其中的值是:

true 如果线程已经终止; false 如果线程在millisecondsTimeout 参数指定的时间量过后仍未终止。

鉴于线程是Stopped,根据ThreadState 文档,这意味着线程要么响应Abort 调用(在上面的代码中没有调用),要么如果

一个线程被终止。

阅读文档Understanding System.Runtime.Loader.AssemblyLoadContext,他们甚至会记下

注意线程竞争。加载可以由多个线程触发。 AssemblyLoadContext 通过将程序集自动添加到其缓存来处理线程竞争。比赛失败者的实例被丢弃。在您的实现逻辑中,不要添加无法正确处理多个线程的额外逻辑。

结合所有这些,我们假设调用Join(Int32) 应该在上面的代码中给出true 的预期结果。

所以,是的,这似乎是一个错误。

但是

如果您将线程开始移动到Main 函数中,而不是在卸载事件处理程序中,则在线程完成之前不会调用Default 上下文中的AssemblyLoadContext.Unloading,并调用Join(Int32)然后,当然会返回预期的结果。

Unloading 事件在线程完成之前不会被调用是有道理的,因为它可以被视为当前程序集上下文的“一部分”,但它不是解释为什么上面代码中的错误仍然会发生。

因此,虽然Join(100) 调用确实 在下面的代码中按预期成功,但似乎是因为AssemblyLoadContext.UnloadingMain 退出后没有被调用,正如人们所期望的那样,而是它在线程完成后调用,这具有上下文意义,但不一定在任何文档中注明。

“成功”代码:

syncbug.cs

using System;
using System.Diagnostics;
using System.Threading;
using System.Runtime.Loader;

public class Program
{
        static Thread t1 = new Thread(ThreadFn);
        static Stopwatch sw = new Stopwatch();
    
        public static void Main()
        {
            AssemblyLoadContext.Default.Unloading += ContextUnloading;
            sw.Start();

            // Get expected result starting thread, but Unloading isn't called until AFTER the thread
            // finishes, which is not the expected result according to the .NET documentation
            Console.WriteLine("{0}ms: starting thread", sw.ElapsedMilliseconds);
            t1.Start();
            
            Console.WriteLine("{0}ms: leaving main", sw.ElapsedMilliseconds);
        }
    
        public static void ThreadFn()
        {
            Console.WriteLine("{0}ms: in ThreadFn, sleeping 1s", sw.ElapsedMilliseconds);
            Thread.Sleep(1000);
            Console.WriteLine("{0}ms: leaving ThreadFn", sw.ElapsedMilliseconds);
        }

        private static void ContextUnloading(AssemblyLoadContext context)
        {
            Console.WriteLine("{0}ms: unloading '{1}', thread state '{2}'", sw.ElapsedMilliseconds, context, t1.ThreadState);
            Console.WriteLine("{0}ms: calling Sleep(1200); expect thread in state '{1}' to end before join called", sw.ElapsedMilliseconds, t1.ThreadState);
            Thread.Sleep(1200);
            Console.WriteLine("{0}ms: calling Join(100) on thread in state '{1}'; expect 'succeeded!'", sw.ElapsedMilliseconds, t1.ThreadState);
            Console.WriteLine("Join(100) {0}", (t1.Join(100) ? "succeeded!" : "failed"));
            Console.WriteLine("{0}ms: done", sw.ElapsedMilliseconds);
        }
}

Run it in dotnetfiddle

运行该代码会给我以下结果:

0ms: starting thread
11ms: leaving main
12ms: in ThreadFn, sleeping 1s
1012ms: leaving ThreadFn
1013ms: unloading '"Default" System.Runtime.Loader.DefaultAssemblyLoadContext #0', thread state 'Stopped'
1014ms: calling Sleep(1200); expect thread in state 'Stopped' to end before join called
2214ms: calling Join(100) on thread in state 'Stopped'; expect 'succeeded!'
Join(100) succeeded!
2215ms: done

您会注意到Unload 事件直到线程完成之后才会被调用。

截至撰写本答案时,有82 open bugs for the AssemblyLoadContext class346 closed。因此,您的问题可能已经以某种方式记录在案,但粗略搜索并没有找到与您的问题相关的任何内容。

由于这似乎是一个合法的错误,并且由于您对代码和正在发生的事情有更深入的了解,我建议您访问他们的 Issues 页面并提交 新问题

【讨论】:

  • 很好的答案!这似乎证实了这确实是一个 .NET 错误。所以我只是在这里开了一张票:github.com/dotnet/runtime/issues/48807,让我们看看 .NET 人在说什么......
  • @Rem 仅供参考:我有一个 github 帐户,所以如果您/他们在该问题上需要任何额外的输入,我也可以在那里插话。顺便说一句,写得很好!
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2014-05-18
  • 1970-01-01
  • 1970-01-01
  • 2021-11-12
  • 1970-01-01
  • 2017-03-13
  • 1970-01-01
相关资源
最近更新 更多