为什么在调用EndAwait之前生成的代码中将内部“状态”设置为0?(C# 5异步CTP)

198

昨天我做了一场关于新的C#“async”功能的讲座,特别是深入研究生成的代码以及GetAwaiter()/ BeginAwait() / EndAwait()调用。

我们详细研究了由C# 编译器生成的状态机,并有两个方面我们无法理解:

  • 为什么生成的类包含一个Dispose()方法和一个$__disposing变量,它们似乎从未被使用(并且该类不实现IDisposable)。
  • 为什么在任何对EndAwait()的调用之前,内部的state变量都被设置为0,而通常0表示“这是初始入口点”。

我怀疑第一个问题可能可以通过在async方法中执行更有趣的操作来回答,但如果有人有进一步的信息,我会很高兴听到。然而,这个问题更多地涉及第二个点。

下面是一个非常简单的示例代码:

using System.Threading.Tasks;

class Test
{
    static async Task<int> Sum(Task<int> t1, Task<int> t2)
    {
        return await t1 + await t2;
    }
}

以下是实现状态机的MoveNext()方法所生成的代码。这段代码是从Reflector直接复制的,我没有更改不可描述的变量名称:

public void MoveNext()
{
    try
    {
        this.$__doFinallyBodies = true;
        switch (this.<>1__state)
        {
            case 1:
                break;

            case 2:
                goto Label_00DA;

            case -1:
                return;

            default:
                this.<a1>t__$await2 = this.t1.GetAwaiter<int>();
                this.<>1__state = 1;
                this.$__doFinallyBodies = false;
                if (this.<a1>t__$await2.BeginAwait(this.MoveNextDelegate))
                {
                    return;
                }
                this.$__doFinallyBodies = true;
                break;
        }
        this.<>1__state = 0;
        this.<1>t__$await1 = this.<a1>t__$await2.EndAwait();
        this.<a2>t__$await4 = this.t2.GetAwaiter<int>();
        this.<>1__state = 2;
        this.$__doFinallyBodies = false;
        if (this.<a2>t__$await4.BeginAwait(this.MoveNextDelegate))
        {
            return;
        }
        this.$__doFinallyBodies = true;
    Label_00DA:
        this.<>1__state = 0;
        this.<2>t__$await3 = this.<a2>t__$await4.EndAwait();
        this.<>1__state = -1;
        this.$builder.SetResult(this.<1>t__$await1 + this.<2>t__$await3);
    }
    catch (Exception exception)
    {
        this.<>1__state = -1;
        this.$builder.SetException(exception);
    }
}

这里有很长的代码,但是对于这个问题来说重要的行数是这些:

// End of awaiting t1
this.<>1__state = 0;
this.<1>t__$await1 = this.<a1>t__$await2.EndAwait();

// End of awaiting t2
this.<>1__state = 0;
this.<2>t__$await3 = this.<a2>t__$await4.EndAwait();
在这两种情况下,状态在下一次明显观察之前都会再次更改...那么为什么要将其设置为0呢?如果此时再次调用MoveNext()(直接或通过Dispose),它实际上会重新启动异步方法,据我所知这完全不合适...如果MoveNext()没有被调用,则状态的更改是无关紧要的。

这是否只是编译器在重用迭代器块生成代码以用于异步时的副作用,其中可能有一个更明显的解释?

重要免责声明

显然这只是 CTP 编译器。我完全希望在最终发布之前 - 可能甚至在下一个 CTP 发布之前 - 事情会发生变化。本问题并不试图声称这是 C# 编译器的缺陷或类似的任何内容。我只是想弄清楚是否有我错过的微妙原因 :)


7
VB编译器生成类似的状态机(不确定是否符合预期,但VB之前没有迭代器块)。 - Damien_The_Unbeliever
5
我认为答案是:这是一个CTP。对团队来说,最重要的是推出这个语言设计验证工具,并且他们做得非常快。你应该期待已发布的实现(编译器而不是MoveNext)有很大的不同。我认为Eric或Lucian会回答说这里没有什么深入的东西,只是一个行为/错误,在大多数情况下并不重要,没有人注意到。因为这是一个CTP。 - Chris Burrows
2
@Stilgar:我刚用ildasm检查了一下,它确实是这样做的。 - Jon Skeet
1
@Chris:顺便说一句,你的声望目前是(2011年2月18日上午8:55 EST)666。 - Jeff Yates
3
请注意,没有人会点赞这些答案。99%的人根本无法判断答案是否正确。 - the_drow
显示剩余16条评论
4个回答

71
好的,我终于有了一个真正的答案。我自己稍微琢磨了一下,但只是在 VB 部分团队的 Lucian Wischik 确认确实有很好的理由之后才弄明白的。非常感谢他 - 请访问他的博客(在archive.org上),非常棒。
这里的值0只是特殊的,因为它不是一个正常情况下你可能会遇到的有效状态,而且它也不是状态机可能在其他地方测试的状态。我相信使用任何非正值都可以工作得很好:-1没有被用于此,因为从逻辑上讲,-1通常表示“完成”。我可以说我们正在给状态0赋予额外的含义,但最终并不重要。这个问题的关键是找出为什么要设置状态。
如果await以捕获的异常结束,则该值是相关的。我们可能会再次回到同一个await语句,但我们不能处于意味着“我正要从那个await返回”的状态,否则所有种类的代码都会被跳过。通过一个例子来展示这是最简单的。请注意,我现在正在使用第二个CTP,因此生成的代码与问题中略有不同。
这是异步方法:
static async Task<int> FooAsync()
{
    var t = new SimpleAwaitable();
    
    for (int i = 0; i < 3; i++)
    {
        try
        {
            Console.WriteLine("In Try");
            return await t;
        }                
        catch (Exception)
        {
            Console.WriteLine("Trying again...");
        }
    }
    return 0;
}

从概念上讲,SimpleAwaitable 可以是任何可等待对象 - 也许是一个任务,也许是其他东西。为了我的测试目的,它总是返回 IsCompleted 的 false,并在 GetResult 中抛出异常。

这是 MoveNext 的生成代码:

public void MoveNext()
{
    int returnValue;
    try
    {
        int num3 = state;
        if (num3 == 1)
        {
            goto Label_ContinuationPoint;
        }
        if (state == -1)
        {
            return;
        }
        t = new SimpleAwaitable();
        i = 0;
      Label_ContinuationPoint:
        while (i < 3)
        {
            // Label_ContinuationPoint: should be here
            try
            {
                num3 = state;
                if (num3 != 1)
                {
                    Console.WriteLine("In Try");
                    awaiter = t.GetAwaiter();
                    if (!awaiter.IsCompleted)
                    {
                        state = 1;
                        awaiter.OnCompleted(MoveNextDelegate);
                        return;
                    }
                }
                else
                {
                    state = 0;
                }
                int result = awaiter.GetResult();
                awaiter = null;
                returnValue = result;
                goto Label_ReturnStatement;
            }
            catch (Exception)
            {
                Console.WriteLine("Trying again...");
            }
            i++;
        }
        returnValue = 0;
    }
    catch (Exception exception)
    {
        state = -1;
        Builder.SetException(exception);
        return;
    }
  Label_ReturnStatement:
    state = -1;
    Builder.SetResult(returnValue);
}

我必须移动Label_ContinuationPoint才能使它成为有效代码 - 否则它不在goto语句的范围内 - 但这不影响答案。

想想当GetResult抛出异常时会发生什么。我们将通过catch块,增加i,然后再次循环(假设i仍小于3)。我们仍处于GetResult调用之前的任何状态......但是当我们进入try块时,我们必须打印"In Try"并再次调用GetAwaiter......只有当状态不为1时才会执行该操作。如果没有state = 0赋值,它将使用现有的awaiter并跳过Console.WriteLine调用。

这是一段相当曲折的代码,但这正是团队需要考虑的事情。我很高兴我不负责实现这个 :)


