Re[5]: multithreading : visibility control
От: okman Беларусь https://searchinform.ru/
Дата: 31.05.13 16:26
Оценка:
Здравствуйте, uzhas, Вы писали:

U>так вот сложность как раз увидеть здесь реордеринг и проследить как же он влияет на визибилити. ведь непонятно что с чем может поменяться местами.


Да, в этом и есть главная сложность — увидеть. Ну или хотя бы заподозрить.

U>когда описывают проблему с переупорядочиванием. всегда фигурирует как минимум две переменные. здесь же переменная одна — глобальный словарь


O>>Если этого не сделать, вызов spawnThread может быть перемещен, например, в начало функции,

O>>до заполнения globalMap данными.

U>я слабо себе представляю законные причины для данного вида переупорядочивания в нашем примере. склоняюсь к тому, что это будет нарушение стандарта, т.к. компилятор должен быть уверен, что данное переупорядочивание не повлияет на логику работу первого потока (? или всего приложения?) (observable behaviour может быть разным)


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

O>>По поводу второго можно не беспокоиться, так как публикация данных для других потоков -

O>>это так или иначе установка какого-нибудь флага, то есть, запись в память. В результате
O>>получается комбинация store-store, которая эффектам hardware ordering не подвержена.

U>тут я не понял о каком флаге идет речь и что такое публикация


Упрощенно:
int  Data;
bool fReady = false;

void thread_producer()
{
    Data  = 123;  // Запись данных.
    Ready = true; // Публикация данных.
}

void thread_consumer()
{
    while (false == fReady); // Ожидание публикации данных.
    assert(123   == Data);   // Чтение данных.
}


Публикация данных одним потоком для других — это практически всегда так или иначе или
запись в память какого-нибудь флага "данные готовы", или установка события, или
порождение/возобновление потока, или что-то другое, но всегда в результате получается,
как минимум, release-барьер памяти. Вы не сможете уведомить другой поток CPU о
наступлении некоего события, не используя запись в память или хотя бы один из системных
механизмов — прерывания, ввод-вывод, блокировка шины, переключение контекста, а они
все действуют, как полный барьер (fence), заставляя процессор форсировать незавершенные
операции, включая те, что находятся в store buffers (временный буфер для операций store).
В итоге, комбинация "запись-публикация" будет на уровне CPU превращаться или в store-store,
или в store-fence.

Ну и симметрично, поток-потребитель должен сначала дождаться публикации, и только после
этого читать данные. А ожидание публикации — это либо операция чтения флага "данные готовы" в
цикле, либо ожидание события ядра, или еще что-то, в результате чего так или иначе получится
либо комбинация load-load, либо fence-load. И опять переупорядочивания на уровне CPU не будет.

Вот почему в данном примере хардварные барьеры не нужны (на IA-32 и AMD64).

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

O>>Еще можно заподозрить компилятор в том, что однажды он проявит излишнюю сообразительность и

O>>реализует globalMap через регистры, из-за чего другие потоки не увидят данных.

U>и какие подсказки надо использовать, чтобы словарь все же расшарился между потоками?


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

Простые типы естественных размеров — BYTE, WORD, DWORD (и QWORD в режиме "Long Mode" CPU)
легко помещаются компилятором в регистры без особых колебаний. std::map в регистр не
запихнешь, но у него есть члены естественных размеров, и они как раз таки тоже могут
быть "закэшированы".

Приведу пример:
#include <Windows.h>
#include <process.h>



int  Value   = 0;
int  Base    = 0;
bool fReady  = false;



unsigned int _stdcall Thread_Consumer(void *)
{
    while (false == fReady);
    if (1 != Value) throw;

    return 0;
}



unsigned int _stdcall Thread_Producer(void *)
{
    Value  = Base + 1;
    fReady = true;
    
    return 0;
}



int main()
{
    HANDLE hThreads[2];
    
    hThreads[0] = (HANDLE)_beginthreadex(NULL, 0, Thread_Consumer, NULL, 0, NULL);
    Sleep(1000);
    hThreads[1] = (HANDLE)_beginthreadex(NULL, 0, Thread_Producer, NULL, 0, NULL);

    WaitForMultipleObjects(2, &hThreads[0], TRUE, INFINITE);

    CloseHandle(hThreads[0]);
    CloseHandle(hThreads[1]);

    return 0;
}




