Проблемы разработки параллельных приложений
Проблемы синхронизации
Решение проблемы гонки данных требует применения средств синхронизации, позволяющих обеспечить взаимно-исключительный доступ к критической секции. Применение синхронизации гарантирует получение корректных результатов, но снижает быстродействие приложения. Чем больше размер критических секций в приложении, тем больше доля последовательного выполнения и ниже эффективность от распараллеливания. Для повышения быстродействия размер критической секции должен быть предельно минимальным – только те операторы, последовательность которых не должна прерываться другим потоком.
В следующем фрагменте каждый поток сохраняет в разделяемом массиве data данные из файла. Для обеспечения согласованного доступа к разделяемому ресурсу используются средства синхронизации.
// Поток №1 <Вход в критическую секцию> StreamReader sr = File.OpenText("file" + ThreadNum); newValue = GetValue(sr); data[cur_index] = newValue; cur_index++; sr.Close(); <Выход из критической секции>
Размер критической секции в этом фрагменте неоправдано большой. Действия по подготовке данных для сохранения (открытие файла, уникального для каждого потока; поиск и чтение необходимой информации) могут быть вынесены за критическую секцию:
// Поток №1 StreamReader sr = File.OpenText("file" + ThreadNum); newValue = GetValue(sr); <Вход в критическую секцию> data[cur_index] = newValue; cur_index++; <Выход из критической секции> sr.Close();
Современные платформы для параллельного программирования, в том числе и среда Framework .NET, предлагают широкий выбор средств синхронизации. В каждой задаче применение того или иного инструмента синхронизации будет более эффективным. Например, для многопоточного увеличения разделяемого счетчика могут использоваться средства организации взаимно-исключительного доступа (объекты Monitor, Mutex), сигнальные сообщения (AutoResetEvent, ManualResetEventSlim), двоичные семафоры (Semaphore). Но максимально эффективным будет использование неблокирующих атомарных операторов (Interlocked.Increment).
Для работы с динамическими списками можно использовать как обычные коллекции с теми или иными средствами синхронизации, так и конкурентные коллекции с встроенной неблокирующей синхронизацией.
Проблемы кэшируемой памяти
Наличие кэшируемой памяти увеличивает быстродействие обработки данных, но усложняет работу системы при многопоточной обработке. Неоптимальная работа с кэшируемой памятью может сильно снизить эффективность параллельной обработки.
Кэш-память каждого процессора (ядра процессора) наполняется данными, необходимыми для работы потока, выполняющегося на этом процессоре. Если потоки работают с общими данными, то на аппаратном уровне должна обеспечиваться согласованность содержимого кэш-памяти. Изменение общей переменной в одном потоке, сигнализирует о недействительности значения переменной, загруженной в кэш-память другого процессора. При этом необходимо сохранить значение переменной в оперативной памяти и обновить кэш-память других процессоров. Большая интенсивность изменений общих переменных, которые используются в нескольких потоках, приводит к большому числу ошибок кэш-памяти (кэш-промахи) и увеличению накладных расходов, связанных с обновлением кэш-памяти.
Распространенной проблемой кэш-памяти является так называемое ложное разделение данных (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 раз.
Все же самым эффективным решением при независимой обработке полей структуры будет применение локальных переменных внутри каждого потока. Разница в быстродействии может достигать нескольких десятков.
Модели параллельных приложений
Существуют следующие распространенные модели параллельных приложений:
- модель делегирования ("управляющий-рабочий");
- сеть с равноправными узлами;
- конвейер;
- модель "производитель-потребитель"
Каждая модель характеризуется собственной декомпозицией работ, которая определяет, кто отвечает за порождение подзадач и при каких условиях они создаются, какие информационные зависимости между подзадачами существуют.
Модель | Описание |
---|---|
Модель делегирования | Центральный поток ("управляющий") создает "рабочие" потоки и назначает каждому из них задачу. Управляющий поток ожидает завершения работы потоков, собирает результаты. |
Модель с равноправными узлами | Все потоки имеют одинаковый рабочий статус. |
Конвейер | Конвейерный подход применяется для поэтапной обработки потока входных данных. |
Модель "производитель-потребитель" | Частный случай конвейера с одним производителем и одним потребителем. |