9
@Shekhar_Pro:是的,这是一个goto语句。你应该预计在自动生成的状态机中会看到很多goto语句 :) - Jon Skeet
12
在手写代码中,这会让代码难以阅读和跟踪。不过,自动生成的代码很少有人会去阅读,除了像我这样反编译的傻瓜 :) - Jon Skeet
当我们在异常之后再次等待时会发生什么呢?我们会重新开始吗? - configurator
1
@configurator:它在可等待对象上调用GetAwaiter,这正是我所期望的。 - Jon Skeet
goto语句并不总是会让代码难以阅读。事实上,有时候甚至使用它们也是有意义的(我知道这是亵渎)。例如,有时您可能需要中断多个嵌套循环。goto的一个较少使用的特性(在我看来也更丑陋)是使switch语句级联。另外,我记得有一天,goto语句是某些编程语言的主要支柱,因此我完全理解为什么仅提到goto就会让开发人员感到恐惧。如果使用不当,它们可能会使事情变得丑陋。 - Ben Lesh

5

如果将其保持为1(第一种情况),则会在没有调用BeginAwait的情况下收到对EndAwait的调用。如果保持为2(第二种情况),您将在另一个等待者上获得相同的结果。

我猜想,如果已经启动了BeginAwait,则调用BeginAwait会返回false(这是我的猜测),并保留原始值以在EndAwait时返回。如果是这种情况,它将正常工作,而如果您将其设置为-1,您可能会在第一种情况下有未初始化的this.<1>t__$await1。

