ConstructorInfo.GetParameters线程安全吗?

5

我一整天都在追踪一个非常奇怪的问题。我不确定这是否是一个错误,但很希望能够得到一些关于为什么会发生这种情况的看法和建议。

我正在使用 xUnit(2.0)来运行我的单元测试。xUnit 的美妙之处在于它自动为您并行运行测试。然而,我发现的问题是,当 ConstructorInfo 被标记为线程安全类型时,Constructor.GetParameters 似乎不是线程安全的。也就是说,如果两个线程同时到达 Constructor.GetParameters,则会产生两个结果,并且对此方法的后续调用将返回创建的第二个结果(无论调用它的线程如何)。

我已经编写了一些代码来演示这种意外行为(如果您想要下载并在本地尝试该项目,则可以访问GitHub)。

以下是代码:

public class OneClass
{
    readonly ITestOutputHelper output;

    public OneClass( ITestOutputHelper output )
    {
        this.output = output;
    }

    [Fact]
    public void OutputHashCode()
    {
        Support.Add( typeof(SampleObject).GetTypeInfo() );
        output.WriteLine( "Initialized:" );
        Support.Output( output );

        Support.Add( typeof(SampleObject).GetTypeInfo() );
        output.WriteLine( "After Initialized:" );
        Support.Output( output );
    }
}

public class AnotherClass
{
    readonly ITestOutputHelper output;

    public AnotherClass( ITestOutputHelper output )
    {
        this.output = output;
    }

    [Fact]
    public void OutputHashCode()
    {
        Support.Add( typeof(SampleObject).GetTypeInfo() );
        output.WriteLine( "Initialized:" );
        Support.Output( output );

        Support.Add( typeof(SampleObject).GetTypeInfo() );
        output.WriteLine( "After Initialized:" );
        Support.Output( output );
    }
}

public static class Support
{
    readonly static ICollection<int> Numbers = new List<int>();

    public static void Add( TypeInfo info )
    {
        var code = info.DeclaredConstructors.Single().GetParameters().Single().GetHashCode();
        Numbers.Add( code );
    }

    public static void Output( ITestOutputHelper output )
    {
        foreach ( var number in Numbers.ToArray() )
        {
            output.WriteLine( number.ToString() );
        }
    }
}

public class SampleObject
{
    public SampleObject( object parameter ) {}
}

两个测试类确保创建并运行两个线程并行执行。运行这些测试后,您应该看到以下结果:
Initialized:
39053774 <---- Different!
45653674
After Initialized:
39053774 <---- Different!
45653674
45653674
45653674

(注意:我已经添加了“&lt; ----不同!”以表示意外值。您将在测试结果中看不到这些。)
正如您所看到的,从第一次调用GetParameters返回的结果与所有后续调用返回的结果不同。
我已经深入研究.NET相当长的时间,但从未见过像这样的情况。这是预期行为吗?有没有首选/已知的初始化.NET类型系统的方法,使其不会发生这种情况?
最后,如果有人感兴趣,我在使用MEF 2时遇到了这个问题,其中一个ParameterInfo用作字典中的键,它与以前保存的值中传递进来的ParameterInfo不相等。当然,这会导致意外的行为并在并发运行时导致测试失败。
编辑:在得到一些好的反馈后,我已经(希望)澄清了这个问题和情景。问题的核心是“线程安全”的“线程安全”类型,并获得更好的了解这是什么意思。 回答:这个问题最终是由多个因素引起的,其中之一是我对多线程场景的无尽无休的无知,似乎我永远在学习,没有预见到未来的结束。我再次感谢xUnit设计得如此出色,以便以如此有效的方式学习这个领域。
另一个问题似乎是.NET类型系统初始化的不一致性。使用TypeInfo/Type时,无论哪个线程访问它多少次,都会得到相同的类型/引用/哈希码。但对于MemberInfo/MethodInfo/ParameterInfo,则不是这种情况。要小心线程访问。
最后,似乎我不是唯一一个有这种困惑的人,这已经被认为是提交给.NET Core GitHub存储库的问题的无效假设(确实被承认)
因此,问题解决了,大部分解决了。我想向所有参与处理我在这个问题上的无知并帮助我学习(我发现这是)这个非常复杂的问题空间的人表示感谢和赞赏。

这是个问题吗?你有两个具有相同值的类实例? - Siderite Zackwehdex
1
正确。第一次调用时是一个实例,然后每次后续调用都是另一个实例。因此,一个线程将在第一次调用时获得一个版本,然后每个线程将在每次后续调用时获得另一个(不变的)实例。如果我使用第一次调用来存储一个键(如上面MEF2的示例),那么是的,这是一个问题。 :) - Mike-E
2个回答

