在结构体方法中将“this”存储为本地变量有什么优势?

37

今天我正在浏览.NET Core源代码树,发现在System.Collections.Immutable.ImmutableArray<T>中有这个模式

T IList<T>.this[int index]
{
    get
    {
        var self = this;
        self.ThrowInvalidOperationIfNotInitialized();
        return self[index];
    }
    set { throw new NotSupportedException(); }
}

这种模式(在本地变量中存储this)似乎在该文件中一贯地应用于同一方法中多次引用this的情况,但当它只被引用一次时则不然。因此我开始思考采用这种方式可能具有的相对优势;在我看来,这种方式的优势很可能与性能相关,因此我进一步探索了这个问题……也许我忽略了其他方面的问题。
发出“将this存储在本地”模式的CIL似乎看起来像是一个ldarg.0,然后是ldobj UnderlyingType,然后是stloc.0,以便稍后的引用来自于ldloc.0而不是像使用多个this那样的裸ldarg.0
也许ldarg.0ldloc.0慢得多,但不足以让C#到CIL的转换或JITter寻找优化机会来为我们优化它,因此在结构体实例方法中发出两个ldarg.0指令时,编写这种看起来奇怪的模式在C#代码中更有意义。更新:或者,您知道,我可以查看该文件顶部的注释,其中explain了正在发生的事情...
2个回答

20

正如你已经注意到的一样,System.Collections.Immutable.ImmutableArray<T> 是一个 结构体

public partial struct ImmutableArray<T> : ...
{
    ...

    T IList<T>.this[int index]
    {
        get
        {
            var self = this;
            self.ThrowInvalidOperationIfNotInitialized();
            return self[index];
        }
        set { throw new NotSupportedException(); }
    }

    ...
var self = this; 创建了一个指向this所引用的结构体的副本。为什么需要这样做呢?这个结构体的源代码注释解释了为什么需要这样做:

/// 此类型应是线程安全的。作为结构体,它无法保护自己的字段
/// 免受其他线程在执行其成员时从另一个线程更改它们的影响
/// 因为结构体可以通过重新分配包含此结构体的字段来进行原地更改。因此非常重要的是:
/// **每个成员只能对此进行一次取消引用**。
/// 如果成员需要引用数组字段,则将其视为对此的取消引用。
/// 调用其他实例成员(属性或方法)也被视为取消引用此项。
/// 任何需要多次使用此项的成员都必须将此项目分配给局部变量,并在余下的代码中使用该变量代替此项。
/// 这实际上将结构体中的一个字段复制到局部变量中,以便
/// 它与其他线程隔离。

简而言之,如果可能有其他线程正在更改结构体的字段或在执行get方法时更改结构体(例如通过重新分配此结构体类型的类成员字段),从而可能导致不良副作用,则需要在处理它之前先制作一个(本地)结构体的副本。
更新:还请阅读supercats答案,该答案详细解释了必须满足哪些条件才能使像制作结构体的局部副本(即var self = this;)这样的操作具有线程安全性,以及如果不满足这些条件会发生什么。

2
@JoeAmenta,请阅读该数据类型的源代码注释(“此类型应该是线程安全的。作为结构体,它无法保护其自身字段免受在其他线程上执行其成员时从一个线程更改,因为结构体可以通过重新分配包含此结构体的字段来“原地”更改”)。还要注意,您正在查看的类可能并不总是不可变的(假设它确实是不可变的,根据源代码注释似乎并非如此),而您看到的代码只是旧版本的“剩余物”……谁知道呢…… - user2819245
1
@JoeAmenta,已完成,我刚刚注意到源代码注释实际上包含了相当详尽的解释。当我写下我的最后一条评论时,我只是匆匆浏览了一下源代码注释而没有仔细看 :) - user2819245
1
@JoeAmenta,这个链接的技巧很不错。我不知道这是可能的(我只是复制了你的问题中的链接而没有查看它)。我会在我的答案中更新链接。但我也会保留答案中的引用(因为这需要我付出很多努力 :p),这样人们就可以在不需要跟随链接的情况下阅读它... - user2819245
2
@elgonzo 的意思是,即使一个不可变的 struct,也可能不是线程安全的,因为整个 struct 可能会被原地修改。 - xanatos
1
@elgonzo的一个简单示例:http://ideone.com/fDnKz9,以及使用源代码建议的“正确”版本:http://ideone.com/tbYeX6。 - xanatos
显示剩余12条评论

