Имитация разрывания двойного в C#

Я работаю на 32-битной машине и могу подтвердить, что длинные значения могут порваться, используя следующий фрагмент кода, который очень быстро срабатывает.

        static void TestTearingLong()
        {
            System.Threading.Thread A = new System.Threading.Thread(ThreadA);
            A.Start();

            System.Threading.Thread B = new System.Threading.Thread(ThreadB);
            B.Start();
        }

        static ulong s_x;

        static void ThreadA()
        {
            int i = 0;
            while (true)
            {
                s_x = (i & 1) == 0 ? 0x0L : 0xaaaabbbbccccddddL;
                i++;
            }
        }

        static void ThreadB()
        {
            while (true)
            {
                ulong x = s_x;
                Debug.Assert(x == 0x0L || x == 0xaaaabbbbccccddddL);
            }
        }

Но когда я пробую что-то похожее с парой, я не могу разорвать. Кто-нибудь знает почему? Насколько я могу судить из спецификации, только присвоение поплавку является атомарным. Присвоение двойного должно иметь риск разрыва.

    static double s_x;

    static void TestTearingDouble()
    {
        System.Threading.Thread A = new System.Threading.Thread(ThreadA);
        A.Start();

        System.Threading.Thread B = new System.Threading.Thread(ThreadB);
        B.Start();
    }

    static void ThreadA()
    {
        long i = 0;

        while (true)
        {
            s_x = ((i & 1) == 0) ? 0.0 : double.MaxValue;
            i++;

            if (i % 10000000 == 0)
            {
                Console.Out.WriteLine("i = " + i);
            }
        }
    }

    static void ThreadB()
    {
        while (true)
        {
            double x = s_x;

            System.Diagnostics.Debug.Assert(x == 0.0 || x == double.MaxValue);
        }
    }

4 ответа

Решение
static double s_x;

Гораздо сложнее продемонстрировать эффект при использовании двойного. Процессор использует специальные инструкции для загрузки и хранения двойных, соответственно, FLD и FSTP. Это гораздо проще с тех пор, как нет единой инструкции, которая загружает / хранит 64-битное целое число в 32-битном режиме. Чтобы наблюдать это, вам нужно, чтобы адрес переменной был смещен, чтобы он перекрывал границу строки кэша процессора.

Этого никогда не случится с объявлением, которое вы использовали, JIT-компилятор гарантирует, что double выровнен должным образом, хранится по адресу, кратному 8. Вы можете сохранить его в поле класса, распределитель GC выравнивает только до 4 в 32-битный режим. Но это чушь.

Лучший способ сделать это - преднамеренно неправильно выровнять двойное число с помощью указателя. Поставьте unsafe перед классом Program и сделайте так, чтобы он выглядел примерно так:

    static double* s_x;

    static void Main(string[] args) {
        var mem = Marshal.AllocCoTaskMem(100);
        s_x = (double*)((long)(mem) + 28);
        TestTearingDouble();
    }
ThreadA:
            *s_x = ((i & 1) == 0) ? 0.0 : double.MaxValue;
ThreadB:
            double x = *s_x;

Это по-прежнему не гарантирует хорошего смещения (хе-хе), поскольку нет способа точно определить, где AllocCoTaskMem() будет выравнивать распределение относительно начала строки кэша процессора. И это зависит от ассоциативности кэша в вашем ядре процессора (у меня это Core i5). Вам придется повозиться со смещением, я получил значение 28 экспериментальным путем. Значение должно делиться на 4, но не на 8, чтобы действительно имитировать поведение кучи GC. Продолжайте добавлять 8 к значению до тех пор, пока не получите двойное, чтобы расположиться между строкой кэша и вызвать утверждение.

Чтобы сделать его менее искусственным, вам нужно написать программу, которая хранит double в поле класса и заставить сборщик мусора перемещать его в памяти, чтобы он не выравнивался. Вроде сложно придумать пример программы, которая это гарантирует.

Также обратите внимание, как ваша программа может продемонстрировать проблему, называемую ложным обменом. Закомментируйте вызов метода Start() для потока B и отметьте, насколько быстрее работает поток A. Вы видите стоимость процессора, поддерживающего согласованность строки кэша между ядрами процессора. Обмен предназначен здесь, так как потоки обращаются к одной и той же переменной. Реальное ложное совместное использование происходит, когда потоки обращаются к различным переменным, которые хранятся в одной и той же строке кэша. В противном случае выравнивание имеет значение, вы можете наблюдать разрыв для двойника только тогда, когда его часть находится в одной строке кэша, а часть - в другой.

Как бы странно это ни звучало, это зависит от вашего процессора. Хотя двойные не гарантированно не порвутся, они не будут на многих современных процессорах. Попробуйте AMD Sempron, если вы хотите порвать в этой ситуации.

РЕДАКТИРОВАТЬ: узнал, что трудный путь несколько лет назад.

Проделав некоторое копание, я обнаружил некоторые интересные чтения, касающиеся операций с плавающей точкой на архитектурах x86:

Согласно Википедии, модуль x86 с плавающей запятой хранил значения с плавающей запятой в 80-битных регистрах:

[...] последующие процессоры x86 затем интегрировали эту функциональность x87 на чипе, что сделало инструкции x87 де-факто неотъемлемой частью набора команд x86. Каждый регистр x87, известный как ST(0) - ST(7), имеет ширину 80 битов и хранит числа в стандартном формате двойной точности с расширенной точностью IEEE.

Также этот другой вопрос SO связан с некоторыми вопросами о точности с плавающей запятой и числовых пределах.

Это может объяснить, почему, хотя double являются 64-битными, они работают атомарно.

Для чего стоит эта тема и пример кода можно найти здесь.

http://msdn.microsoft.com/en-us/magazine/cc817398.aspx

Другие вопросы по тегам