Squeak.ru - шаблоны программирования

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

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

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

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

  1. Тест неверный.
  2. Конкретные временные характеристики теста делают маловероятным/невозможным, что утверждение не будет выполнено.
  3. Вероятность настолько мала, что мне приходится запускать тест гораздо дольше, чтобы повысить вероятность того, что он сработает.
  4. CLR обеспечивает более сильные гарантии атомарности, чем спецификация C#.
  5. Моя ОС/оборудование обеспечивает более надежные гарантии, чем CLR.
  6. Что-то другое?

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

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

  1. Windows 7 64-разрядная + .NET 3.5 SP1
  2. Windows XP 32-разрядная + .NET 2.0

РЕДАКТИРОВАТЬ: Чтобы исключить вероятность того, что комментарий Джона Кугельмана «отладчик не является безопасным для Шредингера» может быть проблемой, я добавил строку someList.Add(dCopy); в метод KeepReading и убедился, что в этом списке нет ни одного устаревшего значения из кеша.

РЕДАКТИРОВАТЬ: на основе предложения Дэна Брайанта: использование long вместо double практически мгновенно ломает его.


  • Мое предположение (и это очень точно) № 3. 09.09.2010
  • Я бы проверил IL, чтобы убедиться, что компилятор не проделывает никаких трюков, но кроме этого я ничего не получил. Я ожидаю, что это сломается на 32-битной машине. 09.09.2010
  • Я бы увеличил количество потоков записи - постарайтесь, чтобы потоки N + 1 работали в системе с N-ядерами. 09.09.2010
  • Попробуйте запустить это с Int64 вместо двойного. FPU — это отдельный зверь в процессорах Intel, который имеет собственный стек регистров, способный хранить 8-байтовые числа с плавающей запятой. Просто убедитесь, что используете число, которое охватывает два слова. 09.09.2010
  • Это упоминается в этом, к сожалению, забытом ответе в этой теме. Бедный парень. Это критически зависит от того, где переменная была выделена в куче загрузчика JIT-компилятором. Который вы не можете контролировать. Не знаете, каковы правила выравнивания для кучи загрузчика, попробуйте добавить больше статики разного размера до и после статического двойника. Вы можете получить их адрес в окне памяти отладчика (например, &d). 10.09.2010

Ответы:


1

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

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


РЕДАКТИРОВАТЬ: я пошел дальше и запустил дизассемблирование (форсируя цель 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, которые могут это подтвердить?


ОБНОВИТЬ:

Как и ожидалось, это не удается, если вы используете 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 

ОБНОВИТЬ:

Мне было любопытно, произойдет ли это с ошибкой, если я заставлю выравнивание двойного поля не по 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 обеспечивают атомарность операций двойной загрузки/сохранения независимо от выравнивания.

09.09.2010

2

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

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

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

09.09.2010
  • Я вижу, что метод KeepReading видит разные значения _d в отладчике. Кроме того, глядя на IL, первая строка внутри цикла — ldsfld float64 Tester.Program::d, так что оптимизация компилятора не происходит. 09.09.2010
  • Отладчик не является безопасным по Шрёдингеру. Вы пытаетесь протестировать что-то очень низкого уровня. Это выходит за рамки моей зарплаты, но я подозреваю, что оптимизатор JIT может оптимизировать чтение во время выполнения. Трудно сказать, это может быть и №2, и №3, и №4. 09.09.2010

  • 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 на double, и все заработает.

        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
    
    09.09.2010
  • Кроме того, вы можете попробовать использовать конструкцию, отличную от Assert. 09.09.2010
  • В режиме выпуска я заменил его на if(...) throw. 09.09.2010
  • Удаление dCopy ничего не дает (за исключением того, что поток читает _d дважды, а не один раз). 09.09.2010

  • 4

    Имхо, правильный ответ №5.

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

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

    Есть также кеши процессора. На моей машине строка кэша 64 байта, а на всех процессорах кратна 8.

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

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

    09.09.2010
    Новые материалы

    Угловая структура архитектуры
    Обратите внимание, что эта статья устарела, я решил создать новую с лучшей структурой и с учетом автономных компонентов: https://medium.com/@marekpanti/angular-standalone-architecture-b645edd0d54a..

    «Данные, которые большинство людей используют для обучения своих моделей искусственного интеллекта, поставляются со встроенным…
    Первоначально опубликовано HalkTalks: https://hacktown.com.br/blog/blog/os-dados-que-a-maioria-das-pessoas-usa-para-treinar-seus-modelos-de-inteligencia-artificial- ja-vem-com-um-vies-embutido/..

    Сильный ИИ против слабого ИИ: различия парадигм искусственного интеллекта
    В последние годы изучению и развитию искусственного интеллекта (ИИ) уделяется большое внимание и прогресс. Сильный ИИ и Слабый ИИ — две основные парадигмы в области искусственного интеллекта...

    Правильный способ добавить Firebase в ваш проект React с помощью React Hooks
    React + Firebase - это мощная комбинация для быстрого и безопасного создания приложений, от проверки концепции до массового производства. Раньше (знаете, несколько месяцев назад) добавление..

    Создайте API с помощью Python FastAPI
    Создание API с помощью Python становится очень простым при использовании пакета FastAPI. После установки и импорта вы можете создать приложение FastAPI и указать несколько конечных точек. Каждой..

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

    Получить бесплатный хостинг для разработчиков | Разместите свой сайт за несколько шагов 🔥
    Статические веб-сайты — это веб-страницы с фиксированным содержанием и его постоянным содержанием. Но теперь статические сайты также обрабатывают динамические данные с помощью API и запросов...