C++20 协程,await_resume、return_value 和 yield_value 的意外重排序

21

背景

我有一个任务类型,可以同时使用co_returnco_yield。在LLVM中,任务按预期工作,并通过了一些早期测试。在MSVC和GCC中,代码以相同的方式失败(巧合吗?)。


简要问题

使用以下测试函数:

Task<int> test_yielding()
{
    co_yield 1;
    co_return 2;
}

从 Task 对象中检索到两个值。

auto a = co_await fn;
auto b = co_await fn;

预期a的值为1,b的值为2。

结果将与a + b == 3进行测试。

上述测试通过,但是以下测试失败:

auto res = co_await fn + co_await fn
GCC和MSVC的res值都是4,这些值都来自于最后的co_return。据我理解,co_await fn的第一次和第二次调用,无论以何种顺序进行,其结果应该分别为1和2。

在MSVC和GCC中,代码会失败,因为它们似乎会重新排序await_resume、return_value和yield_value。

Task<int> test_yielding()
{
    co_yield 1;
    co_return 2;
}

测试 1 (通过):

Title("Test co_yield + co_return lvalue");
auto fn = test_yielding();
auto a = co_await fn;
auto b = co_await fn;
ASSERT(a + b == 3);

测试2(失败):

Title("Test co_yield + co_return rvalue");
auto fn = test_yielding();
auto res =
(
    co_await fn +
    co_await fn
);
ASSERT(res == 3);

测试 MSVC 1 的结果 (通过):

---------------------------------
Title   Test co_yield + co_return lvalue
---------------------------------
        get_return_object: 02F01DA0
        initial_suspend: 02F01DA0
        await_transform: 02D03C80
        AwaitAwaitable: await_ready: 02F01DA0
        AwaitAwaitable: await_suspend: 02F01DA0
        SetCurrent: 02F01DA0
        ContinueWith: 02F01DA0
        yield_value: 02F01DA0
        SetValue: 02F01DA0
        YieldAwaitable: await_ready: 02F01DA0
        YieldAwaitable: await_suspend: 02F01DA0
        ContinueWith: 02F01DA0
        AwaitAwaitable: await_resume: 02F01DA0
        GetValue: 02F01DA0
        await_transform: 02D03C80
        AwaitAwaitable: await_ready: 02F01DA0
        AwaitAwaitable: await_suspend: 02F01DA0
        SetCurrent: 02F01DA0
        ContinueWith: 02F01DA0
        YieldAwaitable: await_resume: 02F01DA0
        return_value: 02F01DA0
        SetValue: 02F01DA0
        final_suspend: 02F01DA0
        YieldAwaitable: await_ready: 02F01DA0
        YieldAwaitable: await_suspend: 02F01DA0
        ContinueWith: 02F01DA0
        AwaitAwaitable: await_resume: 02F01DA0
        GetValue: 02F01DA0
PASS    test_task:323 a + b == 3
        [ result = 3, expected = 3 ]
        Destroy: 02F01DA0

测试 MSVC 2 的结果(失败):

---------------------------------
Title   Test co_yield + co_return rvalue
---------------------------------
        get_return_object: 02F01CA0
        initial_suspend: 02F01CA0
        await_transform: 02D03C80
        AwaitAwaitable: await_ready: 02F01CA0
        AwaitAwaitable: await_suspend: 02F01CA0
        SetCurrent: 02F01CA0
        ContinueWith: 02F01CA0
        yield_value: 02F01CA0
        SetValue: 02F01CA0
        YieldAwaitable: await_ready: 02F01CA0
        YieldAwaitable: await_suspend: 02F01CA0
        ContinueWith: 02F01CA0
        await_transform: 02D03C80
        AwaitAwaitable: await_ready: 02F01CA0
        AwaitAwaitable: await_suspend: 02F01CA0
        SetCurrent: 02F01CA0
        ContinueWith: 02F01CA0
        YieldAwaitable: await_resume: 02F01CA0
        return_value: 02F01CA0
        SetValue: 02F01CA0
        final_suspend: 02F01CA0
        YieldAwaitable: await_ready: 02F01CA0
        YieldAwaitable: await_suspend: 02F01CA0
        ContinueWith: 02F01CA0
        AwaitAwaitable: await_resume: 02F01CA0
        GetValue: 02F01CA0
        AwaitAwaitable: await_resume: 02F01CA0
        GetValue: 02F01CA0
