C#代码优化导致Interlocked.Exchange()出现问题

10

我有一段代码出现了令人沮丧的问题,但不知道为什么会出现这个问题。

//
// .NET FRAMEWORK v4.6.2 Console App

static void Main( string[] args )
{
    var list = new List<string>{ "aa", "bbb", "cccccc", "dddddddd", "eeeeeeeeeeeeeeee", "fffff", "gg" };

    foreach( var item in list )
    {
        Progress( item );
    }
}

private static int _cursorLeft = -1;
private static int _cursorTop = -1;
public static void Progress( string value = null )
{
    lock( Console.Out )
    {
        if( !string.IsNullOrEmpty( value ) )
        {
            Console.Write( value );
            var left = Console.CursorLeft;
            var top = Console.CursorTop;
            Interlocked.Exchange( ref _cursorLeft, Console.CursorLeft );
            Interlocked.Exchange( ref _cursorTop, Console.CursorTop );
            Console.WriteLine();
            Console.WriteLine( "Left: {0} _ {1}", _cursorLeft, left );
            Console.WriteLine( "Top: {0} _ {1}", _cursorTop, top );
        }
    }
}

没有代码优化时,结果与预期相符。当_cursorLeftleft相等,_cursorToptop也相等。

aa
Left: 2 _ 2
Top: 0 _ 0
bbb
Left: 3 _ 3
Top: 3 _ 3

但是当我使用代码优化来运行它时,使用Code optimization两个值_cursorLeft_cursorTop变得非常奇怪:

aa
Left: -65534 _ 2
Top: -65536 _ 0
bb
Left: -65533 _ 3
Top: -65533 _ 3

我找到了2个解决方法:

  1. _cursorLeft_cursorTop设置为0,而不是-1
  2. 让Interlocked.Exchange从lefttop中取值

由于第一个解决方法不符合我的需求,我最终采用了第二个解决方法。

private static int _cursorLeft = -1;
private static int _cursorTop = -1;
public static void Progress( string value = null )
{
    lock( Console.Out )
    {
        if( !string.IsNullOrEmpty( value ) )
        {
            Console.Write( value );

            // OLD - does NOT work!
            //Interlocked.Exchange( ref _cursorLeft, Console.CursorLeft );
            //Interlocked.Exchange( ref _cursorTop, Console.CursorTop );

            // NEW - works great!
            var left = Console.CursorLeft;
            var top = Console.CursorTop;
            Interlocked.Exchange( ref _cursorLeft, left );  // new
            Interlocked.Exchange( ref _cursorTop, top );  // new
        }
    }
}

但这种奇怪的行为是从哪里来的呢?
是否有更好的解决方法/解决方案?


[Matthew Watson编辑:添加简化的重现代码:]

class Program
{
    static void Main()
    {
        int actual = -1;
        Interlocked.Exchange(ref actual, Test.AlwaysReturnsZero);
        Console.WriteLine("Actual value: {0}, Expected 0", actual);
    }
}

static class Test
{
    static short zero;
    public static int AlwaysReturnsZero => zero;
}

[我的修改:]
我想到了一个更短的例子:

class Program
{
    private static int _intToExchange = -1;
    private static short _innerShort = 2;

    // [MethodImpl(MethodImplOptions.NoOptimization)]
    static void Main( string[] args )
    {
        var oldValue = Interlocked.Exchange(ref _intToExchange, _innerShort);
        Console.WriteLine( "It was:   {0}", oldValue );
        Console.WriteLine( "It is:    {0}", _intToExchange );
        Console.WriteLine( "Expected: {0}", _innerShort );
    }
}

除非您不使用优化或将_intToExchange设置为ushort范围内的值,否则您将无法识别问题。


1
我可以重现这个问题。 - Matthew Watson
我趁机添加了简化的重现代码。您可以根据需要将其整合或删除。 - Matthew Watson
@MatthewWatson 好主意!我之前认为这必须是一个具体问题,但看起来它像是一个大漏洞。 - Ronin
1
我已经在这里报告了这个问题:https://connect.microsoft.com/VisualStudio/feedback/details/3131687/incorrect-code-generated-by-64-bit-jitter-ryujit-for-call-to-interlocked-exchange - Matthew Watson
1
将来应该会修复:github.com/dotnet/coreclr/issues/10714 - Staeff
2个回答

