【问题标题】:Where are ref value type parameters stored for asynchronous method calls in Microsoft's CLR?Microsoft 的 CLR 中为异步方法调用存储的 ref 值类型参数在哪里?
【发布时间】:2010-10-13 14:02:25
【问题描述】:

我了解这是一个实施细节。我其实很好奇微软 CLR 中的实现细节

现在,请耐心等待,因为我没有在大学学习 CS,所以我可能错过了一些基本原则。

但我认为,我对今天在 CLR 中实现的“堆栈”和“堆”的理解是可靠的。例如,我不会做出一些不准确的概括性陈述,例如“值类型存储在堆栈上”。但是,在最常见的场景中——值类型的普通本地变量,作为参数传递或在方法中声明而不包含在闭包中——值类型变量存储在堆栈中(再次,在 Microsoft 的 CLR 中)。

我想我不确定ref 值类型参数的来源。

最初我的想法是,如果调用堆栈看起来像这样(左 = 下):

A() -> B() -> C()

...那么在 A 范围内声明并作为 ref 参数传递给 B 的局部变量仍然可以存储在堆栈中——可以不是吗? B 只需要该局部变量存储在 A 的框架内的内存位置(如果这不是正确的术语,请原谅我;我想我很清楚无论如何)。

然而,当我想到我可以做到这一点时,我意识到这不可能完全正确:

delegate void RefAction<T>(ref T arg);

void A()
{
    int x = 100;

    RefAction<int> b = B;

    // This is a non-blocking call; A will return immediately
    // after this.
    b.BeginInvoke(ref x, C, null);
}

void B(ref int arg)
{
    // Putting a sleep here to ensure that A has exited by the time
    // the next line gets executed.
    Thread.Sleep(1000);

    // Where is arg stored right now? The "x" variable
    // from the "A" method should be out of scope... but its value
    // must somehow be known here for this code to make any sense.
    arg += 1;
}

void C(IAsyncResult result)
{
    var asyncResult = (AsyncResult)result;
    var action = (RefAction<int>)asyncResult.AsyncDelegate;

    int output = 0;

    // This variable originally came from A... but then
    // A returned, it got updated by B, and now it's still here.
    action.EndInvoke(ref output, result);

    // ...and this prints "101" as expected (?).
    Console.WriteLine(output);
}

那么在上面的示例中,x(在 A 的范围内)存储在哪里?这是如何工作的?是盒装的吗?如果不是,尽管它是一种值类型,但它现在是否需要进行垃圾收集?还是可以立即回收内存?

对于这个冗长的问题,我深表歉意。但是,即使答案很简单,也许这对于那些发现自己在未来想知道同样事情的其他人来说是有用的。

【问题讨论】:

标签: .net reference heap-memory stack-memory value-type


【解决方案1】:

我不相信当您将 BeginInvoke()EndInvoke()refout 参数一起使用时,您真正通过引用传递变量。 我们必须使用ref 参数调用EndInvoke(),这一事实应该是一个线索。

让我们改变你的例子来演示我描述的行为:

void A()
{
    int x = 100;
    int z = 400;

    RefAction<int> b = B;

    //b.BeginInvoke(ref x, C, null);
    var ar = b.BeginInvoke(ref x, null, null);
    b.EndInvoke(ref z, ar);

    Console.WriteLine(x);  // outputs '100'
    Console.WriteLine(z);  // outputs '101'
}

如果您现在检查输出,您将看到x 的值实际上没有改变。但z 确实现在包含更新值。

我怀疑当您使用异步 Begin/EndInvoke 方法时,编译器会通过 ref 改变传递变量的语义。

查看此代码生成的 IL 后,BeginInvoke()ref 参数似乎仍然传递给 by ref。虽然 Reflector 没有显示此方法的 IL,但我怀疑它根本不会将参数作为 ref 参数传递,而是在幕后创建一个单独的变量以传递给 B()。然后,当您调用 EndInvoke() 时,您必须再次提供 ref 参数以从异步状态中检索值。这些参数很可能实际上存储为IAsyncResult 对象的一部分(或与之结合),该对象是最终检索它们的值所必需的。

让我们思考一下为什么这种行为可能会以这种方式工作。当您对方法进行异步调用时,您是在单独的线程上执行此操作的。该线程有自己的堆栈,因此不能使用典型的别名机制ref/out 变量。但是,为了从异步方法中获取任何返回值,您最终需要调用EndInvoke() 来完成操作并检索这些值。但是,对EndInvoke() 的调用与对BeginInvoke() 的原始调用或方法的实际主体一样容易发生在完全不同的线程上。显然,调用堆栈不是存储此类数据的好地方 - 特别是因为一旦异步操作完成,用于异步调用的线程可能会重新用于不同的方法。因此,需要一些除堆栈之外的机制来“编组”来自被调用方法的返回值和 out/ref 参数,并将它们返回到最终将使用它们的站点。

我相信这个机制(在 Microsoft .NET 实现中)是IAsyncResult 对象。事实上,如果您在调试器中检查IAsyncResult 对象,您会注意到在非公共成员中存在_replyMsg,其中包含Properties 集合。此集合包含 __OutArgs__Return 等元素,其数据似乎反映了它们的同名。

编辑: 这是我想到的关于异步委托设计的理论。似乎BeginInvoke() 的签名很可能和EndInvoke() 被选择为彼此尽可能相似,以避免混淆并提高清晰度。 BeginInvoke() 方法实际上 需要 接受 ref/out 参数 - 因为它只需要它们的值......而不是它们的标识(因为它永远不会将任何东西分配给它们)。但是,如果有一个接受intBeginInvoke() 调用和一个接受ref intEndInvoke() 调用,那就太奇怪了(例如)。现在,开始/结束调用应该具有相同的签名可能有技术原因 - 但我认为清晰和对称的好处足以验证这样的设计。