然而,这假设BeginAwaiter不会在第一次之后的任何调用中实际启动操作,并且在这些情况下返回false。启动当然是不可接受的,因为它可能具有副作用或仅给出不同的结果。它还假定EndAwaiter始终返回相同的值,无论调用多少次,并且可以在BeginAwait返回false时调用(根据上述假设)

这似乎是防止竞态条件的保护措施 如果我们内联调用movenext的语句,使其由不同的线程在状态=0之后调用,则会看起来像以下内容

this.<a1>t__$await2 = this.t1.GetAwaiter<int>();
this.<>1__state = 1;
this.$__doFinallyBodies = false;
this.<a1>t__$await2.BeginAwait(this.MoveNextDelegate)
this.<>1__state = 0;

//second thread
this.<a1>t__$await2 = this.t1.GetAwaiter<int>();
this.<>1__state = 1;
this.$__doFinallyBodies = false;
this.<a1>t__$await2.BeginAwait(this.MoveNextDelegate)
this.$__doFinallyBodies = true;
this.<>1__state = 0;
this.<1>t__$await1 = this.<a1>t__$await2.EndAwait();

//other thread
this.<1>t__$await1 = this.<a1>t__$await2.EndAwait();

如果以上假设正确,则会进行一些不必要的工作,例如获取sawiater并重新将相同的值分配给<1>t__$await1。如果状态保持为1,则最后一部分应该如下所示:

//second thread
//I suppose this un matched call to EndAwait will fail
this.<1>t__$await1 = this.<a1>t__$await2.EndAwait();

如果将其设置为2,状态机会假设它已经获得了第一个操作的值,这是不正确的,并且可能会使用未分配的变量来计算结果。


请记住,在将状态赋值为0和将其赋值为更有意义的值之间,实际上并没有使用状态。如果它旨在防止竞态条件,我希望有其他值来指示,例如-2,并在MoveNext的开头进行检查以检测不适当的使用。请记住,一个单一的实例永远不应该被两个线程同时使用 - 它旨在给出一个单一同步方法调用的幻象,每隔一段时间就会“暂停”。 - Jon Skeet
@Jon 我同意在异步情况下不应该存在竞态条件的问题,但在迭代块中可能会出现问题,也可能是遗留问题。 - Rune FS
@Tony:我想我会等到下一个CTP或beta版本发布,然后检查一下那个行为。 - Jon Skeet

1

这可能与堆叠/嵌套的异步调用有关吗?

i.e:

async Task m1()
{
    await m2;
}

async Task m2()
{
    await m3();
}

async Task m3()
{
Thread.Sleep(10000);
}

在这种情况下,movenext委托会被调用多次吗?

只是一个猜测而已?


在这种情况下,将会生成三个不同的类。每个类都会调用一次MoveNext()方法。 - Jon Skeet

0

实际状态的解释:

可能的状态:

  • 0 初始化(我这么认为)或者等待操作结束
  • >0 刚刚调用了MoveNext,选择下一个状态
  • -1 结束

这个实现是否只是为了确保如果在等待期间发生了来自任何地方的另一个MoveNext调用,它将重新评估整个状态链,从头开始重新评估可能已经过时的结果?


但是为什么它要从头开始呢?那几乎肯定不是你实际想要发生的事情 - 你希望抛出一个异常,因为没有其他东西应该调用MoveNext。 - Jon Skeet

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