Давайте посмотрим ассемблерный код функций потоков.
(Visual C++ 2008/2010/2012, x64, Release, настройки оптимизации и инлайнинга по максимуму).

Thread_Consumer:
?Thread_Consumer@@YAIPEAX@Z PROC            ; Thread_Consumer, COMDAT

; 13   : {

$LN11:
    sub    rsp, 40                    ; 00000028H
    movzx    eax, BYTE PTR ?fReady@@3_NA        ; fReady
    npad    5
$LL3@Thread_Con:

; 14   :     while (false == fReady);

    test    al, al
    je    SHORT $LL3@Thread_Con
    
    ...

Худшие опасения оправдываются — fReady попала в регистр и изменения, сделанные в
другом потоке, не видны. Программа повисает в бесконечном цикле.

Thread_Producer:
?Thread_Producer@@YAIPEAX@Z PROC            ; Thread_Producer, COMDAT

; 24   :     Value  = Base + 1;

    mov    eax, DWORD PTR ?Base@@3HA        ; Base

; 25   :     fReady = true;

    mov    BYTE PTR ?fReady@@3_NA, 1        ; fReady
    inc    eax
    mov    DWORD PTR ?Value@@3HA, eax        ; Value

    ...

И здесь тоже не все гладко — установка fReady в true происходит до записи Value.
Компилятор посчитал, что так будет эффективнее.

В результате на простом с виду коде имеем обе проблемы — видимость и переупорядочивание.
Бороться с этим можно двумя способами — volatile и барьеры компилятора.
Оба способа, во-первых, заставляют компилятор удерживать исходный порядок операций, а
во-вторых, форсят обращение к памяти.

volatile (C++)
http://msdn.microsoft.com/en-us/library/12a04hfd(v=vs.100).aspx

Microsoft Specific

Objects declared as volatile are not used in certain optimizations because their values
can change at any time. The system always reads the current value of a volatile object at
the point it is requested, even if a previous instruction asked for a value from the
same object. Also, the value of the object is written immediately on assignment.

When optimizing, the compiler must maintain ordering among references to volatile objects
as well as references to other global objects. In particular,

A write to a volatile object (volatile write) has Release semantics; a reference to a
global or static object that occurs before a write to a volatile object in the instruction
sequence will occur before that volatile write in the compiled binary.

A read of a volatile object (volatile read) has Acquire semantics; a reference to a
global or static object that occurs after a read of volatile memory in the instruction
sequence will occur after that volatile read in the compiled binary.


_ReadWriteBarrier
http://msdn.microsoft.com/en-us/library/f20w0x5e(v=vs.100).aspx

Microsoft Specific

Forces reads and writes to memory to complete at the point of the call.

The _ReadBarrier, _WriteBarrier, and _ReadWriteBarrier functions help ensure the correct
operation of multithreaded programs that are optimized by the Visual C++ compiler.
A correctly optimized program yields the same results when it executes on multiple
threads as when it executes on a single thread.

The point in an application where a _ReadBarrier, _WriteBarrier, or _ReadWriteBarrier
function executes is called a memory barrier. A memory barrier can be for reads, writes,
or both.

An instruction that accesses a variable in memory might be deleted or moved across a memory
barrier as part of an optimization. Consequently, a thread might read an old value from a
global variable before another thread completes writing a new value to the variable, or
write a new value before another thread completes reading an old value from the variable.

To help ensure that the optimized program operates correctly, the _ReadWriteBarrier function
forces reads and writes to memory to complete at the point of the call. After the call,
other threads can access the memory without fear that the thread that made the call might
have a pending read or write to the memory. A memory barrier prevents the compiler from
optimizing memory accesses across the barrier, but enables the compiler to still optimize
instructions between barriers.


В итоге получается два корректных варианта, один с volatile, второй с _ReadWriteBarrier.
Вариант 1:
// ...

int  volatile Value   = 0;
int  volatile Base    = 0;
bool volatile fReady  = false;

// ...


Вариант 2:
// ...

unsigned int _stdcall Thread_Consumer(void *)
{
    while (false == fReady)
    {
        _ReadWriteBarrier(); // Форсим обращение к памяти fReady.
    }
    
    if (1 != Value) throw;

    return 0;
}



unsigned int _stdcall Thread_Producer(void *)
{
    Value  = Base + 1;
    _ReadWriteBarrier(); // Сохраняем порядок записи в Value и в fReady.
    fReady = true;
    
    return 0;
}

// ...


Сгенерированный код будет примерно одинаковым:
Thread_Consumer:
?Thread_Consumer@@YAIPEAX@Z PROC            ; Thread_Consumer, COMDAT

; 13   : {

$LN11:
    sub    rsp, 40                    ; 00000028H
$LL3@Thread_Con:

; 14   :     while (false == fReady);

    movzx    eax, BYTE PTR ?fReady@@3_NC        ; fReady
    test    al, al
    je    SHORT $LL3@Thread_Con

    ...


Thread_Producer:
?Thread_Producer@@YAIPEAX@Z PROC            ; Thread_Producer, COMDAT

; 24   :     Value  = Base + 1;

    mov    eax, DWORD PTR ?Base@@3HC        ; Base
    inc    eax
    mov    DWORD PTR ?Value@@3HC, eax        ; Value

; 25   :     fReady = true;

    mov    BYTE PTR ?fReady@@3_NC, 1        ; fReady
    
    ...


Теперь пример с STL-контейнером, который уж наверняка не поместится в регистре.
Вопросы синхронизации доступа к std::queue оставим за кадром:
// ...

std::queue<int> g_Queue;



unsigned int _stdcall Thread_Consumer(void *)
{
    while (0 == g_Queue.size());
    if    (123 != g_Queue.front()) throw;

    return 0;
}



unsigned int _stdcall Thread_Producer(void *)
{
    g_Queue.push(123);
    
    return 0;
}

// ...


На тех же настройках компилятора этот код точно также повисает.
Из ассемблерного листинга понятно почему:
?Thread_Consumer@@YAIPEAX@Z PROC            ; Thread_Consumer, COMDAT

; 12   : {

$LN29:
    sub    rsp, 72                    ; 00000048H
    mov    rax, QWORD PTR ?g_Queue@@3V?$queue@HV?$deque@HV?$allocator@H@std@@@std@@@std@@A+32
    npad    5
$LL3@Thread_Con:

; 13   :     while (0 == g_Queue.size());

    test    rax, rax
    je    SHORT $LL3@Thread_Con
    
    ...

Компилятор закэшировал один из членов std::queue в регистре и "не хочет" синхронизировать
его с реальным значением, хранящимся в памяти. Но стоит лишь добавить барьер компилятора,
как все становится на свои места:
// ...

unsigned int _stdcall Thread_Consumer(void *)
{
    size_t Size;
    
    do
    {
        Size = g_Queue.size();
        _ReadWriteBarrier();
    } while (0 == Size);       

    if    (123 != g_Queue.front()) throw;

    return 0;
}

// ...

Здесь обращение к памяти, в которой хранится size, форсится на каждой итерации цикла:
?Thread_Consumer@@YAIPEAX@Z PROC            ; Thread_Consumer, COMDAT

; 12   : {

$LN30:
    sub    rsp, 72                    ; 00000048H
$LL4@Thread_Con:

; 13   :     size_t Size;
; 14   :     
; 15   :     do
; 16   :     {
; 17   :         Size = g_Queue.size();

    mov    rax, QWORD PTR ?g_Queue@@3V?$queue@HV?$deque@HV?$allocator@H@std@@@std@@@std@@A+32

; 18   :         _ReadWriteBarrier();
; 19   :     } while (0 == Size);       

    test    rax, rax
    je    SHORT $LL4@Thread_Con

    ...


Остается вопрос: что выбрать — volatile или _ReadWriteBarrier ?
MSDN пишет:
http://msdn.microsoft.com/en-us/library/f20w0x5e(v=vs.100).aspx

Marking memory with a memory barrier is similar to marking memory with the
volatile (C++) keyword. However, a memory barrier is more efficient because reads and
writes are forced to complete at specific points in the program rather than globally.
The optimizations that can occur if a memory barrier is used cannot occur if the
variable is declared volatile.


Мне больше нравится volatile — поставил один раз и забыл. А за барьерами приходится следить,
чтобы не забыть поставить, где нужно. Это иногда чревато, особенно во время рефакторинга.
Но с другой стороны, если объект C++ объявить volatile, нельзя будет вызывать его
не-volatile методы. Ну то есть, тогда барьерам и альтернативы нет.
 
Подождите ...
Wait...
Пока на собственное сообщение не было ответов, его можно удалить.