10

如果 .NET 中结构实例的基础存储位置是可变的,那么结构实例始终是可变的;如果基础存储位置是不可变的,则结构实例始终是不可变的。虽然结构类型可能“假装”是不可变的,但是 .NET 允许任何可以写入其所在存储位置的东西修改结构类型实例,而结构类型本身对此毫无发言权。

因此,如果有一个结构体:

struct foo {
  String x;
  override String ToString() {
    String result = x;
    System.Threading.Thread.Sleep(2000);
    return result & "+" & x;
  }
  foo(String xx) { x = xx; }
}

如果有两个线程使用相同类型为foo[]的数组myFoos,并且调用以下方法:

myFoos[0] = new foo(DateTime.Now.ToString());
var st = myFoos[0].ToString();
可能出现这样一种情况,无论哪个线程首先启动,其ToString()值都会报告其构造函数调用时写入的时间和另一个线程的构造函数调用报告的时间,而不是报告相同的字符串两次。 对于其目的是验证结构字段并随后使用它的方法,在验证和使用之间更改字段将导致该方法使用未经验证的字段。 复制结构的字段内容(通过仅复制字段或通过复制整个结构)可以避免该危险。
请注意,对于包含Int64UInt64Double类型字段或包含多个字段的结构,可能会发生像var temp=this;这样的语句,而另一个线程正在覆盖存储this的位置,可能最终复制一个结构,其中包含旧内容和新内容的任意混合物。 仅当结构包含引用类型的单个字段或32位或更小原始类型的单个字段时,才保证同时进行读写的读取将产生结构实际拥有的某个值,即使如此也可能存在一些怪癖(例如,在VB.NET中,像someField = New foo("george")这样的语句可能在调用构造函数之前清除someField)。

在.NET中,如果底层存储位置是可变的,则结构实例始终是可变的;如果底层存储位置是不可变的,则结构实例始终是不可变的。有时候应该用天上的火焰文字来写东西,这样任何人都能读懂。今天我第一次发现一个不可变的“struct”可能不是线程安全的……如果我足够思考,就会明白……如果多个线程同时读写它,那么“int64”可能会出现撕裂(高位和低位不同时被修改)……所以任何“struct”都可能受到影响。 - xanatos
@xanatos:包含单个引用类型字段或单个“short”原始类型字段的结构将免于撕裂。在许多情况下,当需要包装对可变类型的不可变实例的单个引用时,结构体可能比类提供更好的性能和更好的语义[例如,如果BigInteger是一个具有类型为int []的单个字段的结构体(使用数组的前几个项目来保存诸如缓存哈希值等内容),那么default(BigInteger)可以作为零值而不是空引用。] - supercat
是的...撕裂是我们在这里讨论的问题的一个交叉类问题...但最终仍然很奇怪,你甚至不能确定int.GetHashcode()是否线程安全...(它是线程安全的,因为它只是return this),但short.GetHashCode可能不是,因为它是:return (int)((ushort)this) | (int)this << 16; - xanatos
1
@AndrewArnott:使用交错读取只能提供关于交错写入的线程安全性。它不能提供关于“普通”写入的线程安全性。一个可变结构体可以持有一个Int64,并允许线程安全的读写,前提是没有其他线程在复制该结构体时尝试复制它。这种线程安全性无法通过“不可变”结构体实现,因为改变其内容的唯一方法将是使用非交错结构复制操作。 - supercat
@AndrewArnott: 我希望C#可以使用一个标记来访问类字段并调用成员函数,其中'this'通过值传递,并使用不同的标记来访问结构体字段和调用成员函数,其中'this'作为'ref'传递,以便对可变异的结构体方法的调用更加显眼,并且只允许在实际有用的情况下进行;使一些类成员接受'this'作为'ref'也很有用,比如委托(因此例如someDelegate.:Combine(otherDelegate)将是一种线程安全的组合委托的方式)。 - supercat
显示剩余2条评论

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