std::move操作导致的一个神秘崩溃

原文:The mystery of the crash that seems to be on a std::move operation – The Old New Thing (microsoft.com) January 20th, 2022

客户遇到了只在ARM上出现的程序崩溃。下面是一个简化的版本

-
void polarity_test(std::shared_ptr<Test> test)
{
    test->harness->callAndReport([test2 = std::move(test)]() mutable
    {
        test2->reverse_polarity();
        ::resume_on_main_thread([test3 = std::move(test2)]()
        {
            test3->reverse_polarity();
        });
    });
}
-

他们说,只在第一行就崩溃了:

test->harness->callAndReport([test2 = std::move(test)]() mutable

现在,std::move实际上并不生成任何代码。它只是把引用从左值改为右值,这是一个完全在计算机大脑中(编译阶段)进行的操作。它不需要生成代码。

问题在别的地方。

由于该问题只发生在一个CPU架构(ARM)上,因此后端代码生成器(编译器)可能存在bug。但是为了安全起见,他们联系了编译器前端团队、后端团队(用于代码生成)和库团队(用于shared_ptr)。

我介入并指出,有一个求值顺序(order-of-evaluation)的依赖关系。

test->harness->callAndReport([test2 = std::move(test)]() mutable

语句的左边从test中读取。lambda捕获修改了test(通过std::move将其移动到捕获的变量test2)。

历史上,大多数子表达式的求值顺序是不确定的,虽然有一些操作定义了一个顺序,最明显的是短路表达式(short-circuiting)求值在第二个操作数之前计算第一个操作数(如果有的话)¹。

传统的表达式排序规则不要求在计算参数之前必须决定调用哪个函数。

传统的依赖关系图是这样的²:

test 
 
operator-> 
 
harness 
 
operator->test2 = std::move(test)
callAndReportlambda constructed
function call

由于在左边读取test和在右边修改test之间没有依赖关系,操作可以以任意一种顺序发生。

然后c++ 17出现了。

c++ 17增加了在传统规则之外的额外的求值顺序规则:在下面的表达式中,a在b之前求值:

OperationDescription
a(b)Function call
a[b]Subscript operator
a.*b
a->*b
Pointer to member
a << b
a >> b
Shifting
b = a
b op= a
Assignment
(note: right to left)

就我个人而言,我发现“标准”选择在参数之前对函数求值是很有趣的。在实践中,如果函数是通过指针追踪来识别的,那么首先计算参数会更方便,因为这样做不会干扰很多寄存器。

无论如何,由于函数调用现在在参数之前求值,从c++ 17开始的求值顺序现在要求在lambda中test2 = std::move(test)之前求值左边的test。

因此,问题归结为客户正在使用的语言版本。
客户回来后说,他们正在使用Visual Studio 2019,但使用的是c++ 14模式。
这就解释了。


下次,我们将看看潜在的修复(除了“升级到c++ 17”)。

¹编译器以任何顺序求值的自由情况导致底层体系结构会影响操作顺序。基于堆栈的参数更有可能在基于寄存器参数之前计算:一旦计算了基于寄存器参数的值,就必须在计算其他参数时找到保存它的位置。你可以尝试将它保存在用来传递参数的寄存器中(好),或者你可以尝试将它暂时保存在另一个寄存器中(好),或者你可以spill它并在调用之前重新加载它(坏)。如果其他参数计算起来很复杂,您可能会被迫spill。另一方面,基于堆栈的参数无论如何都会spill到堆栈中,所以您可以计算它并spill它,这样就完成了。在调用之前,您不必复刻(burn)一个寄存器来保存参数。

这意味着即使您只考虑调用约定,最优的求值顺序也可能在x86-32(没有寄存器参数,除了这个)、x86-64/arm(4个寄存器参数)和arm64(8个寄存器参数)之间变化。

²尽管在传统的排序中,我在前面的操作符->之后显示了harness,这并不是语言的规则,是精神内联的产物。真正发生的是

 is

test 
 
operator->&Test::harness
operator->*
(produces harness)

但是 &Test::harness不依赖任何东西。