Подтвердить что ты не робот

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

Я запускаюсь на 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);
        }
    }
4b9b3361

Ответ 1

static double s_x;

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

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

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

    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;

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

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

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

Ответ 2

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

EDIT: узнал, что трудный путь несколько лет назад.

Ответ 3

Выполняя некоторые копания, я нашел некоторые интересные чтения относительно операций с плавающей запятой на архитектурах x86:

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

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

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

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