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