14 Возможности библиотеки Task Parallel Library. Проблемы кэшируемой памяти

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

Кэш-память каждого процессора (ядра процессора) наполняется данными, необходимыми для работы потока, выполняющегося на этом процессоре. Если потоки работают с общими данными, то на аппаратном уровне должна обеспечиваться согласованность содержимого кэш-памяти. Изменение общей переменной в одном потоке, сигнализирует о недействительности значения переменной, загруженной в кэш-память другого процессора. При этом необходимо сохранить значение переменной в оперативной памяти и обновить кэш-память других процессоров. Большая интенсивность изменений общих переменных, которые используются в нескольких потоках, приводит к большому числу ошибок кэш-памяти (кэш-промахи) и увеличению накладных расходов, связанных с обновлением кэш-памяти.

Распространенной проблемой кэш-памяти является так называемое ложное разделение данных (false sharing). Проблема связана с тем, что потоки работают с разными переменными, которые в оперативной памяти расположены физически близко. Дело в том, что в кэш-память загружается не конкретная переменная, а блок памяти (строка кэша), содержащая необходимую переменную. Размер строки кэша может составлять 64, 128, 512 байт. Если в одной строке кэша расположены несколько переменных, используемых в разных потоках, то в кэш-память каждого процессора будет загружена одна и та же строка. При изменении в одном потоке своей переменной, содержимое кэш-памяти других процессоров считается недействительным и требует обновления.

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

struct data
{
int x;
int y;
}

Первый поток работает только с полем x, второй поток работает только с полем y. Таким образом, разделения данных и проблемы гонки данных между потоками нет. Но последовательное расположение в памяти структуры data, приводит к тому, что в кэш-память одного и другого процессора загружается строка размером 64 байт, содержащая значения и поля x (4 байта), и поля y (4 байта). При изменении поля в одном потоке происходит обновление строки кэша в другом потоке.

// Поток №1
for(int i=0; i<N; i++)
data1.x++;
// Поток №2
for(int i=0; i<N; i++)
data1.y++;

Чтобы избежать последовательного расположения полей x и y в памяти, можно использовать дополнительные промежуточные поля.

Другой подход заключается в явном выравнивании полей в памяти с помощью атрибута FieldOffsetAttribute, который определен в пространстве System.Runtime.InteropServices:
// Явное выравнивание в памяти
[StructLayout(LayoutKind.Explicit)]
struct data
{
[FieldOffset(0)] public int x;
[FieldOffset(64)] public int y;
}

При достаточно большом значении N, разница в быстродействии кода с разделением кэша и без разделения может достигать 1.5 – 2 раз.

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