7
你的诊断是正确的,这是一个优化器错误。它只出现在64位JIT(又名RyuJIT)上,这个问题在VS2015中首次发布。只有通过查看生成的机器代码才能看到它。在我的机器上看起来是这样的:
00000135  movsx       rcx,word ptr [rbp-7Ch]       ; Cursor.Left
0000013a  mov         r8,7FF9B92D4754h             ; ref _cursorLeft
00000144  xchg        cx,word ptr [r8]             ; Interlocked.Exchange

XCHG指令有误,它使用16位操作数(cx和word ptr),但变量类型需要32位操作数。因此,变量的高16位保持为0xffff,整个值变为负数。
这个bug比较棘手,很难隔离出来。让Cursor.Left属性getter内联似乎是触发bug的关键,在底层它访问了一个16位字段。这似乎足够使优化器决定使用16位交换。你的解决方法使用32位变量存储Cursor.Left/Top属性来修复它,并且将优化器推入正确的代码路径。
在这种情况下,解决方法非常简单,除了你找到的方法之外,你根本不需要Interlocked,因为“lock”语句已经使代码线程安全。请在connect.microsoft.com上报告此bug,如果你没有时间,请告诉我,我会处理。

@MatthewWatson 好的 - Ronin
我无法在 .net core 中重现此问题,您是否尝试过? - Evk
我没有。不太可能重现,将Console类跨平台化以便在Linux和OSX上运行,肯定会防止该属性易于内联。 - Hans Passant
1
但是您可以通过仅具有返回类型为int的方法来重现此问题,但其内部返回short,而且根本不需要访问Console类。 - Evk

4

我没有精确的解释,但仍然想分享我的发现。似乎是x64抖动与本地代码实现的Interlocked.Exchange组合中存在一个错误。以下是一个简短的版本,可以重现此问题,而不使用Console类。

class Program {
    private static int _intToExchange = -1;

    static void Main(string[] args) {
        _innerShort = 2;
        var left = GetShortAsInt();
        var oldLeft = Interlocked.Exchange(ref _intToExchange, GetShortAsInt());
        Console.WriteLine("Left: new {0} current {1} old {2}", _intToExchange, left, oldLeft);
        Console.ReadKey();
    }

    private static short _innerShort;
    static int GetShortAsInt() => _innerShort;
}

所以我们有一个int字段和一个返回int但实际上返回“short”的方法(就像Console.LeftCursor一样)。如果我们在优化和x64模式下进行编译,它将输出:

new -65534 current 2 old 65535

发生的问题是抖动内联了GetShortAsInt,但某种方式不正确地执行。我不确定为什么事情会出错。编辑:正如Hans在他的回答中指出的那样-优化器在这种情况下使用不正确的xchg指令来执行交换。
如果您像这样更改:
[MethodImpl(MethodImplOptions.NoInlining)]
static int GetShortAsInt() => _innerShort;

它将按预期工作:

new 2 current 2 old -1

对于非负值,乍一看似乎可行,实际上并不是 - 当_intToExchange超过ushort.MaxValue时 - 它再次出现问题:

private static int _intToExchange = ushort.MaxValue + 2;
new 65538 current 2 old 1

因此,考虑到所有这些因素 - 您的解决方案看起来不错。

你的例子的另一个“解决方法”是:static int GetShortAsInt() => Convert.ToInt32(_innerShort); - Ronin
好的,还有一个解决方法:删除 _innerShort = 2; 并设置 private static short _innerShort = 2; - Ronin
1
@Ronin 我特意设计了这个例子,让它不能正常工作 :) - Evk
@Evk 我完全知道。我只是想报告这种奇怪的行为。 - Ronin
@Staeff 我无法在 .net core 中复现这个问题。 - Evk
显示剩余2条评论

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