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

Почему этот код не демонстрирует неатоматичность чтения/записи?

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

private static double _d;

[STAThread]
static void Main()
{
    new Thread(KeepMutating).Start();
    KeepReading();
}

private static void KeepReading()
{
    while (true)
    {
        double dCopy = _d;

        // In release: if (...) throw ...
        Debug.Assert(dCopy == 0D || dCopy == double.MaxValue); // Never fails
    }
}

private static void KeepMutating()
{
    Random rand = new Random();
    while (true)
    {
        _d = rand.Next(2) == 0 ? 0D : double.MaxValue;
    }
}

К моему удивлению, утверждение отказалось провалиться даже после трех минут исполнения. Что дает?

  • Тест неверен.
  • Специфические временные характеристики теста делают маловероятным/невозможным то, что утверждение не получится.
  • Вероятность настолько низкая, что я должен запустить тест намного дольше, чтобы он мог вызвать его.
  • CLR обеспечивает более надежные гарантии атомарности, чем спецификация С#.
  • Моя ОС/оборудование обеспечивает более надежные гарантии, чем CLR.
  • Что-то еще?

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

FYI, я запускал это как в отчетах Debug, так и Release (изменение Debug.Assert до if(..) throw) в двух отдельных средах:

  • Windows 7 64-бит +.NET 3.5 SP1
  • Windows XP 32-bit +.NET 2.0

EDIT: чтобы исключить возможность комментария Джона Кугельмана "отладчик не является безопасным для Schrodinger", я добавил строку someList.Add(dCopy); к методу KeepReading и проверил, что в этом списке не было ни одного устаревшего значение из кеша.

EDIT: Основываясь на предложении Дэна Брайанта: Использование long вместо double прерывает его практически мгновенно.

4b9b3361

Ответ 1

Вы можете попробовать запустить его через CHESS, чтобы убедиться, что это может заставить чередование, которое нарушает тест.

Если вы посмотрите на графику x86 (видимую от отладчика), вы также можете увидеть, генерирует ли дрожание инструкции, которые сохраняют атомарность.


EDIT: я пошел вперед и запустил разборку (заставляя мишень x86). Соответствующие строки:

                double dCopy = _d;
00000039  fld         qword ptr ds:[00511650h] 
0000003f  fstp        qword ptr [ebp-40h]

                _d = rand.Next(2) == 0 ? 0D : double.MaxValue;
00000054  mov         ecx,dword ptr [ebp-3Ch] 
00000057  mov         edx,2 
0000005c  mov         eax,dword ptr [ecx] 
0000005e  mov         eax,dword ptr [eax+28h] 
00000061  call        dword ptr [eax+1Ch] 
00000064  mov         dword ptr [ebp-48h],eax 
00000067  cmp         dword ptr [ebp-48h],0 
0000006b  je          00000079 
0000006d  nop 
0000006e  fld         qword ptr ds:[002423D8h] 
00000074  fstp        qword ptr [ebp-50h] 
00000077  jmp         0000007E 
00000079  fldz 
0000007b  fstp        qword ptr [ebp-50h] 
0000007e  fld         qword ptr [ebp-50h] 
00000081  fstp        qword ptr ds:[00159E78h] 

Он использует один fstp qword ptr для выполнения операции записи в обоих случаях. Я предполагаю, что процессор Intel гарантирует атомарность этой операции, хотя я не нашел никакой документации для ее поддержки. Любые x86 гуру, которые могут это подтвердить?


UPDATE:

Это не так, как ожидалось, если вы используете Int64, который использует 32-разрядные регистры на процессоре x86, а не специальные регистры FPU. Вы можете увидеть это ниже:

                Int64 dCopy = _d;
00000042  mov         eax,dword ptr ds:[001A9E78h] 
00000047  mov         edx,dword ptr ds:[001A9E7Ch] 
0000004d  mov         dword ptr [ebp-40h],eax 
00000050  mov         dword ptr [ebp-3Ch],edx 

UPDATE:

Мне было любопытно, если это не сработает, если я принудительно выровняю не-8-байтное выравнивание двойного поля в памяти, поэтому я собрал этот код:

    [StructLayout(LayoutKind.Explicit)]
    private struct Test
    {
        [FieldOffset(0)]
        public double _d1;

        [FieldOffset(4)]
        public double _d2;
    }

    private static Test _test;

    [STAThread]
    static void Main()
    {
        new Thread(KeepMutating).Start();
        KeepReading();
    }

    private static void KeepReading()
    {
        while (true)
        {
            double dummy = _test._d1;
            double dCopy = _test._d2;

            // In release: if (...) throw ...
            Debug.Assert(dCopy == 0D || dCopy == double.MaxValue); // Never fails
        }
    }

    private static void KeepMutating()
    {
        Random rand = new Random();
        while (true)
        {
            _test._d2 = rand.Next(2) == 0 ? 0D : double.MaxValue;
        }
    }