当然,所有这些都是 CLR 和 C# 编译器的实现细节,将来可能会发生变化。然而,有趣的是,存在混淆的可能性——如果您期望传递给BeginInvoke() 的原始变量实际上会被修改。它还强调了调用EndInvoke() 来完成异步操作的重要性。

也许 C# 团队的某个人(如果他们看到这个问题)可以提供更多关于此功能背后的细节和设计选择的见解。

【讨论】:

  • 哇,很棒的测试。我什至没有想过尝试这样做(实际上,我想我假设如果我在 A 的框架内调用EndInvoke,这将使我的发现无效,因为我不确定的整个问题是,一旦 A 的框架不再可用,ref 参数如何存储)!不过,这很有趣;这似乎消除了一个混淆点(ref 参数显然不指向原始变量的位置)以换取另一个(因此传递给 BeginInvokeref 参数并不是真正的 @987654364 @ 参数?)。
  • @Dan Tao:正如我上面提到的,来自反射器的 IL 表明 refBeginInvoke() 的参数确实是通过 ref 传递的。但是,我怀疑BeginInvoke() 在内部将值复制到IAsyncResult 对象中,并将副本by ref 传递给B()。最终,只有A() 可以观察到这里的不一致,如果它在调用EndInvoke() 时选择传递x 以外的变量。
  • 是的,正如 Hans 在他更新的答案中提到的那样(如果我理解正确的话),BeginInvoke 调用被赋予了一个指向 copy 位置的 ref 参数i> 的原始变量。我还用一个非局部变量(实际上是一个实例字段)对此进行了测试,并看到了相同的行为(因此在这个人为的示例中,不仅仅是行为很重要):将该字段作为ref 传递BeginInvoke 调用的参数实际上并没有改变字段的值。
  • (LBushkin 询问我对此答案的意见。)我不是这方面的专家,但我相信您的分析是合理的。我的理解是,在这种情况下,跨线程编组器会执行 copy-in-copy-out 语义,因为维护对原始变量的引用显然是不安全的,正如原始海报所指出的那样。
  • @Eric Lippert:谢谢 Eric。我仍然不清楚的一个方面是为什么 Begin/EndInvoke 是本机方法,而不是托管方法。这可能仅仅是因为 CLR 提供了它们的实现。
【解决方案2】:

CLR 完全不在循环中,JIT 编译器的工作是生成适当的机器代码以获取通过引用传递的参数。这本身就是一个实现细节,不同的机器架构有不同的抖动。

但是常见的完全按照 C 程序员的方式来做,它们传递一个指向变量的指针。该指针在 CPU 寄存器或堆栈帧中传递,具体取决于方法采用多少参数。

变量所在的位置无关紧要,指向调用者堆栈帧中变量的指针与指向存储在堆上的引用类型对象成员的指针一样有效。垃圾收集器通过指针值知道它们之间的区别,并在移动对象时根据需要调整指针。

您的代码 sn-p 调用了 .NET 框架内的魔法,这是从一个线程到另一个工作的编组调用所需的。这与使远程处理工作的管道相同。要进行这样的调用,必须在执行调用的线程上创建一个新的堆栈帧。远程处理代码使用委托的类型定义来了解堆栈帧应该是什么样子。它可以处理通过引用传递的参数,它知道它需要在堆栈帧中分配一个插槽来存储指向的变量,i 在你的情况下。 BeginInvoke 调用初始化远程堆栈帧中 i 变量的副本。

同样的事情发生在 EndInvoke() 调用上,结果从线程池线程的堆栈帧中复制回来。关键是实际上并没有指向 i 变量的指针,而是指向它的副本的指针。

不太确定这个答案是否非常清楚,对 CPU 的工作原理和一点 C 知识有一些了解,因此指针的概念是水晶可以提供很大帮助。

【讨论】:

  • 我认为 OPs 示例的构造使得A() 的堆栈帧不再可用。因此,变量如何通过 ref 传递给异步方法的问题。
  • JITter 不是被认为是公共语言运行时的一部分吗(它本身是 CLI 虚拟执行系统的实现,参见 Ecma-335 §12)?
  • @Novox,好吧,值得商榷。但它是代码库中一个非常不同的代码块。以及单独的 DLL。并且在 MSFT 有独立的团队来处理它。
  • 非常有趣!因此,BeginInvoke/EndInvoke 异步调用上下文中的 ref 参数似乎更像是一个“混合”,因为该值作为 copy 传递给 @ 987654325@,然后通过 reference 将该 copy 传递给EndInvoke。我理解正确吗?
  • 不太确定“混合”,这是从一个堆栈帧复制到另一个堆栈帧的值。从逻辑上讲,它类似于 lambda 捕获,但实现细节非常不同。
【解决方案3】:

查看使用反射器生成的代码以找出答案。 我的猜测是生成了一个包含 x 的匿名类,例如当您使用闭包(引用当前堆栈帧中的变量的 lambda 表达式)时。 忘记这一点,阅读其他答案。

【讨论】:

  • 情况似乎并非如此。有关详细信息,请参阅我的答案。
猜你喜欢
  • 2015-01-25
  • 1970-01-01
  • 1970-01-01
  • 2021-07-03
  • 2015-08-19
  • 2011-10-12
  • 1970-01-01
  • 2012-01-04
相关资源
最近更新 更多