Имитация разрыва двойника в 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);
        }
    }

person Michael Covelli    schedule 25.01.2012    source источник
comment
Глупый вопрос - что рвет?   -  person Oded    schedule 25.01.2012
comment
операции с целыми числами гарантированно атомарны в отношении доступа несколькими потоками. С лонгами не так. Разрыв — это сочетание двух промежуточных значений (плохо). Он задается вопросом, почему то же самое не наблюдается в двойниках, поскольку двойники также не гарантируют атомарных операций.   -  person hatchet - done with SOverflow    schedule 25.01.2012
comment
@Oded: на 32-битных машинах одновременно записывается только 32 бита. Если вы записываете 64-битное значение на 32-битной машине и записываете по одному и тому же адресу одновременно в двух разных потоках, у вас фактически четыре записи, а не две потому что запись выполняется по 32 бита за раз. Поэтому потоки могут участвовать в гонках, и когда дым рассеется, переменная будет содержать 32 старших бита, записанных одним потоком, и младшие 32 бита, записанных другим. Таким образом, вы можете написать 0xDEADBEEF00000000 в одном потоке и 0x00000000BAADF00D в другом, и в итоге в памяти останется 0x00000000000000000.   -  person Eric Lippert    schedule 25.01.2012
comment
@EricLippert - Итак, по сути, проблема с операциями над 64-битным значением, которые не являются атомарными на 32-битных машинах?   -  person Oded    schedule 25.01.2012
comment
Извините, должен был определить разрыв. Я имел в виду именно то, что сказали Топор и Эрик.   -  person Michael Covelli    schedule 25.01.2012
comment
@Oded: Совершенно верно.   -  person Eric Lippert    schedule 25.01.2012
comment
@EricLippert - Большое спасибо за четкое и краткое объяснение.   -  person Oded    schedule 25.01.2012


Ответы (4)


static double s_x;

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

Это никогда не произойдет с используемым вами объявлением, JIT-компилятор гарантирует, что двойное значение правильно выровнено, сохранено по адресу, кратному 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, чтобы действительно имитировать поведение кучи сборщика мусора. Продолжайте добавлять 8 к значению, пока вы не получите двойное значение, которое охватит строку кэша и вызовет утверждение.

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

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

person Hans Passant    schedule 29.01.2012
comment
Я не понимаю, как пересечение границы строки кэша может вызвать разрыв. Я думал, что это вызвано только тем, что значение занимает больше места, чем размер регистра. Не могли бы вы рассказать об этом немного подробнее? - person Tudor; 29.01.2012
comment
@Tudor - это совсем другой эффект, не связанный с размером регистра. Сосредоточьтесь на последнем абзаце, обратите внимание, что синхронизация кеша процессора имеет строку кеша в качестве единицы обновления. Для невыровненного двойного значения, расположенного между строками, требуется два обновления, аналогично тому, как для длинного значения требуется две записи в регистр. Это занимает достаточно времени, чтобы позволить коду, работающему на другом ядре, наблюдать за разрывом. - person Hans Passant; 29.01.2012

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

РЕДАКТИРОВАТЬ: Узнал это несколько лет назад на собственном горьком опыте.

person Eugen Rieck    schedule 25.01.2012
comment
TBH Я не имею ни малейшего представления, никогда не заглядывал в это. Мой демон (Free Pascal для всех языков) начал ложно выдавать абсурдные результаты на одной и только одной машине из многих (может быть, 100), все настроено из одного и того же образа и т. д. Оказалось, что это был глобальный двойник, который был обновлен основной поток и вторичный поток, созданный GTK. Никаких блокирующих примитивов в ФПК то... (ругательство, ругательство) - person Eugen Rieck; 25.01.2012
comment
Да, я бы не сомневался, если бы расширения MMX или SSE на процессоре имели к этому какое-то отношение. - person antiduh; 25.01.2012
comment
Машина, на которой я тестирую, говорит, что процессор Intel Xeon E5620 @ 2,40 ГГц (2 процессора). Любая идея, могу ли я ожидать, что двойники вообще не порвутся при работе на Intel Xeon? - person Michael Covelli; 26.01.2012
comment
Двойники AFAIK не порвут все новое, включая архитектуры Intel Core, но, пожалуйста, не принимайте это как должное — следующее поколение может вернуться к старой модели по какой-то неясной причине производительности. - person Eugen Rieck; 26.01.2012
comment
@MichaelCovelli - Похоже, вы действительно пытаетесь выжать из этого приложения некоторую производительность. Если это действительно так важно, я бы рекомендовал вам предоставить обе реализации в вашей программе; когда он запустится, запустите этот точный тест, чтобы узнать, какую реализацию включить. Если тест стоит дорого, вы можете попытаться выполнить такие действия, как его кэширование при установке программного обеспечения или чтение CPUID при каждом запуске машины и повторный запуск теста, если он изменится. - person antiduh; 26.01.2012
comment
Я думаю, что это, вероятно, связано с процессором, как сказал Евгений. Но я все еще немного туманен в деталях. Если мое вышеприведенное тестовое приложение не может найти разрывы на процессорах Intel, которые я использую, должен ли я предполагать, что это действительно невозможно? - person Michael Covelli; 29.01.2012
comment
Я посмотрел на дизассемблирование, и кажется, что чтение и запись из длинных транслируются в 2 инструкции, но дублирование, похоже, происходит всего за один шаг. Но я никогда раньше не тратил много времени на разборку, поэтому я не уверен, действительно ли это означает, что двойники не могут порваться здесь. - person Michael Covelli; 29.01.2012

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

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

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

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

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

person Tudor    schedule 29.01.2012

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

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

person Michael Christensen    schedule 29.01.2012
comment
В этой статье говорится только о длинных, а не о двойных. - person Tudor; 29.01.2012
comment
Согласованный. На самом деле, я думаю, что пример кода, который я разместил в вопросе, взят из этого сообщения (за исключением двойного материала). (У меня это было в тестовом проекте, и я забыл о нем на некоторое время). - person Michael Covelli; 29.01.2012