Это не сработает, и сгенерированные инструкции x86 по существу те же, что и раньше:

                double dummy = _test._d1;
0000003e  mov         eax,dword ptr ds:[03A75B20h] 
00000043  fld         qword ptr [eax+4] 
00000046  fstp        qword ptr [ebp-40h] 
                double dCopy = _test._d2;
00000049  mov         eax,dword ptr ds:[03A75B20h] 
0000004e  fld         qword ptr [eax+8] 
00000051  fstp        qword ptr [ebp-48h] 

Я экспериментировал с заменой _d1 и _d2 для использования с dCopy/set, а также попробовал FieldOffset из 2. Все сгенерировали одни и те же основные инструкции (с разными смещениями выше), и все это не сработало через несколько секунд (вероятно, миллиарды попыток), Я с осторожностью уверен, учитывая эти результаты, по крайней мере, процессоры Intel x86 обеспечивают атомарность операций двойной загрузки/хранения независимо от выравнивания.

Ответ 2

Компилятор разрешает оптимизировать повторные чтения _d. Насколько он знает, просто статически анализируя ваш цикл, _d никогда не изменяется. Это означает, что он может кэшировать значение и никогда не перечитывать поле.

Чтобы предотвратить это, вам нужно либо синхронизировать доступ к _d (т.е. окружить его оператором lock), либо пометить _d как volatile. Если он изменяет значение, он сообщает компилятору, что его значение может измениться в любое время и поэтому никогда не должно кэшировать значение.

К сожалению (или, к счастью), вы не можете пометить поле double как volatile, именно из-за точки, которую вы пытаетесь проверить, double невозможно получить атомарно! Синхронизация доступа к _d заключается в том, что компилятор перечитывает значение, но это также нарушает тест. О, хорошо!

Ответ 3

Вы можете попытаться избавиться от 'dCopy = _d' и просто использовать _d в своем утверждении.

Таким образом, два потока одновременно читают/записывают одну и ту же переменную.

В текущей версии создается копия _d, которая создает новый экземпляр, все в одном потоке, который является безопасным потоком:

http://msdn.microsoft.com/en-us/library/system.double.aspx

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

Однако, если оба потока считывают/записывают один экземпляр переменной, то:

http://msdn.microsoft.com/en-us/library/system.double.aspx

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

Таким образом, если оба потока считывают/записывают в один и тот же экземпляр переменной, вам понадобится блокировка для его защиты (или Interlocked.Read/Increment/Exchange., не уверен, что это работает в двухлокальных номерах)

Изменить

Как отмечают другие, на процессоре Intel, считывающем/записывающем двойной, используется атомная операция. Однако, если программа скомпилирована для X86 и использует 64-битный целочисленный тип данных, тогда операция не будет атомарной. Как показано в следующей программе. Замените Int64 двойным и, похоже, сработает.

    Public Const ThreadCount As Integer = 2
    Public thrdsWrite() As Threading.Thread = New Threading.Thread(ThreadCount - 1) {}
    Public thrdsRead() As Threading.Thread = New Threading.Thread(ThreadCount - 1) {}
    Public d As Int64

    <STAThread()> _
    Sub Main()

        For i As Integer = 0 To thrdsWrite.Length - 1

            thrdsWrite(i) = New Threading.Thread(AddressOf Write)
            thrdsWrite(i).SetApartmentState(Threading.ApartmentState.STA)
            thrdsWrite(i).IsBackground = True
            thrdsWrite(i).Start()

            thrdsRead(i) = New Threading.Thread(AddressOf Read)
            thrdsRead(i).SetApartmentState(Threading.ApartmentState.STA)
            thrdsRead(i).IsBackground = True
            thrdsRead(i).Start()

        Next

        Console.ReadKey()

    End Sub

    Public Sub Write()

        Dim rnd As New Random(DateTime.Now.Millisecond)
        While True
            d = If(rnd.Next(2) = 0, 0, Int64.MaxValue)
        End While

    End Sub

    Public Sub Read()

        While True
            Dim dc As Int64 = d
            If (dc <> 0) And (dc <> Int64.MaxValue) Then
                Console.WriteLine(dc)
            End If
        End While

    End Sub

Ответ 4

IMO правильный ответ # 5.

double имеет длину 8 байтов.

Интерфейс памяти составляет 64 бит = 8 байтов на модуль за такт (т.е. он становится 16 байтами для двухканальной памяти).

Также есть кеши процессора. На моей машине строка кеша составляет 64 байта, а на всех ЦП она имеет 8 символов.

Как сказано выше, даже когда процессор работает в 32-битном режиме, двойные переменные загружаются и сохраняются всего с 1 инструкцией.

Итак, до тех пор, пока ваша двойная переменная выровнена (я подозреваю, что виртуальная машина с общим языковым исполнением выполняет выравнивание для вас), двойные чтения и записи являются атомарными.