6
在第一次调用时,它是一个实例,然后在每个后续调用中都是另一个实例。
好的,这很好。有点奇怪,但是该方法的文档没有始终返回相同的实例。
因此,一个线程将在第一次调用时获得一个版本,然后每个线程将在每个后续调用上获得另一个(不变的)实例。
再一次,很奇怪,但完全合法。
这是预期行为吗?
嗯,在你的实验之前,我不会这么想。但是在你的实验之后,是的,我希望该行为继续下去。
是否有首选/已知的初始化.NET类型系统的方法,以使其不发生这种情况?
据我所知没有。
如果我正在使用第一个调用来存储密钥,那么是的,那是个问题。
那么你有证据表明你应该停止这样做。如果你这样做会有问题,那就别这样做。
ParameterInfo引用应始终表示相同的ParameterInfo引用,无论它位于哪个线程上或访问多少次。
这是关于该功能应该如何设计的道德声明。这不是它的设计方式,显然也不是它的实现方式。您当然可以认为该设计是有问题的。
Lippert先生也正确指出文档没有保证/指定这一点,但这一直是我对此行为的期望和经验,直到现在。
过去的表现并不能保证未来的结果;你的经验直到现在还不够丰富。多线程有一种使人们期望落空的方式!在一个世界中,除非有东西使它保持不变,否则内存会不断地改变,这与我们通常的模式相反,即事物在某些事情改变它们之前都是相同的。

两件事情:首先,产生HashCode的不是数组,而是一个ParameterInfo,我认为它不能被改变。其次,更重要的是,我认为可以合理地假设,对于表示相同参数的两个ParameterInfo对象,Equals()要么始终返回true,要么始终返回false。我有什么遗漏吗? - StriplingWarrior
感谢@Eric-Lippert的回复。请注意,这并不是我本人在使用,而是MEF 2(System.Composition),正如我上面所提到的,这就是为什么我花了一整天来追踪它的原因。我(和MEF 2)的期望/理解是,对任何System.Reflection元素的每次调用都返回查询对象的相同引用。在上面的示例中,除了第一次调用之外,每次调用都满足这个期望。除了增加我的System.Reflection知识外,System.Composition即将在GitHub上发布一个新问题。 :P - Mike-E
@Mike-EEE:听起来MEF2有一个bug!发现得不错。 - Eric Lippert
哦,如果不通过对象标识符,你怎么比较ParameterInfos呢?ParameterInfo或RuntimeParameterInfo上没有Equals。也许这并没有被记录下来,但我相信绝对确保对象唯一性是有意义的。这似乎是一个重要的用例。 - usr
@Mike-EEE:这与线程安全无关。实现可以在单个线程上每次需要时分配一个新对象。它选择在某些情况下不这样做并不会使其不安全。一个类如果在多个线程上调用时遵守相同的合约就是线程安全的;你只是不喜欢这个合约而已。 - Eric Lippert
显示剩余9条评论

1
作为答案,我正在查看.NET源代码,ConstructorInfo类中有这个:

private ParameterInfo[] m_parameters = null; // Created lazily when GetParameters() is called.

这是他们的评论,不是我的。让我们看一下GetParameters:

[System.Security.SecuritySafeCritical]  // auto-generated
internal override ParameterInfo[] GetParametersNoCopy()
{
    if (m_parameters == null)
        m_parameters = RuntimeParameterInfo.GetParameters(this, this, Signature);

    return m_parameters;
}

[Pure]
public override ParameterInfo[] GetParameters()
{
    ParameterInfo[] parameters = GetParametersNoCopy();

    if (parameters.Length == 0)
        return parameters;

    ParameterInfo[] ret = new ParameterInfo[parameters.Length];
    Array.Copy(parameters, ret, parameters.Length);
    return ret;
}

因此,没有锁定,也没有任何会阻止m_parameters被竞争线程覆盖的内容。

更新:这是GetParameters中的相关代码:args[position] = new RuntimeParameterInfo(sig, scope, tkParamDef, position, attr, member); 明显,在这种情况下,RuntimeParameterInfo只是它构造函数中给出的参数的容器。甚至从来没有打算获取相同的实例。

这与TypeInfo不同,后者继承自Type并实现IReflectableType,对于其GetTypeInfo方法,它只返回自身作为IReflectableType,因此保持类型的相同实例。


1
没错,但这里被哈希的不是参数数组 -- 我也曾经短暂地感到困惑。被哈希的是参数数组的内容。但我敢打赌,参数信息生成代码同样具有懒惰逻辑,而且不是线程安全的。而且没有理由让它成为线程安全的;最坏的情况是你得到两个具有相同内容的实例,其中一个被孤立了。 - Eric Lippert
无论行为的确切机制如何,显然反射引擎不会对其返回的对象的引用身份做出任何承诺,因此,依赖于该身份是一个坏主意。 - Eric Lippert
从我的(以及显然MEF2的)观点来看,这里的问题在于在单线程场景中,您可以100%依赖反射对象的引用完整性。 对我来说,这似乎是框架中的一个疏忽,但我知道什么呢。 这就是为什么我在这里提出问题并寻找确切答案的原因。 :) - Mike-E
此外,在文档中,ConstructorInfo 被标记为线程安全,因此它及其所有成员应该保护其资源并确保线程安全,对吗? - Mike-E
@Mike-EEE 不行,你不能依赖它,因为这不是一种记录下来的行为。运行时随时可以更改该行为。在单线程上下文中,某个实现恰好只返回一个实例的事实并不意味着你可以依赖它。 - Servy
好的,我显然在这里被教训了。看起来我正在与自己对这个问题的无知和Siderite在这个答案中突出的不一致行为作斗争。也就是说,似乎Type/TypeInfo保持线程安全的引用完整性(这是我要找的术语吗?),而ParameterInfo则没有(即使在第一次完成调用后它也没有--这使得它更加令人困惑!)。 - Mike-E

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接