在Microsoft的CLR中,异步方法调用的ref值类型参数存储在哪里?

5
我知道这是一项实现细节。但我真的很好奇微软CLR中的这个实现细节是什么。
虽然我没在大学里学过计算机科学,但我的理解是实现在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的作用域内)存储在哪里?这是如何工作的?它被装箱了吗?如果没有,即使是值类型,它是否现在就会受到垃圾回收的影响?或者内存可以立即被回收?

对于这个冗长的问题我感到抱歉。但即使答案非常简单,也可能对那些将来也会想到同样问题的人有所启发。


http://blogs.msdn.com/b/ericlippert/archive/2010/09/30/the-truth-about-value-types.aspx - Brian
3个回答

4

我认为当你使用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参数仍然按引用传递。虽然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参数——因为它只需要它们的值……而不是它们的标识(因为它永远不会将任何东西分配回它们)。但是,如果例如BeginInvoke()调用需要一个intEndInvoke()调用需要一个ref int,那么这将非常奇怪。现在,可能存在让开始/结束调用具有相同签名的技术原因,但我认为清晰度和对称性的好处足以证明这种设计。

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

如果C#团队中的某个人(如果他们看到这个问题)能够提供有关该功能背后的详细信息和设计选择的更多见解,那就更好了。


哇,太棒了。我甚至没有想到尝试这个(实际上,我猜测如果我从 A 的框架内调用 EndInvoke,它会使我的发现无效,因为我不确定的整个问题是当 A 的框架不再可用时如何存储 ref 参数)!不过很有趣;这似乎澄清了一个困惑点(ref 参数显然并不指向原始变量的位置),但换来了另一个困惑(那么传递给 BeginInvokeref 参数根本不是 ref 参数吗?)。 - Dan Tao
@Dan Tao:正如我上面提到的,Reflector显示IL表明BeginInvoke()中的ref参数确实是按引用传递的。然而,我怀疑在内部,BeginInvoke()将值复制到了IAsyncResult对象中,并将副本按引用传递给B()。最终,只有在调用EndInvoke()时选择传递x以外的变量时,A()才能观察到这种不一致性。 - LBushkin
是的,正如汉斯在他更新的答案中提到的(如果我理解正确),BeginInvoke 调用会给出一个 ref 参数,该参数指向原始变量的 副本 的位置。实际上,我还使用了一个非局部变量进行了测试 -- 一个实例字段 -- 并看到了相同的行为(因此它不仅仅是在这个人为的例子中才有意义的行为):将该字段作为 ref 参数传递给 BeginInvoke 调用实际上并没有改变该字段的值。 - Dan Tao
(LBushkin请我对这个回答提出意见。)虽然我不是这个领域的专家,但我认为你的分析很有道理。据我所知,在这种情况下,跨线程的 marshaller 执行的是复制-插入-复制-出的语义,因为保持对原始变量的引用显然是不安全的,正如原帖作者所指出的那样。 - Eric Lippert
@Eric Lippert:谢谢Eric。有一个方面仍然不清楚,那就是为什么Begin/EndInvoke是本地方法而不是托管方法。这可能只是因为CLR提供了它们的实现。 - LBushkin

3
CLR在这方面完全不参与,它的工作是由JIT编译器生成适当的机器码以使参数通过引用传递。这本身就是一种实现细节,不同的机器架构有不同的编译器。
但是常见的编译器和C程序员做的一样,它们传递一个指向变量的指针。该指针通过CPU寄存器或堆栈帧传递,具体取决于该方法需要多少个参数。
变量所在的位置无关紧要,调用者堆栈帧中变量的指针与存储在堆上的引用类型对象的成员的指针一样有效。垃圾回收器通过指针值知道它们之间的区别,并在移动对象时必要时调整指针。
您的代码片段在.NET框架内部调用了必要的魔法,以使从一个线程到另一个线程的调用生效。这与Remoting使用的是相同的基础设施。为了进行这样的调用,必须在执行调用的线程上创建一个新的堆栈帧。远程处理代码使用委托的类型定义来确定该堆栈帧应该是什么样子。它可以处理通过引用传递的参数,知道需要在栈帧中分配一个插槽来存储指向变量的指针,例如您的 i 。BeginInvoke调用初始化了远程堆栈帧中 i 变量的副本。
在EndInvoke()调用上也会发生同样的事情,结果从线程池线程的堆栈帧中复制回来。关键点是实际上没有指向 i 变量的指针,只有指向它的副本的指针。
不太确定这个答案是否非常清晰,对CPU工作原理和一些C知识有一定的了解,因此指针的概念应该很清楚。

1
我认为OP的示例是这样构建的,以至于A()的堆栈帧不再可用。因此,问题在于变量如何通过引用传递给异步方法。 - LBushkin
@Novox,这个有争议。但它是代码库中非常独特的一部分。而且是单独的DLL。还有微软公司专门负责它的团队。 - Hans Passant
非常有趣!因此,在BeginInvoke/EndInvoke异步调用的上下文中,ref参数更像是一个“混合体”,因为该值作为副本传递给BeginInvoke,然后由BeginInvoke通过引用将该副本传递给EndInvoke。我理解得对吗? - Dan Tao
对于“混合”我不是很确定,这是将值从一个堆栈帧复制到另一个堆栈帧。逻辑上类似于lambda捕获,但实现细节非常不同。 - Hans Passant
好的,从你找到的东西开始工作。当变量消失时,它怎么可能传递指向本地变量的指针呢?实际上不是这样的,它传递的是指向另一个变量的指针,即一个副本。这意味着指针具有不同的值。它必须以这种方式工作,因为不同的线程有不同的堆栈。 - Hans Passant
显示剩余3条评论

2
看一下使用反射器生成的代码。我的猜测是生成一个包含x的匿名类,就像使用闭包(引用当前堆栈帧中变量的lambda表达式)时一样。不要担心这个,看其他答案就好了。

这似乎不是事实。请查看我的答案以获取更多细节。 - LBushkin

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