Оптимизация кода С# вызывает проблемы с Interlocked.Exchange()

У меня есть неприятная проблема с небольшим количеством кода и не знаю, почему эта проблема возникает.

//
// .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 );
        }
    }
}

При работе без оптимизации кода результат будет таким, как ожидалось. _cursorLeft и оставлены до тех пор, пока _cursorTop и top равны.

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

Но когда я запускаю его с оптимизацией кода, оба значения _cursorLeft и _cursorTop становятся bizzare:

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

Я обнаружил 2 обходных пути:

  • установите _cursorLeft и _cursorTop в 0 вместо -1
  • пусть Interlocked.Exchange принимает значение слева или. сверху

Поскольку обходной путь №1 не соответствует моим потребностям, я закончил с обходным решением № 2:

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
        }
    }
}

Но откуда это странное поведение?
И есть ли лучшее решение/решение?


[Редактировать Мэтью Уотсон: добавление упрощенного воспроизведения:]

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

Вы правильно определили проблему, это ошибка оптимизатора. Он специфичен для 64-битного джиттера (aka 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, делая все значение отрицательным.

Характеристика этой ошибки немного сложная, ее непросто выделить. Получение свойства Cursor.Left getter inlined, по-видимому, помогает вызвать ошибку, под капотом он обращается к 16-битовому полю. По-видимому, достаточно, чтобы каким-то образом оптимизатор решил, что 16-битный обмен будет выполнен. И причина, почему ваш код обходного решения решил его, используя 32-битные переменные для хранения свойств Cursor.Left/Top, оптимизирует оптимизатор в хорошую кодировку.

Обходной путь в этом случае довольно простой, помимо того, который вы нашли, вам не требуется блокировка вообще, потому что оператор lock уже делает код потокобезопасным. Сообщите об ошибке на сайте connect.microsoft.com, сообщите мне, если вы не хотите тратить время, и я позабочусь об этом.

Ответ 2

У меня нет точного объяснения, но я хочу поделиться своими выводами. Кажется, это ошибка в 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

Что происходит, это дрожание inlines GetShortAsInt, но так или иначе неправильно. Я не совсем уверен, почему все идет не так. EDIT: как указывает Ханс в своем ответе - оптимизатор использует неправильный xchg instuction в этом случае для выполнения в качестве обмена.

Если вы измените так:

[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

Итак, учитывая все это - ваше обходное решение выглядит нормально.