FAIL    test_task:342 res == 3
        [ result = 4, expected = 3 ]
        Destroy: 02F01CA0

如果您查看工作中的MSVC FAIL和MSVC PASS之间的差异(地址已更正),则会出现以下情况:

enter image description here

这清楚地表明以下行已被重新排序:

        AwaitAwaitable: await_resume: 02901E20  
        GetValue: 02901E20

LLVM和GCC的源代码和结果在这里

观察测试2中GCC失败和LLVM通过之间的差异: GCC vs LLVM 在GCC中发生了非常相似的重新排序。

差异中突出显示的行是由以下源代码生成的:

template <typename Promise>
struct AwaitAwaitable
{
    Promise & m_promise;

    bool await_ready() const noexcept
    {
        WriteLine("AwaitAwaitable: ", __func__, ": ", &m_promise);
        return false;
    }

    void await_suspend(default_handle handle) noexcept
    {
        WriteLine("AwaitAwaitable: ", __func__, ": ", &m_promise);
        m_promise.SetCurrent( m_promise.Handle() );
        m_promise.ContinueWith( handle );
    }

    auto await_resume() const noexcept
    {
        WriteLine("AwaitAwaitable: ", __func__, ": ", &m_promise);
        return m_promise.GetValue();
    }
};

有人知道这里发生了什么吗,是编译器/库/用户错误吗?


1
编译器允许在检索值和添加值之前评估co_await fn。由于两者都将结果存储在相同的Task<int>中,因此它会被最后一个值(在本例中为2)覆盖。而auto a = co_await fn则是在第一个co_await之后复制结果。我认为这就是正在发生的事情。据我所知,(co_await fn + co_await fn)行的结果未定义。 - freakish
1
它们可以交错执行,即[eval foo()] -> [eval bar()] -> [read foo() result] -> [read bar() result]是可能的。至少这是我理解https://en.cppreference.com/w/cpp/language/eval_order中“Ordering”部分的方式:“它们可以以任何顺序执行,并且可以重叠”。 - freakish
1
嗯,尽管规则11与此相矛盾。至少对于普通函数调用是如此。然而我在标准中找不到关于co_await的任何关于顺序的内容。我想这目前是未定义行为。 - freakish
1
这太不真实了,这么简单的表达式。这需要修复 :| - David Ledger
1
它现在已经列入公共问题列表: http://www.open-std.org/jtc1/sc22/wg21/docs/cwg_active.html#2466 - David Ledger
显示剩余6条评论
1个回答

8

观察到的行为似乎是由于GCC和MSVC在处理两个co_await表达式作为参数的加法运算符时存在类似的错误。

在这种情况下,无论是GCC还是MSVC都似乎错误地将对两个co_await表达式的await_resume()调用顺序定位在第二次挂起点之后(即在执行加法之前)。

它们应该立即在第一次挂起点恢复执行并且在开始计算第二个co_await表达式之前,对第一个co_await表达式(不确定是哪一个)进行await_resume()调用。


非常感谢Lewis和cppcoro!我一直在努力引起这个问题的关注。我的GCC报告在这里:https://gcc.gnu.org/bugzilla/show_bug.cgi?id=97433 - David Ledger
只需用更好的链接替换我的评论即可 :) https://eel.is/c++draft/intro.execution#9 - David Ledger
4
根据“Section [expr.await] p5.2”所述:“如果 await-ready 的结果为 true,或者协程被恢复,则将计算 await-resume 表达式,并且它的结果是 await-expression 的结果。”对我来说,这意味着在协程恢复时立即评估 await-expression。但是,如果我们参考[intro.execution] p10,并按照其规定的方式未指定子表达式的顺序,以及[expr.add]不为内置operator +参数指定任何排序,则编译器可能会将co_await部分视为无序的...? - Lewis Baker
1
是的,我认为那应该是一个缺陷 - 如果规范的解释是正确的话。 - Lewis Baker
如果能够高效地将结果存储在可等待对象中,那么就有一个合理的解决方法,但由于return_value和yield_value在promise内部,所以这将有些低效 :( - David Ledger
显示剩余4条评论

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