- •10.1. Виды параллельной обработки
- •10.1.1. Классификация систем параллельной обработки
- •10.2. Матричная обработка данных
- •Массив процессорных элементов
- •10.3. Архитектура мультипроцессорных систем общего назначения
- •10.4. Коммуникационные сети
- •10.5. Организация памяти в мультипроцессорных системах
- •10.6. Программный параллелизм и общие переменные
- •10.6.1. Доступ к общим переменным
- •10.6.2. Согласованность кэша
- •10.6.3. Блокировка и согласованность кэш-памяти
- •10.7. Мультикомпьютерные системы
- •10.8. Общая память и передача сообщений
- •10.8.1. Система с общей памятью
- •10.8.2. Система с передачей сообщений
- •10.9. Производительность мультипроцессорных систем
- •10.9.1. Закон Амдала
- •10.9.2. Показатели производительности
10.8. Общая память и передача сообщений
В предыдущих разделах были описаны аппаратные аспекты мультипроцессорных систем с общей памятью и с передачей сообщений. Теперь мы кратко рассмотрим два этих типа систем с точки зрения программиста, который разрабатывает приложение, поддерживающее параллельную обработку. Далее будет приведен небольшой пример с использованием двух процессоров. Это позволит упростить обсуждение и четче выделить основные идеи.
Предположим, необходимо вычислить скалярное произведение двух N-элементных векторов. Последовательная программа решения этой задачи предназначена для выполнения на одном процессоре (рис. 10.15). Логика программы понятна. Инструкции считывания загружают с диска (или с какого-либо другого устройства ввода-вывода) в основную память значения двух векторов. Эта задача выполняется операционной системой. Давайте попробуем распараллелить задачу для реализации на двух процессорах. Сделать это можно в цикле, который вычисляет произведение очередной пары элементов, а результаты суммирует.
10.8.1. Система с общей памятью
Первый вариант программы для двух процессоров показан на рис. 10.16. В случае запуска программы на одном процессоре она загружает векторы в память и присваивает переменной dot_product значение 0. При параллельном выполнении программы на двух процессорах часть вычислений, требуемых для получения скалярного произведения, необходимо возложить на один из них. Для этого мы создаем отдельный поток, предназначенный для выполнения на таком процессоре.
integer array a[l...N], b[l...N]
integer dot_product
…..
read a[l...N] from vector_a
read b[l...N] from vector_b
dot_product :=0
do_dot(a, b)
print dot_product
…..
do_dot (integer array x[l...n], integer array y[l...N])
for k:= 1 to N
dot_product := dot_product + x[k]*y[k]
end
end
Рис. 10.15. Последовательная программа вычисления скалярного произведения
Поток — это независимый фрагмент программы. Потоки могут соответствовать разным фрагментам кода программы или одному и тому же фрагменту, выполняемому несколько раз в различных условиях. Главное, что они могут выполняться параллельно, как отдельные программы, поэтому их можно запускать на разных процессорах. Вместе с тем потоки являются частями одной и той же программы, реализуемыми в одном и том же адресном пространстве. В типичном однопроцессорном окружении каждая программа имеет один поток выполнения.
В программе на рис. 10.16 новый поток создается посредством инструкции create_thread. Вызвав процедуру do_dot, этот поток завершает свою работу. Операционная система присваивает новому потоку идентификационный номер 1. Далее первый процессор выполняет инструкцию do_dot (a,b) как поток 0. Инструкция id := mypid() присваивает переменной id идентификационный номер потока. С помощью переменной id в цикле for мы определяем, какая половина векторов а и b должна обрабатываться данным потоком.
Критической секцией процедуры do_dot является код, изменяющий значение переменной dot_product. Каждый поток должен получать монопольный доступ к указанной переменной. Для этого используется описанный в разделе 10.6.1 механизм блокировок. Поток 0 не идет далее инструкции-барьера barrier в процедуре do_dot, пока другой поток не достигнет той же синхронизационной точки. Это необходимо для того, чтобы оба потока завершили свои вычисления до того, как поток 0 сможет напечатать конечный результат. Инструкцию-барьер можно реализовать двумя способами. Простейший подход заключается в использовании общей переменной, такой как done (рис. 10.16). Она инициализируется значением количества потоков (в нашем примере их два), и когда каждый поток достигает барьера, ее значение уменьшается на единицу.
shared integer array a[l...N], b[l...N]
shared integer dot_product
shared lock dot_product_lock
shared barrier done
…..
read a[l...N] from vector_a
read b[l...N] from vector_b
dot_product :=0
create_thread (do_dot, a, b)
do_dot (a, b)
print dot_product
…..
do_dot (integer array x[l...N], integer array y[l...N])
private integer id
id := mypid()
for k :=(id*N/2) + 1 to (id+l)*N/2
lock (dot_product_lock)
dot_product :- dot_product + x[k] * y[k]
unlock (dot_product_lock)
end
barrier (done)
end
Рис. 10.16. Первый вариант программы вычисления скалярного произведения на двух процессорах в системе с общей памятьюУ программы на рис. 10.16 имеется один существенный недостаток. Используемый в ней механизм блокировок не позволяет по-настоящему параллельно выполнять два потока, поскольку оба потока постоянно пытаются записать данные в одну и ту же общую переменную dot_product, а делать это одновременно они не могут. Таким образом, потенциально параллельные вычисления на самом деле выполняются последовательно.
Чтобы добиться настоящего параллелизма, можно так модифицировать программу, как показано на рис. 10.17. Вместо использования в цикле for общей переменной dot_product мы задействовали локальную переменную local_dot_ product, в которой накапливается частичное скалярное произведение, вычисляемое каждым из потоков. Вход в критическую секцию, где каждый поток обновляет общую переменную dot_product, производится только по окончании цикла. После такой модификации циклы for обоих потоков действительно могут выполняться параллельно.
Приведенный пример легко распространить на большее количество процессоров. С этой целью достаточно создать больше потоков. В цикле for на основе значения переменной id будет определяться диапазон элементов, используемых для вычислений каждым потоком.
Эффективность работы программы на рис. 10.17 зависит от размера векторов данных. Чем они больше, тем более эффективен описанный подход. Для малых же векторов затраты на создание дополнительных потоков и их синхронизацию перевешивают преимущества параллельного выполнения.
shared integer array a[l...N], b[l...N]
shared integer dot_product
shared lock dot_product_lock
shared barrier done
…..
read a[l...N] from vector_a
read b[l...N] from vector_b
dot_product :=0
create_thread (do_dot, a, b)
do_dot (a, b)
print dot_product
…..
do_dot (integer array x[l...N], integer array y[l...N])
private integer local_dot_product
private integer id
id := mypid()
local_dot_product := 0
for k :-(id*N/2) + 1 to (id+l)*N/2
local_dot_product := local_dot_product + x[k] * y[k]
end
lock (dot_product_lock)
dot_product :- dot_product + local_dot_product
unlock (dot_product_lock) ,
barrier (done)
end
Рис. 10.17. Эффективная программа для вычисления скалярного произведения на двух процессорах в системе с общей памятью