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


195

昨天,我在谈论新的C#“异步”功能,特别是深入研究了生成的代码和the GetAwaiter()/ BeginAwait()/ EndAwait()调用。

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

  • 为什么生成的类包含一个Dispose()方法和一个$__disposing变量,而它们似乎从未被使用过(而该类未实现IDisposable)。
  • 为什么在state调用之前将内部变量设置为0 EndAwait(),通常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)再次被调用,它将有效地再次启动async方法,据我所知,如果MoveNext() 被调用,则状态改变是无关紧要的。

这仅仅是编译器为异步使用迭代器块生成代码的副作用,它在其中可能有更明显的解释?

重要免责声明

显然,这只是一个CTP编译器。我完全希望在最终版本发布之前,甚至在下一个CTP版本发布之前,一切都会有所改变。这个问题绝不是要声称这是C#编译器或类似工具中的缺陷。我只是想弄清楚我是否错过了一个微妙的原因:)


7
VB编译器会生成类似的状态机(不知道这是否是预期的,但VB之前没有迭代器块)
Damien_The_Unbeliever 2011年

1
@Rune:MoveNextDelegate只是一个引用MoveNext的委托字段。我相信,它被缓存以避免创建新的Action每次都传递给等待者。
乔恩·斯基特

5
我认为答案是:这是CTP。对于团队来说,最重要的一点是将其付诸实践,并验证了语言设计。他们如此迅速地做到了。您应该期望交付的实现(编译器的实现,而不是MoveNext的实现)明显不同。我认为Eric或Lucian的答案大致上是:没有什么深层的,只是行为/错误在大多数情况下都没有关系,也没有人注意到。因为它是CTP。
克里斯·伯罗斯

2
@Stilgar:我刚刚检查了ildasm,它确实在这样做。
乔恩·斯基特

3
@JonSkeet:请注意没有人赞成答案。我们中有99%的人无法真正分辨出答案是否正确。
the_drow 2011年

Answers:


71

好吧,我终于有了一个真实的答案。我自己解决了这个问题,但是直到团队的VB部分的Lucian Wischik确认确实有这样做的充分理由。非常感谢他-请访问他的博客,该博客令人震惊。

此处的值0仅是特殊的,因为它不是await正常情况下可能处于之前的有效状态。特别是,状态机可能不会在其他地方进行测试。我相信使用任何非正值也将同样有效:-1不被使用,因为它在逻辑上是不正确的,因为-1通常表示“完成”。我可以争辩说,我们现在给状态0赋予了额外的含义,但最终这并不重要。这个问题的重点是找出为什么要设置状态。

如果等待在捕获的异常中结束,则该值相关。我们可能最终会再次返回相同的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调用。

这是一个相当曲折的代码,但是这只是说明团队必须考虑的事情。我很高兴我不负责执行此操作:)


8
@Shekhar_Pro:是的,这是转到。您应该期望在自动生成的状态机中看到大量的goto语句:)
Jon Skeet

12
@Shekhar_Pro:在手动编写的代码中,它是-因为它使代码难以阅读和遵循。虽然没有人会读取自动生成的代码,但像我这样的傻瓜会反编译它:)
Jon Skeet

那么当我们在异常之后再次等待时发生什么呢?我们从头再来吗?
configurator

1
@configurator:它在等待的对象上调用GetAwaiter,这是我期望的。
乔恩·斯基特

gotos并不总是使代码更难阅读。实际上,有时它们甚至很有意义使用(我知道这是讽刺)。例如,有时您可能需要中断多个嵌套循环。我较少使用goto的功能(和IMO较差的用法)是导致switch语句级联。另外,我还记得一个时代,gotos是某些编程语言的主要基础,因此我完全意识到为什么仅仅提及goto会使开发人员感到震惊。如果使用不当,它们会使事情变丑。
Ben Lesh 2012年

5

如果将其保持为1(第一种情况),则EndAwait无需调用即可获得的呼叫BeginAwait。如果将其保持在2(第二种情况),您将在另一个等待者上得到相同的结果。

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

但是,这假设BeginAwaiter在第一个调用之后实际上不会在任何调用上开始操作,并且在这些情况下它将返回false。当然开始是不可接受的,因为它可能有副作用或只是产生不同的结果。还假设EndAwaiter不管调用多少次都将始终返回相同的值,并且可以在BeginAwait返回false时被调用(根据上述假设)

如果我们在状态= 0之后插入语句,其中movenext是由另一个线程调用的,则它看起来像是下面的样子:

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();

如果以上假设是正确的,则可以完成一些不必要的工作,例如获取锯木工并将相同的值重新分配给<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我同意在异步情况下竞争条件不应该成为问题,但可以放在迭代块中,也可以保留下来
Rune FS

@Tony:我想我要等到下一个CTP或Beta发布,然后检查一下该行为。
乔恩·斯基特

1

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

即:

async Task m1()
{
    await m2;
}

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

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

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

真的只是个平底锅吗?


在这种情况下,将生成三个不同的类。MoveNext()每个都会被调用一次。
乔恩·斯基特

0

实际状态说明:

可能的状态:

  • 0初始化(我认为是这样)等待操作结束
  • > 0刚刚称为MoveNext,选择下一个状态
  • -1结束

此实现是否有可能只是想确保,如果从任何地方(在等待时)再次发生对MoveNext的调用,它将从头开始重新评估整个状态链,以重新评估可能已经过时的结果?


但是为什么要从头开始呢?几乎可以肯定,这不是您真正想要发生的事情-您希望引发异常,因为其他任何事情都不调用MoveNext。
乔恩·斯基特
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.