Текст
                    Параллельное
и распределенное
программирование
с использованием
I Трейс
Камерон Хьюз
Хьюз I


п араллельное и распределенное программирование с использованием I I
p arallel and Distributed Programming Using r-'fLj ■.../■:■! Cameron Hughes • Tracey Hughes AAddison-Wesley Pearson Education Boston • San Fransisco • New York • Toronto • Montreal London • Munich • Paris • Madrid Cape Town • Sydney • Tokyo • Singapore • Mexico City
Параллельное и распределенное программирование с использованием Камерон Хьюз • Трейси Хьюз иилыишс Ш Москва • Санкт-Петербург • Киев 2004
ББК 32.973.26-018.2.75 Х98 УДК 681.3.07 Издательский дом "Вильяме" Зав. редакцией С. Н. Тригуб Перевод с английского и редакция Н. М. Ручко По общим вопросам обращайтесь в Издательский дом "Вильяме" по адресу: info@dialektika.com, http://www.dialektika.com Хьюз, Камерон, Хьюз, Трейси. Х98 Параллельное и распределенное программирование на C++. : Пер. с англ. — М. : Издательский дом "Вильяме", 2004. — 672 с.: ил. — Парал. тит. англ. ISBN 5-8459-0686-5 (рус.) В книге представлен архитектурный подход к распределенному и параллельному программированию с использованием языка C++. Здесь описаны простые методы программирования параллельных виртуальных машин и основы разработки кластерных приложений. Эта книга не только научит писать программные компоненты, предназначенные для совместной работы в сетевой среде, но и послужит надежным "путеводителем" по стандартам для программистов, которые занимаются многозадачными и многопоточными приложениями. Многолетний опыт работы привел авторов книги к использованию агентно-ориентированной архитектуры, а для минимизации затрат на обеспечение связей между объектами системы они предлагают применить методологию "классной доски". Эта книга адресована программистам, проектировщикам и разработчикам программных продуктов, а также научным работникам, преподавателям и студентам, которых интересует введение в параллельное и распределенное программирование с использованием языка C++. ББК 32.973.26-018.2.75 Все названия программных продуктов являются зарегистрированными торговыми марками соответствующих фирм. Никакая часть настоящего издания ни в каких целях не может быть воспроизведена в какой бы то ни было форме и какими бы то ни бьыо средствами, будь то электронные или механические, включая фотокопирование и запись на магнитный носитель, если на это нет письменного разрешения издательства Addison-Wesley Publishing Company, Inc. Authorized translation from the English language edition published by Addison-Wesley Publishing Company, Inc , Copyright © 2004 All rights reserved. No part of this book may be reproduced or transmitted in any form or by any means, electronic or mechanical, including photocopying, recording or by any information storage retrieval system, without permission from the Publisher. Russian language edition published by Williams Publishing House according to the Agreement with R&I Enterprises International, Copyright © 2004 ISBN 5-8459-0686-5 (рус.) © Издательский дом "Вильяме", 2004 ISBN 0-13-101376-9 (англ.) © Pearson Education, Inc., 2004
ОГЛАВЛЕНИЕ Введение 1 б Глава 1. Преимущества параллельного программирования 23 Глава 2. Проблемы параллельного и распределенного программирования 42 Глава 3. Разбиение С++-программ на множество задач 57 Глава 4. Разбиение С++-программ на множество потоков 111 Глава 5. Синхронизация параллельно выполняемых задач 183 Глава 6. Объединение возможностей параллельного программирования и С++-средств на основе PVM 211 Глава 7. Обработка ошибок, исключительных ситуаций и надежность программного обеспечения 245 Глава 8. Распределенное объектно-ориентированное программирование в C++ 268 Глава 9. Реализация моделей SPMD и MPMD с помощью шаблонов и MPI-программирования 312 Глава 10. Визуализация проектов параллельных и распределенных систем 336 Глава 11. Проектирование компонентов для поддержки параллелизма 377 Глава 12. Реализация агентно-ориентированных архитектур 427 Глава 13. Реализация технологии "классной доски" с использованием pvm-средств, потоков и компонентов C++ 463 Приложение А 497 Приложение Б 507 Список литературы 657 Предметный указатель 660
СОДЕРЖАНИЕ ^'^\>Ш^^ # Введение 16 Этапы большого пути 17 Подход 18 Почему именно C++ 18 Библиотеки для параллельного и распределенного программирования 19 Новый единый стандарт спецификаций UNIX 19 Для кого написана эта книга 19 Среды разработки 20 Дополнительный материал 20 Глава 1. Преимущества паралельного программирования 23 1.1. Что такое параллелизм 25 1.1.1. Два основных подхода к достижению параллельности 26 1-2. Преимущества параллельного программирования 28 12.1. Простейшая модель параллельного программирования (PRAM) 29 1.2.2. Простейшая классификация схем параллелизма 30 1.3. Преимущества распределенного программирования 31 1.3.1. Простейшие модели распределенного программирования 32 1.3.2. Мультиагентные распределенные системы 32 1-4. Минимальные требования 33 1.4.1. Декомпозиция 33 1.4.2. Связь 34 1.4.3. Синхронизация 34 1 -5. Базовые уровни программного параллелизма 34 1.5.1. Параллелизм на уровне инструкций 34 1.5.2. Параллелизм на уровне подпрограмм 35
8 Содержание 1.5.3. Параллелизм на уровне объектов 35 1.5.4. Параллелизм на уровне приложений 35 1.6. Отсутствие языковой поддержки параллелизма в C++ 36 1.6.1. Варианты реализации параллелизма с помощью C++ 36 1.6.2. Стандарт MPI 37 1.6.3. Pvm: стандарт для кластерного программирования 38 1.6.4. Стандарт CORBA 38 1.6.5. Реализации библиотек на основе стандартов 39 1.7. Среды для параллельного и распределенного программирования 40 1.8. Резюме 40 Глава 2. Проблемы параллельного и распределенного программирования 42 2.1. Кардинальное изменение парадигмы 44 2.2. Проблемы координации 44 2.3. Отказы оборудования и поведение ПО 51 2.4. Негативные последствия излишнего параллелизма и распределения 52 2.5. Выбор архитектуры 53 2.6. Различные методы тестирования и отладки 54 2.7. Связь между параллельным и распределенным проектами 55 2.8. Резюме 56 Глава 3. Разбиение С++-программ на множество задач 57 3.1. Определение процесса 58 3.1.1. Два вида процессов 59 3.1.2. Блок управления процессами 59 3.2. Анатомия процесса 60 3.3. Состояния процессов 63 3.4. Планирование процессов 66 3.4.1. Стратегия планирования 67 3.4.2. Использование утилиты PS 69 3.4.3. Установка и получение приоритета процесса 71 3.5. Переключение контекста 73 3.6. Создание процесса 74 3.6.1. Отношения между родительскими и сыновними процессами 74 3.6.2. Использование системной функции fork() 78 3.6.3. Использование семейства системных функций exec 78 3.6.4. Использование функции system() для порождения процессов 83 3.6.5. Использование posix-функций для порождения процессов 83
Содержание £ 3.6.6. Идентификация родительских и сыновних процессов с помощью функций управления процессами 8£ 3.7. Завершение процесса 8£ 3.7.1. Функции exit(), kill() и abort() 90 3.8. Ресурсы процессов 91 3.8.1. Типы ресурсов 93 3.8.2. Posix-функции для установки ограничений доступа к ресурсам 94 3.9. Асинхронные и синхронные процессы 97 3.9.1. Создание синхронных и асинхронных процессов с помощью функций fork(), exec(), system() и posix_spawn() 99 3.9.2. Функция wait() 99 3.10. Разбиение программы на задачи 101 3.10.1. Линии видимого контура 108 3.11. Резюме 109 Глава 4. Разбиение С++-программ на множество потоков in 4.1. Определение потока 113 4.1.1. Контекстные требования потока 114 4.1.2. Сравнение потоков и процессов 115 4.1.3. Преимущества использования потоков 116 4.1.4. Недостатки использования потоков 117 4.2. Анатомия потока 119 4.2.1. Атрибуты потока 121 4.3. Планирование потоков 123 4.3.1. Состояния потоков 124 4.3.2. Планирование потоков и область конкуренции 125 4.3.3. Стратегия планирования и приоритет 125 4.4. Ресурсы потоков 128 4.5. Модели создания и функционирования потоков 129 4.5.1. Модель делегирования 130 4.5.2. Модель с равноправными узлами 132 4.5.3. Модель конвейера 132 4.5.4. Модель "изготовитель-потребитель" 133 4.5.5. Модели SPMD и MPMD для потоков 133 4.6. Введение в библиотеку PTHREAD 134 4.7. Анатомия простой многопоточной программы 136 4.7.1. Компиляция и компоновка многопоточных программ 137 4-8. Создание потоков 138 4.8.1. Получение идентификатора потока 141 4.8.2. Присоединение потоков 141
10 Содержание 4.8.3. Создание открепленных потоков 142 4.8.4. Использование объекта атрибутов 143 4.9. Управление потоками 145 4.9.1. Завершение потоков 145 4.9.2. Управление стеком потока 154 4.9.3. Установка атрибутов планирования и свойств потоков 157 4.9.4. Использование функции sysconf() 162 4.9.5. Управление критическими разделами 164 4.10. Безопасность использования потоков и библиотек 170 4.11. Разбиение программы на несколько потоков 172 4.11.1. Использование модели делегирования 173 4.11.2. Использование модели сети с равноправными узлами 177 4.11.3. Использование модели конвейера 177 4.11.4. Использование модели "изготовитель-потребитель" 178 4.11.5. Создание многопоточных объектов 180 Резюме 181 Глава 5. Синхронизация параллельно выполняемых задач 183 5.1. Координация порядка выполнения потоков 185 5.1.1. Взаимоотношения между синхронизируемыми задачами 185 5.1.2. Отношения типа старт-старт (СС) 186 5.1.3. Отношения типа финиш-старт (ФС) 187 5.1.4. Отношения типа старт-финиш (СФ) 188 5.1.5. Отношения типа финиш-финиш (ФФ) 188 5.2. Синхронизация доступа к данным 189 5.2.1. Модель PRAM 189 5.3. Что такое семафоры 193 5.3.1. Операции по управлению семафором 193 5.3.2. Мьютексные семафоры 194 5.3.3. Блокировки для чтения и записи 201 5.3.4. Условные переменные 205 5.4. Объектно-ориентированный подход к синхронизации 210 5.5. Резюме 210 Глава 6. Объединение возможностей параллельного программирования и С++-средств на основе PVM 2i i 6.1. Классические модели параллелизма, поддерживаемые системой PVM 213 6.2. Библиотека PVM для языка C++ 214 6.2.1. Компиляция и компоновка С++/РУМ-программ 217 6.2.2. Выполнение PVM-программы в виде двоичного файла 218
Содержание 11 6.2.3. Требования к PVM-программам 220 6.2.4. Объединение динамической С++-библиотеки с библиотекой PVM 222 6.2.5. Методы использования PVM-задач 223 6.3. Базовые механизмы PVM 233 6.3.1. Функции управления процессами 234 6.3.2. Упаковка и отправка сообщений 236 6.4. Доступ к стандартному входному потоку (STDIN) и стандартному выходному потоку (STDOUT) со стороны pvm-задач 242 6.4.1. Получение доступа к стандартному выходному потоку (COUT) из сыновней задачи 243 6.5. Резюме 243 Глава 7. Обработка ошибок, исключительных ситуаций и надежность программного обеспечения 245 7.1. Надежность программного обеспечения 247 7.2. Отказы в программных и аппаратных компонентах 249 7.3. Определение дефектов в зависимости от спецификаций ПО 251 7.4. Обработка ошибок или обработка исключительных ситуаций? 251 7.5. Надежность ПО: простой план 255 7.5.1. План А: модель возобновления, план Б: модель завершения 255 7.6. Использование объектов отображения для обработки ошибок 256 7.7. Механизмы обработки исключительных ситуаций в C++ 259 7.7.1. Классы исключений 260 7.8. Диаграммы событий, логические выражения и логические схемы 264 7.9. Резюме 267 Глава 8. Распределенное объектно-ориентированное программирование в C++ 268 8.1. Декомпозиция задачи и инкапсуляция ее решения 271 8.1.1. Взаимодействие между распределенными объектами 272 8.1.2. Синхронизация взаимодействия локальных и удаленных объектов 274 8.1.3. Обработка ошибок и исключений в распределенной среде 275 8-2. Доступ к объектам из других адресных пространств 275 8.2.1. IOR-доступ к удаленным объектам 277 8.2.2. Брокеры объектных запросов (ORB) 278 8.2.3. Язык описания интерфейсов (IDL): более "пристальный" взгляд на CORBA-объекты 282 8.3. Анатомия базовой CORBA-программы потребителя 286 8.4. Анатомия базовой CORBA-программы изготовителя 288 8.5. Базовый проект CORBA-приложения 289
12 Содержание 8.5.1. IDL-компилятор 292 8.5.2. Получение IOR-ссылки для удаленных объектов 293 8.6. Служба имен 294 8.6.1. Использование службы имен и создание именных контекстов 298 8.6.2. Служба имен "потребитель-клиент" 300 8.7. Подробнее об объектных адаптерах 303 8.8. Хранилища реализаций и интерфейсов 305 8.9. Простые распределенные Web-службы, использующие CORBA-спецификацию 306 8.10. Маклерская служба 307 8.11. Парадигма "клиент-сервер" 309 8.12. Резюме 311 Глава 9. Реализация моделей SPMD и MPMD с помощью шаблонов и МР1-программирования 312 9.1. Декомпозиция работ для МР1-интерфейса 314 9.1.1. Дифференциация задач по рангу 315 9.1.2. Группирование задач по коммуникаторам 317 9.1.3. Анатомия МР1-задачи 319 9.2. Использование шаблонных функций для представления МР1-задач 320 9.2.1. Реализация шаблонов и модель SPMD (типы данных) 321 9.2.2. Использование полиморфизма для реализации MPMD-модели 322 9.2.3. Введение MPMD-модели с помощью функций-объектов 327 9.3. Как упростить взаимодействие между MPI-задачами 328 9.3.1. Перегрузка операторов "«" и "»" для организации взаимодействия между MPI-задачами 332 9.4. Резюме 335 Глава 10. Визуализация проектов параллельных и распределенных систем ззб 10.1. Визуализация структур 338 10.1.1. Классы и объекты 338 10.1.2. Отношения между классами и объектами 347 10.1.3. Организация интерактивных объектов 353 10.2. Отображение параллельного поведения 354 10.2.1. Сотрудничество объектов 354 10.2.2. Последовательность передачи сообщений между объектами 359 10.2.3. Деятельность объектов 360 10.2.4. Конечные автоматы 364 10.2.5. Распределенные объекты 371
Содержание 13 10.3. Визуализация всей системы 371 10.3.1. Визуализация развертывания систем 372 10.3.2. Архитектура системы 373 10.4. Резюме 374 Глава 11 - Проектирование компонентов для поддержки параллелизма 377 11.1. Как воспользоваться преимуществами интерфейсных классов 380 11.2. Подробнее об объектно-ориентированном взаимном исключении и интерфейсных классах 385 11.2.1. "Полуширокие" интерфейсы 386 11.3. Поддержка потокового представления 393 11.3.1. Перегрузка операторов "«" и "»" для PVM-потоков данных 395 11.4. Пользовательские классы, создаваемые для обработки PVM-потоков данных 398 11.5. Объектно-ориентированные каналы и FIFO-очереди как базовые элементы низкого уровня 401 11.5.1. Связь каналов с iostream-объектами с помощью дескрипторов файлов 405 11.5.2. Доступ к анонимным каналам с использованием итератора OSTREAMJTERATOR 408 11.5.3. FIFO-очереди (именованные каналы), iostreams-классы и итераторы типа ostream_iterator 415 11.6. Каркасные классы 421 11.7. Резюме 425 Глава 12. Реализация агентно-ориентированных архитектур 427 12.1. Что такое агенты 429 12.1.1. Агенты: исходное определение 429 12.1.2. Типы агентов 430 12.1.3. В чем состоит разница между объектами и агентами 431 12.2. Понятие об агентно-ориентированном программировании 432 12.2.1. Роль агентов в распределенном программировании 434 12.2.2. Агенты и параллельное программирование 436 12.3. Базовые компоненты агентов 437 12.3.1. Когнитивные структуры данных 438 12.4. Реализация агентов в C++ 444 12.4.1. Типы данных предположений и структуры убеждений 444 12.4.2. Класс агента 449 12.4.3. Простая автономность 460 12.5. Мультиагентные системы 461 12.6. Резюме 462
14 Содержание Глава 13. Реализация технологии "классной доски" с использованием PVM-средств, потоков и компонентов C++ 463 13.1. Модель "классной доски" 465 13.2. Методы структурирования "классной доски" 467 13.3. Анатомия источника знаний 470 13.4. Стратегии управления для "классной доски" 471 13.5. Реализация модели "классной доски" с помощью CORBA-объектов 475 13.5.1. Пример использования corba-объекта "классной доски" 475 13.5.2. Реализация интерфейсного класса blackboard 478 13.5.3. Порождение источников знаний в конструкторе "классной доски" 480 13.6. Реализация модели "классной доски" с помощью глобальных объектов 490 13.7. Активизация источников знаний с помощью потоков 494 13.8. Резюме 495 Приложение А 497 А. 1. Диаграммы классов и объектов 497 А.2. Диаграммы взаимодействия 499 А.2.1. Диаграммы сотрудничества 499 А.2.2. Диаграммы последовательностей 499 А.2.3. Диаграммы видов деятельности 501 А.З. Диаграммы состояний 505 А.4. Диаграммы пакетов 506 Приложение Б 507 Список литературы 657 Предметный указатель 660
Эта книга посвящена всем программистам, "безвредным11 хакерам, инженерам-полуночникам и бесчисленным добровольцам, которые без устали и сожаления отдают свой талант, мастерство, опыт и время, чтобы сделать открытые программные продукты реальностью и совершить революцию в Linux. Без их вклада кластерное, МРР-, SMP- и распределенное программирование не было бы столь доступным для всех желающих, каким оно стало в настоящее время.
ВВЕДЕНИЕ В этой книге представлен архитектурный подход к распределенному и параллельному программированию с использованием языка C++. Особое внимание уделяется применению стандартной С++-библиотеки, алгоритмов и контейнерных классов в распределенных и параллельных средах. Кроме того, мы подробно разъясняем методы расширения возможностей языка C++, направленные на решение задач программирования этой категории, с помощью библиотек классов и функций. При этом нас больше всего интересует характер взаимодействия средств C++ с новыми стандартами POSIX и Single UNIX применительно к организации многопоточной обработки. Здесь рассматриваются вопросы объединения С++-программ с программами, написанными на других языках программирования, для поиска "многоязычных" решений проблем распределенного и параллельного программирования, а также некоторые методы организации программного обеспечения, предназначенные для поддержки этого вида программирования. В книге показано, как преодолеть основные трудности параллелизма, и описано, что понимается под производным распараллеливанием. Мы сознательно уделяем внимание не методам оптимизации, аппаратным характеристикам или производительности, а способам структуризации компьютерных программ и программных систем ради получения преимуществ от параллелизма. Более того, мы не пытаемся применить методы параллельного программирования к сложным научным и математическим алгоритмам, а хотим познакомить читателя с мультипарадигматическим подходом к решению некоторых проблем, которые присущи распределенному и параллельному программированию. Чтобы эффективно решать эти задачи, необходимо сочетать различные программные и инженерные подходы. Например, методы объектно-ориентированного программирования используются для решения проблем "гонки" данных и синхронизации их обработки. При многозадачном и многопоточном управлении мы считаем наиболее перспективной агентно-ориентированную архитектуру. А для минимизации затрат на обеспечение связей между объектами мы привлекаем методологию "классной доски" (стратегия решения сложных системных задач с использованием разнородных источников знаний, взаимодействующих через общее информационное поле). Помимо объектно-ориентированного, агентно-
Введение 17 ориентированного и AI-ориентированного (AI — сокр. от artificial intelligence— искусственный интеллект) программирования, мы используем параметризованное (настраиваемое) программирование для реализации обобщенных алгоритмов, которые применяются именно там, где нужен параллелизм. Опыт разработки программного обеспечения всевозможных форм и объемов позволил нам убедиться в том, что для успешного проектирования программных средств и эффективной их реализации без разносторонности (универсальности) применяемых средств уже не обойтись. Предложения, идеи и решения, представленные в этой книге, отражают практические результаты нашей работы. Этапы большого пути При написании параллельных или распределенных программ, как правило, необходимо "пройти" следующие три основных этапа. 1. Идентификация естественного параллелизма, который существует в контексте предметной области. 2. Разбиение задачи, стоящей перед программным обеспечением, на несколько подзадач, которые можно выполнять одновременно, чтобы достичь требуемого уровня параллелизма. 3. Координация этих задач, позволяющая обеспечить корректную и эффективную работу программных средств в соответствии с их назначением. Эти три этапа достигаются при условии параллельного решения следующих проблем: "гонка" данных обнаружение взаимоблокировки частичный отказ бесконечное ожидание взаимоблокировка отказ средств коммуникации регистрация завершения работы отсутствие глобального состояния проблема многофазной синхронизации несоответствие протоколов локализация ошибок отсутствие средств централизованного распределения ресурсов В этой книге разъясняются все названные проблемы, причины их возникновения и возможные пути решения. Наконец, в некоторых механизмах, выбранных нами для обеспечения параллелизма, в качестве протокола используется TCP/IP (Transmission Control Protocol/Internet Protocol— протокол управления передачей/протокол Internet). В частности, имеются в виду следующие механизмы: библиотека MPI (Message Passing Interface — интерфейс для передачи сообщений), библиотека PVM (Parallel Virtual Machine — параллельная виртуальная машина) и библиотека MICO (или CORBA — Common Object Request Broker Architecture — технология построения распределенных объектных приложений). Эти механизмы позволяют использовать наши подходы в среде Internet/Intranet, а это значит, что программы, работающие параллельно, могут выполняться на различных сайтах Internet (или корпоративной сети intranet) и общаться между собой посредством передачи сообщений. Многие эти идеи служат
18 Введение в качестве основы для построения инфраструктуры Web-служб. В дополнение к MPI- и PVM-процедурам, используемые нами CORBA-объекты, размещенные на различных серверах, могут взаимодействовать друг с другом через Internet. Эти компоненты можно использовать для обеспечения различных Internet/Intranet-служб. Подход При решении проблем, которые встречаются при написании параллельных или распределенных программ, мы придерживаемся компонентного подхода. Наша главная цель — использовать в качестве строительных блоков параллелизма каркасные классы. Каркасные классы поддерживаются объектно-ориентированными мьютексами, семафорами, конвейерами и сокетами. С помощью интерфейсных классов удается значительно снизить сложность синхронизации задач и их взаимодействия. Для того чтобы упростить управление потоками и процессами, мы используем агентно-ориентированные потоки и процессы. Наш основной подход к глобальному состоянию и связанные с ним проблемы включают применение методологии "классной доски". Для получения мультипарадигмати- ческих решений мы сочетаем агентно-ориентированные и объектно- ориентированные архитектуры. Такой мультипарадигматический подход обеспечивают средства, которыми обладает язык C++ для объектно-ориентированного, параметризованного и структурного программирования. Почему именно C++ Существуют С++-компиляторы, которые работают практически на всех известных платформах и в операционных средах. Национальный Институт Стандартизации США (American National Standards Institute — ANSI) и Международная организация по стандартизации (International Organization for Standardization — ISO) определили стандарты для языка C++ и его библиотеки. Существуют устойчиво работающие, так называемые открытые (open source) (т.е. лицензионные программы вместе с их исходными текстами, не связанные ограничениями на дальнейшую модификацию и распространение с сохранением информации о первичном авторстве и внесенных изменениях), а также коммерческие реализации этого языка. Язык C++ был быстро освоен научными работниками, проектировщиками и профессиональными разработчиками всего мира. Его использовали для решения самых разных (по объему и форме) проблем: для написания как отдельных драйверов устройств, так и крупномасштабных промышленных приложений. Язык C++ поддерживает мультипарадигматический подход к разработке программных продуктов и библиотек, которые делают средства параллельного и распределенного программирования легко доступными.
Введение 19 Библиотеки для параллельного и распределенного программирования Для параллельного программирования на основе C++ используются такие библиотеки, как MPICH (реализация библиотеки MPI), PVM и Pthreads (POSIX1 Threads). Для распределенного программирования применяется библиотека MICO (С++-реализация стандарта CORBA). Стандартная библиотека C++ (C++ Standard Library) в сочетании с CORBA и библиотекой Pthreads обеспечивает поддержку концепций агентно- ориентированного программирования и программирования на основе методологии "классной доски", которые рассматриваются в этой книге. Новый единый стандарт спецификаций UNIX Новый единый стандарт спецификаций UNIX (Single UNIX Specifications Standard) версии З — совместный труд Института инженеров по электротехнике и электронике (Institute of Electrical and Electronics Engineers — IEEE2) и организации Open Group — был выпущен в декабре 2001 года. Новый единый стандарт спецификаций UNIX реализует стандарты POSIX и способствует повышению уровня переносимости программных продуктов. Его основное назначение — дать разработчикам программного обеспечения единый набор API-функций (Application Programming Interface — интерфейс прикладного программирования, т.е. набор функций, предоставляемый для использования в прикладных программах), поддерживаемых каждой UNIX-системой. Этот документ обеспечивает надежный "путеводитель" по стандартам для программистов, которые занимаются многозадачными и многопоточными приложениями. В этой книге, рассматривая темы создания процессов, управления процессами, использования библиотеки Pthreads, новых процедур posix_spawn(), POSIX-семафоров и FIFO-очередей (/irst-in, yirst-out— "первым поступил, первым обслужен"), мы опираемся исключительно на новый единый стандарт спецификаций UNIX. В приложении Б представлены выдержки из этого стандарта, которые могут быть использованы в качестве справочника для изложенного нами материала. Для кого написана эта книга Эта книга предназначена для проектировщиков и разработчиков программного обеспечения, прикладных программистов и научных работников, преподавателей и студентов, которых интересует введение в параллельное и распределенное программирование с использованием языка C++. Для освоения материала этой книги читателю необходимо иметь базовые знания языка C++ и стандартной С++-библиотеки классов, поскольку учебный курс по программированию на C++ и по объектно-1 ориентированному программированию здесь не предусмотрен. Предполагается, что читатель должен иметь общее представление о таких принципах объектно-! POSIX— Portable Operating System Interface for computer environments— интерфейс переносимой от рационной системы (набор стандартов IEEE, описывающих интерфейсы ОС для UNIX). IEEE— профессиональное объединение, выпускающие свои собственные стандарты; членами IEEi являются ANSI и ISO.
20 Введение ориентированного программирования, как инкапсуляция, наследование и полиморфизм. В настоящей книге излагаются основы параллельного и распределенного программирования в контексте C++. Среды разработки Примеры и программы, представленные в этой книге, разработаны и протестированы в Linux- и UNIX-средах, а именно — под управлением Solaris 8, Aix и Linux (SuSE, Red Hat). MPI- и PVM-код разработан и протестирован на 32-узловом Linux-ориентированном кластере. Многие программы протестированы на серверах семейства Sun Enterprise 450. Мы использовали Sun C++ Workshop (С++-компилятор компании Portland Group) и проект по свободному распространению программного обеспечения GNU C++. Большинство примеров должны выполняться как в UNIX-, так и Linux- средах. Если конкретный пример не предназначен для выполнения в обеих названных средах, этот факт отмечается в разделе "Профиль программы", которым снабжаются все законченные примеры программ этой книги. Дополнительный материал Диаграммы UML Для построения многих диаграмм в этой книге применяется стандарт UML (Unified Modeling Language — унифицированный язык моделирования). В частности, для описания важных архитектур параллелизма и межклассовых взаимоотношений используются диаграммы действий, развертывания (внедрения), классов и состояний. И хотя знание языка UML не является необходимым условием, все же некоторый уровень осведомленности в этом вопросе окажется весьма полезным. Описание и разъяснение символов и самого языка UML приведено в приложении А. Профили программы Каждая законченная программа в этой книге сопровождается разделом "Профиль программы", который содержит описание таких особенностей реализации, как требуемые заголовки, библиотеки, инструкции по компиляции и компоновке. Профиль программы также включает подраздел "Примечания", содержащий специальную информацию, которую необходимо принять во внимание при выполнении данной программы. Если код не сопровождается профилем программы, значит, он предназначен только для демонстрации. Параграфы Мы посчитали лишним включать сугубо теоретические замечания в такую книгу- введение, как эта. Но в некоторых случаях без теоретических или математических выкладок было не обойтись, и тогда мы сопровождали такие выкладки подробными разъяснениями, оформленными в виде параграфов (например, § 6.1).
Введение 21 Тестирование кода и его надежность Несмотря на то что все примеры и приложения, приведенные в этой книге, были протестированы для подтверждения их корректности, мы не даем никаких гарантий, что эти программы полностью лишены изъянов или ошибок, совместимы с любым конкретным стандартом, годятся для продажи или отвечают вашим конкретным требованиям. На эти программы не следует полагаться при решении проблем, если существует вероятность, что некорректный способ получения результатов может привести к материальном}7 ущербу. Авторы и издатели этой книги не признают какую бы то ни было ответственность за прямой или косвенный ущерб, который может явиться результатом использования примеров, программ или приложений, представленных в этой книге. Ждем ваших отзывов! Вы, читатель этой книги, и есть главный ее критик и комментатор. Мы ценим ваше мнение и хотим знать, что было сделано нами правильно, что можно было сделать лучше и что еще вы хотели бы увидеть изданным нами. Нам интересно услышать и любые другие замечания, которые вам хотелось бы высказать в наш адрес. Мы ждем ваших комментариев и надеемся на них. Вы можете прислать нам бумажное или электронное письмо, либо просто посетить наш Web-сервер и оставить свои замечания там. Одним словом, любым удобным для вас способом дайте нам знать, нравит ся или нет вам эта книга, а также выскажите свое мнение о том, как сделать наши книги более интересными для вас. Посылая письмо или сообщение, не забудьте указать название книги и ее авторов, а также ваш обратный адрес. Мы внимательно ознакомимся с вашим мнением и обязательно учтем его при отборе и подготовке к изданию последующих книг. Наши координаты: E-mail: info@dialektika.com WWW: http://www.dialektika.com Информация для писем из: России: 115419, Москва, а/я 783 Украины: 03150, Киев, а/я 152
Благодарности Мы никогда бы не смогли "вытянуть" этот проект без помощи, поддержки, конструктивной критики и материальных ресурсов многих наших друзей и коллег. В частности, мы хотели бы поблагодарить Терри Льюиса (Terry Lewis) и Дага Джонсона (Doug Johnson) из компании OSC (Ohio Super-Computing) за предоставление доступа к 32-узловому Linux-ориентированному кластеру; Марка Уэлтона (Mark Welton) из компании YSU за экспертный анализ и помощь при конфигурировании кластера для поддержки наших PVM- и MPI-программ; Сэлу Сандерс (Sal Sanders) из компании YSU, позволившую нам работать на Power-PC с установленными Mac OSX и Adobe Illustrator; Брайана Нельсона (Brian Nelson) из YSU за разрешение протестировать многие наши многопоточные и распределенные программы на многопроцессорных вычислительных машинах Sun E-250 и Е-450. Мы также признательны Мэри Энн Джонсон (Mary Ann Johnson) и Джеффри Тримблу (Jeffrey Trimble) из YSU MAAG за помощь в получении справочной информации; Клавдию М. Стэнзиоло (Claudio M. Stanziola), Полетт Голдвебер (Paulette Goldweber) и Жаклин Хэнсон (Jacqueline Hansson) из объединения IEEE Standards and Licensing and Contracts Office за получение разрешения на переиздание фрагментов нового стандарта Single-UNIX/POSIX; Эндрю Джози (Andrew Josey) и Джину Пирсу (Gene Pierce) из организации Open Group за аналогичное содействие. Большое спасибо Тревору Уоткинсу (Trevor Watkins) из организации Z-Group за помощь в тестировании примеров программ; использование его распределенной Linux-среды было особенно важным фактором в процессе тестирования. Особую благодарность заслужили Стив Тарасвеки (Steve Tarasweki) за согласие написать рецензию на эту книгу (несмотря на то, что она была еще в черновом варианте); доктор Юджин Сантос (Eugene Santos) за то, что он указал нужное направление при составлении категорий структур данных, которые можно использовать в PVM (Parallel Virtual Machine — параллельная виртуальная машина); доктор Майк Кресиманно (Mike Crescimanno) из организации Advanced Computing Work Group (ACWG) при компании YSU за разрешение представить некоторые материалы из этой книги на одном из совещаний ACWG. Наконец, мы хотим выразить признательность Полю Петрелия (Paul Petralia) и всему составу производственной группы (особенно Гейлу Кокеру-Богусу (Gail Cocker-Bogusz)) из компании Prentice Hall за их терпение, поддержку, энтузиазм и высокий профессионализм.
ПРЕИМУЩЕСТВА ПАРАЛЛЕЛЬНОГО ПРОГРАММИРОВАНИЯ В этой главе... 1.1. Что такое параллелизм 1.2. Преимущества параллельного программирования 1.3. Преимущества распределенного программирования 1.4. Минимальные требования 1.5. Базовые уровни программного параллелизма 1.6. Отсутствие языковой поддержки параллелизма в C++ 1.7. Среды для параллельного и распределенного программирования 1.8. Резюме
J~y\3J^J^J "Я допускаю, что параллелизм лучше всего поддерживать с помощью библиотеки, причем такую библиотеку можно реализовать без существенных расширений самого языка программирования." — Бьерн Страуструп, создатель языка C++ Для того чтобы в настоящее время разрабатывать программное обеспечение, необходимы практические знания параллельного и распределенного программирования. Теперь перед разработчиками приложений все чаще ставится задача, чтобы отдельные программные составляющие надлежащим образом выполнялись в Internet или Intranet. Если программа (или ее часть) развернута в одной или нескольких таких средах, то к ней предъявляются самые жесткие требования по части производительности. Пользователь всегда надеется, что результаты работы программ будут мгновенными и надежными. Во многих ситуациях заказчик хотел бы, чтобы программное обеспечение удовлетворяло сразу многим требованиям. Зачастую пользователь не видит ничего необычного в своих намерениях одновременно загружать программные продукты и данные из Internet. Программное обеспечение, предназначенное для приема телетекста, также должно быть способно на гладкое воспроизведение графических изображений и звука после цифровой обработки (причем без прерывания). Программное обеспечение Web-сервера нередко выдерживает сотни тысяч посещений в день, а часто посещаемые почтовые серверы— порядка миллиона отправляемых и получаемых сообщений. При этом важно не только количество обрабатываемых сообщений, но и их содержимое. Например, передача данных, содержащих оцифрованные музыку, видео или графические изображения, может "поглотить" всю пропускную способность сети и причинить серьезные неприятности программному обеспечению сервера, которое не было спроектировано должным образом. Обычно мы имеем дело с сетевой вычислительной
1.1. Что такое параллелизм 25 средой, состоящей из компьютеров с несколькими процессорами. Чем больше функций возлагается на программное обеспечение, тем больше к нему предъявляется требований. Чтобы удовлетворить минимальные требования пользователя, современные программы должны быть еще более производительными и интеллектуальными. Программное обеспечение следует проектировать так, чтобы можно было воспользоваться преимуществами компьютеров, оснащенных несколькими процессорами. А поскольку сетевые компьютеры — это скорее правило, чем исключение, то целью проектирования программного обеспечения должно быть его корректное и эффективное выполнение при условии, что некоторые его составляющие будут одновременно выполняться на различных компьютерах. В некоторых случаях используемые компьютеры могут иметь совершенно различные операционные системы с разными сетевыми протоколами! Чтобы справиться с описанными реалиями, ассортимент разработок программных продуктов должен включать методы реализации параллелизма посредством параллельного и распределенного программирования. 1.1. Что такое параллелизм Два события называют одновременными, если они происходят в течение одного и того же временного интервала. Если несколько задач выполняются в течение одного и того же временного интервала, то говорят, что они выполняются параллельно. Для нас термин параллельно необязательно означает "точно в один момент". Например, две задачи могут выполняться параллельно в течение одной и той же секунды, но при этом каждая из них выполняется в различные доли этой секунды. Так, первая задача может отработать в первую десятую часть секунды и приостановиться, затем вторая может отработать в следующую десятую часть секунды и приостановиться, после чего первая задача может возобновить выполнение в течение третьей доли секунды, и т.д. Таким образом, эти задачи могут выполняться по очереди, но поскольку продолжительность секунды с точки зрения человека весьма коротка, то кажется, что они выполняются одновременно. Понятие одновременности (параллельности) можно распространить и на более длинные интервалы времени. Так, две программы, выполняющие некоторую задачу в течение одного и того же часа, постепенно приближаясь к своей конечной цели в течение этого часа, могут (или могут не) работать точно в одни и те же моменты времени. Мы говорим, что данные две программы для этого часа выполняются параллельно, или одновременно. Другими словами, задачи, которые существуют в одно и то же время и выполняются в течение одного и того же интервала времени, являются параллельными. Параллельные задачи могут выполняться в одно- или многопроцессорной среде. В однопроцессорной среде параллельные задачи существуют в одно и то же время и выполняются в течение одного и того же интервала времени за счет контекстного переключения. В многопроцессорной среде, если свободно достаточное количество процессоров, параллельные задачи могут выполняться в одни и те же моменты времени в течение одного и того же периода времени. Основной фактор, влияющий на степень приемлемости для параллелизма того или иного интервала времени, определяется конкретным приложением. Цель технологий параллелизма— обеспечить условия, позволяющие компьютерным программам делать больший объем работы за тот же интервал времени. Поэтому проектирование программ должно ориентироваться не на выполнение одной задачи в некоторый промежуток времени, а на одновременное выполнение нескольких задач, на которые предварительно должна быть разбита программа. Возможны ситуации, когда целью является не выполнение большего объема работы в течение того же
26 Глава 1. Преимущества параллельного программирования интервала времени, а упрощение решения с точки зрения программирования. Иногда имеет смысл думать о решении проблемы как о множестве параллельно выполняемых задач. Например (если взять для сравнения вполне житейскую ситуацию), проблему снижения веса лучше всего представить в виде двух параллельно выполняемых задач: диета и физическая нагрузка. Иначе говоря, для решения этой проблемы предполагается применение строгой диеты и физических упражнений в один и тот же интервал времени (необязательно точно в одни и те же моменты времени). Обычно не слишком полезно (или эффективно) выполнять одну подзадачу в один период времени, а другую — совершенно в другой. Именно параллельность обоих процессов дает естественную форму искомого решения проблемы. Иногда к параллельности прибегают, чтобы увеличить быстродействие программы или приблизить момент ее завершения. В других случаях параллельность используется для увеличения продуктивности программы (объема выполняемой ею работы) за тот же период времени при вторичности скорости ее работы. Например, для некоторых Web-сайтов важно как можно дольше удерживать пользователей. Поэтому здесь имеет значение не то, насколько быстро будет происходить подключение (регистрация) и отключение пользователей, а сколько пользователей сможет этот сайт обслуживать одновременно. Следовательно, цель проектирования программного обеспечения такого сайта — обрабатывать максимальное количество подключений за как можно больший промежуток времени. Наконец, параллельность упрощает само программное обеспечение. Зачастую сложную последовательность операций можно упростить, организовав ее в виде ряда небольших параллельно выполняемых операций. Независимо от частной цели (ускорение работы программ, обработка увеличенной нагрузки или упрощение реализации программы), наша главная цель— усовершенствовать программное обеспечение, воспользовавшись принципом параллельности. 1.1.1. Два основных подхода к достижению параллельности Параллельное и распределенное программирование— это два базовых подхода к достижению параллельного выполнения составляющих программного обеспечения (ПО). Они представляют собой две различные парадигмы программирования, которые иногда пересекаются. Методы параллельного программирования позволяют распределить работу программы между двумя (или больше) процессорами в рамках одного физического или одного виртуального компьютера. Методы распределенного программирования позволяют распределить работу программы между двумя (или больше) процессами, причем процессы могут существовать на одном и том же компьютере или на разных. Другими словами, части распределенной программы зачастую выполняются на разных компьютерах, связываемых по сети, или по крайней мере в различных процессах. Программа, содержащая параллелизм, выполняется на одном и том же физическом или виртуальном компьютере. Такую программу можно разбить на процессы (process) или потоки (thread). Процессы мы рассмотрим в главе 3, а потоки— в главе 4. В изложении материала этой книги мы будем придерживаться того, что распределенные программы разбиваются только на процессы. Многопоточность ограничивается параллелизмом. Формально параллельные программы иногда бывают распределенными, например, при PVM-программировании (Parallel Virtual Machine — параллельная виртуальная машина). Распределенное программирование иногда используется для реализации параллелизма, как в случае с MPI-программированием (Message Passing Interface — интерфейс для
1.1. Что такое параллелизм 27 передачи сообщений). Однако не все распределенные программы включают параллелизм. Части распределенной программы могут выполняться по различным запросам и в различные периоды времени. Например, программу календаря можно разделить на две составляющие. Одна часть должна обеспечивать пользователя информацией, присущей календарю, и способом записи данных о важных для него встречах, а другая часть должна предоставлять пользователю набор сигналов для разных типов встреч. Пользователь составляет расписание встреч, используя одну часть ПО, в то время как другая его часть выполняется независимо от первой. Набор сигналов и компонентов расписания вместе образуют единое приложение, которое разделено на две части, выполняемые по отдельности. При чистом параллелизме одновременно выполняемые части являются компонентами одной и той же программы. Части распределенных приложений обычно реализуются как отдельные программы. Типичная архитектура построения параллельной и распределенной программ показана на рис. 1.2. ПАРАЛЛЕЛЬНА ВЫПОЛНЯЕМЫЕ ПРИЛОЖЕНИЯ I *Ч единый физический или виртуальный 1 Программа Задача А Задача В Задача С Задача D КОМПЬЮТЕР ' :: р. s : "ТТ Р2 . -*~-р> J P, *rrw 4 1 РАСПРЕДЕЛЕННОЕ ПРИЛОЖЕНИЕ Рис1-1 ■ Типичная архитектура построения параллельной и распределенной программ
28 Глава 1. Преимущества параллельного программирования Параллельное приложение, показанное на рис. 1.2, состоит из одной программы, разделенной на четыре задачи. Каждая задача выполняется на отдельном процессоре, следовательно, все они могут выполняться одновременно. Эти задачи можно реализовать в 1.2, состоит из трех отдельных программ, каждая из которых выполняется на отдельном компьютере. При этом программа 3 состоит из двух отдельных частей (задачи А и задачи D), выполняющихся на одном компьютере. Несмотря на это, задачи А и D являются распределенными, поскольку они реализованы как два отдельных процесса. Задачи параллельной программы более тесно связаны, чем задачи распределенного приложения. В общем случае процессоры, связанные с распределенными программами, находятся на различных компьютерах, в то время как процессоры, связанные с программами, реализующими параллелизм, находятся на одном и том же компьютере. Конечно же, существуют гибридные приложения, которые являются и параллельными, и распределенными одновременно. Именно такие гибридные объединения становятся нормой. 1.2. Преимущества параллельного программирования Программы, надлежащее качество проектирования которых позволяет воспользоваться преимуществами параллелизма, могут выполняться быстрее, чем их последовательные эквиваленты, что повышает их рыночную стоимость. Иногда скорость может спасти жизнь. В таких случаях быстрее означает лучше. Иногда решение некоторых проблем представляется естественнее в виде коллекции одновременно выполняемых задач. Это характерно для таких областей, как научное программирование, математическое и программирование искусственного интеллекта. Это означает, что в некоторых ситуациях технологии параллельного программирования снижают трудозатраты разработчика ПО, позволяя ему напрямую реализовать структуры данных, алгоритмы и эвристические методы, разрабатываемые учеными. При этом используется специализированное оборудование. Например, в мультимедийной программе с широкими функциональными возможностями с целью получения более высокой производительности ее логика может быть распределена между такими специализированными процессорами, как микросхемы компьютерной графики, цифровые звуковые процессоры и математические спецпроцессоры. К таким процессорам обычно обеспечивается одновременный доступ. МРР-компьютеры (Massively Parallel Processors — процессоры с массовым параллелизмом) имеют сотни, а иногда и тысячи процессоров, что позволяет их использовать для решения проблем, которые просто не реально решить последовательными методами. Однако при использовании МРР-компьютеров (т.е. при объединении скорости и "грубой силы") невозможное становится возможным. К категории применимости МРР-компьютеров можно отнести моделирование экологической системы (или моделирование влияния различных факторов на окружающую среду), исследование космического пространства и ряд тем из области биологических исследований, например проект моделирования генома человека. Применение более совершенных технологий параллельного программирования открывает двери к архитектурам ПО, которые специально разрабатываются для параллельных сред. Например, существуют специальные муль- тиагентные архитектуры и архитектуры, использующие методологию "классной доски", разработанные специально для среды с параллельными процессорами.
1.2. Преимущества параллельного программирования 29 1.2.1. Простейшая модель параллельного программирования (PRAM) В качестве простейшей модели, отражающей базовые концепции параллельного программирования, рассмотрим модель PRAM (Parallel Random Access Machine — параллельная машина с произвольным доступом). PRAM — это упрощенная теоретическая модель с п процессорами, которые используют общую глобальную память. Простая модель PRAM изображена на рис. 1.2. Все процессоры имеют доступ для чтения и записи к общей глобальной памяти. В PRAM-среде возможен одновременный доступ. Предположим, что все процессоры могут параллельно выполнять различные арифметические и логические операции. Кроме того, каждый из теоретических процессоров (см. рис. 1.2) может обращаться к общей памяти в одну непрерываемую единицу времени. PRAM-модель обладает как параллельными, так и исключающими алгоритмами считывания данных. Параллельные алгоритмы считывания данных позволяют одновременно обращаться к одной и той же области памяти без искажения (порчи) данных. Исключающие алгоритмы считывания данных используются в случае, когда необходима гарантия того, что никакие два процесса никогда не будут считывать данные из одной и той же области памяти одновременно. PRAM- модель также обладает параллельными и исключающими алгоритмами записи данных. Параллельные алгоритмы позволяют нескольким процессам одновременно записывать данные в одну и ту же область памяти, в то время как исключающие алгоритмы гарантируют, что никакие два процесса не будут записывать данные в одну и ту же область памяти одновременно. Четыре основных алгоритма считывания и записи данных перечислены в табл. 1.1. 1P1 р2 Рз Рл « * Общая память Рис. 1.2. Простая модель PRAM Таблица 1.1. Четыре базовых алгоритма считывания и записи данных Типы алгоритмов Описание EREW CREW ERCW CRCW Исключающее считывание/исключающая запись Параллельное считывание/исключающая запись Исключающее считывание/параллельная запись Параллельное считывание/параллельная запись В этой книге мы будем часто обращаться к этим типам алгоритмов для реализации параллельных архитектур. Архитектура, построенная на основе технологии "классной Доски", — это одна из важных архитектур, которую мы реализуем с помощью PRAM- Модели (см. главу 13). Необходимо отметить, что хотя PRAM- это упрощенная теоретическая модель, она успешно используется для разработки практических программ, и эти программы могут соперничать по производительности с программами, которые были разработаны с использованием более сложных моделей параллелизма.
30 Глава 1. Преимущества параллельного программирования 1.2.2. Простейшая классификация схем параллелизма PRAM — это способ построения простой модели, которая позволяет представить, как компьютеры можно условно разбить на процессоры и память и как эти процессоры получают доступ к памяти. Упрощенная классификации схем функционирования параллельных компьютеров была предложена М. Флинном (М. J. Flynn)1. Согласно этой классификации различались две схемы: SIMD (Single-Instruction, Multiple-Data — архитектура с одним потоком команд и многими потоками данных) и MIMD (Multiple-Instruction, Multiple-Data — архитектура со множеством потоков команд и множеством потоков данных). Несколько позже эти схемы были расширены до SPMD (Single-Program, Multiple-Data — одна программа, несколько потоков данных) и MPMD (Multiple-Programs, Multiple-Data — множество программ, множество потоков данных) соответственно. Схема SPMD (SIMD) позволяет нескольким процессорам выполнять одну и ту же инструкцию или программу при условии, что каждый процессор получает доступ к различным данным. Схема MPMD (MIMD) позволяет работать нескольким процессорам, причем все они выполняют различные программы или инструкции и пользуются собственными данными. Таким образом, в одной схеме все процессоры выполняют одну и ту же программу или инструкцию, а в другой все процессоры выполняют различные программы или инструкции. Конечно же, возможны гибриды этих моделей, в которых процессоры могут быть разделены на группы, из которых одни образуют SPMD-модель, а другие — MPMD- модель. При использовании схемы SPMD все процессоры просто выполняют одни и те же операции, но с различными данными. Например, мы можем разбить одну задачу на группы и назначить для каждой группы отдельный процессор. В этом случае каждый процессор при решении задачи будет применять одинаковые правила, обрабатывая при этом различные части этой задачи. Когда все процессоры справятся со своими участками работы, мы получим решение всей задачи. Если же применяется схема MPMD, все процессоры выполняют различные виды работы, и, хотя при этом все они вместе пытаются решить одну проблему, каждому из них выделяется свой аспект этой проблемы. Например, разделим задачу по обеспечению безопасности Web-сервера по схеме MPMD. В этом случае каждому процессору ставится своя подзадача. Предположим, один процессор будет отслеживать работу портов, другой — курировать процесс регистрации пользователей, а третий — анализировать содержимое пакетов и т.д. Таким образом, каждый процессор работает с нужными ему данными. И хотя различные процессоры выполняют разные виды работы, используя различные данные, все они вместе работают в одном направлении — обеспечивают безопасность Web-сервера. Принципы параллельного программирования, рассматриваемые в этой книге, нетрудно описать, используя модели PRAM, SPMD (SIMD) и MPMD (MIMD). И в самом деле, эти схемы и модели успешно используются для реализации практических мелко- и среднемасштабных приложений и вполне могут вас устраивать до тех пор, пока вы не подготовитесь к параллельному программированию более высокой степени организации. M.J. Flynn. Very high-speed computers. Из сборников объединения IEEE, 54, 1901-1909 (декабрь 1966).
1.3. Преимущества распределенного программирования 31 1.3. Преимущества распределенного программирования Методы распределенного программирования позволяют воспользоваться преимуществами ресурсов, размещенных в Internet, в корпоративных Intranet и локальных сетях. Распределенное программирование обычно включает сетевое программирование в той или иной форме. Это означает, что программе, которая выполняется на одном компьютере в одной сети, требуется некоторый аппаратный или программный ресурс, который принадлежит другому компьютеру в той же или удаленной сети. Распределенное программирование подразумевает общение одной программы с другой через сетевое соединение, которое включает соответствующее оборудование (от модемов до спутников). Отличительной чертой распределенных программ является то, что они разбиваются на части. Эти части обычно реализуются как отдельные программы, которые, как правило, выполняются на разных компьютерах и взаимодействуют друг с другом через сеть. Методы распределенного программирования предоставляют доступ к ресурсам, которые географически могут находиться на большом расстоянии друг от друга. Например, распределенная программа, разделенная на компонент Web-сервера и компонент Web-клиента, может выполняться на двух различных компьютерах. Компонент Web-сервера может располагаться, допустим, в Африке, а компонент Web-клиента — в Японии. Часть Web-клиента может использовать программные и аппаратные ресурсы компонента Web-сервера, несмотря на то, что их разделяет огромное расстояние, и почти наверняка они относятся к различным сетям, функционирующим под управлением различных операционных сред. Методы распределенного программирования предусматривают совместный доступ к дорогостоящим программным и аппаратным ресурсам. Например, высококачественный го- лографический принтер может обладать специальным программным обеспечением сервера печати, которое предоставляет соответствующие услуги для ПО клиента. ПО клиента печати размещается на одном компьютере, а ПО сервера печати — на другом. Как правило, для обслуживания множества клиентов печати достаточно только одного сервера печати. Распределенные вычисления можно использовать для создания определенного уровня избыточности вычислительных средств на случай аварии. Если разделить программу на несколько частей, каждая из которых будет выполняться на отдельном компьютере, то некоторым из этих частей мы можем поручить одну и ту же работу. Если по какой-то причине один компьютер откажет, его программу заменит аналогичная программа, выполняющаяся на другом компьютере. Ни для кого не секрет, что базы данных способны хранить миллионы, триллионы и даже квадриллионы единиц информации. И, конечно же, нереально каждому пользователю иметь копию подобной базы данных. А ведь пользователи и компьютер, содержащий базу данных, зачастую находятся не просто в разных зданиях, а в разных городах или даже странах. Но именно методы распределенного программирования дают возможность пользователям (независимо от их местонахождения) обращаться к таким базам данных.
32 Глава 1. Преимущества параллельного программирования 1.3.1. Простейшие модели распределенного программирования Возможно, самой простой и распространенной моделью распределенной обработки данных является модель типа "клиент/сервер". В этой модели программа разбивается на две части: одна часть называется сервером, а другая— клиентом. Сервер имеет прямой доступ к некоторым аппаратным и программным ресурсам, которые желает использовать клиент. В большинстве случаев сервер и клиент располагаются на разных компьютерах. Обычно между клиентом и сервером существует отношение типа "множество-к-одному", т.е., как правило, один сервер отвечает на запросы многих клиентов. Сервер часто обеспечивает опосредованный доступ к огромной базе данных, дорогостоящему оборудованию или некоторой коллекции приложений. Клиент может запросить интересующие его данные, сделать запрос на выполнение вычислительной процедуры или обработку другого типа. В качестве примера приложения типа "клиент/сервер" приведем механизм поиска (search engine). Механизмы (или машины) поиска используются для поиска заданной информации в Internet или корпоративной Intranet. Клиент служит для получения ключевого слова или фразы, которая интересует пользователя. Часть ПО клиента затем передает сформированный запрос той части ПО сервера, которая обладает средствами поиска информации по заданному пользователем ключевому слову или фразе. Сервер либо имеет прямой доступ к информации, либо связан с другими серверами, которые имеют его. В идеальном случае сервер находит запрошенное пользователем ключевое слово или фразу и возвращает найденную информацию клиенту. Несмотря на то что клиент и сервер представляют собой отдельные программы, выполняющиеся на разных компьютерах, вместе они составляют единое приложение. Разделение ПО на части клиента и сервера и есть основной метод распределенного программирования. Модель типа "клиент/сервер" также имеет другие формы, которые зависят от конкретной среды. Например, термин "изготовитель-потребитель" (producer-consumer) можно считать близким родственником термина "клиент/сервер". Обычно клиент-серверными приложениями называют большие программы, а термин "изготовитель-потребитель" относят к программам меньшего объема. Если программы имеют уровень операционной системы или ниже, к ним применяют термин "изготовитель-потребитель", если выше — то термин "клиент/сервер" (конечно же, исключения есть из всякого правила). 1.3.2. Мультиагентные распределенные системы Несмотря на то что модель типа "клиент/сервер" — самая распространенная модель распределенного программирования, все же она не единственная. Используются также агенты — рациональные компоненты ПО, которые характеризуются самонаведением и автономностью и могут постоянно находиться в состоянии выполнения. Агенты могут как создавать запросы к другим программным компонентам, так и отвечать на запросы, полученные от других программных компонентов. Агенты сотрудничают в пределах групп для коллективного выполнения определенных задач. В такой модели не существует конкретного клиента или сервера. Это — модель сети с равноправными узлами (peer-to-peer), в которой все компоненты имеют одинаковые права, и при этом у каждого компонента есть что предложить другому. Например, агент, который назначает цены на восстановление старинных спортивных машин, может работать вместе с другими агентами. Один агент может быть специалистом по мо-
1.4. Минимальные требования 33 торам, другой — по кузовам, а третий предпочитает работать как дизайнер по интерьерам. Эти агенты могут совместно оценить стоимость работ по восстановлению автомобиля. Агенты являются распределенными, поскольку все они размещаются на разных серверах в Internet. Для связи агенты используют согласованный Internet-протокол. Для одних типов распределенного программирования лучше подходит модель типа "клиент/сервер", а для других — модель равноправных агентов. В этой книге рассматриваются обе модели. Большинство требований, предъявляемых к распределенному программированию, удовлетворяется моделями "клиент/сервер" и равноправных агентов. 1.4. Минимальные требования Параллельное и распределенное программирование требует определенных затрат. Несмотря на описанные выше преимущества, написание параллельных и распределенных программ не обходится без проблем и необходимости наличия предпосылок. О проблемах мы поговорим в главе 2, а предпосылки рассмотрим в следующих разделах. Написанию программы или разработке отдельной части ПО должен предшествовать процесс проектирования. Что касается параллельных и распределенных программ, то процесс проектирования должен включать три составляющих: декомпозиция, связь и синхронизация (ДСС). 1.4.1. Декомпозиция Декомпозиция — это процесс разбиения задачи и ее решения на части. Иногда части группируются в логические области (т.е. поиск, сортировка, вычисление, ввод и вывод данных и т.д.). В других случаях части группируются по логическим ресурсам (т.е. файл, связь, принтер, база данных и т.д.). Декомпозиция программного решения часто сводится к декомпозиции работ (work breakdown structure— WBS). Декомпозиция работ определяет, что должны делать разные части ПО. Одна из основных проблем параллельного программирования— идентификация естественной декомпозиции работ для программного решения. Не существует простого и однозначного подхода к идентификации WBS. Разработка ПО — это процесс перевода принципов, идей, шаблонов, правил, алгоритмов или формул в набор инструкций, которые выполняются, и данных, которые обрабатываются компьютером. Это, в основном, и составляет процесс моделирования. Программные модели — это воспроизведение в виде ПО некоторой реальной задачи, процесса или идеала. Цель модели— сымитировать или скопировать поведение и характеристики некоторой реальной сущности в конкретной предметной области. Процесс моделирования вскрывает естественную декомпозицию работ программного решения. Чем лучше модель понята и разработана, тем более естественной будет декомпозиция работ. Наша цель — обнаружить параллелизм и распределение с помощью моделирования. Если естественный параллелизм не наблюдается, не стоит его навязывать насильно. На вопрос, как разбить приложение на параллельно выполняемые части, необходимо найти ответ в период проектирования, и правильность этого ответа должна стать очевидной в модели решения. Если модель задачи и решения не предполагает параллелизма и распределения, следует попытаться найти последовательное решение. Если последовательное решение оказывается неудачным, эта неудача может дать ключ к нужному параллельному решению.
34 Глава 1. Преимущества параллельного программирования 1.4.2. Связь После декомпозиции программного решения на ряд параллельно выполняемых частей обычно возникает вопрос о связи этих частей между собой. Как же реализовать связь, если эти части разнесены по различным процессам или различным компьютерам? Должны ли различные части ПО совместно использовать общую область памяти? Каким образом одна часть ПО узнает о том, что другая справилась со своей задачей? Какая часть должна первой приступить к работе? Откуда один компонент узнает об отказе другого компонента? На эти и многие другие вопросы необходимо найти ответы при проектировании параллельных и распределенных систем. Если отдельным частям ПО не нужно связываться между собой, значит, они в действительности не образуют единое приложение. 1.4.3. Синхронизация Декомпозиция работ, как уже было отмечено выше, определяет, что должны делать разные части ПО. Когда множество компонентов ПО работают в рамках одной задачи, их функционирование необходимо координировать. Определенный компонент должен "уметь" определить, когда достигается решение всей задачи. Необходимо также скоординировать порядок выполнения компонентов. При этом возникает множество вопросов. Все ли части ПО должны одновременно приступать к работе или только некоторые, а остальные могут находиться пока в состоянии ожидания? Каким двум (или больше) компонентам необходим доступ к одному и тому же ресурсу? Кто имеет право получить его первым? Если некоторые части ПО завершат свою работу гораздо раньше других, то нужно ли им "поручать" новую работу? Кто должен давать новую работу в таких случаях? ДСС (декомпозиция, связь и синхронизация) — это тот минимум вопросов, которые необходимо решить, приступая к параллельному или распределенному программированию. Помимо сути проблем, составляющих ДСС, важно также рассмотреть их привязку. Существует несколько уровней параллелизма в разработке приложений, и в каждом из них ДСС-составляющие применяются по-разному. 1.5. Базовые уровни программного параллелизма В этой книге мы исследуем возможности параллелизма в пределах приложения (в противоположность параллелизму на уровне операционной системы или аппаратных средств). Несмотря на то что параллелизм на уровне операционной системы или аппаратных средств поддерживает параллелизм приложения, нас все же интересует само приложение. Итак, параллелизм можно обеспечить на уровне: • инструкций; • подпрограмм (функций или процедур); • объектов; • приложений. 1.5.1. Параллелизм на уровне инструкций Параллелизм на уровне инструкций возникает, если несколько частей одной инструкции могут выполняться одновременно. На рис. 1.3 показан пример декомпозиции одной инструкции с целью достижения параллелизма выполнения отдельных операций.
1.5. Базовые уровни программного параллелизма 35 На рис. 1.3 компонент (А + В) можно вычислить одновременно с компонентом /С _ d) • Этот вид параллелизма обычно поддерживается директивами компилятора и не попадает под управление С++-программиста. X = (A + B)*(C-D) х^а + в хг-c-D Параллельное выполнение j 1 ' | Синхронизация X = Х2 * Х2 Рис. 1.3. Декомпозиция одной инструкции 1.5.2. Параллелизм на уровне подпрограмм ДСС-структуру программы можно представить в виде ряда функций, т.е. сумма работ, из которых состоит программное решение, разбивается на некоторое количество функций. Если эти функции распределить по потокам, то каждую функцию в этом случае можно выполнить на отдельном процессоре, и, если в вашем распоряжении будет достаточно процессоров, то все функции смогут выполняться одновременно. Подробнее потоки описываются в главе 4. 1.5.3. Параллелизм на уровне объектов ДСС-структуру программного решения можно распределить между объектами. Каждый объект можно назначить отдельному потоку или процессу. Используя стандарт CORBA (Common Object Request Broker Architecture — технология построения распределенных объектных приложений), все объекты можно назначить различным компьютерам одной сети или различным компьютерам различных сетей. Более детально технология CORBA рассматривается в главе 8. Объекты, реализованные в различных потоках или процессах, могут выполнять свои методы параллельно. 1.5.4. Параллелизм на уровне приложений Несколько приложений могут сообща решать некоторую проблему. Несмотря на то что какое-то приложение первоначально предназначалось для выполнения отдельной задачи, принципы многократного использования кода позволяют приложениям сотрудничать. В таких случаях два отдельных приложения эффективно работают вместе подобно единому распределенному приложению. Например, буфер обмена (Clipboard) не предназначался для работы ни с каким конкретным приложением, но его успешно использует множество приложений рабочего стола. О некоторых вариантах применения буфера обмена его создатели в процессе разработки даже и не мечтали. Второй и третий уровни — это основные уровни параллелизма, поэтому методам их реализации и уделяется основное внимание в этой книге. Уровня операционной системы и аппаратных средств мы коснемся только в том случае, когда это будет необходимо в контексте проектирования приложений. Получив соответствующую ДСС- структуру для проекта, предусматривающего параллельное или распределенное программирование, можно переходить к следующему этапу— рассмотрению возможности его реализации в C++.
36 Глава 1. Преимущества параллельного программирования 1.6. Отсутствие языковой поддержки параллелизма в C++ Язык C++ не содержит никаких синтаксических примитивов для параллелизма. С++-стандарт ISO также отмалчивается на тему многопоточности. В языке C++ не предусмотрено никаких средств, чтобы указать, что заданные инструкции должны выполняться параллельно. Включение встроенных средств параллелизма в других языках представляется как их особое достоинство. Бьерн Страуструп, создатель языка C++, имел свое мнение на этот счет: Можно организовать поддержку параллелизма средствами библиотек, которые будут приближаться к встроенным средствам параллелизма как по эффективности, так и по удобству применения. Опираясь на такие библиотеки, можно поддерживать различные модели, а не только одну, как при использования встроенных средств параллелизма. Я полагаю, что большинство программистов согласятся со мной, что именно такое направление (создание набора библиотек поддержки параллелизма) позволит решить проблемы переносимости, используя тонкий слой интерфейсных классов. Более того, Страуструп говорит: "Я считаю, что параллелизм в C++ должен быть представлен библиотеками, а не как языковое средство". Авторы этой книги находят позицию Страуструпа и его рекомендации по реализации параллелизма в качестве библиотечного средства наиболее подходящими с практической точки зрения. В настоящей книге рассмотрен только этот вариант, и такой выбор объясняется доступностью высококачественных библиотек, которые успешно можно использовать для решения задач параллельного и распределенного программирования. Библиотеки, которые мы используем для усиления языка C++ с этой целью, реализуют национальные и международные стандарты и используются тысячами С++-программистов во всем мире. 1.6.1. Варианты реализации параллелизма с помощью C++ Несмотря на существование специальных версий языка C++, предусматривающих "встроенные" средства параллельной обработки данных, мы представляем методы реализации параллелизма с использованием стандарта ISO (International Organization for Standardization — Международная организация по стандартизации) для C++. Мы находим библиотечный подход к параллелизму (при котором используются как системные, так и пользовательские библиотеки) наиболее гибким. Системные библиотеки предоставляются средой операционной системы. Например, поточно- ориентированная библиотека POSIX (Portable Operating System Interface— интерфейс переносимой операционной системы) содержит набор системных функций, которые в сочетании с языковыми средствами C++ успешно используются для поддержки параллелизма. Библиотека POSIX Threads является частью нового единого стандарта спецификаций UNIX (Single UNIX Specifications Standard) и включена в набор стандартов IEEE, описывающих интерфейсы ОС для UNIX (IEEE Std. 1003.1-2001). Создание нового единого стандарта спецификаций UNIX финансируется организацией Open Group, а его разработка поручена организации Austin Common Standards Revision Group. В соответствии с документами Open Group новый единый стандарт спецификаций UNIX:
1.6. Отсутствие языковой поддержки параллелизма в C++ 37 • предоставляет разработчикам ПО единый набор API-функций, которые должны поддерживаться каждой UNIX-системой; • смещает акцент с несовместимых реализаций систем UNIX на соответствие единому набору функций API; • представляет собой кодификацию и юридическую стандартизацию общего ядра системы UNIX; • в качестве основной цели преследует достижение переносимости исходного кода приложения. Новый единый стандарт спецификаций UNIX, версия 3, включает стандарт IEEE Std. 1003.1-2001 и спецификации Open Group Base Specifications Issue 6. Стандарты IEEE POSIX в настоящее время представляют собой часть единой спецификации UNIX, и наоборот. Сейчас действует единый международный стандарт для интерфейса переносимой операционной системы. С++-разработчикам это только на руку, поскольку данный стандарт содержит API-функции, которые позволяют создавать потоки и процессы. За исключением параллелизма на уровне инструкций, единственным способом достижения параллелизма с помощью C++ является разбиение программы на потоки или процессы. Именно эти средства и предоставляет новый стандарт. Разработчик может использовать: • библиотеку POSIX Threads (или Pthreads); • POSIX-функцию spawn (); • семейство функций exec (). Все эти средства поддерживаются системными API-функциями и системными библиотеками. Если операционная система отвечает 3-й версии нового единого стандарта UNIX, то С++-разработчику будут доступны эти API-функции (они рассматриваются в главах 3 и 4 и используются во многих примерах этой книги). Помимо библиотек системного уровня, для поддержки параллелизма в C++ могут применяться такие библиотеки пользовательского уровня, как MPI (Message Passing Interface— интерфейс для передачи сообщений), PVM (Parallel Virtual Machine— параллельная виртуальная машина) и CORBA (Common Object Request Broker Architecture— технология построения распределенных объектных приложений). 1.6.2. Стандарт MPI Интерфейс MPI — стандартная спецификация на передачу сообщений — был раз- раоотан с целью достижения высокой производительности на компьютерах с массовым параллелизмом и кластерах рабочих станций (рабочая станция — это сетевой компьютер, использующий ресурсы сервера). В этой книге используется MPICH-реализация стандарта MPI. MPICH — это свободно распространяемая переносимая реализация интерфейса MPI. MPICH предоставляет С++-программисту набор API-функций и библиотек, которые поддерживают параллельное программирование. Интерфейс МР1 особенно полезен для программирования моделей SPMD (Single-Program, Multiple- Uata — одна програмхма, несколько потоков данных) и MPMD (Multiple-Program, Multiple-Data— множество программ, множество потоков данных). Авторы этой книги используют MPICH-реализацию библиотеки MPI для 32-узлового Linux-ориенти- рованного кластера и 8-узлового кластера, управляемого операционными системами
38 Глава 1. Преимущества параллельного программирования Linux и Solaris. И хотя в C++ нет встроенных примитивов параллельного программирования, С++-программист может воспользоваться средствами обеспечения параллелизма, предоставляемыми библиотекой MPICH. В этом и состоит одно из достоинств языка C++, которое заключается в его фантастической гибкости. 1.6.3. PVM: стандарт для кластерного программирования Программный пакет PVM позволяет связывать гетерогенную (неоднородную) коллекцию компьютеров в сеть для использования ее в качестве единого мощного параллельного компьютера. Общая цель PVM-системы — получить возможность совместно использовать коллекцию компьютеров для организации одновременной или параллельной обработки данных. Реализация библиотеки PVM поддерживает: • гетерогенность по компьютерам, сетям и приложениям; • подробно разработанную модель передачи сообщений; • обработку данных на основе выполнения процессов; • мультипроцессорную обработку данных (МРР, SMP)'; • "полупрозрачный" доступ к оборудованию (т.е. приложения могут либо игнорировать, либо использовать преимущества различий в аппаратных средствах); • динамически настраиваемый пул (процессоры могут добавляться или удаляться динамически, возможен также их смешанный состав). PVM — это самая простая (по использованию) и наиболее гибкая среда, доступная для решения задач параллельного программирования, которые требуют применения различных типов компьютеров, работающих под управлением различных операционных систем. PVM-библиотека особенно полезна для объединения в сеть нескольких однопроцессорных систем с целью образования виртуальной машины с параллельно работающими процессорами. Методы использования библиотеки PVM в С++-коде мы рассмотрим в главе 6. PVM — это фактический стандарт для реализации гетерогенных кластеров, который легко доступен и широко распространен. PVM прекрасно поддерживает модели параллельного программирования MPMD (MIMD) и SPMD (SIMD). Авторы этой книги для решения небольших и средних по объему задач параллельного программирования используют PVM-библиотеку, а для более сложных и объемных — MPI-библиотеку. Обе библиотеки PVM и MPI можно успешно сочетать с C++ для программирования кластеров. 1.6.4. Стандарт CORBA CORBA— это стандарт для распределенного кроссплатформенного объектно- ориентированного программирования. Выше упоминалось о применении CORBA для поддержки параллелизма, поскольку реализации стандарта CORBA можно использовать для разработки мультиагентных систем. Мультиагентные системы предлагают важные сетевые модели распределенного программирования с равноправными узлами МРР - Massively Parallel Processors (процессоры с массовым параллелизмом), SMP - symmetric multiprocessor (симметричный мультипроцессор).
1.6. Отсутствие языковой поддержки параллелизма в C++ 39 foeer-to-peer). В мультиагентных системах работа может быть организована параллельно. 9то одна из областей, в которых параллельное и распределенное программирование пе- крываются. Несмотря на то что агенты выполняются на различных компьютерах, это происходит в течение одного и того же промежутка времени, т.е. агенты совместно работают над общей проблемой. Стандарт CORBA обеспечивает открытую, независимую от изготовителя архитектуру и инфраструктуру, которую компьютерные приложения используют для совместного функционирования в сети. Используя стандартный протокол ПОР (Internet InterORB Protocol — протокол, определяющий передачу сообщений между сетевыми объектами по TCP/IP), CORBA-ориентированная программа (созданная любым производителем на любом языке программирования, выполняемая практически на любом компьютере под управлением любой операционной системы в любой сети) может взаимодействовать с другой CORBA-ориентированной программой (созданной тем же или другим производителем на любом другом языке программирования, выполняемой практически на любом компьютере под управлением любой операционной системы в любой сети). В этой книге мы используем MICO-реализацию стандарта CORBA. MICO— свободно распространяемая и полностью соответствующая требованиям реализация стандарта CORBA, которая поддерживает язык C++. 1.6.5. Реализации библиотек на основе стандартов Библиотеки MPICH, PVM, MICO и POSIX Threads реализованы на основе стандартов. Это означает, что разработчики ПО могут быть уверены, что эти реализации широко доступны и переносимы с одной платформы на другую. Эти библиотеки используются многими разработчиками ПО во всем мире. Библиотеку POSIX Threads можно использовать с C++ для реализации многопоточного программирования. Если про-; грамма выполняется на компьютере с несколькими процессорами, то каждый поток может выполняться на отдельном процессоре, что позволяет говорить о реальной параллельности программирования. Если же компьютер содержит только один про-! цессор, то иллюзия параллелизма обеспечивается за счет процесса переключения контекстов. Библиотека POSIX Threads позволяет реализовать, возможно, самый! простой способ введения параллелизма в С++-программу. Если для использования библиотек MPICH, PVM и MICO необходимо предварительно побеспокоиться об их] установке, то в отношении библиотеки POSIX Threads это излишне, поскольку среда любой операционной системы, которая согласована с POSIX-стандартом или новой] спецификацией UNIX (версия 3), оснащена реализацией библиотеки POSIX Threads Все библиотеки предлагают модели параллелизма, которые имеют незначительные) различия. В табл. 1.2 показано, как каждую библиотеку можно использовать с C++. [Табл Таблица 1.2. Использование библиотек MPICH, PVM, MICO и POSIX Threads с C++ Библиотека Использование с C++ MPICH Поддерживает крупномасштабное сложное программирование кластеров. Предпочтительно используется для модели SPMD. Также поддерживает SMP-, МРР- и многопользовательские конфигурации PVM Поддерживает кластерное программирование гетерогенных сред. Легко используется для однопользовательских (мелко- и среднемасштабных) кластерных приложений. Также поддерживает МРР-конфигурации
40 Глава 1. Преимущества параллельного программирования Окончание табл. 1.2 Библиотека Использование с C++ MICO Поддерживает и распределенное, и параллельное программирование. Содержит эффективные средства поддержки агентно- ориентированного и мультиагентного программирования POSIX Поддерживает параллельную обработку данных в одном приложении на уровне функций или объектов. Позволяет воспользоваться преимуществами SMP- и МРР-конфигурации В то время как языки со встроенной поддержкой параллелизма ограничены применением конкретных моделей, С++-разработчик волен смешивать различные модели параллельного программирования. При изменении структуры приложения С++- разработчик в случае необходимости выбирает другие библиотеки, соответствующие новому сценарию работы. 1.7. Среды для параллельного и распределенного программирования Наиболее распространенными средами для параллельного и распределенного программирования являются кластеры, SMP- и МРР-компьютеры. Кластеры — это коллекции, состоящие из нескольких компьютеров, объединенных сетью для создания единой логической системы. С точки зрения приложения такая группа компьютеров выглядит как один виртуальный компьютер. Под МРР- конфигурацией (Massively Parallel Processors — процессоры с массовым параллелизмом) понимается один компьютер, содержащий сотни процессоров, а под SMP-конфи- гурацией (symmetric multiprocessor — симметричный мультипроцессор) — единая система, в которой тесно связанные процессоры совместно используют общую память и информационный канал. SMP-процессоры разделяют общие ресурсы и являются объектами управления одной операционной системы. Поскольку эта книга представляет собой введение в параллельное и распределенное программирование, нас будут интересовать небольшие кластеры, состоящие из 8-32 процессоров, и многопроцессорные компьютеры с двумя-четырьмя процессорами. И хотя многие рассматриваемые здесь методы можно использовать в МРР- или больших SMP-средах, мы в основном уделяем внимание системам среднего масштаба. 1.8. Резюме В этой книге представлен архитектурный подход к параллельному и распределенному программированию. При этом акцент ставится на определении естественного параллелизма в самой задаче и ее решении, который закрепляется в программной модели решения. Мы предлагаем использовать объектно-ориентированные методы, которые бы позволили справиться со сложностью параллельного и распределенного программирования, и придерживаемся следующего принципа: функция следует за формой. В отношении языка C++ используется библиотечный подход к обеспечению
1.8. Резюме 41 поддержки параллелизма. Рекомендуемые нами библиотеки базируются на национальных и международных стандартах. Каждая библиотека легко доступна и широко используется программистами во всем мире. Методы и идеи, представленные в этой книге, не зависят от конкретных изготовителей программных и аппаратных средств, общедоступны и опираются на открытые стандарты и открытые архитектуры. С++-программист и разработчик ПО может использовать различные модели параллелизма, поскольку каждая такая модель обусловливается библиотечными средствами. Библиотечный подход к параллельному и распределенному программированию дает С++-программисту гораздо большую степень гибкости по сравнению с использованием встроенных средств языка. Наряду с достоинствами, параллельное и распределенное программирование не лишено многих проблем, которые рассматриваются в следующей главе.
ПРОБЛЕМЫ ПАРАЛЛЕЛЬНОГО И РАСПРЕДЕЛЕННОГО ПРОГРАММИРОВАНИЯ В этой главе... 2.1. Кардинальное изменение парадигмы 2.2. Проблемы координации 2.3. Отказы оборудования и поведение ПО 2.4. Негативные последствия излишнего параллелизма и распределения 2.5. Выбор архитектуры 2.6. Различные методы тестирования и отладки 2.7. Связь между параллельным и распределенным проектами 2.8. Резюме
Slj\3JS33J J Я1;*\ "Стремление обозначать точные значения любой физической величины (температура, плотность, напряженность потенциального поля или что-либо еще...) есть не что иное как смелая экстраполяция." — Эрвин Шредингер (Erwin Shrodinger), Causality and Wave Mechanics В базовой последовательной модели программирования инструкции компьютерной программы выполняются поочередно. Программа выглядит как кулинарный рецепт, в соответствии с которым для каждого действия компьютера задан порядок и объемы используемых "ингредиентов". Разработчик программы разбивает основную задачу ПО на коллекцию подзадач. Все задачи выполняются по порядку, и каждая из них должна ожидать своей очереди. Все программы имеют начало, середину и конец. Разработчик представляет каждую программу в виде линейной последовательности задач. Эти задачи необязательно должны находиться в одном файле, но их следует связать между собой так, чтобы, если первая задача по какой-то причине не завершила свою работу, то вторая вообще не начинала выполнение. Другими словами, каждая задача, прежде чем приступить к своей работе, должна ожидать до тех пор, пока не получит результатов выполнения предыдущей. В последовательной Модели зачастую устанавливается последовательная зависимость задач. Это означает, что задаче А необходимы результаты выполнения задачи В, а задаче В нужны результаты выполнения задачи С, которой требуется что-то от задачи D и т.д. Если при выполнении задачи В по какой-то причине произойдет сбой, задачи С и D никогда не Приступят к работе. В таком последовательном мире разработчик привычно ориентирует ПО сначала на выполнение действия 1, затем — действия 2, за которым должно следовать действие 3 и т.д. Подобная последовательная модель настолько закрепилась
44 Глава 2. Проблемы параллельного и распределенного программирования в процессе проектирования и разработки ПО, что многие программисты считают ее незыблемой и не допускают мысли о возможности иного положения вещей. Решение каждой проблемы, разработка каждого алгоритма и планирование каждой структуры данных — все это делалось с мыслью о последовательном доступе компьютера к каждой инструкции или ячейке данных. 2.1. Кардинальное изменение парадигмы В мире параллельного программирования все обстоит по-другому. Здесь сразу несколько инструкций могут выполняться в один и тот же момент времени. Одна инструкция разбивается на несколько мелких частей, которые будут выполняться одновременно. Программа разбивается на множество параллельных задач. Программа может состоять из сотен или даже тысяч выполняющихся одновременно подпрограмм. В мире параллельного программирования последовательность и местоположение составляющих ПО не всегда предсказуемы. Несколько задач могут одновременно начать выполнение на любом процессоре без какой бы то ни было гарантии того, что задачи закреплены за определенными процессорами, или такая-то задача завершится первой, или все они завершатся в таком-то порядке. Помимо параллельного выполнения задач, здесь возможно параллельное выполнение частей (подзадач) одной задачи. В некоторых конфигурациях не исключена возможность выполнения подзадач на различных процессорах или даже различных компьютерах. На рис. 2.1 показаны три уровня параллелизма, которые могут присутствовать в одной компьютерной программе. Модель программы, показанная на рис. 2.1, отражает кардинальное изменение парадигмы программирования, которая была характерна для "раннего" сознания программистов и разработчиков. Здесь отображены три уровня параллелизма и их распределение по нескольким процессорам. Сочетание этих трех уровней с базовыми параллельными конфигурациями процессоров показано на рис. 2.2. Обратите внимание на то, что несколько задач может выполняться на одном процессоре даже при наличии в компьютере нескольких процессоров. Такая ситуация создается системными стратегиями планирования. На длительность выполнения задач, подзадач и инструкций оказывают влияние и выбранные стратегии планирования, и приоритеты процессов, и приоритеты потоков, и быстродействие устройств ввода-вывода. На рис. 2.2 следует обратить внимание на различные архитектуры, которые программист должен учитывать при переходе от последовательной модели программирования к параллельной. Основное различие в моделях состоит в переходе от строго упорядоченной последовательности задач к лишь частично упорядоченной (или вовсе неупорядоченной) коллекции задач. Параллелизм превращает ранее известные величины (порядок выполнения, время выполнения и место выполнения) в неизвестные. Любая комбинация этих неизвестных величин является причиной изменения значений программы, причем зачастую непредсказуемым образом. 2.2. Проблемы координации Если программа содержит подпрограммы, которые могут выполняться параллельно, и эти подпрограммы совместно используют некоторые файлы, устройства или области памяти, то неизбежно возникают проблемы координации. Предположим, у нас
2.2. Проблемы координации 45 программа поддержки электронного банка, которая позволяет снимать деньги со Чета и класть их на депозит. Допустим, что эта программа разделена на три задачи (обозначим их А, В и С), которые могут выполняться параллельно. Программа/ приложение Параллелизм на уровне задач Все задачи выполняются параллельно. Задача. А Задача В Параллелизм на уровне подзадач Задача разбивается на параллельно выполняемые подзадачи. Поток 1, Поток 2. Поток 1с Поток 2, Параллелизм на' уровне'инструкций Это компоненты инструкции могут выполняться параллельно. d ,ё Г Рис. 2.1. Три уровня параллелизма, которые возможны в одной компьютерной программе Задача А получает запросы от задачи Б на выполнение операций снятия денег со счета. Задача А также получает запросы от задачи С положить деньги на депозит. Задача А принимает запросы и обрабатывает их по принципу "первым пришел — первым обслужен". Предположим, на счете имеется 1000 долл., при этом задача С требует Положить на депозит 100 долл., а задача В желает снять со счета 1100 долл. Что произойдет, если обе задачи Б и С попытаются обновить один и тот же счет одновременно?
46 Глава 2. Проблемы параллельного и распределенного программирования Параллелизм на уровне задач Программа/ приложение Задача А Задача В Задача С Задач'а В Задача А: Задача С Процессор, Процессор Компьютер 1 Однопроцессорный компьютер Параллелизм на уровне подзадач Программа/ приложение Задача С I Задача I I Задача I I A I I С J I 1 I Поток 1. Поток 2 I AN All Тг Поток 2* Поток 1, Процессор ^^____р Задачз С Процессор..,] Компьютер 3 PVM-среда с несколькими однопроцессорными компьютерами Параллелизм на уровне инструкций Программа/ приложение Задача А Поток 1я Компьютер 5 PVM-среда с несколькими многопроцессорными компьютерами Рис. 2.2. Три уровня параллелизма в сочетании с базовыми параллельными конфигурациями процессоров
2.2. Проблемы координации 47 Каким будет остаток на счете? Очевидно, остаток на счете в каждый момент времени может иметь более одного значения. Задача А применительно к счету должна вы- олнять одновременно только одну транзакцию, т.е. мы сталкиваемся с проблемой координации задач. Если запрос задачи В будет выполнен на какую-то долю секунды быстрее, чем запрос задачи С, то счет приобретет отрицательный баланс. Но если задача С получит первой право на обновление счета, то этого не произойдет. Таким об- пазом, остаток на счете зависит от того, какой задаче (В или С) первой удастся сделать запрос к задаче А. Более того, мы можем выполнять задачи В и С несколько раз с одними и теми же значениями, и при этом иногда запрос задачи В будет произведен на какую-то долю секунды быстрее, чем запрос задачи С, а иногда — наоборот. Очевидно, что необходимо организовать надлежащую координацию действий. Для координации задач, выполняемых параллельно, требуется обеспечить связь между ними и синхронизацию их работы. При некорректной связи или синхронизации обычно возникает четыре типа проблем. Проблема №1: "гонка" данных Если несколько задач одновременно попытаются изменить некоторую общую область данных, а конечное значение данных при этом будет зависеть от того, какая задача обратится к этой области первой, возникнет ситуация, которую называют состоянием "гонок" (race condition). В случае, когда несколько задач попытаются обновить один и тот же ресурс данных, такое состояние "гонок" называют "гонкой" данных (data race). Какая задача в нашей программе поддержки электронного банка первой получит доступ к остатку на счете, определяется результатом работы планировщика задач операционной системы, состоянием процессоров, временем ожидания и случайными причинами. В такой ситуации создается состояние "гонок". И какое значение в этом случае должен сообщать банк в качестве реального остатка на счете? Итак, несмотря на то, что мы хотели бы, чтобы наша программа позволяла одновременно обрабатывать множество операций по снятию денег со счета и вложению их на депозит, нам нужно координировать эти задачи в случае, если окажется, что операции снятия и вложения денег должны быть применены к одному и тому же счету. Всякий раз когда задачи одновременно используют модифицируемый ресурс, к ресурсному доступу этих задач должны быть применены определенные правила и стратегии. Например, в нашей программе поддержки банковских операций со счетами мы могли бы всегда выполнять любые операции по вложению денег до выполнения каких бы то ни было операций по их снятию. Мы могли бы установить правило, в соответствии с которым доступ к счету одновременно могла получать только одна транзакция. И если окажется, что к одному и тому же счету одновременно обращается сразу несколько транзакций, их необходимо задержать, организовать их выполнение в соответствии с некоторым правилом очередности, а затем предоставлять им доступ к счету по одной (в порядке очереди). Такие правила организации позволяют добиться надлежащей синхронизации действий. Проблема №2: бесконечная отсрочка Такое планирование, при котором одна или несколько задач должны ожидать до тех Пор, пока не произойдет некоторое событие или не создадутся определенные условия, Может оказаться довольно непростым для реализации. Во-первых, ожидаемое событие
48 Глава 2. Проблемы параллельного и распределенного программирования или условие должно отличаться регулярностью. Во-вторых, между задачами следует наладить связи. Если одна или несколько задач ожидают сеанса связи до своего выполнения, то в случае, если ожидаемый сеанс связи не состоится, состоится слишком поздно или не полностью, эти задачи могут так никогда и не выполниться. И точно так же, если ожидаемое событие или условие, которое (по нашему мнению) должно произойти (или наступить), но в действительности не происходит (или не наступает), то приостановленные нами задачи будут вечно находиться в состоянии ожидания. Если мы приостановим одну или несколько задач до наступления события (или условия), которое никогда не произойдет, возникнет ситуация, называемая бесконечной отсрочкой (indefinite postponement). Возвращаясь к нашему примеру электронного банка, предположим, что, если мы установим правила, предписывающие всем задачам снятия денег со счета находиться в состоянии ожидания до тех пор, пока не будут выполнены все задачи вложения денег на счет, то задачи снятия денег рискуют стать бесконечно отсроченными. Мы исходили из предположения о гарантированном существовании задач вложения денег на счет. Но если ни один из запросов на пополнение счетов не поступит, то что тогда заставит выполниться задачи снятия денег? И, наоборот, что, если будут без конца поступать запросы на пополнение одного и того же счета? Ведь тогда не сможет "пробиться" к счету ни один из запросов на снятие денег. Такая ситуация также может вызвать бесконечную отсрочку задач снятия денег. Бесконечная отсрочка возникает при отсутствии задач вложения денег на счет или их постоянном поступлении. Необходимо также предусмотреть ситуацию, когда запросы на вложение денег поступают корректно, но нам не удается надлежащим образом организовать связь между событиями и задачами. По мере того как мы будем пытаться скоординировать доступ параллельных задач к некоторому общему ресурсу данных, следует предусмотреть все ситуации, в которых возможно создание бесконечной отсрочки. Методы, позволяющие избежать бесконечных отсрочек, рассматриваются в главе 5. Проблема №3: взаимоблокировка Взаимоблокировка— это еще одна "ловушка", связанная с ожиданием. Для демонстрации взаимоблокировки предположим, что в нашей программе поддержки электронного банка три задачи работают не с одним, а с двумя счетами. Вспомним, что задача А получает запросы от задачи В на снятие денег со счета, а от задачи С — запросы на вложение денег на депозит. Задачи А, В и С могут выполняться параллельно. Однако задачи В и С могут обновлять одновременно только один счет. Задача А предоставляет доступ задач В и С к нужному счету по принципу "первым пришел — первым обслужен". Предположим также, что задача В имеет монопольный доступ к счету 1, а задача С — монопольный доступ к счету 2. При этом задаче В для выполнения соответствующей обработки также нужен доступ к счету 2 и задаче С — доступ к счету 1. Задача В удерживает счет 1, ожидая, пока задача С не освободит счет 2. Аналогично задача С удерживает счет 2, ожидая, пока задача В не освободит счет 1. Тем самым задачи В и С рискуют попасть в тупиковую ситуацию, которую в данном случае можно назвать взаимоблокировкой (deadlock). Ситуация взаимоблокировки между задачами В и С схематично показана на рис. 2.3. Форма взаимоблокировки в данном случае объясняется наличием параллельно выполняемых задач, имеющих доступ к совместно используемым данным, которые им разрешено обновлять. Здесь возможна ситуация, когда каждая из задач будет ожидать до тех пор, пока другая не освободит доступ к общим данным (общими данными здесь являются счет 1 и счет 2). Обе задачи имеют доступ к обоим счетам. Может случиться так,
2.2. Проблемы координации 49 вместо получения доступа одной задачи к двум счетам, каждая задача получит доступ ному из счетов. Поскольку задача В не может освободить счет 1, пока не получит к счету 2, а задача С не может освободить счет 2, пока не получит доступ к счету 1, опэамма обслуживания счетов электронного банка будет оставаться заблокированной. Обоатите внимание на то, что задачи В и С могут ввести в состояние бесконечной отсрочки и другие задачи (если таковые имеются в системе). Если другие задачи ожидают получения доступа к счетам 1 или 2, а задачи В и С "скованы" взаимоблокировкой, то те другие задачи будут ожидать условия, которое никогда не выполнится. При координации параллельно выполняемых задач необходимо помнить, что взаимоблокировка и бесконечная отсрочка — это самые опасные преграды, которые нужно предусмотреть и избежать. Счет №1 Доступ к счету №1 предоставлен задаче В Задача С запрашивает доступ к счету №1 Задача В запрашивает доступ к счету №2 Доступ к счету №2 предоставлен задаче С Рис. 2.3. Ситуация взаимоблокировки между задачами Б и С Проблема №4: трудности организации связи Многие распространенные параллельные среды (например, кластеры) зачастую состоят из гетерогенных компьютерных сетей. Гетерогенные компьютерные сети— это системы, которые состоят из компьютеров различных типов, работающих в общем случае под управлением различных операционных систем и использующих различные сетевые протоколы. Их процессоры могут иметь различную архитектуру, обрабатывать слова различной длины и использовать различные машинные языки. Помимо разных операционных систем, компьютеры могут различаться используемыми стратегиями планирования и системами приоритетов. Хуже того, все системы могут различаться параметрами передачи данных. Это делает обработку ошибок и исключительных ситуаций (исключений) особенно трудной. Неоднородность системы может усугубляться и другими различиями. Например, может возникнуть необходимость в организации совместного использования данных программами, написанными на различных языках или разработанных с использованием различных моделей ПО. Ведь общее системное решение может быть реализовано по частям, написанным на языках Fortran, C++ и Java. Это вносит проблемы межъязыковой связи. И даже если распределенная или параллельная среда не является гетерогенной, остается проблема
50 Глава 2. Проблемы параллельного и распределенного программирования взаимодействия между несколькими процессами или потоками. Поскольку каждый процесс имеет собственное адресное пространство, то для совместного использования переменных, параметров и значений, возвращаемых функциями, необходимо применять технологию межпроцессного взаимодействия (interprocess communication— IPC), или МПВ-технологию. И хотя реализация МПВ-методов необязательно является самой трудной частью разработки системы ПО, тем не менее они образуют дополнительный уровень проектирования, тестирования и отладки в создании системы. POSIX-спецификация поддерживает пять базовых механизмов, используемых для реализации взаимодействия между процессами: • файлы со средствами блокировки и разблокировки; • каналы (неименованные, именованные и FIFO-очереди); • общая память и сообщения; • сокеты; • семафоры. Каждый из этих механизмов имеет достоинства, недостатки, ловушки и тупики, которые проектировщики и разработчики ПО должны обязательно учитывать, если хотят создать надежную и эффективную связь между несколькими процессами. Организовать взаимодействие между несколькими потоками (которые иногда называются облегченными процессами) обычно проще, чем между процессами, так как потоки используют общее адресное пространство. Это означает, что каждый поток в программе может легко передавать параметры, принимать значения, возвращаемые функциями, и получать доступ к глобальным данным. Но если взаимодействие процессов или потоков не спроектировано должным образом, возникают такие проблемы, как взаимоблокировки, бесконечные отсрочки и другие ситуации "гонки" данных. Необходимо отметить, что перечисленные выше проблемы характерны как для распределенного, так и для параллельного программирования. Несмотря на то что системы с исключительно параллельной обработкой отличаются от систем с исключительно распределенной обработкой, мы намеренно не проводили границ)7 между проблемами координации в распределенных и параллельных системах. Частично мы можем объяснить это некоторым перекрытием существующих проблем, и частично тем, что некоторые решения проблем в одной области часто применимы к проблемам в другой. Но главная причина нашего "обобщенного" подхода состоит в том, что в последнее время гибридные (параллельно-распределенные) системы становятся нормой. Современное положение в параллельном способе обработке данных определяют кластеры и сетки. Причудливые кластерные конфигурации составляют из готовых продуктов. Такие архитектуры включают множество компьютеров со многими процессорами, а однопроцессорные системы уже уходят в прошлое. В будущем предполагается, что чисто распределенные системы будут встраиваться в виде компьютеров с несколькими процессорами. Это означает, что на практике проектировщик или разработчик ПО будет теперь все чаще сталкиваться с проблемами распределения и параллелизма. Вот потому-то мы и рассматриваем все эти проблемы в одном пространстве. В табл. 2.1 представлены комбинации параллельного и распределенного программирования с различными конфигурациями аппаратного обеспечения. В табл. 2.1 обратите внимание на то, что существуют конфигурации, в которых параллелизм достигается за счет использования нескольких компьютеров. В этом случае подходит применение библиотеки PVM. И точно так же существуют конфигурации,
2.3. Отказы оборудования и поведение ПО 51 оторых распределение может быть достигнуто лишь на одном компьютере за счет биения Логики ПО на несколько процессов или потоков. Именно факт использо- ания множества процессов или потоков говорит о том, что работа программы носит "оаспределенный" характер. Комбинации параллельного и распределенного программирования, представленные в табл. 2.1, подразумевают, что проблемы конфигурации, обычно присущие распределенному программированию, могут возникнуть «ситуациях, обусловленных параллельным программированием, и, наоборот, проблемы конфигурации, обычно связанные с параллельным программированием, могут возникнуть в ситуациях, обусловленных распределенным программированием. Таблица 2.1. Комбинации параллельного и распределенного программирования с различными конфигурациями аппаратного обеспечения Один компьютер Множество компьютеров Параллельное программирование Распределенное программирование Оснащен множеством процессоров. Использует логическое разбиение на несколько потоков или процессов. Потоки или процессы могут выполняться на различных процессорах. Для координации задач требуется МПВ-технология Наличие нескольких процессоров не является обязательным. Логика ПО может быть разбита на несколько процессов или потоков. Для координации задач требуется МПВ- технология Использует такие библиотеки, как PVM. Требует организации взаимодействия посредством передачи сообщений, что обычно связано с распределенным программированием Реализуется с помощью сокетов и таких компонентов, как CORBA ORB (Object Request Broker — брокер объектных запросов). Может использовать тип взаимодействия, который обычно связан с параллельным программированием Независимо от используемой конфигурации аппаратных средств, существует два базовых механизма, обеспечивающих взаимодействие нескольких задач: общая память и средства передачи сообщений. Для эффективного использования механизма общей памяти программисту необходимо предусмотреть решение проблем "гонки" Данных, взаимоблокировки и бесконечных отсрочек. Схема передачи сообщений Должна предполагать возникновение таких "накладок", как прерывистые передачи, бессмысленные (искаженные), утерянные, ошибочные, слишком длинные, просроченные (с нарушением сроков), преждевременные сообщения и т.п. Эффективное использование обоих механизмов подробно рассматривается ниже в этой книге. 2.3. Отказы оборудования и поведение ПО При совместной работе множества процессоров над решением некоторой задачи возможен отказ одного или нескольких процессоров. Каким в этом случае должно °Ь1ть поведение ПО? Программа должна остановиться или возможно перераспределение работы? Что случится, если при использовании мультикомпыотерной системы Канал связи между несколькими компьютерами временно выйдет из строя? Что произойдет, если поток данных будет настолько медленным, что процессы на каждом
52 Глава 2. Проблемы параллельного и распределенного программирования конце связи превысят выделенный им лимит времени? Как ПО должно реагировать на подобные ситуации? Если, предположим, во время работы системы, состоящей из 50 компьютеров, совместно работающих над решением некоторой проблемы, произойдет отказ двух компьютеров, то должны ли остальные 48 взять на себя их функции? Если в нашей программе электронного банка при одновременном выполнении задач по снятию и вложению денег на счет две задачи попадут в ситуацию взаимоблокировки, то нужно ли прекратить работу серверной задачи? И что тогда делать с заблокированными задачами? А как быть, если задачи по снятию и вложению денег на счет будут работать надлежащим образом, но по какой-то причине будет "парализована" серверная задача? Следует ли в этом случае прекратить выполнение всех "повисших" задач по снятию и вложению денег на счет? Что делать с частичными отказами или прерывистой работой? Подобные вопросы обычно не возникают при работе последовательных программ в одно-компьютерных средах. Иногда отказ системы является следствием административной политики или стратегии безопасности. Например, предположим, что система содержит 1000 подпрограмм, и некоторым из них требуется доступ к файлу для записи в него информации, но они по какой-то причине не могут его получить. В результате возможны взаимоблокировка, бесконечная отсрочка или частичный отказ. А как быть, если некоторые подпрограммы блокируются из-за отсутствия у них прав доступа к нужным ресурсам? Должна ли в таких случаях "вырубаться" вся система целиком? Насколько можно доверять обработанной информации, если в системе произошли сбои в оборудовании, отказ каналов связи или их работа была прерывистой? Тем не менее эти ситуации очень даже характерны (можно сказать, являются нормой) для распределенных или параллельных сред. В этой книге мы рассмотрим ряд архитектурных решений и технологий программирования, которые позволят программному обеспечению системы справляться с подобными ситуациями. 2.4. Негативные последствия излишнего параллелизма и распределения При внедрении технологии параллелизма всегда существует некоторая "точка насыщения", по "ту сторону" которой затраты на управление множеством процессоров превышают эффект от увеличения быстродействия и других достоинств параллелизма. Старая поговорка "процессоров никогда не бывает много" попросту не соответствует истине. Затраты на организацию взаимодействия между компьютерами или обеспечение синхронизации процессоров выливаются "в копеечку". Сложность синхронизации или уровень связи между процессорами может потребовать таких затрат вычислительных ресурсов, что они отрицательно скажутся на производительности задач, совместно выполняющих общую работу. Как узнать, на сколько процессов, задач или потоков следует разделить программу? И, вообще, существует ли оптимальное количество процессоров для любой заданной параллельной программы? В какой "точке" увеличение процессоров или компьютеров в системе приведет к замедлению ее работы, а не к ускорению? Нетрудно предположить, что рассматриваемые числа зависят от конкретной программы. В некоторых областях имитационного моделирования максимальное число процессоров может достигать нескольких тысяч, в то время как в коммерческих приложениях можно ограничиться несколькими сотнями. Для ряда клиент-серверных конфигураций зачастую оптимальное количество составляет восемь процессоров, а добавление девятого уже способно ухудшить работу сервера.
2.5. Выбор архитектуры 53 Всегда необходимо отличать работу и ресурсы, задействованные в управлении па- лельными аппаратными средствами, от работы, направленной на управление па- аллельно выполняемыми процессами и потоками в ПО. Предел числа программных пооцессов может быть достигнут задолго до того, как будет достигнуто оптимальное количество процессоров или компьютеров. И точно так же можно наблюдать снижение эффективности оборудования еще до достижения оптимального количества параллельно выполняемых задач. 2.5. Выбор архитектуры Существует множество архитектурных решений, которые поддерживают параллелизм. Архитектурное решение можно считать корректным, если оно соответствует декомпозиции работ (work breakdown structure— WBR) программного обеспечения (ДРПО). Параллельные и распределенные архитектуры могут быть самыми разнообразными. В то время как некоторые распределенные архитектуры прекрасно работают в Web-среде, они практически обречены на неудачу в среде с реальным масштабом времени. Например, распределенные архитектуры, которые рассчитаны на длинные временные задержки, вполне приемлемы для Web-среды и совершенно неприемлемы для многих сред реального времени. Достаточно сравнить распределенную обработку данных в Web-ориентированной системе функционирования электронной почты с распределенной обработкой данных в банкоматах, или автоматических кассовых машинах (automated teller machine— ATM). Задержка (время ожидания), которая присутствует во многих почтовых Web-системах, была бы попросту губительной для таких систем реального времени, как банкоматы. Одни распределенные архитектуры (имеются в виду некоторые асинхронные модели) справляются с временными задержками лучше, чем другие. Кроме того, необходимо самым серьезным образом подходить к выбору соответствующих архитектур параллельной обработки данных. Например, методы векторной обработки данных наилучшим образом подходят для решения определенных математических задач и проблем имитационного моделирования, но они совершенно неэффективны в применении к мультиагентным алгоритмам планирования. Распространенные архитектуры ПО, которые поддерживают параллельное и распределенное программирование, показаны в табл. 2.2. Четыре базовые модели, перечисленные в табл. 2.2, и их вариации обеспечивают основу для всех параллельных типов архитектур (т.е. объектно-ориентированного, агентно-ориентированного и "классной доски"), которые рассматриваются в этой книге. РазработчикахМ ПО необходимо подробно ознакомиться с каждой из этих моделей и их приложением к параллельному и распределенному программированию. Мы считаем своИхМ долгом предоставить читателю введение в эти модели и дать библиографические сведения по материалам, которые позволят найти о них более детальную информацию. В каждой работе или при решении проблемы лучше всего искать естественный или присущий им параллелизм, а выбранный тип архитектуры Должен максимально соответствовать этому естественному параллелиЗхМу. Например, Параллелизм в решении, возможно, лучше описывать с помощью симметричной модели, или модели сети с равноправными узлами (peer-to-peer model), в которой все сотрудники (исполнители) считаются равноправными, в отличие от несимметричной модели "управляющий/рабочий", в которой существует главный (ведущий) процесс, Управляющий всеми остальными процессами как подчиненными.
54 Глава 2. Проблемы параллельного и распределенного программирования Таблица 2.2. Распространенные архитектуры ПО, используемые для поддержки параллельного и распределенного программирования Модель Архитектура Распределенное Параллельное программирование программирование Модель ведущего узла, именуемая также: • главный/подчиненный; • управляющий/рабочий; • клиент/сервер Модель равноправных узлов Векторная или конвейерная (поточная)обработка Дерево с родительскими и дочерними элементами Главный узел управляет задачами, т.е. контролирует их выполнение и передает работу подчиненным задачам Все задачи, в основном, имеют одинаковый ранг, и работа между ними распределяется равномерно Один исполнительный узел соответствует каждому элементу массива (вектора) или шагу конвейера Динамически генерируемые исполнители в отношении типа "родитель/потомок". Этот тип архитектуры полезно использовать в алгоритмах следующих типов: • рекурсия; • "разделяй и властвуй"; •И/ИЛИ • древовидная обработка S • S 2.6. Различные методы тестирования и отладки При тестировании последовательной программы разработчик может отследить ее логику в пошаговом режиме. Если он будет начинать тестирование с одних и тех же данных при условии, что система каждый раз будет пребывать в одном и том же состоянии, то результаты выполнения программы или ее логические цепочки будут вполне предсказуемыми. Программист может отыскать ошибки в программе, используя соответствующие входные данные и исходное состояние программы, путем проверки ее логики в пошаговом режиме. Тестирование и отладка в последовательной модели зависят от степени предсказуемости начального и текущего состояний программы, определяемых заданными входными данными. С параллельным и распределенньш программированием все обстоит иначе. Здесь трудно воспроизвести точный контекст параллельных или распределенных задач из- за разных стратегий планирования, применяемых в операционной системе, динамически меняющейся рабочей нагрузки, квантов процессорного времени, приоритетов процессов и потоков, времени ых задержек при их взаимодействии и собственно
2.7. Связь между параллельным и распределенным проектами 55 шолнении, а также различных случайных изменений ситуаций, характерных для оаллельных или распределенных контекстов. Чтобы воспроизвести точное состоя- в котором находилась среда при тестировании и отладке, необходимо воссоздать аждую задачу, выполнением которой была занята операционная система. При этом лолжен быть известен режим планирования процессорного времени и точно воспроизведены состояние виртуальной памяти и переключение контекстов. Кроме того, следует воссоздать условия возникновения прерываний и формирования сигналов, а в некоторых случаях— даже рабочую нагрузку сети. При этом нужно понимать, что и сами средства тестирования и отладки оказывают немалое влияние на состояние среды. Это означает, что создание одинаковой последовательности событий для тестирования и отладки зачастую невозможно. Необходимость воссоздания всех перечисленных выше условий обусловлено тем, что они позволяют определить, какие процессы или потоки следует выполнять и на каких именно процессорах. Смешанное выполнение процессов и потоков (в некоторой неудачной "пропорции") часто является причиной возникновения взаимоблокировок, бесконечных отсрочек, "гонки" данных и других проблем. И хотя некоторые из этих проблем встречаются и в последовательном программировании, они не в силах зачеркнуть допущения, сделанные при построении последовательной модели. Тот уровень предсказуемости, который имеет место в последовательной модели, недоступен для параллельного программирования. Это заставляет разработчика овладевать новыми тактическими приемами для тестирования и отладки параллельных и распределенных программ, а также требует от него поиска новых способов доказательства корректности его програМхМ. 2.7. Связь между параллельным и распределенным проектами При создании документации на проектирование параллельного или распределенного ПО необходимо описать декомпозицию работ и их синхронизацию, а также взаимодействие между задачами, объектами, процессами и потоками. При этом проектировщики должны тесно контактировать с разработчиками, а разработчики — с теми, кто будет поддерживать систему и заниматься ее администрированием. В идеале это взаимодействие должно осуществляться по действующим стандартам. Однако найти единый язык, понятный всем сторонам и позволяющий четко представить мультипара- Дигматическую природу всех этих систем, — труднодостижимая цель. Мы остановили свой выбор на языке UML (Unified Modeling Language — унифицированный язык моделирования). В табл. 2.3 перечислено семь UML-диаграмм, которые часто используются При создании многопоточных, параллельных или распределенных программ. Семь диаграмм, перечисленных в табл. 2.3, представляют собой лишь подмножество диаграмм, которые предусмотрены языком UML, но они наиболее всего подходят к том)-, что мы хотим подчеркнуть в наших проектах параллельного ПО. В частности, UML-диаграМхМЫ деятельности, развертывания и состояний весьма полезны для °Писания взаимодействующего поведения параллельной и распределенной подсистем обработки данных. Поскольку UML— это фактический стандарт, используемый пРи создании взаимодействующих объектно-ориентированных и агентно- °рИентированных проектов, при изложении материала в этой книге мы опираемся иМенно на него. Описание обозначений и символов, используемых в перечисленных вЬ1Ше диаграммах, содержится в приложении А.
56 Глава 2. Проблемы параллельного и распределенного программирования Таблица 2.3. UML-диаграммы, используемые при создании многопоточных, параллельных или распределенных программ UML-диаграммы Описание Диаграмма (видов) деятельности Диаграмма взаимодействия Диаграмма (параллельных) состояний Диаграмма последовательностей Диаграмма сотрудничества Диаграмма развертывания (внедрения) Диаграмма компонентов Разновидность диаграммы состояний, в которой большинство состояний (или все) представляют виды деятельности, а большинство переходов (или все) активизируются при выполнении некоторого действия в исходных состояниях Тип диаграммы, которая отображает взаимодействие между объектами. Взаимодействия описываются в виде сообщений, которыми они обмениваются. К диаграммам взаимодействия относятся диаграммы сотрудничества, диаграммы последовательностей и диаграммы (видов) деятельности Диаграмма, которая показывает последовательность преобразований объекта в процессе его реакции на события. При использовании диаграммы параллельных состояний эти преобразования могут происходить в течение одного и того же интервала времени Диаграмма взаимодействия, в которой отображается организация структуры объектов, принимающих или отправляющих сообщения (с акцентом на упорядочении сообщений по времени) Диаграмма взаимодействия, в которой отображается организация структуры объектов, принимающих или отправляющих сообщения (с акцентом на структурной организации) Диаграмма, которая показывает динамическую конфигурацию узлов обработки, аппаратных средств и программных компонентов в системе Диаграмма взаимодействия, в которой отображается организация физических модулей программного кода (пакетов) в системе и зависимости между ними 2.8. Резюме При создании параллельного и распределенного ПО разработчиков ожидает множество проблем. Поэтому при проектировании ПО им необходимо искать новые архитектурные подходы и технологии. Многие фундаментальные допущения, которых придерживались разработчики при построении последовательных моделей программирования, совершенно неприемлемы в области создания параллельного и распределенного ПО. В программах, включающих элементы параллелизма, программисты чаще всего сталкиваются со следующими четырьмя проблемами координации: "гонка" данных, бесконечная отсрочка, взаимоблокировка и проблемы синхронизации при взаимодействии задач. Наличие параллелизма и распределения оказывает огромное влияние на все аспекты жизненного цикла разработки ПО: начиная эскизным проектом и заканчивая тестированием готовой системы и подготовкой документации. В этой книге мы представляем архитектурные подходы к решению многих упомянутых проблем, используя преимущества мультипарадигматических средств языка C++, которые позволяют справиться со сложностью параллельных и распределенных программ.
РАЗБИЕНИЕ С++- ПР0ГРАММ НА МНОЖЕСТВО ЗАДАЧ В этой главе... 3.1. Определение процесса 3.2. Анатомия процесса 3.3. Состояния процессов 3.4. Планирование процессов 3.5. Переключение контекста 3.6. Создание процесса 3.7. Завершение процесса 3.8. Ресурсы процессов 3.9. Асинхронные и синхронные процессы 3.10. Разбиение программы на задачи 3.11. Резюме
J~1/\ZJSJZJ Коль выполнение параллельных процессов возможно на более низком (нейронном) уровне, то на символическом уровне мышление человека с принципиальной точки зрения можно рассматривать как последовательную машину, которая использует временно создаваемые последовательности процессов, выполнение которых длится сотни миллисекунд. — Герберт Саймон (Herbert A. Simon), The Machine As Mind Параллельность в С++-программе достигается путем ее (программы) разложения на несколько процессов или потоков. Несмотря на существование различных вариантов организации логики С++-программы (например, с помощью объектов, функций или обобщенных шаблонов), под параллелизмом все же понимается использование множества процессов и потоков. Прочитав эту главу, вы поймете, что такое процесс и как С++-программы можно разделить на несколько процессов. 3.1. Определение процесса Процесс (process) — это некоторая часть (единица) работы, создаваемая операционной системой. Важно отметить, что процессы и программы — необязательно эквивалентные понятия. Программа может состоять из нескольких процессов. В некоторых ситуациях процесс может быть не связан с конкретной программой. Процессы — это артефакты операционной системы, а программы — это артефакты разработчика. Такие операционные системы, как UNIX/Linux позволяют управлять сотнями или даже тысячами параллельно загружаемых процессов.
3.1. Определение процесса 59 Чтобы некоторую часть работы можно было назвать процессом, она должна иметь оесное пространство, назначаемое операционной системой, и идентификатор, или идентификационный номер (^процесса). Процесс должен обладать определенным статусом и иметь свой элемент в таблице процессов. В соответствии со стандартом POSIX он должен содержать один или несколько потоков управления, выполняющихся в рамках его адресного пространства, и использовать системные ресурсы, требуемые для этих потоков. Процесс состоит из множества выполняющихся инструкций, размещенных в адресном пространстве этого процесса. Адресное пространство процесса распределяется между инструкциями, данными, принадлежащими процессу, и стеками, обеспечивающими вызовы функций и хранение локальных переменных. 3.1.1. Два вида процессов При выполнении процесса операционная система назначает ему некоторый процессор. Процесс выполняет свои инструкции в течение некоторого периода времени. Затем он выгружается, освобождая процессор для другого процесса. Планировщик операционной системы переключается с кода одного процесса на код другого, предоставляя каждому процессу шанс выполнить свои инструкции. Различают пользовательские процессы и системные. Процессы, которые выполняют системный код, называются системными и применяются к системе в целом. Они занимаются выполнением таких служебных задач, как распределение памяти, обмен страницами между внутренним и вспомогательным запоминающими устройствами, контроль устройств и т.п. Они также выполняют некоторые задачи "по поручению" пользовательских процессов, например, делают запросы на ввод-вывод данных, выделяют память и т.д. Пользовательские процессы выполняют собственный код и иногда обращаются к системным функциям. Выполняя собственный код, пользовательский процесс пребывает в пользовательском режиме (user mode). В пользовательском режиме процесс не может выполнять определенные привилегированные машинные команды. При вызове системных функций (например read(), write () или open ()) пользовательский процесс выполняет инструкции операционной системы. При этом пользовательский процесс "удерживает" процессор до тех пор, пока не будет выполнен системный вызов. Для выполнения системного вызова процессор обращается к ядру операционной системы. В это время о пользовательском процессе говорят, что он пребывает в привилегированном режиме, или режиме ядра (kernel mode), и не может быть выгружен никаким другим пользовательским процессом. 3.1.2. Блок управления процессами Процессы имеют характеристики, используемые для идентификации и определения их поведения. Ядро поддерживает необходимые структуры данных и предоставляет системные функции, которые дают возможность пользователю получить доступ к этой информации. Некоторые данные хранятся в блоках управления процессами (process control block— PCB), или БУЛ. Данные, хранимые в БУП-блоках, описывают Процесс с точки зрения потребностей операционной системы. С помощью этой информации операционная система может управлять каждым процессом. Когда операци- °нная система переключается с одного процесса на другой, она сохраняет текущее со- стояние выполняющегося процесса и его контекст в области сохранения БУП-блока, т°бы надлежащим образом возобновить выполнение этого процесса в следующий раз,
60 Глава 3. Разбиение С++-программ на множество задач когда ему снова будет выделен центральный процессор (ЦП). БУП-блок считывается и обновляется различными модулями операционной системы. Модули "отвечают" за контроль производительности операционной системы, планирование, распределение ресурсов и доступ к механизму обработки прерываний и/или модифицируют БУП-блок. Блок БУП содержит следующую информацию: • текущее состояние и приоритет процесса; • идентификатор процесса, а также идентификаторы родительского и сыновнего процессов; • указатели на выделенные ресурсы; • указатели на область памяти процесса; • указатели на родительский и сыновний процесс; • процессор, занятый процессом; • регистры управления и состояния; • стековые указатели. Среди данных, содержащихся в БУП-блоке, есть такие, которые "отвечают" за управление процессом, т.е. отражают его текущее состояние и приоритет, указывают на БУП-блоки родительского и сыновнего процессов, а также выделенные ресурсы и память. Кроме того, этот блок включает информацию, связанную с планированием, привилегиями процессов, флагами, сообщениями и сигналами, которыми обмениваются процессы (имеется в виду межпроцессное взаимодействие— mter/?rocess communication, или IPC). С помощью информации, связанной с управлением процессами, операционная система может координировать параллельно выполняемые процессы. Стековые указатели и содержимое регистров пользователя, управления и состояния содержат информацию, связанную с состоянием процессора. При выполнении процесса соответствующая информация размещается в регистрах ЦП. При переключении операционной системы с одного процесса на другой вся информация из этих регистров сохраняется. Когда процесс снова получает ЦП во "временное пользование", ранее сохраненная информация может быть восстановлена. Есть еще один вид информации, который связан с идентификацией процесса. Имеется в виду идентификатор процесса (id), или PID, и идентификатор родительского процесса (PPID). Эти идентификационные номера (которые представлены положительными целочисленными значениями) уникальны для каждого процесса. 3.2. Анатомия процесса Адресное пространство процесса делится на три логических раздела: текстовый (для кода программы), информационный (для данных программы) и стековый (для стеков программы). Логическая структура процесса показана на рис. 3.1. Текстовый раздел (расположенный в нижней части адресного пространства) содержит подлежащие выполнению инструкции, которые называются программным кодом. Раздел данных (расположенный над текстовым разделом) содержит инициализированные глобальные, внешние и статические переменные процесса. Раздел стеков содержит локально создаваемые переменные и параметры, передаваемые функциям. Поскольку процесс может
3.2. Анатомия процесса 61 вызывать как системные функции, так и функции, определенные пользователем, в стековом разделе поддерживаются два стека: стек пользователя и стек ядра. При вызове функции создается стековый фрейм функции, который помещается в стек пользователя или стек ядра в зависимости от того, в каком режиме пребывает процесс в данный момент: в пользовательском или привилегированном (режиме ядра). Стековый раздел имеет тенденцию расти в направлении раздела данных. При выходе из функции ее стековый фрейм извлекается из стека. Разделы кода, данных и стеков, а также блок управления процессом образуют часть того, из чего складывается образ процесса (process image). БУП ОБРАЗ ПРОЦЕССА ИДЕНТИФИКАЦИЯ ПРОЦЕССА ИНФОРМАЦИЯ 0 СОСТОЯНИИ ПРОЦЕССА ИНФОРМАЦИЯ ОБ УПРАВЛЕНИИ ПРОЦЕССОМ РАЗДЕЛ СТЕКОВ Стек ядра Стек пользователя РАЗДЕЛ ДАННЫХ • инициализированные глобальные переменные • внешние переменные « статические переменные РАЗДЕЛ КОДА • код программы Рис. 3.1. Адресное пространство процесса делится на три логических раздела: текстовый, информационный и стековый. Так выглядит логическая структура процесса Адресное пространство процесса виртуально. Применение виртуальной памяти позволяет отделить адреса, используемые в текущем процессе, от адресов, реально доступных во внутренней памяти. Тем самым значительно увеличивается задействованное пространство адресов памяти по сравнению с реально доступными адресами. Разделы виртуального адресного пространства процесса представляют собой смежные блоки памяти. Каждый такой раздел и физическое адресное пространство разделены на участки памяти, именуемые страницами. У каждой страницы есть уникальный номер страничного шока (page frame number). В качестве индекса для входа в таблицы страничных блоков Ipage frame table) используется номер виртуального страничного блока. Каждый элемент таб- ицы страничных блоков содержит номер физического страничного блока, что позволяет установить соответствие между виртуальными и физическими страничными блоками. Это °ответствие отображено на рис. 3.2. Как видите, виртуальное адресное пространство епрерывно, но устанавливаемое с его помощью соответствие физическим страницам е является упорядоченным. Другими словами, при последовательных виртуальных ад- Р сах соответствующие им физические страницы не будут последовательными.
62 Глава 3. Разбиение С++-программ на множество задач Несмотря на то что виртуальное адресное пространство каждого процесса защищено, т.е. приняты меры по предотвращению доступа к нему со стороны другого процесса, текстовый раздел процесса может совместно использоваться несколькими процессами. На рис. 3.2 также показано, как два процесса могут разделять один и тот же программный код. При этом в элементах таблиц страничных блоков обоих процессов хранится один и тот же номер физического страничного блока. Как показано на рис. 3.2, виртуальный страничный блок с номером 0 процесса А соответствует физическому страничному блоку с номером 5, что также справедливо и для виртуального страничного блока с номером 2 процесса В. ВИРТУАЛЬНОЕ АДРЕСНОЕ ПРОСТРАНСТВО ПРОЦЕССА В ТАБЛИЦЫ СТРАНИЧНЫХ БЛОКОВ ПРОЦЕССА В ФИЗИЧЕСКАЯ ПАМЯТЬ НСБЗ НСБ35 НСБ18 НСБ13 НСБ5 НСБ11 I НВСБ 8 НВСБ 7 НВСБ 6 НВСБ 5 НВСБ 4 НВСБЗ 1 НВСБ 2 НВСБ 1 НВСБ 0 НСБ 4 НС6 5 НСБ6 НСБ 7 НСБ 8 НСБ 9 НВСБ 8 I НВСБ 7 I НВСБ 6 НВСБ 5 I НВСБ 4 | НВСБ 3 | НВСБ 2 НВСБ 1 НВСБ 0 1 НСБ 5 НСБ 16 НСБЗ НСБ 26 НСБ1 ТАБЛИЦЫ СТРАНИЧНЫХ БЛОКОВ ПРОЦЕССА А ВИРТУАЛЬНОЕ АДРЕСНОЕ ПРОСТРАНСТВО ПРОЦЕССА А РАЗДЕЛ СТЕКОВ h РАЗДЕЛ ДАННЫХ РАЗДЕЛ КОДА Рис. 3.2. Соответствие последовательных виртуальных страничных блоков страницам физической памяти (НСБ— номер страничного блока; НВСБ— номер виртуального страничного блока)
3.3. Состояния процессов 63 Чтобы операционная система могла управлять всеми процессами, хранимыми во внутренней памяти, она создает и поддерживает таблицы процессов (process table). В действительности операционная система содержит отдельные таблицы для всех объектов, которыми она управляет. Следует иметь в виду, что операционная система управляет не только процессами, но и всеми ресурсами компьютера, т.е. устройствами ввода-вывода, памятью и файлами. Часть памяти, устройств и файлов управляется от имени пользовательских процессов. Эта информация отмечена в БУП-блоках как ресурсы, выделенные процессу. Таблица процессов должна иметь соответствующую структуру для каждого образа процесса в памяти. Каждая такая структура содержит идентификаторы (id) самого процесса и родительского процесса, идентификаторы реального и эффективного пользователей, идентификатор группы, список подвешенных сигналов, местоположение текстового, информационного и стекового разделов, а также текущее состояние процесса. Если операционной системе нужен доступ к определенному процессу, в таблице процессов разыскивается информация о нем, а затем в памяти размещается его образ (рис. 3.3). Память < Файлы < Устройства < Процессы < >— 1—► г* > ► Таблицы использования памяти Таблицы файлов Таблицы устройств ввода-вывода PID PPID STAT 19 45 12 30 6 90 15 9 D S R S Свободный элемент таблицы 42 30 S Образ j процесса : cPID^ 19! Образ : процесса ; с PiD - 45 ! I Образ процесса с РГО = 42 ТАБЛИЦЫ ПРОЦЕССОВ Рис. 3.3. Операционная система управляет таблицами. Каждая структура в массиве таблиц процессов представляет процесс в системе 3.3. Состояния процессов Во время выполнения процесса его состояние изменяется. Под состоянием процесса подразумевается его текущий режим, или статус. В среде UNIX процесс может пребывать в одном из следующих состояний: • выполнения; работоспособности (готовности);
64 Глава 3. Разбиение С++-программ на множество задач • "зомби"; • ожидания (блокирования); • останова. Состояние процесса меняется при определенных обстоятельствах, создаваемых существованием процесса или операционной системы. Под сменой состояний, или переходом из одного состояния в другое, понимают обстоятельства, которые заставляют процесс изменить свое состояние. На рис. 3.4 отображена диаграмма состояний для среды UNIX. Диаграмма состояний содержит узлы и направленные ребра, соединяющие эти узлы. Каждый узел представляет состояние процесса, а направленные ребра между узлами — переходы из одного состояния в другое. Возможные смены состояний (с их кратким описанием) перечислены в табл. 3.1. На рис. 3.4 и в табл. 3.1 показано, что между состояниями разрешены только определенные переходы. Например, между состояниями готовности и выполнения существует переход (ребро диаграммы), а между состояниями ожидания и выполнения— нет. Это означает, что возможны обстоятельства, заставляющие процесс перейти из состояния готовности в состояние выполнения, но нет обстоятельств, которые могут заставить процесс перейти в состояние выполнения из состояния ожидания. ОСТАНОВЛЕН Выдан сигнал/ Выдан сигнал Выгрузка Вх°Д ^/ ГОТОВ ^1 (работоспособен) Произошло * событие или\ завершена операция ввода-вывода Загрузка ВЫПОЛНЯЕТСЯ Выход J<oHei4 кванта времени Ожидание события или у завершения операции ввода-вывода ОЖИДАЕТ ] " Прекращение k Выход "ЗОМБИ" Рис. 3.4. Состояния процессов и переходы между ними в средах UNIX/Linux Когда процесс только создается, он готов к выполнению своих инструкций, но должен ожидать "своего часа" до тех пор, пока не освободится процессор. Каждому процессу единолично разрешается использовать процессор в течение дискретного временного интервала, именуемого квантом времени (time slice). Процессы, ожидающие использования процессора, "занимают" очередь, т.е. помещаются в очереди готовых процессов. Только из таких очередей планировщик выбирает процесс, который будет использовать процессорное время. Процессы, находящиеся в очередях готовых процессов, пребывают в состоянии работоспособности. Когда процессор становится доступным,
3.3. Состояния процессов 65 Таблица 3.1. Переходы процессов из одного состояние в другое Переходы между состояниями Описание ГОТОБЫЙ-^ВЫПОЛНЯЮЩИИСЯ (загрузка) выполняющийся^готовый (конец кванта времени) ВЫПОЛНЯЮЩИЙСЯ->ГОТОВЫЙ (досрочная выгрузка) выполняющийся-» ОЖИДАЮЩИЙ (блокировка) ОЖИДАЮЩИЙ->ГОТОВЫЙ (разблокировка) ВЫПОЛНЯЮЩИЙСЯ-» ОСТАНОВЛЕННЫЙ ОСТАНОВЛЕННЫЙ->ГОТОВЫЙ ВЫПОЛНЯЮЩИЙСЯ->"ЗОМБИ" "ЗОМБИ"->ВЫХОД ВЫПОЛНЯЮЩИЙСЯ->ВЫХОД Процесс назначается процессору Квант времени процесса, который назначен процессору, истек. Процесс возвращается назад в очередь готовых процессов Процесс выгружается до истечения его кванта времени. (Это возможно в случае, если стал готовым процесс с более высоким приоритетом.) Выгруженный процесс помещается назад в очередь готовых процессов Процесс отказывается от процессора до истечения его кванта времени. Процессу, возможно, нужно подождать наступления некоторого события, или он вызывает системную функцию, например, делает запрос на ввод-вывод данных. Процесс помещается в очередь ждущих процессов Событие, наступления которого ожидал процесс, произошло, или завершилось выполнение системной функции, например, удовлетворен запрос на ввод-вывод данных Процесс отказывается от процессора из-за получения им сигнала останова Процесс получил сигнал продолжать и возвращается назад в очередь готовых процессов Процесс прекращен и ожидает, пока родительский процесс не извлечет из таблицы процессов его статус завершения Родительский процесс извлекает из таблицы процессов статус завершения и процесс-зомби покидает систему Процесс завершен, но он покидает систему после того как родительский процесс извлечет из таблицы процессов его статус завершения Диспетчер (dispatcher) назначает его работоспособному (готовому) процессу, который занимает его в течение своего кванта времени. По истечении этого кванта времени процесс покидает процессор, независимо от того, выполнил он все свои инструкции или нет. Этот процесс снова помещается в очередь готовых процессов (как в "зал ожидания") ожидать следующего сеанса работы процессора. Тем временем из очереди выбирается новый процесс, котором)7 выделяется его квант процессорного времени. Системные процессы не выгружаются, т.е., "заполучив" процессор, они выполняются до полного завершения. Если квант времени еще не исчерпан, но процесс не в состоянии Продолжить выполнение, он может добровольно отказаться от процессорного времени.
66 Глава 3. Разбиение С++-программ на множество задач Причины отказа могут быть разными. Например, процесс может сделать запрос на получение доступа к устройству ввода-вывода, вызвав системную функцию, или ему необходимо подождать освобождения объекта (переменной) синхронизации. Процессы, которые не могут продолжать выполнение из-за необходимости ожидать некоторого события, "засыпают", т.е. переходят в состояние ожидания. Они помещаются в очередь ждущих процессов. После наступления ожидаемого ими события они удаляются из этой очереди и возвращаются в очередь готовых процессов. Текущий процесс, т.е. процесс, занимающий процессорное время, может быть лишен его еще до исчерпания кванта времени, если заявит о своей готовности процесс с более высоким приоритетом (например, системный процесс). Выгруженный досрочно процесс сохраняет статус работоспособного и поэтому снова помещается в очередь готовых процессов. Выполняющийся процесс может получить сигнал остановить выполнение. Состояние останова отличается от состояния ожидания, потому что при этом не был исчерпан квант времени и процесс не делал никакого системного запроса. Процесс мог получить сигнал остановиться либо по причине пребывания в режиме отладки, либо из- за возникновения особой ситуации в системе. Получив сигнал остановиться, процесс переходит из состояния выполнения в состояние останова. Позже процесс может быть "разбужен" или ликвидирован. Выполнив все свои инструкции, процесс покидает систему. В этом случае процесс удаляется из таблицы процессов, его БУП-блок разрушается, и все занимаемые им ресурсы освобождаются и возвращаются в системный пул доступных ресурсов. Процесс, который неспособен продолжать выполнение, но при этом не может выйти из системы, считается "зомбированным". Зомбированный процесс не использует никаких системных ресурсов, но сохраняет свою структуру в таблице процессов. Если в таблице процессов окажется слишком много зомбированных процессов, это негативно отразится на производительности системы и может вызвать ее перезагрузку. 3.4. Планирование процессов Если готовых к выполнению процессов больше одного, планировщик должен определить, какой из них первым назначить процессору. С этой целью планировщик поддерживает структуры данных, которые позволяют наиболее эффективным образом распределять между процессами процессорное время. Каждый процесс получает класс (тип) приоритета и размещается в соответствующей очереди вместе с другими работоспособными процессами того же приоритетного класса. Поэтому существует несколько приоритетных очередей, которые представляют различные классы приоритетов, используемые системой. Эти приоритетные очереди упорядочиваются и помещаются в массив распределения, именуемый также многоуровневой приоритетной очередью (multilevel priority queue), показанной на рис. 3.5. Каждый элемент этого массива связан с конкретной приоритетной очередью. Для выполнения процессором планировщик назначает тот процесс, который стоит в головной части непустой очереди, имеющей самый высокий приоритет. Приоритеты могут быть динамическими или статическими. Однажды установленный статический приоритет процесса изменить нельзя, а динамические — можно. Процессы с самым высоким приоритетом могут монополизировать использование процессора. Если же приоритет процессора динамический, то его начальный уровень может быть заменен более высоким значением, в результате чего такой процесс будет
3.4. Планирование процессов 67 пе еден в очередь с более высоким приоритетом. Кроме того, процесс, который 1 полИзирует процессор, может получить более низкий приоритет, или же другие иессы могут получить более высокий приоритет, чем процесс-монополист. В сре- UNIX/Linux для уровней приоритетов предусмотрен диапазон от -20 до 19. Чем е значение уровня, тем ниже приоритет процесса. При назначении приоритета пользовательскому процессу следует учитывать, на что нно эТОТ процесс тратит большую часть времени. Одни процессы отличаются повышенной интенсивностью использования процессорного времени (они используют процессор в течение всего кванта процессорного времени). У других же большая часть времени уходит на ожидание выполнения операций ввода-вывода или наступления некоторых иных событий. Если такой процесс готов к использованию процессора, ему следует немедленно предоставить процессор, чтобы он мог сделать следующий запрос к устройствам ввода-вывода. Процессы, которые взаимодействуют между собой, могут требовать довольно высокий приоритет, чтобы рассчитывать на приличное время реакции. Системные процессы имеют более высокий приоритет, чем пользовательские. Процессор PID71 i выполняется к ДИСПЕТЧЕР 2 1 0 -1 -2 -3 • PID12 PID90 |РЮ71 L PID17 PID43 PID35 PID10 PID63 PID50 Рис. 3.5. Многоуровневая приоритетная очередь (массив распределения), каждый элемент которой указывает на очередь готовых процессов с одинаковым уровнем приоритета 3.4.1. Стратегия планирования Процессы размещаются в приоритетных очередях в соответствии со стратегией фланирования. В системах UNIX/Linux используются две стратегии планирования: '1FO (сокр. от First In First Out, т.е. первым прибыл, первым обслужен) и RR (сокр. от
68 Глава 3. Разбиение С++-программ на множество задач round-robin, т.е. циклическая). Схема действия стратегии FIFO показана на рис. 3.6, а. При использовании стратегии FIFO процессы назначаются процессору в соответствии со временем поступления в очередь. После истечения кванта времени процесс помещается в начало (головную часть) своей приоритетной очереди. Когда ждущий процесс становится работоспособным (готовым к выполнению), он помещается в конец своей приоритетной очереди. Процесс может вызвать системную функцию и отказаться от процессора в пользу другого процесса с таким же уровнем приоритета. Такой процесс также будет помещен в конец своей приоритетной очереди. а) FIFO-планирование Выход Процессор1 PID71 выполняется назначен. ОЧЕРЕДЬ ГОТОВЫХ ПРОЦЕССОВ Прибыл первъ!М_ Прибыл последним PID71 L —ж — Конец кванта времени Запрос на операцию ввода-вывода PID90 PID43 PID10 PID50 ОЧЕРЕДЬ ЖДУЩИХ ПРОЦЕССОВ I Операция _' ввода-вывода завершена б) RR-планирование ОЧЕРЕДЬ ГОТОВЫХ ПРОЦЕССОВ Выход Процессор1 РЮ71 выполняется Прибыл первым Прибыл последним назначен ЦП I Запрос на операцию ввода-вывода ОЧЕРЕДЬ ЖДУЩИХ ПРОЦЕССОВ Операция ввода-вывода завершена Рис. 3.6. Схемы действия FIFO- и RR-стратегий планирования При использовании стратегии FIFO процессы назначаются процессору в соответствии со временем поступления в очередь. При использовании стратегии RR процессы назначаются процессору по правилам FIFO-стратегии, но с одним отличием: после истечения кванта времени процесс помещается не в начало, а в конец своей приоритетной очереди
3.4. Планирование процессов 69 В соответствии с циклической стратегией планирования (RR) все процессы счи- аются равноправными (см. рис. 3.6, б). RR-планирование совпадает с FIFO-плани- ованием с одним исключением: после истечения кванта времени процесс помещается не в начало, а в конец своей приоритетной очереди, и процессору назначается следующий (по очереди) процесс. 3.4.2. Использование утилиты ps Утилита ps генерирует отчет, который содержит статистические данные о выполнении текущих процессов. Эту информацию можно использовать для контроля за их состоянием. В табл. 3.8 перечислены общие заголовки и описаны выходные данные, генерируемые утилитой ps для сред Solaris/Linux. В любой многопроцессорной среде утилита ps успешно применяется для мониторинга состояния процессов, степени использования ЦП и памяти, приоритетов и времени запуска текущих процессов. Ниже приведены командные опции, которые позволяют управлять информацией, содержащейся в отчете (с их помощью можно уточнить, что именно и какие процессы вас интересуют). В среде Solaris по умолчанию (без командных опций) отображается информация о процессах с тем же идентификатором эффективного пользователя и управляющим терминалом инициатора вызова. В среде Linux по умолчанию отображается информация о процессах, id пользователя которых совпадает с id инициатора запуска. В обеих средах в этом случае отображаемая информация, ограниченная следующими составляющими: PID, TTY, TIME и COMMAND. Перечислим опции, которые позволяют получить информацию о нужных процессах. -t term Список процессов, связанных с терминалом, заданным значением term ~е Все текущие процессы "а (Linux) Все процессы с терминалом tty за исключением лидеров сеанса (Solaris) Большинство часто запрашиваемых процессов за исключением лидеров группы и процессов, не связанных с терминалом Все текущие процессы за исключением лидеров сеанса (Linux) Все процессы, связанные с данным терминалом (Linux) Все процессы, включая процессы остальных пользователей (Linux) Только выполняющиеся процессы -d Т а г Таблица 3.2. Общие заголовки, используемые для утилиты ps в средах Solaris/Linux Заголовки Описание Пользовательское имя владельца процесса ID процесса ID родительского процесса ID лидирующего процесса в группе ID лидера сеанса
70 Глава 3. Разбиение С++-программ на множество задач Окончание таил. 3.2 Описание %CPU Коэффициент использования времени ЦП (в процентах) процессом в течение последней минуты RSS Объем реального ОЗУ, занимаемый процессом в данный момент (в Кбайт) %МЕМ Коэффициент использования реального ОЗУ процессом в течение последней минуты SZ Размер виртуальной памяти, занимаемой данными и стеком процесса (в Кбайт или страницах) WCHAN Адрес события, в ожидании которого процесс пребывает в состоянии ожидания COMMAND Имя команды и аргументы CMD ТТ, TTY Управляющий терминал процесса S, STAT Текущее состояние процесса TIME Общее время ЦП, используемое процессом (HH:MM:SS) STIME, START Время или дата старта процесса N1 Фактор уступчивости процесса PRI Приоритет процесса С, СР Коэффициент краткосрочного использования ЦП для вычисления планировщиком значения PRI ADDR Адрес памяти, выделенной процессу LWP ID потока NLWP Количество потоков Синопсис (Linux) ps -[опции в стиле [опции в стиле --[GNU-опции в (Solaris) ps [-aAdeflcjLPy][ [-G grouplist][ Unix98] BSD] длинном формате] -о format][-t -p proclist][- termlist][-u -g pgrplist] [- userlist] -s sidlist] J В следующий список включены командные опции, которые используются для управления отображаемой информацией о процессах: - f полные распечатки -1 в длинном формате -j в формате задания Приведем пример использования утилиты ps в средах Solaris/Linux: ps -f По этой команде будет отображена полная информация о процессах, которая выводится по умолчанию в каждой среде. На рис. 3.7 показан результат выполнения этой команды Заголовки
3.4. Планирование процессов 71 де Solaris. Командные опции можно использовать тандемом (одна за другой). На с 3 7 также показан результат совместного использования опций -1 и - f в среде Solaris: ps -If Командная опция 1 позволяет отобразить дополнительные заголовки: F, S, С, PRI, N1, ADDR и WCHAN. При использовании командной опции Р отображается заголовок PSR, означающий номер процессора, которому назначается (или за которым закрепляется) процесс. //SOLARIS $ ps -f UID PID PPID C STIME TTY TIME CMD cameron 2214 2212 0 21:03:35 pts/12 0:00 -ksh cameron 2396 2214 2 11:55:49 pts/12 0:01 nedit $ ps -If F s UID PID PPID С PRI N1 ADDR SZ WCHAN STIME TTY TIME CMD 8 S cameron 2214 2212 0 51 20 70e80f00 230 70e80f6c 21:03:35 pts/12 0:00 -ksh 8 S cameron 2396 2214 1 53 24 70d747b8 843 70152aba 11:55:49 pts/12 0:01 nedit Рис. 3.7. Результат выполнения команд ps -f и ps -lfB среде Solaris На рис. 3.8 показан результат выполнения утилиты ps с использованием командных опций Тих в среде Linux. Данные, выводимые с помощью заголовков %CPU, %MEM и STAT, отображаются для процессов. В многопроцессорной среде с помощью этой информации можно узнать, какие процессы являются доминирующими с точки зрения использования времени ЦП и памяти. Заголовок STAT отображает состояние или статус процесса. Ниже приведены символы, обозначающие статус, и дано соответствующее описание. Заголовок STAT позволяет узнать дополнительную информацию о статусе процесса. D (BSD) Ожидание доступа к диску Р (BSD) Ожидание доступа к странице X (System V) Ожидание доступа к памяти W (BSD) Процесс выгружен на диск К (AIX) Доступный процесс ядра N (BSD) Приоритет выполнения понижен > (BSD) Приоритет выполнения повышен искусственно < (Linux) Процесс с высоким приоритетом L (Linux) Страницы заблокированы в памяти Эти символы должны предшествовать коду статуса. Например, е>сли перед кодом статуса стоит символ N, значит, процесс выполняется с более низким уровнем приоритета. Если код статуса процесса отображен символами SW<, это означает, что процесс пребывает в ждущем режиме, выгружен и имеет высокий уровень приоритета. 3.4.3. Установка и получение приоритета процесса Уровень приоритета процесса можно изменить с помощью функции nice (). Каж- Дьш процесс имеет фактор уступчивости (nice value), который используется для выделения уровня приоритета вызывающего процесса. Процесс наследует приоритет Р°Цесса, который его создал. Чтобы понизить приоритет процесса, следует увели- 1Ть его фактор уступчивости. Лишь процессы привилегированных пользователей яДра системы могут увеличивать уровни своих приоритетов.
72 Глава 3. Разбиение С++-программ на множество задач //Linux [tdhughes@colony]$ ps USER PID tdhughes 19259 tdhughes 19334 taiughes 19336 tdhughes 19337 tdhughes 19338 taiughes 19341 taiughes 19400 taiughes 19401 %CPU 0.0 0.0 0.0 18.0 18.0 17.9 0.0 0.0 Tux %MEM 0.1 0.0 0.0 2.4 2.3 2.3 0.0 0.1 vsz 2448 1732 1928 26872 26872 26872 2544 2448 RSS TTY STAT 1356 pts/4 S 860 pts/4 S 780 pts/4 S 24856 pts/4 R 24696 pts/4 R 24556 pts/4 R 692 pts/4 R 1356 pts/4 R START 20:29 20:33 20:33 20:33 20:33 20:33 20:38 20:38 Рис. З.8. Результат выполнения команды ps Tux в среде Linux Синопсис #include <unistd. int nice(int incr h> jj TIME 0:00 0:00 0:00 0:47 0:47 0:47 0:00 0:00 COMMAND -bash /home/tdhughes/pv / home / taiughes /pv / home / taiughes/pv /home/tdhughes/pv /home/tdhughes/pv ps Tux -bash Чем ниже фактор уступчивости, тем выше уровень приоритета процесса. Параметр incr содержит значение, добавляемое к текущему фактору уступчивости вызывающего процесса. Значение параметра incr может быть отрицательным или положительным, а фактор уступчивости представляет собой неотрицательное число. Положительное значение incr увеличивает фактор уступчивости, а значит, понижает уровень приоритета. Отрицательное значение incr уменьшает фактор уступчивости, тем самым повышая уровень приоритета. Если значение incr изменяет фактор уступчивости выше или ниже соответствующих предельных величин, он будет установлен равным самому высокому или самому низкому пределу соответственно. При успешном выполнении функция nice () возвращает новый фактор уступчивости процесса, в противном случае — число -1, а прежнее значение фактора уступчивости при этом не изменяется. Синопсис #include <sys/resource.h> int getpriority(int which, id_t who); int setpriority(int which, id_t who, int value); Функция setpriority () устанавливает фактор уступчивости для заданного процесса, группы процессов или пользователя. Функция getpriority () возвращает приоритет заданного процесса, группы процессов или пользователя. Синтаксис использования функций setpriority () и getpriority () для установки и считывания фактора уступчивости текущего процесса демонстрируется в листинге 3.1. // Листинг 3.1. Использование функций setpriority() и // getpriority() #include <sys/resource.h> //. . . id_t pid = 0; int which = PRIO_PROCESS; int value = 10; int nice_value; int ret; nice_value = getpriority(which,pid) ,
3.5. Переключение контекста 73 if<nice_value < value){ t = setpriority(which,pid,value); } В листинге 3.1 возвращается и устанавливается приоритет вызывающего процесса. Если фактор уступчивости вызывающего процесса оказывается меньше 10, он устанавливается равным 10. Процесс задается значениями, хранимыми в параметрах which и who (см. соответствующий синопсис). Параметр which может определять процесс, группу процессов или пользователя и иметь следующие значения. PRI0_PR0CESS Означает процесс PRICL.PGRP Означает группу процессов PRICMJSER Означает пользователя В зависимости от значения параметра which параметр who содержит идентификационный номер (id) процесса, группы процессов или эффективного пользователя. В листинге 3.1 параметру which присваивается значение PRIO_PROCESS. В листинге 3.1 параметр who устанавливается равным 0, означая тем самым текущий процесс. Параметр value для функции setpriority () определяет новое значение фактора уступчивости для заданного процесса, группы процессов или пользователя. Факторы уступчивости в среде Linux должны находиться в диапазоне от -20 до 19. В листинге 3.1 фактор уступчивости устанавливается равным 10, если текущее его значение оказывается меньше 10. В отличие от функции nice (), значение, передаваемое функции setpriority (), является фактическим значением фактора уступчивости, а не смещением, которое суммируется с текущим фактором уступчивости. Если процесс имеет несколько потоков, модификация приоритета процесса повлияет на приоритет всех его потоков. При успешном выполнении функции getpriority () возвращается фактор уступчивости заданного процесса, а при успешном выполнении функции setpriority () — значение 0. В случае неудачи обе функции возвращают число -1. Однако число -1 является допустимым значением фактора уступчивости для любого процесса. Чтобы уточнить, не было ли ошибок при выполнении функции getpriority (), имеет смысл протестировать внешнюю переменную errno. 3.5. Переключение контекста Переключение контекста происходит в момент, когда процессор переключается с одного процесса на другой. При переключении контекста система сохраняет контекст текущего процесса и восстанавливает контекст следующего процесса, выбранного для использования процессора. БУП-блок прерванного процесса при этом обновляется, а также изменяется значение поля состояния процесса (т.е. признак состояния выполнения заменяется признаком другого состояния: готовности, локирования или "зомби"). Сохраняется и обновляется содержимое регистров процессора, состояние стека, данные об идентификации (и привилегиях) пользователя процесса, а также о стратегии планирования и учетная информация. Система должна отслеживать статус устройств ввода-вывода процесса и других ресурсов, а также состояние всех структур данных, связанных с управлением памятью. Сгруженный (прерванный) процесс помещается в соответствующую очередь.
74 Глава 3. Разбиение С++-программ на множество задач Переключение контекста происходит в случаях, когда: • процесс выгружается; • процесс добровольно отказывается от процессора; • процесс делает запрос к устройству ввода-вывода или должен ожидать наступления события; • процесс переходит из пользовательского режима в режим ядра. Когда выгруженный процесс снова выбирается для использования процессора, его контекст восстанавливается, и выполнение продолжается с точки, на которой он был прерван в предыдущем сеансе. 3.6. Создание процесса Чтобы выполнить любую программу, операционная система должна сначала создать процесс. При создании нового процесса в главной таблице процессов создается новая структура. Создается и инициализируется новый блок БУП, и в его раздел идентификации процесса записывается уникальный идентификационный номер процесса (id) и id родительского процесса. Программный счетчик устанавливается указателем на входную точку программы, а указатели системных стеков устанавливаются таким образом, чтобы определить стековые границы для процесса. Процесс инициализируется любыми требуемыми атрибутами. Если процессу не присвоено значение приоритета, то по умолчанию ему присваивается самое низкое значение. Изначально процесс не обладает никакими ресурсами, если нет явного запроса на ресурсы или если они не были унаследованы от процесса-создателя. Процесс "входит" в состояние выполнения и помещается в очередь готовых к выполнению процессов. Для него выделяется адресное пространство, размер которого определяется по умолчанию на основе типа процесса. Кроме того, размер можно установить по запросу от создателя процесса. Процесс-создатель может передать системе размер адресного пространства в момент создания процесса. 3.6.1. Отношения между родительскими и сыновними процессами Процесс, который создает, или порождает, другой процесс, является родительским (parent) процессом по отношению к порожденному, или сыновнему (child) процессу. Процесс init— родитель (или предок) всех пользовательских процессов— первый процесс, видимый системой UNIX после ее загрузки. Процесс init организует систему, при необходимости выполняет другие программы и запускает демон-программы (daemon), т.е. сетевые программы, работающие в фоновом режиме. Идентификатор процесса init (PID) равен 1. Сыновний процесс имеет собственный уникальный идентификатор PID, БУП-блок и отдельную структуру в таблице процессов. Сыновний процесс также может породить новый процесс. Выполняющееся приложение может создать дерево процессов. Например, родительский процесс выполняет поиск накопителя на жестких дисках для заданного HTML-документа. Имя этого HTML- документа записано в глобальной структуре данных, подобной списку, который содержит все запросы на документы. После успешного обнаружения документ удаляется из списка запросов, и его путь (маршрут в сети) записывается в другую глобальную структуру данных, которая содержит пути найденных документов. Чтобы обеспечить
3.6. Создание процесса 75 Приемлемую реакцию на пользовательские запросы, для процесса предусматривается 0граничение в виде пяти необработанных запросов в списке. По достижении этого Предела порождаются два новых процесса. Если порожденный процесс в свою очередь достигнет установленного предела, он создаст еще два новых процесса. Создаваемое таким способом дерево процессов показано на рис. 3.9. Любой процесс может иметь только один родительский, но множество сыновних процессов. PID 1 Процесс in it PID 12 Поиск HTML-документов При R > 5 порождение 2 процессов При R > 54 порождение 2 процессов PID76 Поиск HTML-документов PID89 Поиск HTML-документов Рис. 3.9. Дерево процессов. При определенных условиях процесс порождает два новых потомка Сыновний процесс может быть создан с собственным исполняемым образом или в виде дубликата родительского процесса. При создании в качестве дубликата предка сыновний процесс наследует множество его атрибутов, включая среду, приоритет, стратегию планирования, ограничения по peqpcaM, открытые файлы и разделы общей памяти. Если сыновний процесс перемещает указатель текущей позиции в файле или закрывает файл, то результаты этих действий будут видны родительскому процессу. Если Родителю выделяются любые дополнительные ресурсы уже после создания процесса- °томка, то °ни не будут доступны потомку. В свою очередь, если сыновний процесс ис- °льзует какие-либо ресурсы, они также будут недоступны для процесса-родителя. Некоторые атрибуты родителя не наследуются потомком. Как упоминалось выше, !Новний процесс не наследует PID родителя и его БУП-блок. Потомок не наследует каких файловых блокировок, созданных родителем или необработанными сигна- ли. Для сыновнего процесса используются собственные значения таких времениых рактеристик, как коэффициент загрузки процессора и время создания. Несмотря на то сыновние процессы связаны определенными отношениями с родителями, они
76 Глава 3. Разбиение С++-программ на множество задач все же функционируют как отдельные процессы. Их программные и стековые счетчики действуют раздельно. Поскольку разделы данных копируются, а не используются совместно, процесс-потомок может изменять значения своих переменных, не оказывая влияния на родительскую копию данных. Родительский и сыновний процесс совместно используют раздел программного кода и выполняют инструкции, расположенные непосредственно после вызова системной функции, создавшей сыновний процесс. Они не выполняют эти инструкции на этапе блокировки из-за соперничества за процессор со всеми остальными процессами, загруженными в память. После создания образ сыновнего процесса может быть заменен другим исполняемым образом. Разделы программного кода, данных и стеков, а также его "куча" памяти перезаписывается новым образом процесса. Новый процесс сохраняет свои идентификационные номера (PID и PPID). Атрибуты, сохраняемые новым процессом после замены его исполняемого образа, перечислены в табл. 3.3. В ней также указаны системные функции, которые возвращают эти атрибуты. Переменные среды также сохраняются, если во время замены исполняемого образа процесса не были заданы новые переменные среды. Файлы, которые были открыты до момента замены исполняемого образа, остаются открытыми. Новый процесс будет создавать файлы с теми же файловыми разрешениями. Время ЦП при этом не сбрасывается. Таблица 3.3. Атрибуты, сохраняемые новым процессом после замены его исполняемого образа образом нового процесса Сохраняемые атрибуты Функция Идентификатор (ID) процесса ID родительского процесса ID группы процессов Сеансовое членство Идентификатор эффективного пользователя Идентификатор эффективной группы Дополнительные ID групп Время, оставшееся до сигнала тревоги Фактор уступчивости Время, используемое до настоящего момента Маска сигналов процесса Ожидающие сигналы Предельный размер файла Предельный объем ресурсов Маска создания файлового режима Текущий рабочий каталог Корневой каталог getpidO getppid() getpgidO getsid() getuid() getgidO getgroups() alarm() nice() times () sigprocmask() sigpending() ulimit() getrlimit() umask() getcwd()
3.6. Создание процесса 77 3.6.1.1- Утилита pstree Утилита pstree в среде Linux отображает дерево процессов (точнее, она отображает выполняющиеся процессы в форме древовидной структуры). Корнем этого дерева является процесс ini t. Синопсис pstree [-а] [-с] [-h | [pid | user] pstree -V -Hpid] [-1] [-n] [-p] [-u] [-G] | -U] При вызове этой утилиты можно использовать следующие опции. -а Отобразить аргументы командной строки. -h Выделить текущий процесс и его предков. -Н Аналогично опции -п, но выделению подлежит заданный процесс. -п Отсортировать процессы с одинаковым предком по значению PID, а не по имени. -р Отобразить значения PID. На рис. 3.10 показан результат выполнения команды pstree -h в среде Linux. ka:~ # pstree -h init-+-applix l-atd I-axmain -axnet -cron l-gpm I-inetd I-9*[kdeinit] j-kdeinit -+-kdeinit I | -kdeinit bash gimp script-fu j '-kdeinit bash -+-man sh sh less | *-pstree I-kdeinit cat I-kdm-+-X I * - kdm kde ksms erver -kflushd -khubd -klogd I-knotify I-kswapd -kupdate I -login bash |-lpd I-mdrecoveryd I-5*[mingetty] -nscd nscd 5* [nscd] I-sshd I-syslogd |-usbmgr *-xconsole Ис- 3.10. Результат выполнения команды pstree -h в среде Linux
78 Глава 3. Разбиение С++-программ на множество задач 3.6.2. Использование системной функции fork() Системная функция (или системный вызов) fork () создает новый процесс, который представляет собой дубликат вызывающего процесса, т.е. его родителя. При успешном выполнении функция fork () возвращает родительскому и сыновнему процессам два различных значения. Сыновнему возвращается число 0, а родительскому — значение PID сыновнего процесса. Родительский и сыновний процессы продолжают выполняться с инструкции, непосредственно следующей за функцией fork (). В случае неудачного выполнения (оно выражается в том, что сыновний процесс не был создан) родительскому процессу возвращается число -1. Синопсис #include <unistd pid_t fork(void), h> Неудачный исход функции fork () возможен в случае, если система не обладает ресурсами для создания еще одного процесса. Это происходит при превышении ограничения (если оно существует) на количество сыновних процессов, которое может порождать родитель, или на количество выполняющихся процессов в масштабе всей системы. В этом случае устанавливается переменная errno, которая означает наличие ошибки. 3.6.3. Использование семейства системных функций exec Семейство функций exec предназначено для замены образа вызывающего процесса образом нового процесса. При вызове функции fork () создается новый процесс, который является точной копией родительского процесса, а функция exec () заменяет образ "скопированного" процесса образом копии. Образ нового процесса представляет собой обычный выполняемый файл, который немедленно запускается на выполнение. Этот файл можно задать с помощью имени и пути доступа к нему. Функции семейства exec могут передать новому процессу аргументы командной строки, а также установить переменные среды. Если функция выполнилась успешно, она не возвращает никакого значения, поскольку образ процесса, который содержал обращение к функции exec, уже перезаписан. В случае неудачи вызывающему процессу возвращается число -1. Все функции exec () могут иметь неудачный исход при следующих условиях: • разрешения не признаны; разрешение на поиск отвергается для каталога выполняемых файлов; разрешение на выполнение отвергается для выполняемого файла; • файлы не существуют, выполняемый файл не существует; каталог не существует; • файл невозможно выполнить; файл невозможно выполнить, поскольку он открыт для записи другим процессом; файл не является выполняемым;
3.6. Создание процесса 79 проблемы с символическими ссылками; при анализе пути к исполняемому файлу символические ссылки образуют циклы; символические ссылки делают путь к исполняемому файлу слишком длинным. функции семейства exec используются совместно с функцией fork (). Функция f rk () создает и инициализирует сыновний процесс "по образу и подобию" роди- льского. Образ сыновнего процесса затем заменяет образ своего предка посредством вызова функции exec (). Пример использования функций fork () и exec () показан в листинге 3.2. II листинг 3.2. Использование системных функций II fork() и exec() RtValue = fork(); if(RtValue == 0){ execl("/path/direct","direct","."); > В листинге 3.2 демонстрируется вызов функции fork (). Значение, которое она возвращает, сохраняется в переменной RtValue. Если значение RtValue равно 0, значит, это— сыновний процесс, и в нем вызывается функция execl () с параметрами. Первый параметр содержит путь к выполняемому модулю, второй — инструкцию для выполнения, а третий — аргумент. Второй параметр, direct, представляет собой имя утилиты, которая перечисляет все каталоги и подкаталоги из данного каталога. Всего существует шесть версий функций exec, предназначенных для использования различных соглашений о вызовах. 3.6.3.1. Функции execl () Функции execl (), execle () и execlp () передают аргументы командной строки в виде списка. Количество аргументов командной строки должно быть известно во время компиляции. • int execl(const char *path,const char *arg0,.../*, (char *)0 */); Здесь path — путевое имя выполняемой программы. Его можно задать в виде полного составного имени либо относительного составного имени из текущего каталога. Последующие параметры представляют собой список аргументов командной строки, от argO до argn. Всего может быть п аргументов. Этот список завершается NULL-указателем. • int execle(const char *path,const char *arg0#.../*, (char *)0 *, char *const envp[]*/); Эта функция аналогична функции execl () с одним отличием: она имеет дополнительный параметр, envp [ ]. Этот параметр указывает на новую среду для нового процесса, т.е. envp [ ] — это указатель на строковый массив с завершающим нулевым символом. Каждая его строка, также завершающаяся нулевым символом, имеет следующую форму: name=value
80 Глава 3. Разбиение С++-программ на множество задач Здесь name — имя переменной среды, a value — сохраняемая строка. Значение параметру envp [ ] можно присвоить следующим образом: char *const envp[] = {"PATH=/opt/kde2:/sbin", "HOME=/home",NULL}; Здесь PATH и HOME — переменные среды. • int execlp(const char *file,const char *arg0, .. ./*, (char *)0 */) ; Здесь file — имя выполняемой программы. Для определения местоположения выполняемых программ используется переменная среды PATH. Остальные параметры представляют собой список аргументов командной строки (см. описание функции execl ()). Вот примеры применения синтаксиса функций execl () с различными аргументами: char *const args[] = {"direct",".",NULL}; char *const envp[] = {"files=50",NULL}; execl("/path/direct","direct",".".NULL); execle("/path/direct","direct", " . ",NULL,envp); execlp("direct","direct",".",NULL); Здесь в каждом примере вызова execl-функции активизированный процесс выполняет программу direct. Синопсис #include <unistd.h> int execl(const char *path,const char *arg0,.../*, (char *)0 */); int execle(const char *path,const char *arg0,.../*, (char *)0 *,char *const envp[]*/); int execlp(const char *file,const char *arg0,.../*, (char *)0 */); int execv(const char *path/char *const arg[]); int execve(const char *path,char *const arg[], char *const envp[]); Iint execvp(const char *file,char *const arg[]); | 3.6.3.2. Функции execv () Функции execv (), execve () и execvp () передают аргументы командной строки в векторе указателей на строки с завершающим нулевым символом. Количество аргументов командной строки должно быть известно во время компиляции. Элемент argv [ 0 ] обычно представляет собой команду. • int execv(const char *path/char *const arg[]); Здесь path — путевое имя выполняемой программы. Его можно задать в виде полного составного имени либо относительного составного имени из текущего каталога. Последующий параметр представляет вектор (с завершающим нулевым символом), содержащий аргументы командной строки представленные в виде строк с завершающими нулевыми символами. Всего может быть п аргу-
3.6. Создание процесса 81 ментов. Этот вектор завершается NULL-указателем. Элементу arg[] можно присвоить значение таким образом: char *const arg[] = {"traverse",".", ">","1000",NULL}; Бот пример вызова этой функции: execv("traverse", arg) ; Б этом случае утилита traverse перечислит все файлы в текущем каталоге, размер которых превышает 1000 байт. • int execve(const char *path,char *const arg[], char *const envp[]); Эта функция аналогична функции execv (), с одним отличием: она имеет дополнительный параметр, envp [ ], который описан выше. • int execvp(const char *file/char *const arg[]); Здесь file— имя выполняемой программы. Последующий параметр представляет собой вектор (с завершающим нулевым символом), содержащий аргументы командной строки, представленные в виде строк с завершающими нулевыми символами. Всего может быть п аргументов. Этот вектор завершается NULL-указателем. Вот примеры применения синтаксиса функций execv () с различными аргументами: char *const arg[] = {"traverse"/.", ">", " 1000" ,NULL} ; char *const envp[] = {"files=50",NULL}; execv("/path/traverse",arg); execve("/path/traverse",arg,envp); execvp("traverse",arg); Здесь в каждом примере вызова execv-функции активизированный процесс выполняет программу traverse. 3.6.3.3. Определение ограничений для функций exec () Существуют ограничения на размеры вектора argv [ ] и массива envp [ ], передаваемые функциям семейства exec. Для определения максимального размера аргументов командной строки и размера переменных среды при использовании ехес- функций (которые принимают параметр envp [ ]) можно использовать функцию sysconf (). Чтобы эта функция возвратила размер, ее параметру паше необходимо Передать значение _SC_ARG_MAX. Синопсис #include <unistd.h> Ipng sysconf(int name) Еще одним ограничением при использовании функций семейства exec и других Функций, применяемых для создания процессов, является максимальное количество Дновременно выполняемых процессов, которое допустимо для одного пользователя. тобы функция sysconf () возвратила это число, ее параметру name необходимо передать значение _SC_CHILD_MAX.
82 Глава 3. Разбиение С++-программ на множество задач 3.6.3.4. Чтение и установка переменных среды Переменные среды представляют собой строки с завершающими нулевыми символами, в которых хранится такая системная информация, как пути к каталогам, содержащим команды, библиотеки, функции и процедуры, используемые процессом. Их также можно использовать для передачи любых определенных пользователем данных между родительскими и сыновними процессами. Они обеспечивают механизм предоставления процессу специальной информации без необходимости жесткого ее связывания с кодом программы. Переменные среды предопределены системой и совместно используются всеми ее оболочками и процессами. Эти переменные инициализируются файлами запуска. Чаще всего используются следующие системные переменные. $НОМЕ Полное составное имя каталога пользователя. $РАТН Список каталогов для поиска выполняемых файлов при выполнении команд. $MAIL Полное составное имя почтового ящика пользователя. $USER Идентификатор (id) пользователя. $ SHELL Полное составное имя командной оболочки зарегистрированного пользователя. $TERM Тип терминала пользователя. Переменные среды могут храниться в файле или в списке, принадлежащем среде. Этот список среды содержит указатели на строки с завершающими нулевыми символами. Когда процесс начинает выполняться, переменная extern char **environ будет указывать на список среды. Строки, составляющие список среды, имеют следующий формат: name=value Процессы, инициализированные с помощью функций execl(), execlpO, execv () и execvp (), наследуют конфигурацию среды родительского процесса. Процессы, инициализированные с помощью функций execve () и execle (), сами устанавливают среду. Существуют функции и утилиты, которые позволяют опросить, добавить или модифицировать переменные среды. Функция getenv() используется для определения факта установки заданной переменной. Интересующая вас переменная задается с помощью параметра name. Если заданная переменная не установлена, функция возвращает значение NULL. В противном случае (если переменная установлена), функция возвращает указатель на строку, содержащую ее значение. Синопсис #include <stdlib.h> char *getenv(const char *name); int setenv(const char *name, const char *value, int overwrite); void unsetenv(const char *name); Рассмотрим пример: string Path; Path = getenv("PATH")
3.6. Создание процесса 83 Члесь строке Path присваивается значение, содержащееся во встроенной переменной среды PATH. функция setenvO используется для изменения значения существующей переменной среды или добавления новой переменной в среду вызывающего процесса. Параметр name содержит имя переменной среды, которую надлежит добавить или изменить. Заданной переменной присваивается значение, переданное в параметре value. Если переменная, заданная параметром name, уже существует, ее прежнее значение заменяется значением, заданным параметром value при условии, если параметр overwrite содержит ненулевое значение. Если же значение overwrite равно 0, содержимое заданной переменной среды не модифицируется. Функция setenv () возвращает 0 при успешном выполнении, в противном случае — значение - 1. функция unsetenv () удаляет переменную среды, заданную параметром name. 3.6.4. Использование функции system () для порождения процессов Функция system () используется для выполнения команды или запуска программы. Функция system () выполняет функцию f ork(), а затем сыновний процесс вызывает функцию exec () с оболочкой, выполняя заданную команду или программу. Синопсис #include <stdlib.h> int system(const char *string); В качестве параметра string можно передать системную команду или имя выполняемого файла. При удачном исходе функция возвращает статус завершения команды или значение, возвращаемое программой (если таковое предусмотрено). Ошибки могут возникнуть на нескольких уровнях, т.е. ошибка может произойти при выполнении функции fork () или exec () либо заданная оболочка может оказаться неподходящей для выполнения команды или программы. Функция system () возвращает значение родительскому процессу. При неудачном исходе функции exec () возвращается число 127, а при обнаружении других ошибок — число -1. Эта функция не влияет на состояние ожидания сыновних процессов. 3.6.5. Использование POSIX-функций для порождения процессов Подобно созданию процессов с помощью функций system () и fork-exec, функции posix_spawn() создают новые сыновние процессы из заданных образов процессов. Однако функции posix_spawn() позволяют при этом реализовать более * Ногослойные "рычаги" управления, т.е. они управляют следующими атрибутами сы- °вних процессов, унаследованных от родительского процесса: • Дескрипторы файлов; • стратегия планирования; • идентификатор группы процессов;
84 Глава 3. Разбиение С++-программ на множество задач • идентификатор пользователя и группы; • маска сигналов. Функции posix_spawn() позволяют управлять тем, будут ли сигналы, проигнорированные родительским процессом, игнорироваться его потомком или устанавливаться для выполнения действий, заданных по умолчанию. Управление дескрипторами файлов позволяет сыновнему процессу получить самостоятельный доступ к потоку данных, независимо открытому родителем. Возможность установить для сыновнего процесса идентификатор группы повлияет на то, как управление сыновней задачей будет связано с управлением родителем. Наконец, стратегию планирования сыновнего процесса можно установить отличной от стратегии планирования его родителя. IСинопсис I #include <spawn.h> int posix_spawn(pid_t *restrict pid, const char *restrict path, const posix_spawn_file_actions_t *file_actions, const posix__spawnattr_t *restrict attrp, char *const argv[restrict] , char *const envp[restrict]); int posix_spawnp(pid_t *restrict pid, const char *restrict file, const posix__spawn_file_actions_t *file__actions, const posix__spawnattr_t *restrict attrp, char *const argv[restrict], char *const envp[restrict]) ; Различие между этими двумя функциями состоит в том, что функции posix_spawn () передается параметр path, а функции posix__spawnp () — параметр file. Параметр path в функции posix_spawn() принимает полное или относительное составное имя выполняемого файла, а параметр file в функции posix_spawnp () — только имя выполняемой программы. Если этот параметр содержит символ "косая черта", то содержимое параметра file используется в качестве составного путевого имени. В противном случае путь к выполняемому файлу определяется с помощью переменной среды PATH. Параметр file_actions представляет собой указатель на структуру posix_spawn_file_actions__t: struct posix_spawn_file_actions_t{ { int allocated; int used; struct spawn_action *actions; int pad [16] ; }; Структура posix_spawn_f ile_actions_t содержит информацию о действиях, выполняемых в новом процессе над дескрипторами файлов. Параметр f ile_actions используется для преобразования родительского набора дескрипторов открытых файлов в набор дескрипторов файлов для порожденного сыновнего процесса. Эта структура может содержать ряд файловых операций, предназначенных для выполнения в последовательности, в которой они были добавлены в объект действий над файлами. Эти файловые операции выполняются над дескрипторами открытых файлов родительского процесса и позволяют копировать, добавлять, удалять
3.6. Создание процесса 85 иЛи закрывать дескрипторы заданных файлов от имени сыновнего процесса даже до его создания. Если параметр f ile_actions содержит нулевой указатель, то дескрипторы файлов, открытые родительским процессом, останутся открытыми для его потомка без каких-либо модификаций. Функции, используемые для добавления действий над файлами в объект типа posix_spawn_f ile_actions, перечислены в табл. 3.4. Таблица 3.4. Функции, используемыедля добавления действий над файлами в объект типа posix_spawn_f ile_actions Функции Описание int posix_spawn_f ile_actions_addclose (posix_spawn_f ile_actions_t *file_actions, int fildes); int posix_spawn_f ile_actions_addopen (posix_spawn_f ile_actions_t *file_actions, int fildes, const char *restrict path, int oflag, mode_t mode); int posix_spawn_f ile_actions_adddup2 (posix_spawn_f ile_actions_t *file_actions, int fildes, int new fildes); int posix_spawn_file_actions_destroy (posix_spawn_f ile_actions_t *file_actions); int Posix_spawn_file_actions_init (posix_spawn_file_actions_t *file_actions) , Добавляет действие close () в объект действий над файлами, заданный параметром f ile_actions. В результате при порождении нового процесса с помощью этого объекта будет закрыт файловый дескриптор fildes Добавляет действие open () в объект действий над файлами, заданный параметром f ile_actions. В результате при порождении нового процесса с помощью этого объекта будет открыт файл, заданный параметром path, с использованием дескриптора fildes Добавляет действие dup2 () в объект действий над файлами, заданный параметром f ile_actions. В результате при порождении нового процесса с помощью этого объекта будет создан дубликат файлового дескриптора fildes с использованием файлового дескриптора newf ildes Разрушает объект, заданный параметром f ile_actions, что приводит к деинициализации этого объекта. Затем его можно инициализировать повторно с помощью функции posix_spawn_f ile_actions_init () Инициализирует объект, заданный параметром f ile_actions. После инициализации этот объект не будет содержать действий, предназначенных для выполнения над файлами Параметр attrp указывает на структуру posix_spawnattr_t: struct posix_spawnattr_t short int flags; Pid_t pgrp;
86 Глава 3. Разбиение С++-программ на множество задач sigset_t sd; sigset_t ss; struct sched_param sp; int policy; int pad [16] ; }; Эта структура содержит информацию о стратегии планирования, группе процессов, сигналах и флагах для нового процесса. Ниже следует описание отдельных атрибутов этой структуры. flags Используется для индикации того, какие атрибуты процесса должны быть модифицированы в порожденном процессе. Эти атрибуты организованы поразрядно по принципу включающего ИЛИ: POSIX_SPAWN_RESETIDS POSIX_SPAWN_SETPGROUP POSIX_SPAWN_SETSIGDEF POSIX_SPAWN_SETSIGMASK POSIX_SPAWN_SETSCHEDPARAM POSIX_SPAWN_SETSCHEDULER pgrp Идентификатор группы процессов, подлежащих объединению с новым процессом. sd Представляет множество сигналов, подлежащих обработке по умолчанию новым процессом. ss Представляет маску сигналов, подлежащую использованию новым процессом. sp Представляет параметр планирования, подлежащий назначению новому процессу. —policy Представляет стратегию планирования, предназначенную для нового процесса. Функции, используемые для установки и считывания отдельных атрибутов, содержащихся в структуре posix_spawnattr_t, перечислены в табл. 3.5. Таблица 3.5. Функции, используемые для установки и считывания отдельных атрибутов структуры posix_spawnattr_t Функции Описание int posix_spawnattr_getflags Возвращает значение атрибута flags, xpa- (const posix_spawnattr_t нимого в объекте, заданном параметром attr *restrict attr, short *restrict flags); int posix_spawnattr_setflags Устанавливает значение атрибута flags, (posix_spawnattr_t *attr, хранимого в объекте, заданном параметром short flags) ; attr, равным значению параметра flags _
3.6. Создание процесса 87 Продолжение табл. 3.5 Функць Описание int posix_spawnattr__getpgroup (const posix_spawnattr_t *restrict attr, pid_t *restrict pgroup); int posix_spawnattr_setpgroup (posix_spawnattr_t ♦attr, pid_t pgroup); int posix_spawnattr_getschedparam (const posix_spawnattr_t ♦restrict attr, struct sched_param ♦restrict schedparam); int posix_spawnattr_setschedparam (posix_spawnattr_t *attrf const struct sched_param ♦restrict schedparam); int pos ix_spawna t tr_gets chedpolicy (const posix_spawnattr_t ♦restrict attr, int ♦restrict schedpolicy); int pos ix_spawnattr_setschedpolicy (posix_spawnattr_t ♦attr, int schedpolicy); int posix_spawnattr_getsigdefault (const posix_spawnattr_t ♦restrict attr, sigset_t ♦restrict sigdefault); int Posix_spawnattr_setsigdefault (posix_spawnattr_t *attr, const sigset_t ♦restrict sigdefault); lnt P°six_spawnattr_getsigmask (const posix_spawnattr_t ♦restrict attr, sigset_t ♦restrict sigmask); Возвращает значение атрибута pgroup, хранимого в объекте, заданном параметром attr, и сохраняет его в параметре pgroup Устанавливает значение атрибута pgroup, хранимого в объекте, заданном параметром attr, равным параметру pgroup, если в атрибуте flags установлен признак POSIX_SPAWN_SETPGROUP Возвращает значение атрибута sp, хранимого в объекте, заданном параметром attr, и сохраняет его в параметре schedparam Устанавливает значение атрибута sp, хранимого в объекте, заданном параметром attr, равным параметру schedparam, если в атрибуте flags установлен признак POSIX_SPAWN_SETSCHEDPARAM Возвращает значение атрибута policy, хранимого в объекте, заданном параметром attr, и сохраняет его в параметре schedpolicy Устанавливает значение атрибута policy, хранимого в объекте, заданном параметром attr, равным параметру schedpolicy, если в атрибуте flags установлен признак POSIX_SPAWN_SETSCHEDULER Возвращает значение атрибута sd, хранимого в объекте, заданном параметром attr, и сохраняет его в параметре sigdef ault Устанавливает значение атрибута sd, хранимого в объекте, заданном параметром attr, равным параметру sigde fault, если в атрибуте flags установлен признак POSIX_SPAWN_SETSIGDEF Возвращает значение атрибута ss, хранимого в объекте, заданном параметром attr, и сохраняет его в параметре sigmask
88 Глава 3. Разбиение С++-программ на множество задач Окончание табл. 3.5 Функции Описание int posix_spawnattr_setsigmask Устанавливает значение атрибута ss, xpa- (posix_spawnattr_t нимого в объекте, заданном параметром *restrict attrf attr, равным параметру sigmask, если в ат- const slf^ £ siamask) . рибуте _flags установлен признак restrict sigmask), POSIX_SPAWN_SETSIGMASK int posix_spawnattr_destroy Разрушает объект, заданный параметром (posix_spawnattr_t *attr) ; attr. Этот объект можно затем снова инициализировать с помощью функции posix_spawnattr_init() int posix_spawnattr_init Инициализирует объект, заданный парамет- (posix_spawnattr_t *attr) ; pOM attr, значениями, действующими по умолчанию для всех атрибутов, содержащихся в этой структуре Пример использования функции posix_spawn () для создания процесса приведен в листинге 3.3. // Листинг 3.3. Порождение процесса с помощью // функции posix_spawn(), которая // вызывает утилиту ps #include <spawn.h> #include <stdio.h> #include <errno.h> #include <iostream> { //. .. posix_spawnattr_t X; posix_spawn_file_actions_t Y; pid_t Pid; char *const argv[] = {"/bin/ps","-If",NULL}; char *const envp[] = {"PROCESSES=2"}; posix_spawnattr_init(&X); posix_spawn_file_actions_init(&Y); posix_spawn(&Pid,"/bin/ps", &Y,&X,argv,envp); perror("posix_spawn"); cout « "spawned PID: " « Pid « endl; //. . . return(0) ; } В листинге 3.3 инициализируются объекты posix_spawnattr_t и posix_spawn_ f ile_actions_t. Функция posix_spawn() вызывается с такими аргументами: Pid, путь " /bin/ps", Y, X, массив argv (который содержит команду в качестве первого элемента и опцию в качестве второго) и массив envp, содержащий список переменных среды. При успешном выполнении функции posix_spawn() значение, хранимое в параметре Pid, станет идентификатором (PID) порожденного процесса, а функция perror () отобразит следующий результат: posix_spawn: Success
3.7. Завершение процесса 89 Чатем будет выведено значение Pid. В данном случае порожденный процесс выполняет следующую команду: /bin/ps -If При успешном выполнении POSIX-функции возвращают (обычным путем) число О и в параметре pid идентификатор (id) сыновнего процесса (для родительского процесса). В случае неудачи сыновний процесс не создается, следовательно, значение pid не имеет смысла, а сама функция возвращает значение ошибки. При использовании spawn-функций ошибки могут возникать на трех уровнях. Во- первых, это возможно, если окажутся недействительными объекты f ile_actions или attr objects. Если это произойдет после успешного (казалось бы) завершения функции (т.е. после порождения сыновнего процесса), такой сыновний процесс может получить статус завершения со значением 127. Если ошибка связана с функциями управления атрибутами порожденных процессов, возвращается код ошибки, сгенерированный конкретной функцией (см. табл. 3.4 и 3.5). Если spawn-функция успела успешно завершиться, то сыновний процесс может иметь статус завершения со значением 127. Ошибки также возникают при попытке породить сыновний процесс. Эти ошибки будут такими же, как при выполнении функций fork () или exec (). В этом случае они (ошибки) займут место значений, возвращаемых spawn-функциями. Если сыновний процесс генерирует ошибку, родительский процесс не получает "дурного известия" автоматически. Для извещения родителя об ошибке сыновнего процесса необходимо использовать другие механизмы, поскольку информация об этом не сохраняется в статусе завершения потомка. С этой целью можно использовать механизм межпроцессного взаимодействия либо специальный флаг, устанавливаемый сыновним процессом и видимый для его родителя. 3.6.6. Идентификация родительских и сыновних процессов с помощью функций управления процессами Существуют две функции, которые возвращают значение идентификатора (PID) вызывающего процесса и значение идентификатора (PPID) родительского процесса. Функция getpid () возвращает идентификатор вызывающего процесса, а функция getppid () — идентификатор процесса, который является родительским для вызывающего процесса, эти функции всегда завершаются успешно, поэтому коды ошибок не определены. Синопсис #include <unistd.h> Pid-t getpid(void); £jg-t getppid(void); 3.7. Завершение процесса Когда процесс завершается, его блок БУП разрушается, а используемое им адрес- °е пространство и ресурсы освобождаются. Код завершения помещается в главную ЛИцУ процессов. Как только родительский процесс примет этот код, соответст-
90 Глава 3. Разбиение С++-программ на множество задач вующая структура таблицы процессов будет удалена. Процесс завершается, если соблюдены следующие требования. • Все инструкции выполнены. • Процесс явным образом передает управление родительскому процессу или вызывает системную функцию, которая завершает процесс. • Сыновние процессы могут завершаться автоматически при завершении родительского процесса. • Родительский процесс посылает сигнал о завершении своих сыновних процессов. Аварийное завершение процесса может произойти в случае, если процесс выполняет недопустимые действия. • Процесс требует больше памяти, чем система может ему предоставить. • Процесс пытается получить доступ к неразрешенным ресурсам. • Процесс пытается выполнить некорректную инструкцию или запрещенные вычисления. Завершение процесса может быть инициировано пользователем, если этот процесс является интерактивным. Родительский процесс несет ответственность за завершение (освобождение) своих потомков. Родительский процесс должен ожидать до тех пор, пока не завершатся все его сыновние процессы. Если родительский процесс выполнит считывание кода завершения сыновнего процесса, процесс-потомок покидает систему нормально. Процесс остается в "зомбированном" состоянии до тех пор, пока его родитель не примет соответствующий сигнал. Если родитель никогда не примет сигнал (поскольку он уже успел сам завершиться и выйти из системы или не ожидал завершения сыновнего процесса), процесс-потомок остается в "зомбированном" состоянии до тех пор, пока процесс init (исходный системный процесс) не примет его код завершения. Большое количество "зомбированных" процессов может негативно отразиться на производительности системы. 3.7.1. Функции exit (), kill () и abort () Для самостоятельного завершения процесс может вызвать одну из двух функций: exit () и abort О . Функция exit() обеспечивает нормальное завершение вызывающего процесса. При этом будут закрыты все дескрипторы открытых файлов, связанные с процессом. Функция exit () сбросит на диск все открытые потоки, содержащие еще не переписанные буферизованные данные, после чего открытые потоки будут закрыты. Параметр status принимает статус завершения процесса, возвращаемый ожидающему родительскому процессу, который затем перезапускается. Параметр status может принимать такие значения: 0, EXIT_FAILURE или EXIT_SUCCESS. Значение 0 говорит об успешном завершении процесса. Ожидающий родительский процесс имеет доступ только к младшим восьми битам значения параметра status. Если родительский процесс не ожидает завершения сыновнего процесса, его (ставшего "зомбированным") "усыновляет" процесс init. Функция abort () вызывает аварийное окончание вызывающего процесса, что по последствиям равноценно результату выполнения функции fcloseO для всех открытых потоков. При этом ожидающий родительский процесс получит сигнал о пре-
3.8. Ресурсы процессов 91 крашении выполнения сыновнего процесса. Процесс может прибегнуть к преждевременному прекращению только в случае, если он обнаружит ошибку, с которой не сможет справиться программным путем. Синопсис #include <stdlib.h> void exit(int status); void abort(void) ; функцию kill () можно использовать для принудительного завершения другого процесса. Эта функция отправляет сигнал процессам, заданным параметром pid. Параметр sig — это сигнал, предназначенный для отправки заданному процессу. Возможные сигналы перечислены в заголовке <signal.h>. Для уничтожения процесса параметр sig должен иметь значение SIGKILL. Чтобы иметь право отсылать сигнал процессу, вызывающий процесс должен обладать соответствующими привилегиями, либо его реальный или идентификатор эффективного пользователя должен совпадать с реальным или сохраненным пользовательским идентификатором процесса, который принимает этот сигнал. Вызывающий процесс может иметь разрешение на отправку процессам только определенных (а не любых) сигналов. При успешной отправке сигнала функция возвращает вызывающему процессу значение 0, в противном случае — число -1. Вызывающий процесс может отправить сигнал одному или нескольким процессам при таких условиях. pid > 0 Сигнал будет отослан процессу, идентификатор (PID) которого равен значению параметра pid. pid = 0 Сигнал будет отослан всем процессам, у которых идентификатор группы процессов совпадает с идентификатором вызывающего процесса. pid = -1 Сигнал будет отослан всем процессам, для которых вызывающий процесс имеет разрешение отправлять этот сигнал. Сигнал будет отослан всем процессам, у которых идентификатор группы процессов равен абсолютному значению параметра pid, и для которых вызывающий процесс имеет разрешение отправлять этот сигнал. pid < -1 Синопсис #include <signal.h> Apt kill(pjd_t pid, int sig); 3.8. Ресурсы процессов При выполнении возложенной на процесс задачи часто приходится записывать Данные в файл, отправлять их на принтер или отображать полученные результаты на кране. Процессу могут понадобиться данные, вводимые пользователем с клавиатуры и с°Держащиеся в файле. Кроме того, процессы в качестве ресурса могут использо- ать другие процессы, например, подпрограммы. Подпрограммы, файлы, семафоры, Ыотексы, клавиатуры и экраны дисплеев — все это примеры ресурсов, которые мо-
92 Глава 3. Разбиение С++-программ на множество задач жет затребовать процесс. Под ресурсом понимается все то, что использует процесс в любое заданное время в качестве источника данных, средств обработки, вычислений или отображения информации. Чтобы процесс получил доступ к ресурсу, он должен сначала сделать запрос, обратившись с ним к операционной системе. Если ресурс свободен, операционная система позволит процессу его использовать. После использования ресурса процесс освобождает его, чтобы он стал доступным для других процессов. Если ресурс недоступен, запрос отвергается, и процесс должен подождать его освобождения. Как только ресурс станет доступным, процесс активизируется. Таков базовый подход к распределению ресурсов между процессами. На рис. 3.11 показан граф распределения ресурсов, по которому можно понять, какие процессы удерживают ресурсы, а какие их ожидают. Так, процесс В делает запрос на ресурс 2, который удерживается процессом С. Процесс С делает запрос на ресурс 3, который удерживается процессом D. Рис. 3.11. Граф распределения ресурсов, который показывает, какие процессы удерживают ресурсы, а какие их запрашивают Если удовлетворяется сразу несколько запросов на получение доступа к ресурсу, этот ресурс является совместно используемым, или разделяемым (эта ситуация также отображена на рис. 3.11). Процесс А разделяет ресурс R, с процессом D. Разделяемые ресурсы могут допускать параллельный доступ сразу нескольких процессов или разрешать доступ только одному процессу в течение ограниченного промежутка времени, после чего аналогичным доступом сможет воспользоваться другой процесс. Примером такого типа разделяемых ресурсов может служить процессор. Сначала процессор назначается одному процессу в течение короткого интервала времени, а затем процессор "получает" другой процесс. Если удовлетворяется только один запрос на получение доступа к ресурсу, и это происходит после того, как ресурс освободит другой процесс, такой ресурс является неразделяемым, а о процессе говорят, что он имеет монопольный доступ (exclusive access) к ресурсу. В многопроцессорной среде важно знать, какой доступ можно организовать к разделяемому ресурсу: параллельный или последовательный (передавая "эстафету" поочередно от ресурса к ресурсу). Это позволит избежать ловушек, присущих параллелизму. Одни ресурсы могут изменяться или модифицироваться процессами, а другие — нет. Поведение разделяемых модифицируемых или немодифицируемых ресурсов определяется типом ресурса.
3.8. Ресурсы процессов 93 fe 3.1 -Граф распределения ресурсов , Гоафы распределения ресурсов — это направленные графы, которые показывают, как Определяются реСурСЫ в системе. Такой граф состоит из множества вершин V множества ребер Е. Множество вершин делится на две категории: р1{р,.рг....р.} r-{R,.R. »»> Множество Р— это множество всех процессов, a R— это множество всех ресурсов в системе. Ребро, направленное от процесса к ресурсу, называется ребром запроса, а ребро, направленное от ресурса к процессу, называется ребром назначения. Направленные ребра обозначаются следующим образом: р _» r Ребро запроса: процесс Р{ запрашивает экземпляр типа ресурса R. r _> р. Ребро назначения: экземпляр типа ресурса R. выделен процессу R Каждый процесс в графе распределения ресурсов отображается кругом, а каждый ресурс — прямоугольником. Поскольку может быть много экземпляров одного типа ресурса, то каждый из них представляется точкой внутри прямоугольника. Ребро запроса указывает на периметр прямоугольника ресурса, а ребро назначения берет начало из точки и касается периметра круга процесса. Граф распределения ресурсов, показанный на рис. 3.11, отображает следующее. Множества Р, R и Е P-{P..PbiPefPd} R={RI,R2,R,} E-{R,->P4tR1->PdtPb^R2tRf->PctPc->RvR3^Pd} 3.8.1. Типы ресурсов Существуют три основных типа ресурсов: аппаратные, информационные и программные. Аппаратные ресурсы представляют собой физические устройства, подключенные к компьютеру (например, процессоры, основная память и все устройства ввода-вывода, включая принтеры, жесткий диск, накопитель на магнитной ленте, дисковод с zip-архивом, мониторы, клавиатуры, звуковые, сетевые и графические карты, а также модемы. Все эти устройства могут совместно использовать несколько процессов. Некоторые аппаратные ресурсы прерываются, чтобы разрешить доступ к ним различных процессов. Например, прерывания процессора позволяют различным процессам выполняться по очереди. Оперативное запоминающее устройство, или ОЗУ \КАМ),— это еще один пример ресурса, разделяемого посредством прерываний. Кода процесс не выполняется, некоторые страничные блоки, которые он занимает, мо- Ут быть выгружены во вспомогательное запоминающее устройство, а на их место за- РУжены данные, относящиеся к другому процессу. В любой момент времени весь Д*апазон памяти может быть занят страничными блоками только одного процесса, римером разделяемого, но непрерываемого ресурса может служить принтер. При вместном использовании принтера задания, посылаемые на печать каждым процес- м» хранятся в очереди. Каждое задание печатается до конца, и только потом начи-
94 Глава 3. Разбиение С++-программ на множество задач нает выполняться следующее задание. Принтер не прерывается ни одним ждущим заданием, если не отменяется текущее задание. Информационные ресурсы— к ним относятся данные (например, объекты), системные данные (например, переменные среды, файлы и дескрипторы) и такие глобально определенные переменные, как семафоры и мьютексы, — являются разделяемыми ресурсами, которые могут быть модифицированы процессами. Обычные файлы и файлы, связанные с физическими устройствами (например, принтером), могут открываться с учетом ограничивающего типа доступа со стороны процессов. Другими словами, процессы могут обладать правом доступа только для чтения, или только для записи, или для чтения и записи. Сыновний процесс наследует ресурсы родительского процесса и права доступа к ним, существующие на момент создания процесса- потомка. Сыновний процесс может переместить файловый указатель, закрыть, модифицировать или перезаписать содержимое файла, открытого родителем. Доступ к совместно используемым файлам и памяти с разрешением записи должен быть синхронизирован. Для синхронизации доступа к разделяемым ресурсам данных можно использовать такие разделяемые данные, как семафоры и мьютексы. Разделяемые библиотеки могут служить примером программных ресурсов. Разделяемые библиотеки предоставляют общий набор функций для процессов. Процессы могут также совместно использовать приложения, программы и утилиты. В этом случае в памяти находится только одна копия программного кода, например, приложения (приложений). При этом должны существовать отдельные копии данных, по одной для каждого пользователя (процесса). К неизменяемому программному коду (который также именуется реентерабельным, или повторно используемым) могут получать доступ несколько процессов одновременно. 3.8.2, POSIX-функции для установки ограничений доступа к ресурсам В библиотеке POSIX определены функции, которые ограничивают возможности процесса по использованию определенных ресурсов. Так, операционная система устанавливает ограничения на возможности процесса по использованию системных ресурсов, а именно: • размер стека процесса; • размер создаваемого файла и файла ядра; • объем времени ЦП, выделенный процессу (размер кванта времени); • объем памяти, используемый процессом; • количество дескрипторов открытых файлов. Операционная система устанавливает жесткие ограничения на использование ресурсов процессом. Процесс может установить или изменить мягкие ограничения ресурсов, но это значение не должно превысить жесткий предел, установленный операционной системой. Процесс может понизить свой жесткий предел, но его значение не должно быть меньше мягкого предела. Операция по понижению процессом своего жесткого предела необратима. Его могут повысить только процессы, обладающие специальными привилегиями.
3.8. Ресурсы процессов 95 Синопсис #include <sys/resource.h> int setrlimit(int resource, const struct rlimit *rlp); int getrlimit(int resource, struct rlimit *rlp); int getrusage(int who, struct rusage *r__usage) ; функция setrlimit () используется для установки ограничений на потребление заданных ресурсов. Эта функция позволяет установить как жесткий, так и мягкий пределы. Параметр resource представляет тип ресурса. Значения типов ресурсов (и их краткое описание) приведено в табл. 3.6. Жесткие и мягкие пределы заданного ресурса представляются параметром rip, который указывает на структуру rlimit, содержащую два объекта типа rlim_t. struct rlimit { rlim_t rlim_cur; rlim_t rlim_max; }; Тип rlim_t — это целочисленный тип без знака. Член rlim_cur содержит значение текущего, или мягкого предела, а член rlim_max— значение максимума, или жесткого предела. Членам rlim_cur и rlim_max можно присвоить любые значения, а также символические константы, определенные в заголовке <sys /resource. h>. RLIM__INFINITY Отсутствие ограничения. RLIM_SAVED_MAX Непредставимый хранимый жесткий предел. RLIM_SAVED_CUR Непредставимый хранимый мягкий предел. Как жесткий, так и мягкий пределы можно установить равными значению RLIM__INFINITY, которое подразумевает, что ресурс неограничен. [Таблица 3.6. Значения параметра resource Определение ресурса Описание RLIMIT__CORE Максимальный размер файла ядра в байтах, который может быть создан процессом IMIT__CPU Максимальный объем времени ЦП в секундах, которое может быть использовано процессом IMIT__DATA Максимальный размер раздела данных процесса в байтах MIT__FSIZE Максимальный размер файла в байтах, который может быть создан процессом MIT__NOFILE Увеличенное на единицу максимальное значение, которое система может назначить вновь созданному дескриптору файла IT__STACK Максимальный размер стека процесса в байтах — -ET__AS Максимальный размер доступной памяти процесса в байтах
95 Глава 3. Разбиение С++-программ на множество задач Функция getrlimit () возвращает значения мягкого и жесткого пределов заданного ресурса в объекте rip. Обе функции возвращают значение 0 при успешном завершении и число -1 в противном случае. Пример установки процессом мягкого предела для размера файлов в байтах приведен в листинге 3.4. // Листинг 3.4. Использование функции setrlimitO для // установки мягкого предела для размера // файлов #include <sys/resource.h> //. . . struct rlimit R_limit; struct rlimit R_limit_values; //. . . R_limit.rlim_cur = 2 000; R_limit.rlim_max = RLIM_SAVED_MAX; setrlimit (RLIMIT_FSIZE, &R__limit) ; getrlimit(RLIMIT_FSIZE,&R_limit_values); cout « "мягкий предел для размера файлов: " « R_limit_values .rlim_cur « endl; //. . . В листинге 3.4 мягкий предел для размера файлов устанавливается равным 2000 байт, а жесткий предел — максимально возможному значению. Функции setrlimit () передаются значения RLIMIT_FSIZE и R__limit, а функции getrlimit () — значения RLIMIT_FSIZE и R_limit_values. После их выполнения на экран выводится установленное значение мягкого предела. Функция getrusage () возвращает информацию об использовании ресурсов вызывающим процессом. Она также возвращает информацию о сыновнем процессе, завершения которого ожидает вызывающий процесс. Параметр who может иметь следующие значения: RUSAGE_SELF RUSAGE_CHILDREN Если параметру who передано значение RUSAGE_SELF, то возвращаемая информация будет относиться к вызывающему процессу. Если же параметр who содержит значение RUSAGE_CHILDREN, то возвращаемая информация будет относиться к потомку вызывающего процесса. Если вызывающий процесс не ожидает завершения своего потомка, информация, связанная с ним, отбрасывается (не учитывается). Возвращаемая информация передается через параметр r__usage, который указывает на структуру rusage. Эта структура содержит члены, перечисленные и описанные в табл. 3.7. При успешном выполнении функция возвращает число 0, в противном случае — число -1.
3.9. Асинхронные и синхронные процессы 97 Таблица 3.7. Члены структуры rusage Член структуры Описание struct timeval ru_utime struct timeval ru__sutime long ru_maxrss long long long long long long long long long long long long long ru_maxixrss ru_maxidrss ru_maxisrss ru_minfIt ru_majfIt ru_nswap ru_inblock ru_oublock ru_msgsnd ru_msgrcv ru_nsignals ru_nvcsw ru_nivcsw Время, потраченное пользователем Время, использованное системой Максимальный размер, установленный для резидентной программы Размер разделяемой памяти Размер неразделяемой области данных Размер неразделяемой области стеков Количество запросов на страницы Количество ошибок из-за отсутствия страниц Количество перекачек страниц Блочные операции по вводу данных Блочные операции операций по выводу данных Количество отправленных сообщений Количество полученных сообщений Количество полученных сигналов Количество преднамеренных переключений контекста Количество принудительных переключений контекста 3.9. Асинхронные и синхронные процессы Асинхронные процессы выполняются независимо один от другого. Это означает, что процесс А будет выполняться до конца безотносительно к процессу В. Между асинхронными процессами могут быть прямые родственные ("родитель-сын") отношения, а могут и не быть. Если процесс А создает процесс В, они оба могут выполняться независимо, но в некоторый момент родитель должен получить статус завершения сыновнего процесса. Если между процессами нет прямых родственных отношений, у них может быть общий родитель. Асинхронные процессы могут выполняться последовательно, параллельно или с перекрытием. Эти сценарии изображены на рис. 3.12. В ситуации 1 до самого конца вы- олняется процесс А, затем процесс В и процесс С выполняются до самого конца. Это есть последовательное выполнение процессов. В ситуации 2 процессы выполняются ДНовременно. Процессы А и В — активные процессы. Во время выполнения процесса А роцесс В находится в состоянии ожидания. В течение некоторого интервала времени а пР°Цесса пребывают в ждущем режиме. Затем процесс В "просыпается", причем Р ныне процесса А, а через некоторое время "просыпается" и процесс А, и теперь оба роцесса выполняются одновременно. Эта ситуация показывает, что асинхронные роцессы могут выполняться одновременно только в течение определенных интерва- в времени. В ситуации 3 выполнение процессов А и В перекрывается.
дя Глава 3. Разбиение С++-программ на множество задач АСИНХРОННЫЕ ПРОЦЕССЫ выполняется [Ситуация 1jJ- ПРОЦЕССА i ПРОЦЕСС В выполняется ^ ПРОЦЕСС С выполняется. [Ситуация 2^J- _ЛЛ . DDK IUJ 1ПМС1^М иЖИДЦЦ I DDII IUJ 1ПЛСIU71 л ПРОЦЕСС А шшшшшшшшшшшшшш -■ -** , ■ > * ммннЦ ожидает выполняется , ПРОЦЕСС В — — -.— — — — — — шшшшшшшшшшшшшшшшшшш\ выполняется ожидает выполняется [Ситуация 3jJ- ПРОЦЕСС А ПРОЦЕСС В выполняется ожидает выполняется СИНХРОННЫЕ ПРОЦЕССЫ [Ситуация 4м выполняется ожидает выполняется t ПРОЦЕСС А нншц ~, ,,..._•... - шшшшшшшшшшшш^ fork() ; ; 1 выполняется ' возвращает код выхода ПРОЦЕСС В ншн^ Рис. 3.12. Возможные сценарии асинхронных и синхронных процессов Асинхронные процессы могут совместно использовать такие ресурсы, как файлы или память. Это может потребовать (или не потребовать) синхронизации или взаимодействия при разделении ресурсов. Если процессы выполняются последовательно (ситуация 1), то они не потребуют никакой синхронизации. Например, все три процесса, А, В и С, могут разделять некоторую глобальную переменную. Процесс А (перед тем как завершиться) записывает значение в эту переменную, затем процесс В во время своего выполнения считывает данные, хранимые в этой переменной и (перед тем как завершиться) записывает в нее "свое" значение. Затем во время своего выполнения процесс С считывает данные из этой переменной. Но в ситуациях 2 и 3 процессы могут попытаться одновременно модифицировать эту переменную, поэтому здесь не обойтись без синхронизации доступа к ней. Мы определяем синхронные процессы как процессы с перемежающимся выполнением, когда один процесс приостанавливает свое выполнение до тех пор, пока не
3.9. Асинхронные и синхронные процессы 99 ершится другой. Например, процесс А, родительский, при выполнении создает оцесс В, сыновний. Процесс А приостанавливает свое выполнение до тех пор, ка не завершится процесс В. После завершения процесса В его выходной код по- ешается в таблицу процессов. Тем самым процесс А уведомляется о завершении ооцесса В. Процесс А может продолжить выполнение, а затем завершиться или за- еошиться немедленно. В этом случае выполнение процессов А и В является синхронизированным. Сценарий синхронного выполнения процессов А и В (для сравнения с асинхронным) также показан на рис. 3.12. 3.9.1- Создание синхронных и асинхронных процессов с помощью функций fork (), exec (), system () и posix_spawn() Функции fork (), fork-exec и posix_spawn () позволяют создавать асинхронные процессы. При использовании функции fork () дублируется образ родительского процесса. После создания сыновнего процесса эта функция возвращает родителю (через параметр) идентификатор (PID) процесса-потомка и (обычным путем) число О, означающее, что создание процесса прошло успешно. При этом родительский процесс не приостанавливается; оба процесса продолжают выполняться независимо от инструкции, следующей непосредственно за вызовом функции fork (). При создании сыновнего процесса посредством f ork-exec-комбинации его образ инициализируется с помощью образа нового процесса. Если функция exec () выполнилась успешно (т.е. успешно прошла инициализация), она не возвращает родительскому процессу никакого значения. Функции posix_spawn() создают образы сыновних процессов и инициализируют их. Помимо идентификатора (PID), возвращаемого (через параметр) функцией posix_spawn() родительскому процессу, обычным путем возвращается значение, служащее индикатором успешного порождения процесса. После выполнения функции posix_spawn() оба процесса выполняются одновременно. Функция system() позволяет создавать синхронные процессы. При этом создается оболочка, которая выполняет системную команду или запускает выполняемый файл. В этом случае родительский процесс приостанавливается до тех пор, пока не завершится сыновний процесс и функция system () не возвратит значение. 3.9.2. Функция wait () Асинхронный процесс, вызвав функцию wait (), может приостановить выполнение до тех пор, пока не завершится сыновний процесс. После завершения сыновнего роцесса ожидающий родительский процесс считывает статус завершения своего по- омка, чтобы не допустить создания процесса-"зомби". Функция wait () получает ста- ,с завершения из таблицы процессов. Параметр status указывает на ту область, ко- рая содержит статус завершения сыновнего процесса. Если родительский процесс * еет не один, а несколько сыновних процессов и некоторые из них уже завершись, функция wait () считывает из таблицы процессов статус завершения только для ого сыновнего процесса. Если информация о статусе окажется доступной еще до полнения функции wait (), эта функция завершится немедленно. Если родитель- и процесс не имеет ни одного потомка, эта функция возвратит код ошибки.
1.00 Глава 3. Разбиение С++-программ на множество задач Функцию wait () можно использовать также в том случае, когда вызывающий процесс должен ожидать до тех пор, пока не получит сигнал, чтобы затем выполнить определенные действия по его обработке. I Синопсис I #include <sys/wait.h> pid_t wait(int *status); pid_t waitpid(pid_t pid, int *status, int options); Функция waitpidO аналогична функции wait () за исключением того, что она принимает дополнительные параметры pid и options. Параметр pid задает множество сыновних процессов, для которых считывается статус завершения. Другими словами, значение параметра pid определяет, какие процессы попадают в это множество. pid > 0 Единственный сыновний процесс. pid = 0 Любой сыновний процесс, групповой идентификатор которого совпадает с идентификатором вызывающего процесса. pid < -1 Любые сыновние процессы, групповой идентификатор которых равен абсолютному значению pid. pid = -1 Любые сыновние процессы. Параметр options определяет, как должно происходить ожидание процесса, и может принимать одно из значений следующих констант, определенных в заголовке <sys/wait .h>: WCONTINUED Сообщает статус завершения любого продолженного сыновнего процесса (заданного параметром pid), о статусе которого не было доложено с момента продолжения его выполнения. WUNTRACED Сообщает статус завершения любого остановленного сыновнего процесса (заданного параметром pid), о статусе которого не было доложено с момента его останова. WN0HANG Вызывающий процесс не приостанавливается, если статус завершения заданного сыновнего процесса недоступен. Эти константы могут быть объединены с помощью логической операции ИЛИ и переданы в качестве параметра options (например, WCONTINUED | | WUNTRACED). Обе эти функции возвращают идентификатор (PID) сыновнего процесса, для которого получен статус завершения. Если значение, содержащееся в параметре status, равно числу 0, это означает, что сыновний процесс завершился при таких условиях: • процесс вернул значение 0 из функции main (); • процесс вызвал некоторую версию функции exit () с аргументом 0; • процесс был завершен, поскольку завершился последний поток процесса. В табл. 3.8 перечислены макросы, которые позволяют вычислить значение статуса завершения.
3.10. Разбиение программы на задачи 101 Таблица 3.8. Макросы, которые позволяют вычислить значение статуса завершения Макрос Описание WIFEXITED Приводится к ненулевому значению, если статус был возвращен нормально завершенным сыновним процессом WEXITSTATUS Если значение WIFEXITED оказывается ненулевым, то оцениваются младшие 8 бит аргумента status, переданного завершенным сыновним процессом функции _exit () или exit (), либо значения, возвращенного функцией main () WIFSIGNALED Приводится к ненулевому значению, если статус был возвращен от сыновнего процесса, который завершился, поскольку ему был послан сигнал, но этот сигнал не был перехвачен WTERMSIG Если значение WIFSIGNALED оказывается ненулевым, то оценивается номер сигнала, который послужил причиной завершения сыновнего процесса WIFSTOPPED Приводится к ненулевому значению, если статус был возвращен от сыновнего процесса, который в данный момент остановлен WSTOPSIG Если значение WIFSTOPPED оказывается ненулевым, то оценивается номер сигнала, который послужил причиной останова сыновнего процесса WIFCONTINUED Приводится к ненулевому значению, если статус был возвращен от сыновнего процесса, который продолжил выполнение после сигнала останова, принятого от блока управления заданиями 3.10. Разбиение программы на задачи Рассматривая разбиение программы на несколько задач, вы делаете первый шаг к внесению параллелизма в свою программу. В однопроцессорной среде параллелизм реализуется посредством многозадачности. Это достигается путем переключения процессов. Каждый процесс выполняется в течение некоторого короткого интервала времени, после чего процессор "передается" другому процессу. Это происходит настолько быстро, что создается иллюзия одновременного выполнения процессов, многопроцессорной среде процессы, принадлежащие одной программе, могут быть азначены одному или различным процессорам. Процессы, назначенные различным роцессорам, выполняются параллельно. Различают два уровня параллельной обработки в приложении или системе: ,ровень процессов и уровень потоков. Параллельная обработка на уровне пото- носит название многопоточности (она рассматривается в следующей главе), ооы разумно разделить программу на параллельные задачи, необходимо опре- лить, где "гнездится" параллелизм и где можно воспользоваться преимуществами от ° реализации. Иногда в параллелизме нет насущной необходимости. Программа т интерпретироваться с учетом параллелизма, но и при последовательном
102 Глава 3. Разбиение С++-программ на множество задач выполнении действий она прекрасно работает. Безусловно, внесение параллелизма может повысить ее быстродействие и понизить уровень сложности. Одни программы обладают естественным параллелизмом, а другим больше подходит последовательное выполнение действий. Программы также могут иметь двойственную интерпретацию. При декомпозиции программы на функции обычно используется нисходящий принцип, а при разделении на объекты — восходящий. При этом необходимо определить, какие функции или объекты лучше реализовать в виде отдельных программ или подпрограмм, а какие — в виде потоков. Подпрограммы должны выполняться операционной системой как процессы. Отдельные подпрограммы, или процессы, выполняют задачи, порученные проектировщиком ПО. Задачи, на которые будет разделена программа, могут выполняться параллельно, причем здесь можно выделить следующие три способа реализации параллелизма. 1. Выделение в программе одной основной задачи, которая создает некоторое количество подзадач. 2. Разделение программы на множество отдельных выполняемых файлов. 3. Разделение программы на несколько задач разного типа, отвечающих за создание других подзадач только определенного типа. Эти способы реализации параллелизма отображены на рис. 3.13. Например, эти методы реализации параллелизма можно применить к программе визуализации. Под визуализацией будем понимать процесс перехода от представления трехмерного объекта в форме записей базы данных в двухмерную теневую графическую проекцию на поверхность отображения (экран дисплея). Изображение представляется в виде теневых многоугольников, повторяющих форму объекта. Этапы визуализации показаны на рис. 3.14. Визуализацию можно разбить на ряд отдельных задач. 1. Установить структуру данных для сеточных моделей многоугольников. 2. Применить линейные преобразования. 3. Отбраковать многоугольники, относящиеся к невидимой поверхности. 4. Выполнить растеризацию. 5. Применить алгоритм удаления скрытых поверхностей. 6. Затушевать отдельные пиксели. Первая задача состоит в представлении объекта в виде массива многоугольников, в котором каждая вершина многоугольника описывается в трехмерной мировой системе координат. Вторая задача — применить линейные преобразования к сеточной модели многоугольников. Эти преобразования используются для позиционирования объектов на сцене и создания точки обзора или поверхности отображения (области, которая видима наблюдателю с его точки обзора). Третья задача — отбраковать невидимые поверхности объектов на сцене. Это означает удаление линий, принадлежащих тем частям объектов, которые невидимы с точки обзора. Четвертая задача— преобразовать модель вершин в набор координат пикселей. Пятая задача — удалить любые скрытые поверхности. Если сцена содержит
3.10. Разбиение программы на задачи 103 имодействующие объекты, например, когда одни объекты заслоняют другие, то В ытые (передними объектами) поверхности должны быть удалены. Шестая зада- ~ наложить на поверхности изображения тень. ПРОГРАММА СПОСОБ 1 Представление программы в виде родительского процесса, который создает некоторое количество сыновних процессов РОДИТЕЛЬСКИЙ ПРОЦЕСС г— Задача А Задача В Задача С Задача D СПОСОБ 2 Разделение программы на множество отдельных выполняемых файлов ПРОГРАММА ОБОЛОЧКА I ►] Задача А I I Г I — Задача В J Задача С Задача D СПОСОБ 3 Разделение программы на несколько процессов, отвечающих за создание других процессов только определенного типа ПРОГРАММА ЗАДАЧА А ЗАДАЧА В Задача А1 Задача А2 Задача В1 Задача В2 рис. 3.13. Способы разбиения программы на отдельные задачи
104 Глава 3. Разбиение С++-программ на множество задач ЭТАПЫ ВИЗУАЛИЗАЦИИ № вершины 1 2 3 4 5 6 7 8 9 10 1,40000 1,40000 0,78400 0,00000 1,33750 1,33750 0,74900 0,00000 1,43750 1,43750 0,00000 2,30000 -0,78400 2,30000 -1,40000 2,30000 -1,40000 2,30000 0,00000 2,53125 -0,75000 2,53125 -1,33700 2,53125 -1,33700 2,53125 0,00000 2,53125 -0,90500 2,53125 <Ф* 306 1,42500 -0,79800 0,00000 1. Представление трехмерного объекта в виде записей базы данных. Рис. 3.14. Этапы визуализации 2. Построение многоугольной сеточной модели трехмерного объекта. 3. Наложение тени на трехмерный объект. Решение каждой задачи представляется в виде отдельного выполняемого файла. Первые три задачи (Taskl, Task2 и Task3) выполняются последовательно, а остальные три (Task4, Task5 и Task6) — параллельно. Реализация первого способа создания программы визуализации приведена в листинге 3.5. // Листинг 3.5. Использование способа 1 // для создания процессов #include <spawn.h> #include <stdlib.h> #include <stdio.h> #include <sys/wait.h> #include <errno.h> #include <unistd.h> int main(void) { posix__spawnattr__t Attr; posix_spawn__f ile_actions_t FileActions ; char *const argv4[] = {"Task4",.../NULL}; char *const argv5[] = {"TaskS", .../NULL}; char *const argv6[] = {"Task6n,...,NULL}; pid_t Pid; int stat; //. .. // Выполняем первые три задачи синхронно, system("Taskl ...") system("Task2 ...") system("Task3 ..."),
3.10. Разбиение программы на задачи 105 ,/ инициализируем структуры. 11 ix_spawnattr_init(&Attr) ; Р° . spawn_f ile__actions__init (&FileActions) ; Выполняем последние три задачи асинхронно. ix__spawn(&Pid,"Task4",&FileActions,&Attr,argv4,NULL) p0^ix^spawn(&Pid/ "Task5", &FileAct:ions, &Attr, argv5,NULL) po •x~~Spawn(&Picl, "Тазкб", &FileAct ions, &Attr,argv6, NULL) // Подобно хорошему родителю, ожидаем возвращения // своих "детей". wait (&stat); wait (&stat); wait (fcstat); return(0); } В листинге 3.5 из функции main () с помощью функции system () вызываются на выполнение задачи Taskl, Task2 и Task3. Каждая из них выполняется синхронно с родительским процессом. Задачи Task4, Task5 и Task6 выполняются асинхронно родительскому процессу благодаря использованию функций posix_spawn(). Многоточие (...) используется для обозначения файлов, требуемых задачам. Родительский процесс вызывает три функции wait (), и каждая из них ожидает завершения одной из задач (Task4, Task5 или Task6). Используя второй способ, программу визуализации можно запустить из сценария командной оболочки. Преимущество этого сценария состоит в том, что он позволяет использовать все команды и операторы оболочки. В нашей программе визуализации для управления выполнением задач используются метасимволы & и &&. Taskl . . . && Task2 ... && Task3 Task4 ... & Task5 ... & Task6 Здесь благодаря использованию метасимвола && задачи Taskl, Task2 и Task3 выполняются последовательно при условии успешного выполнения предыдущей задачи. Задачи же Task4, Task5 и Task6 выполняются одновременно, поскольку использован метасимвол &. Приведем некоторые метасимволы, применяемые при разделении команд в средах UNIX/Linux, и способы выполнения этих команд. Каждая следующая команда будет выполняться только в случае успешного выполнения предыдущей команды. ' • Каждая следующая команда будет выполняться только в случае неудачного выполнения предыдущей команды. Команды должны выполняться последовательно. Все команды должны выполняться одновременно. При использовании третьего способа задачи делятся по категориям. При декомпо- и пР°граммы следует разобраться, можно ли в ней выделить различные катего- г задач. Например, одни задачи могут "отвечать" за интерфейс пользователя, т.е. создание, ввод данных, вывод данных и пр. Другим задачам поручаются вычисле- » Управление данными и пр. Такой подход весьма полезен не только при проекти- г ании программы, но и при ее реализации. В нашей программе визуализации мы Жем Разделить задачи по следующим категориям:
106 Глава 3. Разбиение С++-программ на множество задач • задачи, которые выполняют линейные преобразованиях преобразования изображения на экране при изменении точки обзора; преобразования сцены; • задачи, которые выполняют растеризацию: вычерчивание линий; заливка участков сплошного фона; растеризация многоугольников; • задачи, которые выполняют удаление поверхностей: удаление скрытых поверхностей; удаление невидимых поверхностей; • задачи, которые выполняют наложение теней: затенение отдельных пикселей; затенение изображения в целом. Разбиение задач по категориям позволяет нашей программе приобрести более общий характер. Процессы при необходимости создают другие процессы, предназначенные для выполнения действий только определенной категории. Например, если нашей программе предстоит визуализировать лишь один объект, а не всю сцену, то нет никакой необходимости порождать процесс, который выполняет удаление скрытых поверхностей; вполне достаточно будет удаления невидимых поверхностей (одного объекта). Если объект не нужно затенять, то нет необходимости порождать задачу, выполняющую наложение тени; обязательным остается лишь линейное преобразование при решении задачи растеризации. Для запуска программы с использованием третьего способа можно использовать родительский процесс или сценарий оболочки. Родительский процесс может определить, какой нужен тип визуализации, и передать соответствующую информацию каждому из специализированных процессов, чтобы они "знали", какие процессы им следует порождать. Эта информация может быть также перенаправлена каждому из специализированных процессов из сценария оболочки. Реализация третьего способа представлена в листинге 3.6. // Листинг 3.6. Использование третьего метода для // создания процессов. Задачи запускаются из // родительского процесса #include <spawn.h> #include <stdlib.h> #include <stdio.h> #include <sys/wait.h> #include <errno.h> #include <unistd.h> int main(void) { posix_spawnattr_t Attr; posix_spawn__f ile__actions_t FileActions ; pid_t Pid; int stat;
3.10. Разбиение программы на задачи 107 m("Taskl _.»); // Выполняется безотносительно к s^s // типу используемой визуализации. Определяем, какой нужен тип визуализации. Это можно // сделать, получив информацию от пользователя или // выполнив специальный анализ. // затем сообщаем о результате другим задачам с помощью // аргументов. char *const argv4[] = {"TaskType4",.../NULL} char *const argv5[] = { MTaskType5", . . . ,N171,1,} char *const argv6[] = {"ТавкТуреб", .../NULL} system("TaskType2 ..."); system(nTaskType3 ..."); // Инициализируем структуры. posix_spawnattr_init (&Attr) ; posix_spawn_f ile_actions_init (&FileActions) ; posix_spawn(&Pid, "TaskType4", &FileActions, &Attr,argv4, NULL); posix_spawn(&Pid, "TaskTypeS", &FileActions, &Attr,argv5, NULL); if(Y){ posix_spawn(&Pid,"TaskType6",&FileActions,&Attr, argv6,NULL); } // Подобно хорошему родителю, ожидаем возвращения // своих "детей". wait(&stat) wait(&stat) wait(&stat) return(0); } // Все TaskType-задачи должны быть аналогичными. int main(int argc, char *argv[]) int Rt; //. .. if(argv[l] == X){ // Инициализируем структуры. //. . . posix_spawn(&Pid,"TaskTypeX",&FileActions,&Attr NULL); else{ // Инициализируем структуры.
108 Глава 3. Разбиение С++-программ на множество задач //. • • posix_spawn(&Pid,"TaskTypeY", &FileActions,&Attr, ...,NULL); } wait(&stat); exit(0); В листинге 3.6 тип каждой задачи (а следовательно, и тип порождаемого процесса) определяется на основе информации, передаваемой от родительского процесса или сценария оболочки. 3.10.1. Линии видимого контура Порождение процессов, как показано в листинге 3.7, возможно с помощью функций, вызываемых из функции main (). // Листинг 3.7. Стержневая ветвь программы, из которой // вызывается функция, порождающая процесс int main(int argc, char *argv[]) { //. . - Rt = funcMX, Y, Z) ; } // Определение функции. int fund (char *M, char *N, char *V) { char *const args[] = {"TaskX",M,N,V,NULL}; Pid = fork(); if(Pid == 0) { exec("TaskX",args); } if(Pid > 0) { //.. . } wait(&stat); } В листинге 3.7 функция fund () вызывается с тремя аргументами. Эти аргументы передаются порожденному процессу. Процессы также могут порождаться из методов, принадлежащих объектам. Как показано в листинге 3.8, объекты можно объявить в любом процессе.
3.11. Резюме 109 // Листинг 3.8. Объявление объекта в процессе my_pbject MyObject; //••• // объявление и определение класса. class my_object { public: int spawnProcess(int X) ; }; int my_object: : spawnProcess (int X) { // posix__spawn() или system() } Как показано в листинге 3.8, объект может создавать любое количество процессов из любого метода. 3.11. Резюме Параллелизм в С++-программе достигается за счет ее разложения на несколько процессов или несколько потоков. Процесс— это "единица работы", создаваемая операционной системой. Если программа— это артефакт (продукт деятельности) разработчика, то процесс — это артефакт операционной системы. Приложение может состоять из нескольких процессов, которые могут быть не связаны с какой-то конкретной программой. Операционные системы способны управлять сотнями и даже тысячами параллельно загруженных процессов. Некоторые данные и атрибуты процесса хранятся в блоке управления процессами iprocess control block— PCB), или БУП, используемом операционной системой для Дентификации процесса. С помощью этой информации операционная система Управляет процессами. Многозадачность (выполнение одновременно нескольких Роцессов) реализуется путем переключения контекста. Текущее состояние выпол- еМого процесса и его контекст сохраняются в БУП-блоке, что позволяет успешно обновить этот процесс в следующий раз, когда он будет назначен центральному Роцессору. Занимая процессор, процесс пребывает в состоянии выполнения, а когда °жидает использования ЦП,— то в состоянии готовности (ожидания). Получить формацию о процессах, выполняющихся в системе, можно с помощью утилиты ps. процессы, которые создают другие процессы, вступают с ними в "родственные" Цььи-дети) отношения. Создатель процесса называется родительским, а создан-
110 Глава 3. Разбиение С++-программ на множество задач ный процесс — сыновним. Сыновние процессы наследуют от родительских множество атрибутов. "Святая обязанность" родительского процесса— подождать, пока сыновний не покинет систему. Для создания процессов предусмотрены различные системные функции: fork (), fork-exec (), system () и posit_spawn (). Функции forkO, fork-exec () и posix__spawn() создают процессы, которые являются асинхронными, в то время как функция system () создает сыновний процесс, который является синхронным по отношению к родительскому. Асинхронные родительские процессы могут вызвать функцию wait (), после чего "синхронно" ожидать, пока сыновние процессы не завершатся или пока не будут считаны коды завершения для уже завершившихся сыновних процессов. Программу можно разбить на несколько процессов. Эти процессы может породить родительский процесс, либо они могут быть запущены из сценария оболочки как отдельные выполняемые программы. Специализированные процессы могут при необходимости порождать другие процессы, предназначенные для выполнения действий только определенного типа. Порождение процессов может быть осуществлено как из функций, так и из методов.
РАЗБИЕНИЕ С++- ПРОГРАММ НА МНОЖЕСТВО ПОТОКОВ В этой главе... 4.1. Определение потока 4.2. Анатомия потока 4.3. Планирование потоков 4.4. Ресурсы потоков 4.5. Модели создания и функционирования потоков 4.6. Введение в библиотеку Pthread 4.7. Анатомия простой многопоточной программы 4.8. Создание потоков 4.9. Управление потоками 4.10. Безопасность использования потоков и библиотек 4.11. Разбиение программы на несколько потоков 4.12. Резюме
Jls\ZJSJZJ A 11 Непрерывное усложнение компьютерных систем вселяет в нас надежду, что мы и в дальнейшем сможем успешно управлять этим видом абстракции. — Эндрю Кёниг и Барбара My (Andrew Koening and Barbara Moo), Ruminations on C++ Работу любой последовательной программы можно разделить между несколькими подпрограммами. Каждой подпрограмме назначается конкретная задача, и все эти задачи выполняются одна за другой. Вторая задача не может начаться до тех пор, пока не завершится первая, а третья — пока не закончится вторая и т.д. Описанная схема прекрасно работает до тех пор, пока не будут достигнуты границы производительности и сложности. В одних случаях единственное решение проблемы производительности — найти возможность выполнять одновременно более одной задачи. В других ситуациях работа подпрограмм в программе настолько сложна, что имеет смысл представить эти подпрограммы в виде мини-программ, которые выполняются параллельно внутри основной программы. В главе 3 были представлены методы разбиения одной программы на несколько процессов, каждый из которых выполняет отдельную задачу. Такие методы позволяют приложению в каждый момент времени выполнять сразу несколько действий. Однако в этом случае каждый процесс имеет собственные адресное пространство и ресурсы. Поскольку каждый процесс занимает отдельное адресное пространство, то взаимодействие между процессами превращается в настоящую проблему. Для обеспечения связи между раздельно выполняемыми частями общей программы нужно реализовать такие средства межпроцессного взаимодействия, как каналы, FIFO-очереди (с дисциплиной обслуживания по принципу "первым пришел — первым обслужен") и переменные среды. Иногда нужно иметь одну программу (которая выполняет несколько задач одновременно), не разбивая ее на множество мини-программ. В таких обстоятельствах можно использовать
4.1. Определение потока 113 токи. Потоки позволяют одной программе состоять из параллельно выполняемых и, причем все части имеют доступ к одним и тем же переменным, константам и аденому пространству в целом. Потоки можно рассматривать как мини-программы в ос- вной программе. Если программа разделена на несколько процессов, как было пока- ано в главе 3, то с выполнением каждого отдельного процесса связаны определенные траты системных ресурсов. Для потоков требуется меньший объем затрат системных ecvpcoB. Поэтому потоки можно рассматривать как облегченные процессы, т.е. они позволяют воспользоваться многими преимуществами процессов без больших затрат на организацию взаимодействия между ними. Потоки обеспечивают средства разделения основного "русла" программы на несколько параллельно выполняемых "ручейков". 4.1. Определение потока Под потоком подразумевается часть выполняемого кода в UNIX- или Linux-процессе, которая может быть регламентирована определенным образом. Затраты вычислительных ресурсов, связанные с созданием потока, его поддержкой и управлением, у операционной системы значительно ниже по сравнению с аналогичными затратами для процессов, поскольку объем информации отдельного потока гораздо меньше, чем у процесса. Каждый процесс имеет основной, или первичный, поток. Под основным потоком процесса понимается программный поток управления или поток выполнения. Процесс может иметь несколько потоков выполнения и, соответственно, столько же потоков управления. Каждый поток, имея собственную последовательность инструкций, выполняется независимо от других, а все они — параллельно друг другу. Процесс с несколькими потоками, называется многопоточным. Многопоточный процесс, состоящий из нескольких потоков, показан на рис. 4.1. ПОТОКИ ВЫПОЛНЕНИЯ ПРОЦЕССА Поток А JL ' 1 Основной поток ± « создать » « создать » г Поток В 1 «♦—Потоки выполнения —► Т 1 V | Рис. 4.1. Потоки выполнения многопоточного процесса
114 Глава 4. Разбиение С++-программ на множество потоков 4.1.1. Контекстные требования потока Все потоки одного процесса существуют в одном и том же адресном пространстве. Все ресурсы, принадлежащие процессу, разделяются между потоками. Потоки не владеют никакими ресурсами. Ресурсы, которыми владеет процесс, совместно используются всеми потоками этого процесса. Потоки разделяют дескрипторы файлов и файловые указатели, но каждый поток имеет собственные программный указатель, набор регистров, состояние и стек. Все стеки потоков находятся в стековом разделе своего процесса. Раздел данных процесса совместно используется потоками процесса. Поток может считывать (и записывать) информацию из области памяти своего процесса. Когда основной поток записывает данные в память, то любые сыновние потоки могут получить к ним доступ. Потоки могут создавать другие потоки в пределах того же процесса. Все потоки в одном процессе считаются равноправными. Потоки также могут приостановить, возобновить или завершить другие потоки в своем процессе. Потоки — это выполняемые части программы, которые соревнуются за использование процессора с потоками того же самого или других процессов. В многопроцессорной системе потоки одного процесса могут выполняться одновременно на различных процессорах. Однако потоки конкретного процесса выполняются только на процессоре, который назначен этому процессу. Если, например, процессоры 1, 2 и 3 назначены процессу А, а процесс А имеет три потока, то любой из них может быть назначен любому процессору. В среде с одним процессором потоки конкурируют за его использование. Параллельность же достигается за счет переключения контекста. Контекст переключается, если операционная система поддерживает многозадачность при наличии единственного процессора. Многозадачность позволяет на одном процессоре одновременно выполнять несколько задач. Каждая задача выполняется в течение выделенного интервала времени. По истечении заданного интервала или после наступления некоторого события текущая задача снимается с процессора, а ему назначается другая задача. Когда потоки выполняются параллельно в одном процессе, то о таком процессе говорят, что он — многопоточный. Каждый поток выполняет свою подзадачу таким образом, что подзадачи процесса могут выполняться независимо от основного потока управления процесса. При многозадачности потоки могут конкурировать за использование одного процессора или назначаться другим процессорам. Но в любом случае переключение контекста между7 потоками одного и того же процесса требует меньше ресурсов, чем переключение контекста между потоками различных процессов. Процесс использует много системных ресурсов для отслеживания соответствующей информации, а на управление этой информацией при переключении контекста между процессами требуется значительное время. Большая часть информации, содержащейся в контексте процесса, описывает адресное пространство процесса и ресурсы, которыми он владеет. Переключаясь между потоками, определенными в различных адресных пространствах, контекст переключается и между процессами. Поскольку потоки в рамках одного процесса не имеют собственного адресного пространства (или ресурсов), то операционном системе приходится отслеживать меньший объем информации. Контекст потока состоит только из идентификационного номера (id), стека, набора регистров и приоритета. В регистрах содержится программный указатель и указатель стека. Текст (программный код) потока содержится в текстовом разделе соответствующего процесса. Поэтому переключение контекста между потоками одного процесса займет меньше времени и потребует меньшего объема системных ресурсов.
4.1. Определение потока 115 4.1.2. Сравнение потоков и процессов У потоков и процессов есть много общего. Они имеют идентификационный но- (id), состояние, набор регистров, приоритет и привязку к определенной страниц плаНирования. Подобно процессам, потоки имеют атрибуты, которые описывают их для операционной системы. Эта информация содержится в информационном блоке потока, подобном информационному блоку процесса. Потоки и сыновние процессы разделяют ресурсы родительского процесса. Ресурсы, открытые родительским процессом (в его основном потоке), немедленно становятся доступными всем потокам и сыновним процессам. При этом никакой дополнительной инициализации или подготовки не требуется. Потоки и сыновние процессы независимы от родителя (создателя) и конкурируют за использование процессора. Создатель процесса или потока управляет своим потомком, т.е. он может отменить, приостановить или возобновить его выполнение либо изменить его приоритет. Поток или процесс может изменить свои атрибуты и создать новые ресурсы, но не может получить доступ к ресурсам, принадлежащим другим процессам. Однако между потоками и процессами есть множество различий. 4.1.2.1. Различия между потоками и процессами Основное различие между потоками и процессами состоит в том, что каждый процесс имеет собственное адресное пространство, а потоки — нет. Если процесс создает множество потоков, то все они будут содержаться в его адресном пространстве. Вот почему они так легко разделяют общие ресурсы, и так просто обеспечивается взаимодействие между ними. Сыновние процессы имеют собственные адресные пространства и копии разделов данных. Следовательно, когда процесс-потомок изменяет свои переменные или данные, это не влияет на данные родительского процесса. Если необходимо, чтобы родительский и сыновний процессы совместно использовали данные, нужно создать общую область памяти. Для передачи данных между родителем и потомком используются такие механизмы межпроцессного взаимодействия, как каналы и FIFO-очереди. Потоки одного процесса могут передавать информацию и связываться друг с другом путем непосредственного считывания и записи общих данных, которые доступны родительскому процессу. 4.1.2.2. Потоки, управляющие другими потоками В то время как процессы могут управлять другими процессами, если между ними установлены отношения типа "родитель-потомок", потоки одного процесса считаются равноправными и находятся на одном уровне, независимо от того, кто кого создал. Любой поток, имеющий доступ к идентификационном)' номеру (id) некоторого другого потока, может отменить, приостановить, возобновить выполнение этого потока либо изменить его приоритет. Отмена основного потока приведет к завершению всех потоков процесса, т.е. к ликвидации процесса. Любые изменена, внесенные в основной поток, могут повлиять на все потоки процесса. При из- * еНении приоритета процесса все его потоки, которые унаследовали этот приори- ет» должны также изменить свои приоритеты. Сходства и различия между потока- Ми и процессами сведены в табл. 4.1.
116 Глава 4. Разбиение С++-программ на множество потоков Таблица 4.1. Сходства и различия между потоками и процессами Сходства Различия Оба имеют идентификационный номер (id), состояние, набор регистров, приоритет и привязку к определенной стратегии планирования И поток, и процесс имеют атрибуты, которые описывают их особенности для операционной системы Как поток, так и процесс имеют информационные блоки Оба разделяют ресурсы с родительским процессом Оба функционируют независимо от родительского процесса Их создатель может управлять потоком или процессом И поток, и процесс могут изменять свои атрибуты Оба могут создавать новые ресурсы Как поток, так и процесс не имеют доступа к ресурсам другого процесса Потоки разделяют адресное пространство процесса, который их создал; процессы имеют собственное адресное пространство Потоки имеют прямой доступ к разделу данных своего процесса; процессы имеют собственную копию раздела данных родительского процесса Потоки могут напрямую взаимодействовать с другими потоками своего процесса; процессы должны использовать специальный механизм межпроцессного взаимодействия для связи с "братскими" процессами Потоки почти не требуют системных затратна поддержку процессов требуются значительные затраты системных ресурсов Новые потоки создаются легко; новые процессы требуют дублирования родительского процесса Потоки могут в значительной степени управлять потоками того же процесса; процессы управляют только сыновними процессами Изменения, вносимые в основной поток (отмена, изменение приоритета и т.д.), могут влиять на поведение других потоков процесса; изменения, вносимые в родительский процесс, не влияют на сыновние процессы 4.1.3. Преимущества использования потоков При управлении подзадачами приложения использование потоков имеет ряд преимуществ. • Для переключения контекста требуется меньше системных ресурсов. • Достигается более высокая производительность приложения. • Для обеспечения взаимодействия между задачами не требуется никакого специального механизма. • Программа имеет более простую структуру. 4.1.3.1. Переключение контекста при низкой (ограниченной) доступности процессора При организации процесса для выполнения возложенной на него функции может оказаться вполне достаточно одного основного потока. Если же процесс имеет множество параллельных подзадач, то их асинхронное выполнение можно обеспечить с помощью нескольких потоков, на переключение контекста которых потребуются незначительные затраты системных ресурсов. При ограниченной доступности процессора
4.1. Определение потока 117 ли при наличии в системе только одного процессора параллельное выполнение про- ессов потребует существенных затрат системных ресурсов в связи с необходимостью беспечить переключение контекста. В некоторых ситуациях контекст процессов переключается только тогда, когда процессору последовательно назначаются потоки из разных процессов. Под системными затратами подразумеваются не только системные ресурсы, но время, требуемое на переключение контекста. Но если система содержит достаточное количество процессоров, то переключение контекста не является проблемой. 4.1.3.2. Возможности повышения производительности приложения Создание нескольких потоков повышает производительность приложения. При использовании одного потока запрос к устройствам ввода-вывода может остановить весь процесс. Если же в приложении организовано несколько потоков, то пока один из них будет ожидать удовлетворения запроса ввода-вывода, другие потоки, которые не зависят от заблокированного, смогут продолжать выполнение. Тот факт, что не все потоки ожидают завершения операции ввода-вывода, означает, что приложение в целом не заблокировано ожиданием, а продолжает работать. 4.1.3.3. Простая схема взаимодействия между параллельно выполняющимися потоками Потоки не требуют специального механизма взаимодействия между подзадачами. Потоки могут напрямую передавать данные другим потокам и получать данные от них, что также способствует экономии системных ресурсов, которые при использовании нескольких процессов пришлось бы направлять на настройку и поддержку специальных механизмов взаимодействия. Потоки же используют общую память, выделяемую в адресном пространстве процесса. Процессы также могут взаимодействовать через общую память, но они имеют раздельные адресные пространства, и поэтому такая общая память должна быть вне адресных пространств обоих взаимодействующих процессов. Этот подход увеличит времениые и пространственные расходы системы на поддержку и доступ к общей памяти. Схема взаимодействия между потоками и процессами показана на рис. 4.2. 4.1.3.4. Упрощение структуры программы Потоки можно использовать, чтобы упростить структуру приложения. Каждому потоку назначается подзадача или подпрограмма, за выполнение которой он отвечает. Поток должен независимо управлять выполнением своей подзадачи. Каждому потоку можно присвоить приоритет, отражающий важность выполняемой им задачи Для приложения. Такой подход позволяет упростить поддержку программного кода. 4.1.4. Недостатки использования потоков Простота доступности потоков к памяти процесса имеет свои недостатки. • Потоки могут легко разрушить адресное пространство процесса. • Потоки необходимо синхронизировать при параллельном доступе (для чтения или записи) к памяти. • Один поток может ликвидировать целый процесс или программ}7. • Потоки существуют только в рамках единого процесса и, следовательно, не являются многократно используемыми.
118 Глава 4. Разбиение С++-программ на множество потоков АДРЕСНОЕ ПРОСТРАНСТВО ПРОЦЕССА А РАЗДЕЛ СТЕКОВ Локальные переменные СВОБОДНАЯ ПАМЯТЬ | Локальные переменные I Глобальные j переменные РАЗДЕЛ ДАННЫХ РАЗДЕЛ КОДА Глобальные I структуры данных Глобальные переменные J Константы I Статические переменные [ Разделяемая память АДРЕСНОЕ ПРОСТРАНСТВО ПРОЦЕССА В РАЗДЕЛ СТЕКОВ Локальные переменные Стек потока А Стек потока В СВОБОДНАЯ ПАМЯТЬ^ Локальные переменные Глобальные переменные РАЗДЕЛ ДАННЫХ РАЗДЕЛ КОДА ! Глобальные j структуры данных i Глобальные переменные ] Константы \ (Статические переменные | « деревья | • графы | '• очереди Кол потеши А Рис. 4.2. Взаимодействие между потоками одного процесса и взаимодействие между несколькими процессами 4.1.4.1. Потоки могут легко разрушить адресное пространство процесса Потоки могут легко разрушить информацию процесса во время "гонки" данных, если сразу несколько потоков получат доступ для записи одних и тех же данных. При использовании процессов это невозможно. Каждый процесс имеет собственные данные, и другие процессы не в состоянии получить к ним доступ, если не сделать это специально. Защита информации обусловлена наличием у процессов отдельных адресных пространств. Тот факт, что потоки совместно используют одно и то же адресное пространство, делает данные незащищенными от искажения. Например, процесс имеет три потока — А, В и С. Потоки А и В записывают информацию в некоторую область памяти, а поток С считывает из нее значение и использует его для вычислений. Потоки А и В могут попытаться одновременно записать информацию в эту область памяти. Поток В может перезаписать данные, записанные потоком А, еще до того, как поток С получит возможность считать их. Поведение этих потоков должно быть синхронизировано таким образом, чтобы поток С мог считать данные, записанные потоком А, до того, как поток В их перезапишет. Синхронизация защищает данные от перезаписи до их использования. Тема синхронизации потоков рассматривается в главе 5.
4.2. Анатомия потока 119 4.1.4.2. Один поток может ликвидировать целую программу Поскольку потоки не имеют собственного адресного пространства, они не изолированы. Если поток стал причиной фатального нарушения доступа, это может привести к завершению всего процесса. Процессы изолированы друг от друга. Если процесс разрушит свое адресное пространство, проблемы ограничатся этим процессом. Процесс может допустить нарушение доступа, которое приведет к его завершению, но все остальные процессы будут продолжать выполнение. Это нарушение не окажется фатальным для всего приложения. Ошибки, связанные с некорректностью данных, могут не выйти за рамки одного процесса. Но ошибки, вызванные некорректньш поведением потока, как правило, гораздо серьезнее ошибок, допущенных процессом. Потоки могут стать причиной ошибок, которые повлияют на все адресное пространство всех потоков. Процессы защищают свои ресурсы от беспорядочного доступа со стороны других процессов. Потоки же совместно используют ресурсы со всеми остальными потоками процесса. Поэтому поток, разрушающий ресурсы, оказывает негативное влияние на процесс или программу в целом. 4.1.4.3. Потоки не могут многократно использоваться другими программами Потоки зависят от процесса, в котором они существуют, и их невозможно от него отделить. Процессы отличаются большей степенью независимости, чем потоки. Приложение можно так разделить на задачи, порученные процессам, что эти процессы можно оформить в виде модулей, которые возможно использовать в других приложениях. Потоки не могут существовать вне процессов, в которых они были созданы и, следовательно, они не являются повторно используемыми. Преимущества и недостатки потоков сведены в табл. 4.2. 4.2. Анатомия потока Образ потока встраивается в образ процесса. Как было описано в главе 3, процесс имеет разделы программного кода, данных и стеков. Поток разделяет разделы кода и данных с остальными потоками процесса. Каждый поток имеет собственный стек, выделенный ему в стековом разделе адресного пространства процесса. Размер потокового стека устанавливается при создании потока. Если создатель потока не определяет размер его стека, то система назначает размер по умолчанию. Размер, устанавливаемый по умолчанию, зависит от конкретной системы, максимально возможного количества потоков в процессе, размера адресного пространства, выделяемого процессу, и пространства, используемого системными ресурсами. Размер потокового стека должен быть достаточно большим для любых функций, вызываемых потоком, Юоого кода, который является внешним по отношению к процессу (например, это • Южет быть библиотечный код), и хранения локальных переменных. Процесс с не- °лькими потоками должен иметь стековый раздел, который будет вмещать все сте- его потоков. Адресное пространство, выделенное для процесса, ограничивает раз- Р стека, ограничивая тем самым размер, который может иметь каждый поток. На \- показана схема процесса, который содержит два потока. Клк показано на рис. 4.3, процесс содержит два потока А и В, и их стеки располо- ы в стековом разделе процесса. Потоки выполняют различные функции: поток А Ь1Полняет функцию fund (), а поток В - функцию func2 ().
120 Глава 4. Разбиение С++-программ на множество потоков scope = process stack size = 1000 priority = 2 joinable II... funcIO Count'= 10 АДРЕСНОЕ ПРОСТРАНСТВО ПРОЦЕССА РАЗДЕЛ СТЕКОВ "^1 Ътек потока Threads "^ ~~ I func20 Count'-100 Стек потока ThreadA main() -.РАЗДЁД..ДАННЫХ ThreadA ThreadB X Y AttrObj .РАЗДЕЛ-КОДА.. void fune1(...) Count- 10; void tunc2(.,.: { Counts 10C pthreaoM ThreadA; pthreadj ThreadB; int X; int Y: pthreadjattrj AttrObj; main() it //... pthread_attrjnit(&AttrObj); pthread_create(&ThreadAf&AttrObj.func1, pthread_create{&ThreadB.&AttrObj!func2....};t| Рис. 4.3. Схема процесса, содержащего два потока (SP — указатель стека, PC — счетчик команд)
4.2. Анатомия потока 121 Таблица 4.2. Преимущества и недостатки потоков Преимущества потоков Недостатки потоков Для переключения контекста требуется меньше системных ресурсов Потоки способны повысить производительность приложения Для обеспечения взаимодействия между потоками никакого специального механизма не требуется Благодаря потокам структуру программы можно упростить Для параллельного доступа к памяти (чтения или записи данных) требуется синхронизация Потоки могут разрушить адресное пространство своего процесса Потоки существуют в рамках только одного процесса, поэтому их нельзя повторно использовать 4.2.1. Атрибуты потока Атрибуты процесса содержат информацию, которая описывает процесс для операционной системы. Операционная система использует эту информацию для управления процессами, а также для того, чтобы отличать один процесс от другого. Процесс совместно использует со своими потоками практически все, включая ресурсы и переменные среды. Разделы данных, раздел программного кода и все ресурсы связаны с процессом, а не с потоками. Все, что нужно для функционирования потока, определяется и предоставляется процессом. Потоки же отличаются один от другого идентификационным номером (id), набором регистров, определяющих состояние потока, его приоритетом и стеком. Именно эти атрибуты формируют уникальность каждого потока. Как и при использовании процессов, информация о потоках хранится в структурах данных и возвращается функциями, поддерживаемыми операционной системой. Например, часть информации о потоке содержится в структуре, именуемой информационным блоком потока, который создается вместе с потоком. Идентификационный номер (id) потока— это уникальное значение, которое идентифицирует каждый поток во время его существования в процессе. Приоритет потока определяет, каким потокам предоставлен привилегированный доступ к процессору в выделенное время. Под состоянием потока понимаются условия, в которых он пребывает в любой момент времени. Набор регистров для потока включает программный счетчик и указатель стека. Программный счетчик содержит адрес инструкции, которую поток должен выполнить, а указатель стека ссылается на вершину стека потока. Библиотека потоков POSIX определяет объект атрибутов потока, инкапсулирующий свойства потока, к которым его создатель может получить доступ и модифицировать их. Объект атрибутов потока определяет следующие компоненты: область видимости; размер стека; адрес стека; приоритет; состояние; стратегия планирования и параметры.
122 Глава 4. Разбиение С++-программ на множество потоков Объект атрибутов потока может быть связан с одним или несколькими потоками. При использовании этого объекта поведение потока или группы потоков определяется профилем. Все потоки, которые используют объект атрибутов, приобретают все свойства, определенные этим объектом. На рис. 4.3 показаны атрибуты, связанные с каждым потоком. Как видите, оба потока (А и В) разделяют объект атрибутов, но они поддерживают свои отдельные идентификационные номера и наборы регистров. После того как объект атрибутов создан и инициализирован, его можно использовать в любых обращениях к функциям создания потоков. Следовательно, можно создать группу потоков, которые будут иметь "малый стек и низкий приоритет" или "большой стек, высокий приоритет и состояние открепления". Открепленный (detached) поток— это поток, который не синхронизирован с другими потоками в процессе. Иначе говоря, не существует потоков, которые бы ожидали до тех пор, пока завершит выполнение открепленный поток. Следовательно, если уж такой поток существует, то его ресурсы (а именно id потока) немедленно принимаются на повторное использование. Для установки и считывания значений этих атрибутов предусмотрены специальные методы. После создания потока его атрибуты нельзя изменить до тех пор, пока он существует. Атрибут области видимости описывает, с какими потоками конкретный поток конкурирует за обладание системными ресурсами. Потоки соперничают за ресурсы в рамках двух областей видимости: процесса (потоки одного процесса) и системы (все потоки в системе). Конкуренция потоков в пределах одного и того же процесса происходит за дескрипторы файлов, а конкуренция потоков в масштабе всей системы — за ресурсы, которые выделяются системой (например, реальная память). Потоки соперничают с потоками, которые имеют область видимости процесса, и потоками из других процессов за использование процессора в зависимости от состязательного режима и областей выделения ресурсов (набора процессоров). Поток, обладающий системной областью видимости, будет обслуживаться с учетом его приоритета и стратегии планирования, которая действует для всех потоков в масштабе всей системы. Члены POSIX-объекта атрибутов потока перечислены в табл. 4.3. Таблица 4.3. Члены объекта атрибутов потока Атрибуты Функции Описание detachstate guardsize inheritsched int pthread_attr_ setdetachstate (pthread_attr_t *attr, int detachstate); int pthread_attr_ setguardsize (pthread_attr_t *attr, size_t guardsize) int pthread_attr_ setinheritsched (pthread_attr_t *attr, int inheritsched) Атрибут detachstate определяет, является ли новый поток открепленным. Если это соответствует истине, то его нельзя объединить ни с каким другим потоком Атрибут guardsize позволяет управлять размером защитной области стека нового потока. Он создает буферную зону размером guardsize на переполненяемом конце стека Атрибут inheritsched определяет, как будут установлены атрибуты планирования для нового потока, т.е. будут ли они унаследованы от потока- создателя или установлены атрибутным объектом
4.3. Планирование потоков 123 Окончание табл. 4.3 Атрибуты param Функць Описание schedpolicy contentionscope stackaddr stacksize stackaddr stacksize int pthread_attr_ setschedparam (pthread_attr_t *restrict attr, const struct sched_param *restrict param); int pthread_attr_ setschedpolicy (pthread_attr_t *attr, int policy); int pthread_attr_ setscope (pthread_attr_t *attr, int contentionscope); int pthread_attr_ setstack (pthread_attr_t *attr, void *stackaddr, size_t stacksize), int pthread_attr_ setstackaddr (pthread_attr_t *attr, void *stackaddr); int pthread_attr_ setstacksize (pthread_attr_t *attr, size_t stacksize) Атрибут param— это структура, которую можно использовать для установки приоритета нового потока Атрибут schedpolicy определяет стратегию планирования создаваемого потока Атрибут contentionscope определяет, с каким множеством потоков будет соперничать создаваемый поток за использование процессорного времени. Область видимости процесса означает, что поток будет состязаться со множеством потоков одного процесса, а область видимости системы означает, что поток будет состязаться с потоками в масштабе всей системы (т.е. сюда входят потоки других процессов) Атрибуты stackaddr и stacksize определяют базовый адрес и минимальный размер (в байтах) стека, выделяемого для создаваемого потока, соответственно Атрибут stackaddr определяет базовый адрес стека, выделяемого для создаваемого потока Атрибут stacksize определяет минимальный размер стека в байтах, выделяемого для создаваемого потока 4.3. Планирование потоков огда подходит время для выполнения процесса, процессор занимает один из его оков. Если процесс имеет только один поток, то именно он (т.е. основной поток) на- ается процессору. Если процесс содержит несколько потоков и в системе есть доста- °е количество процессоров, то процессорам назначаются все потоки. Потоки сопер- ак>т за процессор либо со всеми потоками из активного процесса системы, либо только
124 Глава 4. Разбиение С++-программ на множество потоков с потоками из одного процесса. Потоки помещаются в очереди готовых потоков, отсортированные по значению их приоритета. Потоки с одинаковым приоритетом назначаются процессорам в соответствии с некоторой стратегией планирования. Если система не содержит достаточного количества процессоров, поток с более высоким приоритетом может выгрузить поток, выполняющийся в данный момент. Если новый активный поток принадлежит тому же процессу, что и выгруженный, возникает переключение контекста потоков. Если же новый активный поток "родом" из другого процесса, то сначала происходит переключение контекста процессов, а затем — контекста потоков. 4.3.1. Состояния потоков Потоки имеют такие же состояния и переходы между ними (см. главу 3), как и процессы. Диаграмма состояний, показанная на рис. 4.4, — это копия диаграммы, изображенной на рис. 3.4 из главы 3. (Вспомним, что процесс может пребывать в одном из четырех состояний: готовности, выполнения, останова и ожидания, или блокирования.) Состояние потока — это режим или условия, в которых поток существует в данный момент. Поток находится в состоянии готовности (работоспособности), когда он готов к выполнению. Все готовые к работе потоки помещаются в очереди готовности, причем в каждой такой очереди содержатся потоки с одинаковым приоритетом. Когда поток выбирается из очереди готовности и назначается процессору, он (поток) переходит в состояние выполнения. Поток снимается с процессора, если его квант времени истек, или если перешел в состояние готовности поток с более высоким приоритетом. Выгруженный поток снова помещается в очередь готовых потоков. Поток пребывает в состоянии ожидания, если он ожидает наступления некоторого события или завершения операции ввода-вывода. Поток прекращает выполнение, получив сигнал останова, и остается в этом состоянии до тех пор, пока не получит сигнал продолжить работу. ОСТАНОВЛЕН Выдан сигнал/ Выдан сигнал Выгрузка Вх°Д ^( ГОТОВ (работоспособен) Произошло I событие или\ завершена операция ввода-вывода Загрузка ВЫПОЛНЯЕТСЯ Выход J<OHeu кванта времени Ожидание события или } завершения операции ввода-вывода ОЖИДАЕТ Прекращение Выход "ЗОМБИ" Рис. 4.4. Состояния потоков и переходы между ними
4.3. Планирование потоков 125 При получении этого сигнала поток переходит из состояния останова в состояние готовности. Переход потока из одного состояния в другое является своего рода сигналом о наступлении некоторого события. Переход конкретного потока из состояния готовности состояние выполнения происходит потому, что система выбрала именно его для выполнения, т.е. поток отправляется (dispatched) на процессор. Поток снимается, или выгружается (preempted), с процессора, если он делает запрос на ввод-вывод данных (или какой- либо иной запрос к ядру), или если существуют какие-то причины внешнего характера. Один поток может определить состояние всего процесса. Состояние процесса с одним потоком синонимично состоянию его основного потока. Если его основной поток находится в состоянии ожидания, значит, и весь процесс находится в состоянии ожидания. Если основной поток выполняется, значит, и процесс выполняется. Что касается процесса с несколькими потоками, то для того, чтобы утверждать, что весь процесс находится в состоянии ожидания или останова, необходимо, чтобы все его потоки пребывали в состоянии ожидания или останова. Но если хотя бы один из его потоков активен (т.е. готов к выполнению или выполняется), процесс считается активным. 4.3.2. Планирование потоков и область конкуренции Область конкуренции потоков определяет, с каким множеством потоков будет соперничать рассматриваемый поток за использование процессорного времени. Если поток имеет область конкуренции уровня процесса, он будет соперничать за ресурсы с потоками того же самого процесса. Если же поток имеет системную область конкуренции, он будет соперничать за процессорный ресурс с равными ему по правам потоками (из одного с ним процесса) и с потоками других процессов. Пусть, например, как показано на рис. 4.5, существуют два процесса в мультипроцессорной среде, которая включает три процессора. Процесс А имеет четыре потока, а процесс В — три. Для процесса А "расстановка сил" такова: три (из четырех его потоков) имеют область конкуренции уровня процесса, а один — уровня системы. Для процесса В такая "картина": два (из трех его потоков) имеют область конкуренции уровня процесса, а один— уровня системы. Потоки процесса А с процессной областью конкуренции соперничают за процессор А, а потоки процесса В с такой же (процессной) областью конкуренции соперничают за процессор С. Потоки процессов А и В с системной областью конкуренции соперничают за процессор В. ПРИМЕЧАНИЕ: потоки при моделировании их реального поведения в приложении Должны иметь системную область конкуренции. 4.3.3. Стратегия планирования и приоритет Стратегия планирования и приоритет процесса принадлежат основному потоку, 'каждый поток (независимо от основного) может иметь собственную стратегию планирования и приоритет. Потокам присваиваются целочисленные значения приоритета, °торые лежат в диапазоне между заданными минимальным и максимальным значения- и. Схема приоритетов используется при определении, какой поток следует назначить роцессору: поток с более высоким приоритетом выполняется раньше потока с более изким приоритетом. После назначения потокам приоритетов задачам, которые тре- У^т немедленного выполнения или ответа от системы, предоставляется необходимое
126 Глава 4. Разбиение С++-программ на множество потоков процессорное время. В операционной системе с приоритетами выполняющийся поток снимается с процессора, если в состояние готовности переходит поток с более высоким приоритетом, обладающий при этом тем же уровнем области конкуренции. Например как показано на рис. 4.5, потоки с процессной областью конкуренции соревнуются за процессор с потоками того же процесса, имеющими такой же уровень области конкуренции. Процесс А имеет два потока с приоритетом 3, и один из них назначен процессору. Как только поток с приоритетом 2 заявит о своей готовности, активный поток будет вытеснен, а процессор займет поток с более высоким приоритетом. Кроме того, в процессе В есть два потока (процессной области конкуренции) с приоритетом 1 (приоритет 1 выше приоритета 2). Один из этих потоков назначается процессору. И хотя другой поток с приоритетом 1 готов к выполнению, он не вытеснит поток с приоритетом 2 из процесса А, поскольку эти потоки соперничают за процессор в рамках своих процессов. Потоки с системной областью конкуренции и более низким приоритетом не вытесняются ни одним из потоков из процессов А или В. Они соперничают за процессорное время только с потоками, имеющими системную область конкуренции. ПРОЦЕСС А ОБЛАСТЬ ВИДИМОСТИ ПРОЦЕССА Т1 Т2 ТЗ ПРИОР ПРИОР ОБЛАСТЬ ВИДИМОСТИ СИСТЕМЫ ОБЛАСТЬ ВИДИМОСТИ ПРОЦЕССА Т1 Т2 | ПР¥\ОР РА РВ PC Рис. 4.5. Планирование потоков (с процессной и системной областями конкуренции) в мультипроцессорной среде Как упоминалось в главе 3, очереди готовности организованы в виде отсортированных списков, в которых каждый элемент представляет собой уровень приоритета. Под уровнем приоритета понимается очередь потоков с одинаковым значением приоритета. Все потоки одного уровня приоритета назначаются процессору с использованием стратегии планирования: FIFO (сокр. от First In First Out, т.е. первым прибыл, первым обслужен), RR (сокр. от round-robin, т.е. циклическая) или какой-либо другой. При использовании
4.3. Планирование потоков 127 и планирования FIFO поток, квант процессорного времени которого истек, по- СТР гется в головную часть очереди соответствующего приоритетного уровня, а процес- МС назначается следующему потоку из очереди. Следовательно, поток будет выполняться тех пор, пока он не завершит выполнение, не перейдет в состояние ожидания Г снет") или не получит сигнал остановиться. Когда "спящий" поток "просыпается", он метается в конец очереди соответствующего приоритетного уровня. Стратегия плани- вания RR аналогична FIFO-стратегии, за исключением того, что по истечении кванта оиессорного времени поток помещается не в начало, а в конец "своей" очереди. Циклическая стратегия планирования (RR) считает все потоки обладающими одинаковыми приоритетами и каждому потоку предоставляет процессор только в течение некоторого кванта времени. Поэтому выполнение задач получается попеременным. Например, программа, которая выполняет поиск файлов по заданным ключевым словам, разбивается на два потока. Один поток (1) находит все файлы с заданным расширением и помещает их пути в контейнер. Второй поток (2) выбирает имена файлов из контейнера, просматривает каждый файл на предмет наличия в нем заданных ключевых слов, а затем записывает имена файлов, которые содержат такие слова. Если к этим потокам применить циклическую стратегию планирования с единственным процессором, то поток 1 использовал бы свой квант времени для поиска файлов и вставки их путей в контейнер. Поток 2 использовал бы свой квант времени для выделения имен файлов и поиска заданных ключевых слов. В идеальном мире потоки 1 и 2 должны выполняться попеременно. Но в действительности все может быть иначе. Например, поток 2 может выполниться до потока 1, когда в контейнере еще нет ни одного файла, или поток 1 может так долго искать файл, что до истечения кванта времени не успеет записать его путь в контейнер. Такая ситуация требует синхронизации, краткое рассмотрение которой приводится ниже в этой главе и в главе 5. Стратегия планирования FIFO позволяет каждому потоку выполняться до завершения. Если рассмотреть тот же пример с использованием FIFO-стратегии, то поток 1 будет иметь достаточно времени, чтобы отыскать все нужные файлы и вставить их пути в контейнер. Поток 2 затем выделит имена файлов и выполнит поиск заданных ключевых слов. В идеальном мире завершение выполнения потока 2 будет означать завершение программы в целом. Но в реальном мире поток 2 может быть назначен процессору до потока 1, когда контейнер еще не будет содержать файлов для поиска в них ключевых слов. После "холостого" выполнения потока 2 процессору будет назначен поток 1, который может успешно отыскать нужные файлы и поместить в контейнер их пути. Однако поиск ключевых слов выполнять уже будет некому. Поэтому программа в целом потерпит фиаско. При использовании FIFO-стратегии не предусматривается перемешивания задач. Поток, назначенный процессору, занимает его До полного выполнения своей задачи. Такую стратегию планирования можно исполь- овать для приложений, в которых потоки необходимо выполнить как можно скорее. °Д Другими" стратегиями планирования подразумеваются уже рассмотренные, но небольшими вариациями. Например, FIFO-стратегия может быть изменена таким °разом, чтобы позволить разблокировать потоки, выбранные случайно. 4-3.3.1. Изменение приоритета потоков приоритеты потоков следует менять, чтобы ускорить выполнение потоков, от кото- г зависит выполнение других потоков. И, наоборот, этого не следует делать ради того, bi какой-то конкретный поток получил больше процессорного времени. Это может
128 Глава 4. Разбиение С++-программ на множество потоков изменить обшую производительность системы. Потоки с более высоким классом приоритета получают больше процессорного времени, чем потоки с более низким классом приоритета, поскольку они выполняются чаще. Потоки с более высоким приоритетом практически монополизируют процессор, не выделяя потокам с более низким приоритетом такое ценное процессорное время. Эта ситуация получила название информационного голода (starvation). Системы, в которых используются механизмы динамического назначения приоритетов, реагируют на подобную ситуацию путем назначения приоритетов, которые бы действовали в течение коротких периодов времени. Система регулирует приоритет потоков таким образом, чтобы потоки с более низким приоритетом увеличили время выполнения. Такой подход должен повысить общую производительность системы. Гарантировать, что конкретный процесс или поток будет выполняться до его полного завершения, — все равно что присвоить ему самый высокий приоритет. Однако реализация такой стратегии может повлиять на общую производительность системы. Такие привилегированные потоки могут нарушить взаимодействие программных компонентов через сетевые средства коммуникации, вызвав потерю данных. На потоки, которые управляют интерфейсом пользователя, может быть оказано чрезмерно большое влияние, выраженное в замедлении реакции на использование клавиатуры, мыши или экрана. В некоторых системах пользовательским процессам или потокам не назначается более высокий приоритет, чем системным процессам. В противном случае системные процессы или потоки не смогли бы реагировать на критические изменения в системе. Поэтому большинство пользовательских процессов и потоков попадают в категорию программных компонентов с нормальным (средним) приоритетом. 4.4. Ресурсы потоков Потоки используют большую часть своих ресурсов вместе с другими потоками из того же процесса. Собственные ресурсы потока определяют его контекст. Так, в контекст потока входят его идентификационный номер, набор регистров (включающих указатель стека и программный счетчик) и стек. Остальные ресурсы (процессор, память и файловые дескрипторы), необходимые потоку для выполнения его задачи, он должен разделять с другими потоками. Дескрипторы файлов выделяются каждому процессу в отдельности, и потоки одного процесса соревнуются за доступ к этим дескрипторам. Что касается памяти, процессора и других глобально распределяемых ресурсов, то за доступ к ним потоки конкурируют с другими потоками своего процесса, а также с потоками других процессов. Поток при выполнении может запрашивать дополнительные ресурсы, например, файлы или мьютексы, но они становятся доступными для всех потоков процесса. Существуют ограничения на ресурсы, которые может использовать один процесс. Таким образом, все потоки в общей сложности не должны превышать предельный объем ресурсов, выделяемых процессу. Если поток попытается расходовать больше ресурсов, чем предусмотрено предельным объемом, формируется сигнал о том, что достигнут предельный объем ресурсов для данного процесса. Потоки, которые используют ресурсы, должны следить за тем, чтобы эти ресурсы не оставались в нестабильном состоянии после их аннулирования. Поток, который открыл файл или создал мьютекс, может завершиться, оставив этот файл открытым или мьютекс заблокированным. Если приложение завершилось, а файл не был закрыт надлежащим
4.5. Модели создания и функционирования потоков 129 м это может привести к его разрушению или потере данных. Завершение по- после блокировки мьютекса надежно запирает доступ к критическому разделу, ооый находится под контролем этого мьютекса. Перед завершением поток должен полнить некоторые действия очистительно-восстановительного характера, чтобы допустить возникновения нежелательных ситуаций. 4.5. Модели создания и функционирования потоков Цель потока— выполнить некоторую работу от имени процесса. Если процесс содержит несколько потоков, каждый поток выполняет некоторые подзадачи как части общей задачи, выполняемой процессом. Потокам делегируется работа в соответствии с конкретной стратегией, которая определяет, каким образом реализуется делегирование работы. Если приложение моделирует некоторую процедуру или объект, то выбранная стратегия должна отражать эту модель. Используются следующие распространенные модели: • делегирование ("управляющий-рабочий"); • сеть с равноправными узлами; • конвейер; • "изготовитель-потребитель". Каждая модель характеризуется собственной декомпозицией работ (Work Breakdown Structure — WBS), которая определяет, кто отвечает за создание потоков и при каких условиях они создаются. Например, существует централизованный подход, при котором один поток создает другие потоки и каждому из них делегирует некоторую работу. Существует также конвейерный (assembly-line) подход, при котором на различных этапах потоки выполняют различную работу. Созданные потоки могут выполнять одну и ту же задачу на различных наборах данных, различные задачи на одном и том же наборе данных или различные задачи на различных наборах данных. Потоки подразделяются на категории по выполнению задач только определенного типа. Например, можно создать группы потоков, которые будут выполнять только вычисления, только ввод или только вывод данных. Возможны задачи, для успешного решения которых следует комбинировать перечисленные выше модели. В главе 3 мы рассматривали процесс визуализации. Задачи 1, 2 и 3 выполнялись последовательно, а задачи 4, 5 и 6 могли выполняться параллельно. Все задачи можно выполнить различными потоками. Если необходимо визуализировать несколько изображений, потоки 1, 2 и 3 могут сформировать кон- Веиер. По завершении потока 1 изображение передается потоку 2, в то время ак поток 1 может выполнять свою работу над следующим изображением. После }феризации изображений потоки 4, 5 и 6 могут реализовать параллельную обра- тку. Модель функционирования потоков представляет собой часть структуриро- ания параллелизма в приложении, в котором каждый поток может выполняться на Дельном процессоре. Модели функционирования потоков (и их краткое описа- Ние) приведены в табл. 4.4.
130 Глава 4. Разбиение С++-программ на множество потоков Таблица 4.4. Модели функционирования потоков Модель Описание Модель Центральный поток ("управляющий") создает потоки ("рабочие"), на- делегирования значая каждому из них задачу. Управляющий поток может ожидать до тех пор, пока все потоки не завершат выполнение своих задач Модель с равно- Все потоки имеют одинаковый рабочий статус. Такие потоки называют- правными узлами ся равноправными. Поток создает все потоки, необходимые для выполнения задач, но не осуществляет никакого делегирования ответственности. Равноправные потоки могут обрабатывать запросы от одного входного потока данных, разделяемого всеми потоками, или каждый поток может иметь собственный входной поток данных Конвейер Конвейерный подход применяется для поэтапной обработки потока входных данных. Каждый этап — это поток, который выполняет работу на некоторой совокупности входных данных. Когда эта совокупность пройдет все этапы, обработка всего потока данных будет завершена Модель Поток-"изготовитель" готовит данные, потребляемые потоком- "изготовителъ- "потребителем". Данные сохраняются в блоке памяти, разделяемом потребитель" потоками — "изготовителем" и "потребителем" 4.5.1. Модель делегирования В модели делегирования один поток ("управляющий") создает потоки ("рабочие") и назначает каждому из них задачу. Управляющему потоку нужно ожидать до тех пор, пока все потоки не завершат выполнение своих задач. Управляющий поток делегирует задачу, которую каждый рабочий поток должен выполнить, путем задания некоторой функции. Вместе с задачей на рабочий поток возлагается и ответственность за ее выполнение и получение результатов. Кроме того, на этапе получения результатов возможна синхронизация действий с управляющим (или другим) потоком. Управляющий поток может создавать рабочие потоки в результате запросов, обращенных к системе. При этом обработка запроса каждого типа может быть делегирована рабочему потоку. В этом случае управляющий поток выполняет некоторый цикл событий. По мере возникновения событий рабочие потоки создаются и на них тут же возлагаются определенные обязанности. Для каждого нового запроса, обращенного к системе, создается новый поток. При использовании такого подхода процесс может превысить предельный объем выделенных ему ресурсов или предельное количество потоков. В качестве альтернативного варианта управляющий поток может создать пул потоков, которым будут переназначаться новые запросы. Управляющий поток создает во время инициализации некоторое количество потоков, а затем каждый поток приостанавливается до тех пор, пока не будет добавлен запрос в их очередь. По мере размещения запросов в очереди управляющий поток сигнализирует рабочему о необходимости обработки запроса. Как только поток справится со своей задачей, он извлекает из очереди следующий запрос. Если в очереди больше нет доступных запросов, поток приостанавливается до тех пор. пока управляющий поток не просигналит ему о появлении очередного задания в очереди. Если все рабочие потоки должны разделять одну очередь, то их можно
4.5. Модели создания и функционирования потоков 131 ограммировать на обработку запросов только определенного типа. Если тип 3 оса в очереди не совпадает с типом запросов, на обработку которых ориенти- 33 ан данный поток, то он может снова приостановиться. Главная цель управляю- ^.—^ потока— создать все потоки, поместить задания в очередь и "разбудить" рабо шего потоки, когда эти задания станут доступными. Рабочие потоки справляются наличии запроса в очереди, выполняют назначенную задачу и приостанавливают- ми, еСЛи для них больше нет работы. Все рабочие и управляющий потоки вы- лняются параллельно. Описанные два подхода к построению модели делегирования представлены для сравнения на рис. 4.6. Мде?ь делегирования 1 Управляющий поток создает новый поток для каждого нового запроса. ПРОГРАММА А поток. Запрос 1 -—- | Поток данных -»; Запрос 2 - Запрос 3 - Запрос Н- создает Поток 1 обрабатывает ь Поток 2 обрабатывает Поток 3 обрабатывает Поток N обрабатывает WMtfBI I Модель делегирования 2 Управляющий поток создает пул потоков, которые обрабатывают все запросы. ПРОГРАММА В рЧоток Данных Управляющий поток Пул потоков - Запрос типа А ■ Запрос типа А ■ Запрос типа С- Запрос типа В ■ создает ► Поток 1 обрабатывает запрос типа А Поток 2 обрабатывает запрос типа В Поток 3 обрабатывает запрос типа С 1 • Два подхода к реализации модели делегирования
132 Глава 4. Разбиение С++-программ на множество потоков 4.5.2. Модель с равноправными узлами Если в модели делегирования есть управляющий поток, который делегирует задачи рабочим потокам, то в модели с равноправными узлами все потоки имеют одинаковый рабочий статус. Несмотря на существование одного потока, который изначально создает все потоки, необходимые для выполнения всех задач, этот поток считается рабочим потоком, но он не выполняет никаких функций по делегированию задач. В этой модели нет никакого централизованного потока, но на рабочие потоки возлагается большая ответственность. Все равноправные потоки могут обрабатывать запросы из одного входного потока данных, либо каждый рабочий поток может иметь собственный входной поток данных, за который он отвечает. Входной поток данных может также храниться в файле или базе данных. Рабочие потоки могут нуждаться во взаимодействии и разделении ресурсов. Модель равноправных потоков представлена на рис. 4.7. ПРОГРАММА А - .Узловой поток Узел 1 ———, Узел 2 УэелЗ создает L Поток 1 Поток 2 Поток 3 <— Поток входных данных, " разделяемый потоками выполнения Узловой поток Узел 1 Узел 2 Узел 3 создает ПРОГРАММА В Поток 1 обрабатывает входной поток А У каждого потока есть собственный входной поток Ц Поток 2 обрабатывает входной поток В Поток 3 обрабатывает | входной поток С Рис. 4.7. Модель равноправных потоков (или модель с равноправными узлами) 4.5.3. Модель конвейера Модель конвейера подобна ленте сборочного конвейера в том, что она предполагает наличие потока элементов, которые обрабатываются поэтапно. На каждом этапе отдельный поток выполняет некоторые операции над определенной совокупностью входных данных. Когда эта совокупность данных пройдет все этапы, обработка всего входного
4.5. Модели создания и функционирования потоков 133 а данных будет завершена. Этот подход позволяет обрабатывать несколько вход- П° потоков одновременно. Каждый поток отвечает за получение промежуточных ре- нь*х и . * татов, делая их доступными для следующего этапа (или следующего потока) конвей- Последний этап (или поток) генерирует результаты работы конвейера в целом. По мере того как входные данные проходят по конвейеру, не исключено, что неко- е иХ порции придется буферизировать на определенных этапах, пока потоки еще нимаются обработкой предыдущих порций. Это может вызвать торможение конвейе- если окажется, что обработка данных на каком-то этапе происходит медленнее, чем на'других. При этом образуется отставание в работе. Чтобы предотвратить отставание, можно для "слабого*' этапа создать дополнительные потоки. Все этапы конвейера должны быть уравновешены по времени, чтобы ни один этап не занимал больше времени, чем другие. Для этого необходимо всю работу распределить по конвейеру равномерно. Чем больше этапов в конвейере, тем больше должно быть создано потоков обработки. Увеличение количества потоков также может способствовать предотвращению отставаний в работе. Модель конвейера представлена на рис. 4.8. ПРОГРАММА Поток -* данных Поток 1 обрабатывает входные данные 6 ЭТАП1 Поток 1 обрабатывает входные данные 5 ЭТАП 2 БУФЕР Ш 3 4 Ш-с- ш Поток 3 II обрабатывает L входные данные 2 | ЭТАП 3 Обработка входных данных 1 завершена Рис. 4.8. Модель конвейера 4.5.4. Модель "изготовитель-потребитель" В модели "изготовитель-потребитель" существует поток-"изготовитель", который готовит данные, потребляемые потоком-"потребителем". Данные сохраняются в блоке памяти, разделяемом между потоками "изготовителем" и "потребителем". Поток- изготовитель" должен сначала приготовить данные, которые затем поток- ^потребитель" получит. Такому процессу необходима синхронизация. Если поток- изготовитель" будет поставлять данные гораздо быстрее, чем поток-"потребитель" сможет их потреблять, поток-"изготовитель" несколько раз перезапишет результаты, °лученные им ранее, прежде чем поток-"потребитель" успеет их обработать. Но если оток- потребитель" будет принимать данные гораздо быстрее, чем поток- зготовитель" сможет их поставлять, поток-"потребитель" будет либо снова обраба- шать уже обработанные им данные, либо попытается принять еще не подготовлен- е Данные. Модель "изготовитель-потребитель" представлена на рис. 4.9. 4-5.5. Модели SPMD и MPMD для потоков каждой из описанных выше моделей потоки вновь и вновь выполняют одну и ту задачу на различных наборах данных или им назначаются различные задачи для SPK/m Нения на различных наборах данных. Эти потоковые модели используют схемы и (Single-Program, Multiple-Data— одна программа, несколько потоков данных)
134 Глава 4. Разбиение С++-программ на множество потоков и MPMD (Multiple-Programs, Multiple-Data — множество программ, множество потоков данных). Эти схемы представляют собой модели параллелизма, которые делят программы на потоки инструкций и данных. Их можно использовать для описания типа работы, которую реализуют потоковые модели с использованием параллелизма. В контексте нашего изложения материала модель MPMD лучше представить как модель M7MD (Multiples-Threads, Multiple-Data— множество потоков выполнения, множество потоков данных). Эта модель описывает систему с различными потоками выполнения (thread), которые обрабатывают различные наборы данных, или потоки данных (stream). Аналогично модель SPMD нам лучше рассматривать как модель S7MD (Single- Thread, Multiple- Data— один поток выполнения, несколько потоков данных). Эта модель описывает систему с одним потоком выполнения, который обрабатывает различные наборы, или потоки, данных. Это означает, что различные наборы данных обрабатываются несколькими идентичными потоками выполнения (вызывающими одну и ту же подпрограмму). ПРОГРАММА Потребитель использует д Рис. 4.9. Модель конвейера Как модель делегирования, так и модель равноправных потоков могут использовать модели параллелизма STMD и MTMD. Как было описано выше, пул потоков может выполнять различные подпрограммы для обработки различных наборов данных. Такое поведение соответствует модели MTMD. Пул потоков может быть также настроен на выполнение одной и той же подпрограммы. Запросы (или задания), отсылаемые системе, могут представлять собой различные наборы данных, а не различные задачи. И в этом случае поведение множества потоков, реализующих одни и те же инструкции, но на различных наборах данных, соответствует модели STMD. Модель равноправных потоков может быть реализована в виде потоков, выполняющих одинаковые или различные задачи. Каждый поток выполнения может иметь собственный поток данных или несколько файлов сданными, предназначенных для обработки каждым потоком. В модели конвейера используется MTMD-модель параллелизма. На разных этапах выполняются различные виды обработки, поэтому в любой момент времени различные совокупности входных данных будут находиться на различных этапах выполнения. Модельное представление конвейера было бы бесполезным, если бы на каждом этапе выполнялась одна и та же обработка. Модели параллелизма STMD и MTMD представлены на рис. 4.10. 4.6. Введение в библиотеку Pthread Библиотека Pthread предоставляет API-интерфейс для создания и управления потоками в приложении. Библиотека Pthread основана на стандартизированном интерфейсе программирования, который был определен комитетом по выпуску стандартов IEEE в стандарте POSIX 1003.1с. Сторонние фирмы-изготовители придерживаются стандарта POSIX в реализациях, которые именуются библиотеками потоков Pthread или POSIX. Изготовитель создает и 1 БУФЕР Данны %■
4.6. Введение в библиотеку Pthread 135 Набор данных 1 | | Набор данных 2 Поток 1 выполняет .подпрограмму А j Поток 2 jвыполняет | подпрограмму А | Набор данных 1 | | Набор данных 2 Поток 1 | выполняет i подпрограмму А | I .Поток;2, j выполняет" I подпрограмму Э Рис. 4.10. Модели параллелизма STMD и MTMD Библиотека Pthread содержит более 60 функций, которые можно разделить на следующие категории. 1. Функции управления потоками. 1.1. Конфигурирование потоков. 1.2. Отмена потоков. 1.3. Стратегии планирования потоков. 1.4. Доступ к данным потоков. 1.5. Обработка сигналов. 1.6. Функции доступа к атрибутам потоков. 1.6.1. Конфигурирование атрибутов потоков. 1.6.2. Конфигурирование атрибутов, относящихся к стекам потоков. 1.6.3. Конфигурирование атрибутов, относящихся к стратегиям планирования потоков. 2. Функции управления мьютексами. 2.1. Конфигурирование мьютексов. 2.2. Управление приоритетами. 2.3. Функции доступа к атрибутам мьютексов. 2.3.1. Конфигурирование атрибутов мьютексов.* 2.3.2. Конфигурирование атрибутов, относящихся к протоколам мьютексов. 2.3.3. Конфигурирование атрибутов, относящихся к управлению приоритетами мьютексов. 3- Функции управления условными переменными. 3.1. Конфигурирование условных переменных. 3.2. Функции доступа к атрибутам условных переменных. 3.2.1. Конфигурирование атрибутов условных переменных. 3.2.2. Функции совместного использования условных переменных. №лиотека Pthread может быть реализована на любом языке, но для соответствия Дарту POSIX она должна быть согласована со стандартизированным интерфей-
136 Глава 4. Разбиение С++-программ на множество потоков сом. Библиотека Pthread— не единственная реализация потокового API-интерфейса Существуют другие реализации, созданные сторонними фирмами-производителями аппаратных и программных средств. Например, среда Sun поддерживает библиотеку Pthread и собственный вариант библиотеки потоков Solaris. В этой главе мы рассмотри некоторые функции библиотеки Pthread, которые реализуют управление потоками. 4.7. Анатомия простой многопоточной программы Любая простая многопоточная программа должна состоять из основного потока и функций, которые будут выполнять другие потоки. Выбранная для реализации модель создания и функционирования потоков определяет, каким образом в программе будут создаваться потоки и как будет осуществляться управление ими. Потоки создаются по принципу "все и сразу" или при определенных условиях. Пример простой многопоточной программы, в которой реализована модель делегирования, представлен в листинге 4.1. // Листинг 4.1. Использование модели делегирования в // простой многопоточной программе #include <iostream> #include <pthread.h> void *taskl(void *X) // Определяем задачу для выполнения // потоком ThreadA. { //. .. cout « "Поток А завершен." « endl; } void *task2(void *X) // Определяем задачу для выполнения // потоком ThreadB. { //. . . cout « "Поток В завершен." « endl; } int main(int argc, char *argv[]) { pthread_t ThreadA,ThreadB; // Объявляем потоки. pthread_create(&ThreadA,NULL,taskl,NULL); // Создаем // потоки. pthread__create(&ThreadB,NULL,task2/NULL); // Дополнительная обработка. pthreadyjoin(ThreadA,NULL); // Ожидание завершения pthread_join(ThreadB,NULL); // потоков, return (0) ; } В листинге 4.1 делается акцент на определении набора инструкций для основного потока. Основным в данном случае является управляющий поток, который объявляет два рабочих потока ThreadA и ThreadB. С помощью функции pthread_create () эти два потока связываются с задачами, которые они должны выполнить (taskl и task2). Здесь (ради простоты примера) эти задачи всего лишь
4.7. Анатомия простой многопоточной программы 137 вляют сообщение в стандартный выходной поток, но понятно, что они могли °Т 6 ггь запрограммированы на нечто более полезное. При вызове функции d create () потоки немедленно приступают к выполнению назначенных Р задач. Работа функции pthread_join() аналогична работе функции wait () процессов. Основной поток ожидает до тех пор, пока не завершатся оба рабо- потока. Диаграмма последовательностей, соответствующая листингу 4.1, пока- на рис. 4.11. Обратите внимание на то, что происходит с потоками выполне- при вызове функций pthread_create () и pthread_j oin (). На рис. 4.11 показано, что вызов функции pthread_create () является причи- ой разветвления, или образования "вилки" в основном потоке выполнения, в ре- ьтате чего образуются два дополнительных "ручейка" (по одному для каждой задачи), которые выполняются параллельно. Функция pthread_create () завершается сразу же после создания потоков. Эта функция предназначена для создания асинхронных потоков. Это означает, что, как рабочие, так и основной поток, выполняют свои инструкции независимо друг от друга. Функция pthread_join () заставляет основной поток ожидать до тех пор, пока все рабочие потоки завершатся и "присоединятся" к основному. Основной поток Поток — выполнения « создать » ThreadA :создать: Ожидание присоединения Т : присоединиться: ThreadB )\««— Выход « присоединиться » Рис. 4.11. Диаграмма последовательностей, соответствующая листингу 4.1 4-7.1. Компиляция и компоновка многопоточных программ се многопоточные программы, использующие библиотеку потоков POSIX, долж- Ы Включать заголовок: ^thread. h>
138 Глава 4. Разбиение С++-программ на множество потоков Для компиляции многопоточного приложения в средах UNIX или Linux с помощью компиляторов командной строки д++ или дсс необходимо скомпоновать его с библиотекой Pthreads. Для задания библиотеки используйте опцию -1. Так, команда -lpthread обеспечит компоновку вашего приложения с библиотекой, которая согласуется с многопоточным интерфейсом, определенным стандартом POSIX 1003.1с. Библиотеку Pthread, libpthread. so, следует поместить в каталог, в котором хранится системная стандартная библиотека, обычно это /usr/lib. Если она будет находиться не в стандартном каталоге, то для того, чтобы обеспечить поиск компилятора в заданном каталоге до поиска в стандартных, используйте опцию -L. По команде д++ -о blackboard -L /src/local/lib blackboard.cpp -lpthread компилятор выполнит поиск библиотеки Pthread сначала в каталоге / src / local /1 ib, а затем в стандартных каталогах. Законченные программы, представленные в этой книге, сопровождаются профилем. Профиль программы содержит такие специальные сведения по ее реализации, как необходимые заголовки и библиотеки, а также инструкции по компиляции и компоновке. Профиль программы также включает раздел примечаний, содержащий специальную информацию, которую необходимо учитывать при выполнении программы. 4.8. Создание потоков Библиотека Pthreads используется для создания, поддержки и управления потоками многопоточных программ и приложений. При создании многопоточной программы потоки могут создаваться на любом этапе выполнения процесса, поскольку это — динамические образования. Функция pthread_create () создает новый поток в адресном пространстве процесса. Параметр thread указывает на дескриптор, или идентификатор (id), создаваемого потока. Новый поток будет иметь атрибуты, заданные объектом attr. Созданный поток немедленно приступит к выполнению инструкций, заданных параметром start_routine с использованием аргументов, заданных параметром arg. При успешном создании потока функция возвращает его идентификатор (id), значение которого сохраняется в параметре thread. Синопсис #include <pthread.h> int pthread_create(pthread_t *restrict thread, const pthread_attr_t *restrict attr, void *(*start_routine)(void*), I void ^restrict arg) ; | Если параметр attr содержит значение NULL, новый поток будет использовать атрибуты, действующие по умолчанию. В противном случае новый поток использует атрибуты, заданные параметром attr при его создании. Если же значение параметра attr изменится после того, как поток был создан, это никак не отразится на его атрибутах. При завершении параметра-функции start_routine завершается и поток, причем так, как будто была вызвана функция pthread_exit () с использованием в качестве статуса завершения значения, возвращаемого функцией start_routine.
4.8. Создание потоков 139 При успешном завершении функция возвращает число 0. В противном случае по- не создается, и функция возвращает код ошибки. Если в системе отсутствуют ре- сы для создания потока или в процессе достигнут предел по количеству возмож- ых потоков, выполнение функции считается неудачным. Неудачным оно также будет в случае, если атрибут потока задан некорректно или если инициатор вызова потока имеет разрешения на установку необходимых атрибутов потока. Приведем примеры создания двух потоков с заданными по умолчанию атрибутами: thread_create(&threadA/NULL/ taskl,NULL) ; pthread_create (&threadB, NULL, task2, NULL) ; Это — два вызова функции pthread_create () из листинга 4.1. Оба потока создаются с атрибутами, действующими по умолчанию. В программе 4.1 отображен основной поток, который передает аргумент из командной строки в функции, выполняемые потоками. // Программа 4.1 #include <iostream> #include <pthread.h> #include <stdlib.h> int main(int argcf char *argv[]) { pthread_t ThreadA, ThreadB; int N; if(argc != 2) { cout « "error" << endl; exit (1); } N = atoi(argv[l]); pthread_create (&ThreadA,NULL, taskl,&N) ; pthread_create(&ThreadB,NULL,task2, &N); cout « "Ожидание присоединения потоков." « endl; pthread__join(ThreadA/NULL) ; pthread_join(ThreadB,NULL); return (0) ; В программе 4.1 показано, как основной поток может передать аргументы из командной строки в каждую из потоковых функций. Число в командной строке имеет строковый тип. Поэтому в основном потоке аргумент сначала преобразуется в цело- исленное значение, и только после этого результат преобразования передается при аждом вызове функции pthread_create () посредством ее последнего аргумента. рограмме 4.2 представлена каждая из потоковых функций. 11 Программа 4.2 ^°id *taskl(void *X) int *Temp; Temp = static_cast<int *>(X); f°r(int Count = l;Count < *Temp;Count++){ cout « "В потоке A: " « Count « " * 2 = "
140 Глава 4. Разбиение С++-программ на множество потоков << Count * 2 « endl; } cout « "Поток А завершен." « endl; } void *task2(void *X) { int *Temp; Temp = static_cast<int *>(X); for(int Count = l;Count < *Temp;Count++){ cout « "В потоке В: " « Count « " + 2 = " << Count + 2 « endl; } cout << "Поток В завершен." « endl; } В программе 4.2 функции taskl и task2 выполняют цикл, количество итераций которого равно числу, переданному каждой функции в качестве параметра. Одна функция увеличивает переменную цикла на два, вторая — умножает ее на два, а затем каждая из них отправляет результат в стандартный поток вывода данных. По выходу из цикла каждая функция выводит сообщение о завершении выполнения потока. Инструкции по компиляции и выполнению программ 4.1 и 4.2 содержатся в профиле программы 4.1. [ Профиль программы 4.1 Имя программы ,program4-12.ее * Описание Принимает целочисленное значение из командной строки и передает функциям: потоков. Каждая функция выполняет цикл, в котором переменная цикла ' увеличивается (в одной функции на два, а в другой в два раза), а затем результат отсылается в стан-дартный поток вывода данных. Код основного потока выполнения приведен в программе 4.1, а код функций — в программе 4.2. Требуемая библиотека libpthread Требуемые заголовки <pthread.h> <iostream> <stdlib.h> Инструкции по компиляции и компоновке программ C++ -о program4-12 program4-12.ee -Ipthread Среда для тестирования SuSE Linux 7.1, gec 2.95.2, Инструкции по выполнению ./program4-12 34 Примечания Эта программа требует задания аргумента командной строки.
4.8. Создание потоков 141 r этом разделе был приведен пример передачи функции потока лишь одного аргу- нта. Если необходимо передать функции потока несколько аргументов, создайте тоуктуру (struct) или контейнер, содержащий все требуемые аргументы, и пере- айте функции потока указатель на эту структуру. 4.8.1. Получение идентификатора потока Как упоминалось выше, процесс разделяет все свои ресурсы с потоками, используя лишь собственное адресное пространство. Потокам в собственное пользование выделяются весьма небольшие их объемы. Идентификатор потока (id) — это один из ресурсов, уникальных для каждого потока. Чтобы узнать свой идентификатор, потоку необходимо вызвать функцию pthread_self (). Синопсис #include <pthread.h> pthread_t pthread_self(void); Эта функция аналогична функции getpid () для процессов. При создании потока его идентификатор возвращается его создателю или вызывающему потоку. Однако идентификатор потока не становится известным созданному потоку автоматически. Но если уж поток обладает собственным идентификатором, он может передать его (предварительно узнав его сам) другим потокам процесса. Функция pthread__self () возвращает идентификатор потока, не определяя никаких кодов ошибок. Вот пример вызова этой функции: //... pthread_t Threadld; Threadld = pthread_self(); Поток вызывает функцию pthread_self (), а значение, возвращаемое ею (идентификатор потока), сохраняет в переменной Threadld типа pthread_t. 4.8.2. Присоединение потоков Функция pthread_join () используется для присоединения или воссоединения потоков выполнения в одном процессе. Эта функция обеспечивает приостановку выпол- ения вызывающего потока до тех пор, пока не завершится заданный поток. По своему Действию эта функция аналогична функции wait (), используемой процессами. Эту функцию может вызвать создатель потока, после чего он будет ожидать до тех пор, пока завершится новый (созданный им) поток, что, образно говоря, можно назвать воссо- инением потоков выполнения. Функцию pthread_j oin () могут также вызывать ноправные потоки, если потоковый дескриптор является глобальным. Это позволя- любому потоку соединиться с любым другим потоком выполнения в процессе. Если шающий поток аннулируется до завершения заданного (для присоединения) потопа! Т°Т Заданный поток не станет открепленным (detached) потоком (см. следующий t, л'* Если различные равноправные потоки одновременно вызовут функцию а^Ooin () для одного и того же потока, его дальнейшее поведение не определено.
142 Глава 4. Разбиение С++-программ на множество потоков Синопсис 1 Uinclude <pthread.h> int pthread_join(pthread_t thread, void **value_ptr); Параметр thread представляет поток, завершения которого ожидает вызывающий поток. При успешном выполнении этой функции в параметре value_ptr будет записан статус завершения потока. Статус завершения — это аргумент, передаваемый при вызове функции pthread_exit () завершаемым потоком. При неудачном выполнении эта функция возвратит код ошибки. Функция не будет выполнена успешно, если заданный поток не является присоединяемым, т.е. создан как открепленный. Об успешном выполнении этой функции не может быть и речи, если заданный поток попросту не существует. Функцию pthread_join () необходимо вызывать для всех присоединяемых потоков. После присоединения потока операционная система сможет снова использовать память, которую он занимал. Если присоединяемый поток не был присоединен ни к одному потоку или если поток, который вызывает функцию присоединения, аннулируется, то заданный поток будет продолжать использовать свою память. Это состояние аналогично зомбированному процессу, в которое переходит сыновний процесс, когда его родитель не принимает статус завершения потомка, и этот "беспризорный" сыновний процесс продолжает занимать структуру в таблице процессов. 4.8.3. Создание открепленных потоков Открепленным называется завершаемый поток, который не присоединился или завершения которого не дождался другой поток. При завершении потока ограниченные ресурсы, которые он использовал, включая его идентификатор, освобождаются и возвращаются системе. Потокам нет необходимости получать статус завершения. Попытка со стороны любого потока вызвать функцию pthread_join () для открепленного потока обречена на неудачу. Существует функция pthread_detach (), которая открепляет поток, заданный параметром thread. По умолчанию все потоки создаются как присоединяемые, если атрибутным объектом не обусловлено иное. Эта функция позволяет открепить уже существующие присоединяемые потоки. Если поток не завершен, обращение к этой функции не обеспечит его завершения. Синопсис #include <pthread.h> I int pthread_detach(pthread_t thread thread); | При успешном выполнении эта функция возвращает число 0, в противном случае— код ошибки. Функция pthread_detach () не будет успешной, если заданный поток уже откреплен или поток, заданный параметром thread, не был обнаружен. Вот пример открепления уже существующего присоединяемого потока: //. . . pthread_create (ScthreadA, NULL, taskl, NULL) ; pthread_detach(threadA); //. . .
4.8. Создание потоков 143 Пои выполнении этих строк кода поток threadA станет открепленным. Чтобы соз- ть открепленный поток (в противоположность динамическому откреплению пото- а) необходимо установить атрибут detachstate в объекте атрибутов потока и ис- ользовать этот объект при создании потока. 4.8.4. Использование объекта атрибутов Объект атрибутов инкапсулирует атрибуты потока или группы потоков. Он используется для установки атрибутов потоков при их создании. Атрибутный объект потока имеет тип pthread_attr_t. Он представляет собой структуру, позволяющую хранить следующие атрибуты: • размер стека потока; • местоположение стека потока; • стратегия планирования, наследование и параметры; • тип потока: открепленный или присоединяемый; • область конкуренции потока. Для типа pthread_attr_t предусмотрен ряд методов, которые могут быть вызваны для установки или считывания каждого из перечисленных выше атрибутов (см. табл. 4.3). Для инициализации и разрушения атрибутного объекта потока используются функции pthread_attr_init () и pthread_at trades troy () соответственно. Синопсис #include <pthread.h> int pthread_attr_init(pthread_attr_t *attr); |int pthread_attr_destroy(pthread_attr_t *attr) Функция pthread_attr_init () инициализирует атрибутный объект потока с помощью стандартных значений, действующих для всех этих атрибутов. Параметр attr представляет собой указатель на объект типа pthread_attr_t. После инициализации attr-объекта значения его атрибутов можно изменить с помощью функций, перечисленных в табл. 4.3. После соответствующей модификации атрибутов значение attr используется в качестве параметра при вызове функции создания потока Pthread_create (). При успешном выполнении эта функция возвращает число О, впротивном случае— код ошибки. Функция pthread_attr_init () завершится не- ' Пещно, если для создания объекта в системе недостаточно памяти. Функцию pthread_attr_destroy () можно использовать для разрушения объ- а типа pthread_attr_t, заданного параметром attr. При обращении к этой Ч\ нкции будут удалены любые скрытые данные, связанные с этим атрибутным объ- ом потока. При успешном выполнении эта функция возвращает число 0, в пробном случае - код ошибки.
144 Глава 4. Разбиение С++-программ на множество потоков 4.8.4.1. Создание открепленных потоков с помощью объекта атрибутов После инициализации объекта потока его атрибуты можно модифицировать. Для установки атрибута detachstate атрибутного объекта используется функция pthread__attr_setdetachstate(). Параметр detachstate описывает поток как открепленный или присоединяемый. I Синопсис I #include <pthread.h> int pthread_attr_setdetachstate( pthread_attr_t *attr, int *detachstate); int pthread_attr_getdetachstate( const pthread_attr_t *attr, int *detachstate); Параметр detachstate может принимать одно из следующих значений: PTHREAD_CREATE_DETACHED PTHREAD_CREATE_JOINABLE Значение PTHREAD_CREATE_DETACHED "превращает" все потоки, которые используют этот атрибутный объект, в открепленные, а значение PTHREAD_CREATE_JOINABLE — в присоединяемые. Значение PTHREAD__CREATE_JOINABLE атрибут detachstate принимает по умолчанию. При успешном выполнении функция pthread_attr__setdetachstate () возвращает число 0, в противном случае— код ошибки. Эта функция выполнится неуспешно, если значение параметра detachstate окажется недействительным. Функция pthread__attr__getdetachstate () возвращает значение атрибута detachstate атрибутного объекта потока. При успешном выполнении эта функция возвращает значение атрибута detachstate в параметре detachstate и число О обычным способом. При неудаче функция возвращает код ошибки. В листинге 4.2 показано, как открепляются потоки, созданные в программе 4.1. В этом примере при создании одного из потоков используется объект атрибутов. // Листинг 4.2. Использование атрибутного объекта для // создания открепленного потока //... int main(int argc, char *argv[]) { pthread_t ThreadA,ThreadB; pthread_attr_t DetachedAttr; int N; if(argc != 2){ cout « "Ошибка" « endl; exit (1); } N = atoi(argv[l]) ; pthread_attr_init(&DetachedAttr);
4.9. Управление потоками 145 pthread_attr_setdetachstate(&DetachedAttr, | PTHREAD_CREATE_DETACHED); pthread_create(&ThreadA,NULL,taskl,&N); pthread_create(&ThreadB,&DetachedAttr,task2,&N); ' cout << "Ожидание присоединения потока А." << endl; ' pthread_join(ThreadA,NULL); return (0) ; } В листинге 4.2 объявляется атрибутный объект DetachedAttr, для инициализации которого используется функция pthread_attr_init (). После инициализации этого объекта вызывается функция pthread_attr_detachstate (), которая изменяет свойство detachstate ("присоединяемость"), установив значение PTHREAD_CREATE_DETACHED ("открепленность"). При создании потока ThreadB с помощью функции pthread_create () в качестве ее второго аргумента используется модифицированный объект DetachedAttr. Для потока ThreadB вызов функции pthread_join() не используется, поскольку открепленные потоки присоединить невозможно. 4.9. Управление потоками Создавая приложение с несколькими потоками, можно по-разному организовать их выполнение, использование ими ресурсов и состязание за ресурсы. Управление потоками по большей части осуществляется путем установки стратегий планирования и значений приоритета. Эти факторы влияют на эффективность потока. Кроме них, эффективность потока также определяется тем, как потоки состязаются за ресурсы: в рамках одного процесса либо в масштабе всей системы. Стратегию планирования, приоритет и область конкуренции потока можно установить с помощью объекта атрибутов потока. Поскольку потоки совместно используют ресурсы, доступ к ним необходимо синхронизировать. Эту тему мы кратко затронем в этой главе и более подробно— в главе5. К вопросам синхронизации также относятся и такие: где и как завершаются и аннулируются потоки. 4.9.1. Завершение потоков Выполнение потока может быть прервано по разным причинам: • в результате выхода из процесса с возвращаемым им статусом завершения (или без него); • в результате собственного завершения и предоставления статуса завершения; • в результате аннулирования другим потоком в том же адресном пространстве. Завершаясь, функция присоединения потока pthread_join() возвращает вызы- а*ощему потоку статус завершения, передаваемый функции pthread_exit (), которая была вызвана завершающимся потоком. Если завершающийся поток не обращал- к Функции pthread_exit (), то в качестве статуса завершения будет использовано чение, возвращаемое этой функцией, если оно существует; в противном случае атус завершения равен значению NULL. озможна ситуация, когда одному потоку необходимо завершить другой поток м же процессе. Например, приложение может иметь поток, который контролиру- ра&оту других потоков. Если окажется, что некоторый поток "плохо себя ведет"
146 Глава 4. Разбиение С++-программ на множество потоков или больше не нужен, то ради экономии системных ресурсов, возможно, его нулсно завершить. Завершающийся поток может окончиться немедленно или отложить завершение до тех пор, пока не достигнет в своем выполнении некоторой логической точки. При этом вполне вероятно, что такой поток (прежде чем завершиться) должен выполнить некоторые действия очистительно-восстановительного характера. Поток имеет также возможность отказаться от завершения. Для завершения вызывающего потока используется функция pthread_exit () Значение value_.pt г передается потоку, который вызывает функцию pthread_Join() для этого потока. Еще не выполненные процедуры, связанные с "уборкой", будут выполнены вместе с деструкторами, предусмотренными для потоковых данных. Никакие ресурсы, используемые потоками, при этом не освобождаются Синопсис | #include <pthread.h> int pthread_exit(void *value_ptr); При завершении последнего потока в процессе завершается сам процесс со статусом завершения, равным 0. Эта функция не может вернуться к вызывающему потоку и не определяет никаких кодов ошибок. Для отмены выполнения некоторого потока по инициативе потока из того же адресного пространства используется функция pthread__cancel (). Отменяемый поток задается параметром thread. I Синопсис #include <pthread.h> int pthread_cancel(pthread. _t thread thread); Обращение к функции pthread_cancel () представляет собой запрос аннулировать поток. Этот запрос может быть удовлетворен немедленно, с отсрочкой или проигнорирован. Когда произойдет аннулирование (и произойдет ли оно вообще), зависит от типа аннулирования и состояния потока, подлежащего этой кардинальной операции. Для удовлетворения запроса на отмену потока предусмотрен процесс аннулирования, который происходит асинхронно (т.е. не совпадает по времени) по отношению к выходу из функции pthread_cancel () и ее возврату в вызывающий поток. Если потоку нужно выполнить "уборочные" задачи, они обязательно выполняются. После выполнения последней такой задачи-обработчика вызываются деструкторы потоковых объектов, если таковые предусмотрены, и только после этого поток завершается. В этом и состоит процесс аннулирования потока. При успешном выполнении функция pthread_cancel () возвращает число 0, в противном случае— код ошибки. Эта функция не выполнится успешно, если параметр thread не соответствует ни одному из существующих потоков. Некоторые потоки могут потребовать принять меры безопасности против преждевременного их аннулирования. Внесение в потоковую функцию средств безопасности может предотвратить возникновение некоторых нежелательных ситуаций. Потоки разделяют общие данные, и (в зависимости от используемой потоковой модели; один поток может обрабатывать данные, которые должны быть переданы другому потоку для последующей обработки. Пока поток обрабатывает данные, он является HN
4.9. Управление потоками 147 инственным обладателем благодаря блокированию мьютекса, связанного с этими иными. Если поток, имеющий заблокированный мьютекс, аннулируется до его ос- обождения, возникает взаимоблокировка. Для того чтобы снова использовать дан- ые их следует привести в определенное состояние. Если поток отменяется до освобождения мьютекса, могут возникнуть нежелательные условия. Другими словами, в зависимости от типа обработки, которую выполняет поток, его аннулирование должно происходить тогда, когда это безопасно. Об опасных и безопасных периодах "знает" только сам поток, и поэтому только он может предотвратить свое аннулирование в опасные периоды. Следовательно, круг потоков, которые можно аннулировать, должен быть ограничен потоками, которые не относятся к числу "жизненно важных" или которые не имеют блокировок ресурсов. Кроме того, аннулирование может быть отсрочено до тех пор, пока не будут выполнены "жизненно важные" действия. Состояние готовности к аннулированию (cancelability state) описывает условия, при которых поток может (или не может) быть аннулирован. Тип аннулирования (cancelabilty type) потока определяет способность потока продолжать выполнение после получения запросов на аннулирование. Поток может отреагировать на аннулирующий запрос немедленно или отложить аннулирование до определенной (более поздней) точки в его выполнении. Состояние готовности к аннулированию и тип аннулирования устанавливаются динамически самим потоком. Для определения состояния готовности к аннулированию и типа аннулирования вызывающего потока используются функции pthread_setcancelstate () Hpthread_setcanceltype(). Функция pthread_set cancel state () устанавливает вызывающий поток в состояние, заданное параметром state, и возвращает предыдущее состояние в параметре olds tat е. Синопсис ♦include <pthread.h> int pthread_setcancelstate(int state, int *oldstate) [int pthread_setcanceltype(int type, int *oldtype); Параметры state и oldstate могут принимать такие значения: PTHREAD_CANCEL_DI SABLE PTHREAD_CANCEL_ENABLE Значение PTHREAD_CANCEL_DISABLE определяет состояние, в котором поток будет игнорировать запрос на аннулирование, а значение PTHREAD_CANCEL_ENABLE — со- °яние, в котором поток "согласится" выполнить соответствующий запрос (это со- °яние по умолчанию устанавливается для каждого нового потока). При успешном олнении функция возвращает число 0, в противном случае— код ошибки. Функ- Ptnread_set cancel state () не может выполниться успешно, если переданное ачение параметра state окажется недействительным. ункция pthread_setcanceltype () устанавливает для вызывающего потока тип '" иР°вания, заданный параметром type, и возвращает предыдущее значение типа раметре oldtype. Параметры type и oldtype могут принимать такие значения: ^THRpAD-CANCEL-DEFFERED HEAD^CANCEL_ASYNCHRONOUS
148 Глава 4. Разбиение С++-программ на множество потоков Значение PTHREAD_CANCEL_DEFFERED определяет тип аннулирования, при котором поток откладывает завершение до тех пор, пока он не достигнет точки, в котором его аннулирование возможно (этот тип по умолчанию устанавливается для каждого нового потока). Значение PTHREAD_CANCEL_ASYNCHRONOUS определяет тип аннулирования, при котором поток завершается немедленно. При успешном выполнении функция возвращает число 0, в противном случае— код ошибки. Функция р t hread_set cane el type () не может выполниться успешно, если переданное ей значение параметра type окажется недействительным. Функции pthread_setcancelstate() и pthread_setcanceltype() используются вместе для установки отношения вызывающего потока к потенциальному запросу на аннулирование. Возможные комбинации значений состояния и типа аннулирования перечислены и описаны в табл. 4.5. Таблица 4.5. Комбинации значений состояния и типа аннулирования Состояние Тип Описание Отсроченное аннулирование. Эти состояние и тип аннулирования потока устанавливаются по умолчанию. Аннулирование потока происходит, когда он достигает соответствующей точки в своем выполнении или когда программист определяет точку аннулирования с помощью функции pthread__testcancel () Асинхронное аннулирование. Аннулирование потока происходит немедленно Аннулирование запрещено. Оно вообще не выполняется 4.9.1.1. Точки аннулирования потоков Если удовлетворение запроса на аннулирование потока откладывается, значит, оно произойдет позже, когда это делать "безопасно", т.е. когда оно не попадает на период выполнения некоторого критического кода, блокирования мьютекса или пребывания данных в некотором "промежуточном" состоянии. Вне этих "опасных" раз* делов кода потоков вполне можно устанавливать точки аннулирования. Точка аннулирования — это контрольная точка, в которой поток проверяет факт существования каких-либо ждущих (отложенных) запросов на аннулирование и, если таковые имеются, разрешает завершение. Точки аннулирования можно пометить с помощью функции pthread_t est cancel () • Эта функция проверяет наличие необработанных запросов на аннулирование. Если они есть, она активизирует процесс аннулирования в точке своего вызова. В противном случае функция продолжает выполнение потока без каких-либо последствий. Вызов этой функции можно разместить в любом месте кода потока, которое считается безопасным для его завершения. PTHREAD_CANCEL_ PTHREAD_CANCEL_ ENABLE DEFERRED PTHREAD_CANCEL_ PTHREAD_CANCEL_ ENABLE ASYNCHRONOUS PTHREAD_CANCEL_ Игнорируется DISABLE
4.9. Управление потоками 149 синопсис ^include <pthread.h> void pthread_testcancel(void) Программа 4.3 содержит функции, которые вызывают функции othread_set cancel state (), pthread_setcanceltype () и pthread_testcancel (), связанные с установкой типа аннулирования потока и состояния готовности к аннулированию. // Программа 4.3 #include <iostream> #include <pthread.h> void *taskl(void *X) { int OldState; // Запрет на аннулирование. pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, &OldState); for(int Count = 1;Count < 100;Count++) { cout << "В потоке A: " << Count « endl; } } void *task2 (void *X) { int OldState,OldType; // Разрешено аннулирование асинхронного типа. pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, &OldState); pthread_setcancel type (PTHREAD_CANCEL_ASYNCHRONOUS, &OldType); for(int Count = 1;Count < 100;Count++) { cout « "В потоке В: " « Count « endl; } } void *task3(void *X) int OldState,OldType; ' Разрешено аннулирование отложенного типа. Pthread_setcancelstate (PTHREAD_CANCEL_ENABLE, &01dState); Pthread_setcanceltype (PTHREAD_CANCEL_DEFERRED, f &01dType); j°r(lnt Count = l;Count < 1000;Count++) cout « »b потоке С: " « Count « endl; lf((Count%100) == 0){ Pthread_testcancel();
150 Глава 4. Разбиение С++-программ на множество потоков } } } В программе 4.3 каждая задача устанавливает свое условие аннулирования. В задаче taskl аннулирование потока запрещено, поскольку вся она состоит из критического кода, который должен быть выполнен до конца. В задаче task2 аннулирование потока разрешено. Обращение к функции pthread_setcancelstate () является необязательным, поскольку все новые потоки имеют статус разрешения аннулирования. Тип аннулирования здесь устанавливается равным значению PTHREAD_CANCEL_ASYNCHRONOUS. Это означает, что после поступления запроса на аннулирование поток немедленно запустит соответствующую процедуру, независимо от того, на какой этап его выполнения придется этот запрос. А поскольку этот поток установил для себя именно такой тип аннулирования, значит, он не выполняет никакого "жизненно важного" кода. Например, вызовы системных функций должны попадать под категорию опасных для аннулирования, но в задаче task2 таких нет. Там выполняется лишь цикл, который будет работать до тех пор, пока не поступит запрос на аннулирование. В задаче task3 аннулирование потока также разрешено, а тип аннулирования устанавливается равным значению PTHREAD__CANCEL_DEFFERED. Эти состояние и тип аннулирования действуют по умолчанию для новых потоков, следовательно, обращения к функциям pthread_setcancelstate () и р thread_set cancel type () здесь необязательны. Критический код этого потока здесь может спокойно выполняться после установки состояния и типа аннулирования, поскольку процедура завершения не стартует до вызова функции pthread_testcancel (). Если не будут обнаружены ждущие запросы, поток продолжит свое выполнение до тех пор, пока не встретит очередные обращения к функции pthread_testcancel () (если таковые предусмотрены). В задаче task3 функция pthread_cancel () вызывается только после того, как переменная Count без остатка разделится на число 100. Код, расположенный между точками аннулирования потока, не должен быть критическим, поскольку он может не выполниться. Программа 4.4 содержит управляющий поток, который делает запрос на аннулирование каждого рабочего потока. // Программа 4.4 int main(int argc, char *argv[]) { pthread_t Threads[3]; void *Status; pthread_create(&(Threads[0]),NULL,taskl,NULL); pthread_create(&(Threads[1]), NULL,task2,NULL); pthread_create(&(Threads[2]),NULL,task3,NULL); pthread_cancel(Threads[0]); pthread_cancel(Threads[1]); pthread_cancel(Threads[2] ) ; for(int Count = 0;Count < 3;Count++) { pthread_join(Threads[Count],&Status); if(Status == PTHREAD_CANCELED){ cout « "Поток" << Count « " аннулирован." << endl; }
4.9. Управление потоками 151 else{ cout « "Поток" « Count « " продолжает выполнение." « endl; } } return (0) ; } Управляющий поток в программе 4.4 сначала создает три рабочих потока, затем делает запрос на аннулирование каждого из них. Управляющий поток для каждого рабочего потока вызывает функцию pthread_join(). Эта функция завершается успешно даже тогда, когда она пытается присоединить поток, который уже завершен, функция присоединения в этом случае просто считывает статус завершения завершенного потока. И такое поведение весьма кстати, поскольку поток, который сделал запрос на аннулирование, и поток, вызвавший функцию pthread_join (), могут оказаться совсем разными потоками. Мониторинг функционирования всех рабочих потоков может оказаться единственной задачей того потока, который "по совместительству" и аннулирует потоки. Опрашивать же статус завершения потоков с помощью функции pthread_join() может совершенно другой поток. Этот тип информации используется для получения статистической оценки того, какой из потоков наиболее эффективен. В рассматриваемой нами программе все это делает один управляющий поток: в цикле он и присоединяет рабочие потоки, и проверяет их статус завершения. Поток Thread [0] не аннулирован, поскольку он имеет запрет на аннулирование, в то время как два остальных потока были аннулированы. Статус завершения аннулируемого потока может иметь, например, значение PTHREAD_CANCELED. Профили программ 4.3 и 4.4 представлены в разделе "Профиль программы 4.2". IПрофиль программы 4.2 Имя программы jProgram4-34.cc ; Описание Демонстрирует аннулирование потоков. Три потока имеют различные типы «и состояния аннулирования. Каждый поток выполняет цикл. Состояние и тип аннулирования определяет количество итераций цикла и то, будет ли цикл выполняться вообще. Основной поток определяет статус завершения каждого , рабочего потока. Требуемая библиотека Hbpthread Требуемые заголовки ^thread.h> <iostream> ^рукции по компиляции и компоновке программ + -о program4-34 program4-34.cc -lpthread РЗДа для тестирования j5uSE Linux 7.1, gec 2.95.2. :.И»струкции по выполнению --:/Program4-34
152 Глава 4. Разбиение С++-программ на множество потоков В функциях, определенных пользователем, используются точки аннулирования отмеченные обращением к функции pthread_testcancel (). Библиотека Pthread определяет в качестве точек аннулирования выполнение других функций. Эти функ- ции блокируют вызывающий поток, а заблокированному потоку аннулирование не грозит. Вот эти функции библиотеки Pthread: pthread_testcancel() pthread_cond_wait() pthread_timedwait pthread_join() Если поток, пребывающий в состоянии отсроченного аннулирования, имеет ждущий запрос на аннулирование, то при вызове одной из перечисленных выше функций библиотеки Pthread будет инициирована процедура аннулирования. Некоторые из системных функций, претендующих на роль точек аннулирования, перечислены в табл. 4.6. Таблица 4.6. Системные POSIX-функции, претендующие на роль точек аннулирования accept() aio__suspend() clock_nanosleep() close() connect() creat() fcntl() fsync() getmsg() lockf() mq_receive() mq_send() mq_timedreceive() mq_timedsend () msgrcv() msgsnd() msync() nanosleep() open() pause() poll() pread() pthread_cond_timedwait() pthread__cond_wait () pthread_j oin() putmsg() putpmsg() pwrite() read() readv() recvfrom() recvmsg() select() sem_timedwait() sem_wait() send() sendmsg() sendto() sigpause() sigsuspend() sigtimedwait() sigwait() sigwaitinfo() sleep() system() usleep() wait() waitpid() write() writev() Несмотря на то что эти функции безопасны для отсроченного аннулирования потоков, они могут не быть таковыми для асинхронного аннулирования. Асинхронное аннулирование во время вызова библиотечной функции, которая не является асинхронно-безопасной, может привести к тому, что библиотечные данные останутся не в надлежащем состоянии. Библиотека выделит память от имени потока, и, когда поток будет аннулирован, продолжит удерживать "за собой" эту память. Для других библиотечных и системных функций, которые не являются безопасными для аннулир0' вания (асинхронного или отсроченного), возможно, имеет смысл написать код, пре"
4.9. Управление потоками 153 ятствующий завершению потока путем установки категорического запрета на анну- ирование или использования отсроченного аннулирования до тех пор, пока эти функции не будут выполнены. 4.9.1-2. Очистка перед завершением Поток, "позволивший" себя аннулировать, прежде чем завершиться, обычно должен выполнить некоторые заключительные действия. Так, нужно закрыть файлы, привести разделяемые данные в надлежащее состояние, снять блокировки или освободить занимаемые ресурсы. Библиотека Pthread определяет механизм поведения каждого потока "в последние минуты своей жизни". С каждым потоком связывается стек очистительно-восстановительных операций (cleanup stack), который содержит указатели на процедуры (или функции), предназначенные для выполнения во время аннулирования потока. Для того чтобы поместить в этот стек указатель на процедуру, предусмотрена функция pthread_cleanup_push (). Синопсис #include <pthread.h> void pthread_cleanup_push(void (*routine)(void *), void *arg); void pthread cleanup pop(int execute); Параметр routine представляет собой указатель на функцию, помещаемый в стек завершающих процедур. Параметр arg содержит аргумент, передаваемый этой routine-функции, которая вызывается при завершении потока с помощью функции pthread_exit (), когда поток "покоряется" запросу на аннулирование или явным образом вызывает функцию pthread__cleanup_pop () с ненулевым значением параметра execute. Функция, заданная параметром routine, не возвращает никакого значения. Функция pthread_cleanup_pop() удаляет указатель routine-функции из вершины стека завершающих процедур вызывающего потока. Параметр execute может принимать значение 1 или 0. Если его значение равно 1, поток выполняет routine- функцию, даже если он при этом и не завершается. Поток продолжает свое выполнение с инструкции, расположенной за вызовом функции pthread__cleanup_pop (). £сли значение параметра execute равно 0, указатель извлекается из вершины стека потока без выполнения routine-функции. Необходимо позаботиться о том, чтобы для каждой функции занесения в стек vpush) существовала функция извлечения из стека (pop) в пределах одной и той же ексической области видимости. Например, для функции f uncA () обязательно вы- °лнение cleanup-обработчика при ее нормальном завершении или аннулировании: void *funcA(void *Х) int *Tid; Tid = new int; / Выполнение некоторых действий. ^h^ead~cleanup_push(cleanup_funcA/Tid); Выполнение некоторых действий. } Pthrea3-Cleanup__pop (0 ) ;
154 Глава 4. Разбиение С++-программ на множество потоков Здесь функция funcA() помещает указатель на обработчик cleanup_funcA() в стек завершающих процедур путем вызова функции pthread_cleanup_push (). Каждому обращению к этой функции должно соответствовать обращение к функции pthread_cleanup_pop(). Если функции извлечения указателя из стека (pop- функции) передается значение 0, то извлечение из стека состоится, но без выполнения обработчика. Обработчик будет выполнен лишь при аннулировании потока, выполняющего функцию f uncA (). Для функции f uncB () также требуется cleanup-обработчик: void *funcB(void *X) { int *Tid; Tid = new int; // Выполнение некоторых действий. //. . . pthread__cleanup_push(cleanup_funcB,Tid); // Выполнение некоторых действий. //. . . pthread_cleanup_pop(1); } Здесь функция f uncB () помещает указатель на обработчик cleanup_f uncB () в стек завершающих процедур. Отличие этого примера от предыдущего состоит в том, что функции pthread_cleanup_pop () передается параметр со значением 1, т.е. после извлечения из стека указателя на обработчик этот обработчик будет тут же выполнен. Необходимо отметить, что выполнение обработчика в данном случае состоится "при любой погоде", т.е. и при аннулировании потока, который обеспечивает выполнение функции funcB(), и при обычном его завершении. Обработчики-"уборщики", cleanup_funcA() и cleanup_f uncB (), — это обычные функции, которые можно использовать для закрытия файлов, освобождения ресурсов, разблокирования мьютексов и пр. 4.9.2, Управление стеком потока Адресное пространство процесса делится на раздел кода, раздел статических данных, свободную память и раздел стеков. Стекам потоков выделяется область из стекового раздела процесса. Стек потока предназначен для хранения стекового фрейма, связанного с каждой процедурой (функцией), которая была вызвана, но еще не завершена. Стековый фрейм содержит временные переменные, локальные переменные, адреса точек возврата и любую другую дополнительную информацию, которая необходима потоку, чтобы найти "обратную дорогу" к ранее вызванным процедурам. При выходе из процедуры (функции) ее стековый фрейм извлекается из стека. Расположение фреймов в стеке схематично показано на рис. 4.12. Предположим, что поток А (см. рис. 4.12) выполняет функцию taskl () , которая создает некоторые локальные переменные, выполняет заданную обработку, а затем вызывает функцию taskX (). При этом для функции taskl () создается стековый фрейм, который помещается в стек потока. Функция taskX () выполняет "свои" действия, создает локальные переменные, а затем вызывает функцию taskC (). Нетрудно догадаться, что стековый фрейм, созданный для функции taskX () , также помещается в стек. Функция taskC () вызывает функцию taskY () и т.д. Каждый стек должен иметь достаточно большой размер, чтобы поместить всю
4.9. Управление потоками 155 . рмацию, необходимую для выполнения всех функций потока, а также цепочки И гих подпрограмм, которые будут вызваны потоковыми функциями. Размером естоположением стека потока управляет операционная система, но для установ- и считывания этой информации предусмотрены методы, которые определены в объекте атрибутов потока. АДРЕСНОЕ ПРОСТРАНСТВО ПРОЦЕССА [ВАЗДЕЛ. СТЕКОВ, ... 1 —• ■ : г- ■ ■ ■ . taskCO _ „_*, __■ г_ _ __ _ —.«. — *_ ^ taskX{) А= 1 В = 3 ^._*!*^.-__ — ~_.^.._-_ч. _ task1() Х= 10' ,r Г taskCO { II... } taskX() A = 1 B = 3 taskC(); II... } task1() X = 10; Y = 5; taskX(); II... ) Рис. 4.12. Стековые фреймы, сгенерированные потоками Функция pthread_attr__get stacksize () возвращает минимальный размер стека, устанавливаемый по умолчанию. Параметр attr определяет объект атрибутов потока, из которого считывается стандартный размер стека. При успешном выполнении функция возвращает значение 0, а стандартный размер стека, выраженный в байтах, сохра- яется в параметре stacksize. В случае неудачи функция возвращает код ошибки. Функция pthread__attr_setstacksize() устанавливает минимальный размер стека По *• параметр attr определяет объект атрибутов потока, для которого устанав- ается размер стека. Параметр stacksize содержит минимальный размер стека, раженный в байтах. При успешном выполнении функция возвращает значение 0, ротивном случае — код ошибки. Функция завершается неудачно, если значение раметра stacksize оказывается меньше значения PTHREAD_MIN_STACK или МенВЫШаеТ СИстемный минимум. Вероятно, значение PTHREAD_STACK_MIN будет Pth минимального размера стека, возвращаемого функцией СТе — attr_getstacksize (). Прежде чем увеличивать минимальный размер pt^ от°ка, следует поинтересоваться значением, возвращаемым функцией цИе — attr_getstacksize (). Размер стека фиксируется, чтобы его расшире- пРост Ремя выполнения программы ограничивалось рамками фиксированного СТВа стека, установленного во время компиляции.
156 Глава 4. Разбиение С++-программ на множество потоков Синопсис I #include <pthread.h> void pthread_attr_getstacksize( const pthread_attr_t *restrict attr, void **restrict stacksize); void pthread_attr_setstacksize(pthread_attr__t *attr, void *stacksize); Местоположение стека потока можно установить и прочитать с помощью функций pthread_attr__set stackaddr() и pthread_attr_getstackaddr(). Функция pthread_attr_setstackaddr () для потока, создаваемого с помощью атрибутного объекта, заданного параметром attr, устанавливает базовый адрес стека равным адресу, заданному параметром stackaddr. Этот адрес stackaddr должен находиться в пределах виртуального адресного пространства процесса. Размер стека должен быть не меньше минимального размера стека, задаваемого значением PTHREAD_STACK_MIN. При успешном выполнении функция возвращает значение О, в противном случае — код ошибки. Функция pthread_attr_getstackaddr () считывает базовый адрес стека для потока, создаваемого с помощью атрибутного объекта потока, заданного параметром attr. Считанный адрес сохраняется в параметре stackaddr. При успешном выполнении функция возвращает значение 0, в противном случае — код ошибки. Синопсис #include <pthread.h> void pthread_attr_setstackaddr(pthread_attr_t *attr, void *stackaddr); void pthread_attr_getstackaddr( const pthread_attr__t *restrict attr, I void **restrict stackaddr); J Атрибуты стека (размер и местоположение) можно установить с помощью одной функции. Функция pthread_attr_setstack() устанавливает как размер, так и адрес стека для потока, создаваемого с помощью атрибутного объекта, заданного параметром attr. Базовый адрес стека устанавливается равным адресу, заданному параметром stackaddr, а размер стека— равным значению параметра stacksize. Функция pthread_attr_getstack() считывает размер и адрес стека для потока, создаваемого с помощью атрибутного объекта, заданного параметром attr. Пр*1 успешном выполнении функция возвращает значение 0, в противном случае— коД ошибки. Если считывание атрибутов стека прошло успешно, адрес будет записан в параметр stackaddr, а размер— в параметр stacksize. функция pthread_setstack () выполнится неудачно, если значение параметра stacksize окажется меньше значения PTHREAD_STACK_MIN или превысит некоторый преДеЛ' определяемый реализацией.
4.9. Управление потоками 157 Синопсис #include <pthread.h> -A Dthread__attr_setstack(pthread_attr_t *attr/ vo1 V void *stackaddr, size_t stacksize) ; void pthread_attr_getstack( const pthread_attr_t *restrict attr, void **restrict stackaddr, size_t stacksize) ; В листинге 4.3 показан пример установки размера стека для потока, создаваемого с помощью атрибутного объекта. // Листинг 4.3. Изменение размера стека для потока // с использованием смещения pthread_attr__getstacksize (&SchedAttr, &DefaultSize) ; if (DefaultSize < Min_Stack_Req) { SizeOffset = Min__Stack_JReq - DefaultSize; NewSize = DefaultSize + SizeOffset; pthread_attr_setstacksize(&Attrl,(size_t)NewSize); } В листинге 4.3 сначала из атрибутного объекта потока считывается размер стека, действующий по умолчанию. Затем, если окажется, что этот размер меньше желаемого минимального размера стека, вычисляется разность между сравниваемыми размерами, после чего значение этой разности (смещение) суммируется с размером стека, используемым по умолчанию. Результат суммирования становится новым минимальным размером стека для этого потока. ПРИМЕЧАНИЕ: установка размера и местоположения стека может сделать вашу программу непереносимой. Размер и местоположение стека, устанавливаемые на одной платформе, могут оказаться неподходящими для использования в качестве размера и местоположения стека для другой платформы. 4-9.3. Установка атрибутов планирования и свойств потоков Добно процессам, потоки выполняются независимо один от другого. Каждый по- начается процессору для выполнения его задачи. Для каждого потока определя- OH - ^атегия планирования и приоритет, которые предписывают, как и когда именно Пы п Назначен процессору. Стратегия планирования и приоритет потока (или груп- Dt-ъ К )станавливаются с помощью объекта атрибутов и следующих функций: Pthrea^attr-Setinheritsched() Pthre^""attr-Setschedpolicy(} a-attr-setschedparam()
158 Глава 4. Разбиение С++-программ на множество потоков Для получения информации о характере выполнения потока используются еле дующие функции: pthread_attr_getinheritsched() pthread_attr_getschedpolicy() pthread_attr_getschedparam() I Синопсис | #include <pthread.h> Uinclude <sched.h> void pthread_attr_setinheritsched( pthread_attr_t *attr, int inheritsched); void pthread_attr_setschedpolicy( pthread_attr_t *attr, int policy); void pthread_attr_setschedparam( pthread_attr_t *restrict attr, const struct sched_param *restrict param); Функции pthread_attr_setinheritsched(), pthread_attr_setschedpolicy () и pthread_attr_setschedparam() используются для установки стратегии планирования и приоритета потока. Функция pthread_attr_setinheritsched() позволяет определить, как будут устанавливаться атрибуты планирования потока: путем наследования от потока-создателя или в соответствии с содержимым объекта атрибутов. Параметр inherit sched может принимать одно из следующих значений. PTHREAD_INHERIT_SCHED Атрибуты планирования потока должны быть унаследованы от потока-создателя, при этом любые атрибуты планирования, определяемые параметром attr, будут игнорироваться. PTHREAD_EXPLICIT_SCHED Атрибуты планирования потока должны быть установлены в соответствии с атрибутами планирования, хранимыми в объекте, заданном параметром attr. Если параметр inheritsched получает значение PTHREAD_EXPLICIT_SCHED, то функция pthread_attr_setschedpolicy () используется для установки стратегии планирования, а функция pthread_attr_setschedparam () — установки приоритета. Функция pthread_attr_setschedpolicy () устанавливает член объекта атрибутов потока (заданного параметром attr), "отвечающий" за стратегию планирования потока. Параметр policy может принимать одно из следующих значений, определенных в заголовке <sched. h>. SCHED_FIFO Стратегия планирования типа FIFO (первым прибыл, первым обслужен), при которой поток выполняется до конца. SCHED_RR Стратегия циклического планирования, при которой каждый поток назначается процессору только в течение некоторого кванта времени- SCHED_OTHER Стратегия планирования другого типа (определяемая реализацией)- Для любого нового потока эта стратегия планирования принимается по умолчанию.
4.9. Управление потоками 159 кция pthread_attr_setschedparam() используется для установки членов бтного объекта (заданного параметром attr), связанных со стратегией плани- аТР ' Параметр param представляет собой структуру, которая содержит эти чле- Р° с DVKTypa sched_param включает по крайней мере такой член данных: struct sched_param { int sched_priority; }; Возможно, эта структура содержит и другие члены данных, а также ряд функций, назначенных для уСТановки и считывания минимального и максимального значений приоритета, атрибутов планировщика и пр. Но если для задания стратегии планирования используется либо значение SCHED_FIFO, либо значение SCHED_RR, то в структуре sched_param достаточно определить только член sched_priority. Чтобы получить минимальное и максимальное значения приоритета, используйте функции sched_get_priority_min () и sched_get_priority_max (). Синопсис #include <sched.h> int sched_get_priority_max(int policy); int sched_get_priority_min(int policy); Обеим функциям в качестве параметра policy передается значение, определяющее выбранную стратегию планирования, для которой нужно установить значения приоритета, и обе функции возвращают соответствующее значение приоритета (минимальное и максимальное) для заданной стратегии планирования. Как установить стратегию планирования и приоритет потока с помощью атрибутного объекта, показано в листинге 4.4. // Листинг 4.4. Использование атрибутного объекта потока ' I для установки стратегии планирования и '' приоритета потока define Min_Stack_Req 3000000 Pthread_t ThreadA; Pthread_attr_t SchedAttr; int^ DefaultSize,SizeOffsetfNewSize; srb ^lnPriority»MaxPriority#MidPriority; cne^aram SchedParam; \nt main(int argC/ char *argv[]) Инициализируем объект атрибутов. a -att^init(&SchedAttr) ; // при ЫВаем минимальное и максимальное значения toinpri .Тета Для стратегии планирования. Махр^^^ = sched-get-Priority-max(SCHED-RR) ; ritV = sched_get_priority_min(SCHED_RR);
160 Глава 4. Разбиение С++-программ на множество потоков // Вычисляем значение приоритета. MidPriority = (MaxPriority + MinPriority)/2; // Записываем значение приоритета в структуру sched__param. SchedParam.sched_priority = MidPriority; // Устанавливаем объект атрибутов. pthread_attr_setschedparam(&Attrl,&SchedParam); // Обеспечиваем установку атрибутов планирования //с помощью объекта атрибутов. pthread_attr_setinheritsched(&Attrl, PTHREAD_EXPLICIT_SCHED); // Устанавливаем стратегию планирования. pthread__attr_setschedpolicy (&Attrl, SCHEDJRR) ; // Создаем поток с помошью объекта атрибутов. pthread_create(&ThreadA&Attrl,task2,Value); } В листинге 4.4 стратегия планирования и приоритет потока ThreadA устанавливаются с использованием атрибутного объекта SchedAttr. Выполним следующие действия. 1. Инициализируем атрибутный объект. 2. Считаем минимальное и максимальное значения приоритета для стратегии планирования. 3. Вычислим значение приоритета. 4. Запишем значение приоритета в структуру sched_param. 5. Установим атрибутный объект. 6. Обеспечим установку атрибутов планирования с помощью атрибутного объекта. 7. Установим стратегию планирования. 8. Создадим поток с помощью атрибутного объекта. Последовательное выполнение этих действий позволяет установить стратегию планирования и приоритет потока до его создания. Для динамического изменения стратегии планирования и приоритета используйте функции pthread_setschedparam() и pthread_setschedprio(). Синопсис #include <pthread.h> int pthread_setschedparam( pthread__t thread, int policy, const struct sched_param *param); int pthread_getschedparam( pthread_t thread, int ^restrict policy, struct sched_param *restrict param); int pthread_setschedprio (pthread_t thread, int prio) ; ^^^
4.9. Управление потоками 161 . кцИЯ pthread_setschedparam() устанавливает как стратегию планирова- к и приоритет потока без использования атрибутного объекта. Параметр нИ ' d содержит идентификатор потока, параметр policy— новую стратегию пла- ^ вания и параметр param — значения, связанные с приоритетом. Функция Н ь ead__getschedparam() сохраняет значения стратегии планирования и приори- Р в параметрах policy и par am соответственно. При успешном выполнении обе киии возвращают число 0, в противном случае — код ошибки. Условия, при кото- v эти функции могут завершиться неудачно, перечислены в табл. 4.7. Таблица 4.7. Условия потенциального неудачного завершения функций установки стратегии планирования и приоритета Функции Условия отказа int pthread_getschedparam (pthread_t thread, int *restrict policy, struct sched_param *restrict par am) ; int pthread_setschedparam (pthread_t thread, int *policy, const struct sched__param *param); lr*t pthread_setschedprio (pthread_t thread, int prio); Параметр thread не ссылается на существующий поток Некорректен параметр policy или один из членов структуры, на которую указывает параметр param Параметр policy или один из членов структуры, на которую указывает параметр param, содержит значение, которое не поддерживается в данной среде Вызывающий поток не имеет соответствующего разрешения на установку значений приоритета или стратегии планирования для заданного потока Параметр thread не ссылается на существующий поток Данная реализация не позволяет приложению заменить один из параметров планирования заданным значением Параметр prio не подходит к стратегии планирования заданного потока Параметр prio имеет значение, которое не поддерживается в данной среде Вызывающий поток не имеет соответствующего разрешения на установку приоритета для заданного потока Параметр thread не ссылается на существующий поток Данная реализация не позволяет приложению заменить значение приоритета заданным
162 Глава 4. Разбиение С++-программ на множество потоков Функция pthread_setschedprio () используется для установки значения прц оритета выполняемого потока, идентификатор которого задан параметром thread В результате выполнения этой функции текущее значение приоритета будет заменено значением параметра prio. При успешном выполнении функция возвращает число о в противном случае — код ошибки. При неуспешном выполнении функции приоритет потока изменен не будет. Условия, при которых эта функция может завершиться неуспешно, также перечислены в табл. 4.7. ПРИМЕЧАНИЕ: к изменению стратегии планирования или приоритета выполняемого потока необходимо отнестись очень осторожно. Это может непредсказуемым образом повлиять на общую эффективность приложения. Потоки с более высоким приоритетом будут вытеснять потоки с более низким, что приведет к зависанию либо к тому, что поток будет постоянно выгружаться с процессора и поэтому не сможет завершить выполнение. 4.9.3.1. Установка области конкуренции потока Область конкуренции потока определяет, какое множество потоков с одинаковыми стратегиями планирования и приоритетами будут состязаться за использование процессора. Область конкуренции потока устанавливается его атрибутным объектом. Синопсис #include <pthread.h> int pthread_attr_setscope(pthread_attr_t *attr, int contentionscope); int pthread_attr_getscope( const pthread_attr_t *restrict attr, int ^restrict contentionscope) ; Функция pthread_attr_setscope () устанавливает член объекта атрибутов потока (заданного параметром attr), связанный с областью конкуренции. Область конкуренции потока будет установлена равной значению параметра contentionscope, который может принимать следующие значения. PTHREAD__SCOPE_SYSTEM Область конкуренции системного уровня PTHREAD_SCOPE_PROCESS Область конкуренции уровня процесса Функция pthread_attr_getscope () возвращает атрибут области конкуренции из объекта атрибутов потока, заданного параметром attr. При успешном выполнении значение области конкуренции сохраняется в параметре contentionscope. Обе функ" ции при успешном выполнении возвращают число 0, в противном случае — код ошибки- 4.9.4. Использование функции sysconf () Знание пределов, устанавливаемых системой на использование ресурсов, позволит в» шему приложению эффективно управлять ресурсами. Например, максимальное количество потоков, приходящихся на один процесс, составляет верхнюю границу числа pav° чих потоков, которое может быть создано процессом. Функция sysconf () использу^ ся для получения текущего значения конфигурируемых системных пределов или опЦИ*1'
4.9. Управление потоками 163 С^яопсис . „тпЛе <unistd.n> int «ygeonf <int паше) П оаметр name — это запрашиваемая системная переменная. Функция возвращает ения, соответствующие стандарту POSIX IEEE Std. 1003.1-2001 для заданных сис- 3 ых переменных. Эти значения можно сравнить с константами, определенными ТС й реализацией стандарта, чтобы узнать, насколько они согласуются между собой. Я я ояда системных переменных существуют константы-аналоги, относящиеся к потокам, процессам и семафорам (см. табл. 4.8). Если параметр name не действителен, функция sysconf () возвращает число -1 и устанавливает переменную errno, свидетельствующую об ошибке. Однако для заданного параметра name предел может быть не определен, и функция может возвращать число -1 как действительное значение. В этом случае переменная errno не устанавливается. Необходимо отметить, что неопределенный предел не означает безграничность ресурса. Это просто означает, что не определен максимальный предел и (при условии доступности системных ресурсов) могут поддерживаться более высокие предельные значения. Рассмотрим пример вызова функции sysconf (): if (PTHREAD_STACK_MIN } (sysconf (_SC_THREAD_STACK_MIN) ) ) { Значение константы PTHREAD_STACK_MIN сравнивается со значением, возвращаемым функцией sysconf (), вызванной с параметром _SC_THREAD_STACK_MIN. Таблица 4.8. Системные переменные и соответствующие им символьные константы Переменная Значение Описание -SC_THREADS -SC_THREAD_ATTR__ STACKADDR -SCJTHREAD_ATTR STACKSIZE rSC_THREAD_STACK MIN - ~SCLTHREAD_THREADS MAX — -SC_THREAD_KEYS_MAX -SC-THREAD_PRi0 _POSIX_THREADS _POS IX_THREAD_ATTR_ STACKADDR _POSIX_THREAD_ATTR_ STACKSIZE PTHREAD_STACK_MIN PTHREAD THREADS MAX PTHREAD_KEYS_MAX __POSIX_THREAD_PRIO_ INHERIT _POSIX_THREAD_PRIO_ Поддерживает потоки Поддерживает атрибут адреса стека потока Поддерживает атрибут размера стека потока Минимальный размер стека потока в байтах Максимальное количество потоков на процесс Максимальное количество ключей на процесс Поддерживает опцию наследования приоритета Поддерживает опцию приоритета потока
164 Глава 4. Разбиение С++-программ на множество потоков Окончание табл. 4. s Переменная Значение Описание _SC_THREAD_PRIORITY_ SCHEDULING _SC_THREAD_PROCESS_ SHARED _SC_THREAD_SAFE_ FUNCTIONS _SC__THREAD__ DESTRUCTOR_ ITERATIONS _SC_CHILD_MAX _SC_PRIORITY_ SCHEDULING _SC_REALTIME_ SIGNALS _SC_XOPEN_REALTIME_ THREADS _SC_STREAM__MAX _SC_SEMAPHORES _SC_SEM_NSEMS_MAX _POSIX_THREAD__PRIORITY_ SCHEDULING _POSIX_THREAD_PROCESS_ SHARED __POSIX_THREAD_SAFE_ FUNCTIONS _PTHREAD_THREAD_ DESTRUCTOR_ITERATIONS CHILD_MAX _POSIX_PRIORITY_ SCHEDULING _POSIX_REALTIME_ SIGNALS _XOPENJREALTIME_ THREADS STREAMLMAX _POSIX_SEMAPHORES SEM NSEMS MAX _SC_SEM_VALUE_MAX SEM__VALUE_MAX _SC_SHARED_MEMORY_ OBJECTS _POSIX_SHARED_MEMORY_ OBJECTS Поддерживает опцию планирования приоритета потока Поддерживает синхронизацию на уровне процесса Поддерживает функции безопасности потока Определяет количество попыток, направленных на разрушение потоковых данных при завершении потока Максимальное количество процессов, разрешенных для UID Поддерживает планирование процессов Поддерживает сигналы реального времени Поддерживает группу потоковых средств реального времени X/Open POSIX Определяет количество потоков данных, которые один процесс может открыть одновременно Поддерживает семафоры Определяет максимальное количество семафоров, которое может иметь процесс Определяет максимальное значение, которое может иметь семафор Поддерживает объекты общей 4.9.5. Управление критическими разделами Параллельно выполняемые процессы (или потоки в одном процессе) могут совместно использовать структуры данных, переменные или отдельные данные. Разделение глобальной памяти позволяет процессам или потокам взаимодействовать ДрУг с другом и получать доступ к общим данным. При использовании нескольких процессов разделяемая глобальная память является внешней по отношению к ним. Внешнюю структуру данных можно использовать для передачи данных или команд межДУ процессами. Если же необходимо организовать взаимодействие потоков, то они мог)'1'
4.9. Управление потоками 165 ь доступ к структурам данных или переменным, являющимся частью одного получ ^ прОЦесса, которому они принадлежат. иГ° существуют процессы или потоки, которые получают доступ к разделяемым , ЦИруемым данным, структурам данных или переменным, то все эти данные М ятся в критической области (или разделе) кода процессов или потоков. Крити- Н •* тчлел кода — это та его часть, в которой обеспечивается доступ потока или ческии р^/А ^ сса к разделяемому блоку модифицируемой памяти и обработка соответствую- данных. Отнесение раздела кода к критическому можно использовать для управ- состоянием "гонок". Например, создаваемые в программе два потока, поток А поток В, используются для поиска нескольких ключевых слов во всех файлах системы. Поток А просматривает текстовые файлы в каждом каталоге и записывает нужные пути в списочную структуру данных TextFiles, а затем инкрементирует переменную FileCount. Поток В выделяет имена файлов из списка TextFiles, декрементирует переменную FileCount, после чего просматривает файл на предмет поиска в нем заданных ключевых слов. Файл, который их содержит, переписывается в другой файл, и инкрементируется еще одна переменная FoundCount. К переменной FoundCount поток А доступа не имеет. Потоки А и В могут выполняться одновременно на отдельных процессорах. Поток А выполняется до тех пор, пока не будут просмотрены все каталоги, в то время как поток В просматривает каждый файл, путь к которому выделен из переменной TextFiles. Упомянутый список поддерживается в отсортированном порядке, и в любой момент его содержимое можно отобразить на экране. Здесь возможна масса проблем. Например, поток В может попытаться выделить имя файла из списка TextFiles до того, как поток А его туда поместит. Поток В может попытаться декрементировать переменную SearchCount до того, как поток А ее инкрементирует, или же оба потока могут попытаться модифицировать эту переменную одновременно. Кроме того, во время сортировки элементов списка TextFiles поток А может попытаться записать в него имя файла, или поток В будет в это время пытаться выделить из него имя файла для выполнения своей задачи. Описанные проблемы— это примеры условий "гонок", при которых несколько потоков (или процессов) пытаются одновременно модифицировать один и тот же блок общей памяти. Если потоки или процессы одновременно лишь читают один и тот же блок памяти, условия "гонок" не возникают. Они возникают в случае, когда несколько процессов или потоков одновременно получают доступ к одному и тому же блоку памяти, и по раинеи мере один из этих процессов или потоков делает попытку модифицировать иные. Раздел кода становится критическим, когда он делает возможными одновре- * енные попытки изменить один и тот же блок памяти. Один из способов защитить Р тический раздел — разрешить только монопольный доступ к блоку памяти. Моно- НЫи °осЩп означает, что к разделяемому блоку памяти будет иметь доступ один Р есс или поток в течение короткого промежутка времени, при этом всем осталь- Р°Цессам или потокам запрещено (путем блокировки) входить в свои критиче- разделы, которые обеспечивают доступ к тому же самому блоку памяти. Ров ^71Равления условиями "гонок" можно использовать такой механизм блоки- "wu/n 1 ^ взаимно исключающий семафор, или мьютекс (mutex— сокращение от кРит slon"~ взаимное исключение). Мьютекс используется для блокирования Него — СКОГО РазДела: он блокируется до входа в критический раздел, а при выходе из Деблокируется:
166 Глава 4. Разбиение С++-программ на множество потоков Блокирование мьютекса // Вход в критический раздел. // Доступ к разделяемой модифицируемой памяти. // Выход из критического раздела. Деблокирование мьютекса Класс pthread_mutex_t позволяет смоделировать мьютексный объект. Прежде чем объект типа pthread_mutex_t можно будет использовать, его необходимо инициализировать. Для инициализации мьютекса используется функция pthread_mutex_init (). Инициализированный мьютекс можно заблокировать деблокировать и разрушить с помощью функций pthread_mutex_lock() pthread_mutex_unlock () и pthread_mutex_destroy () соответственно. В программе 4.5 содержится функция, которая выполняет поиск текстовых файлов а в программе 4.6 — функция, которая просматривает каждый текстовый файл на предмет содержания в нем заданных ключевых слов. Каждая функция выполняется потоком. Основной поток реализован в программе 4.7. Эти программы реализуют модель "изготовитель-потребитель" для делегирования задач потокам. Программа 4.5 содержит поток-"изготовитель", а программа 4.6 — поток-"потребитель". Критические разделы выделены в них полужирным шрифтом. // Программа 4.5 1 int isDirectory(string FileName) 2 { 3 struct stat StatBuffer; 4 5 Istat(FileName.c_str(),fcStatBuffer); 6 if((StatBuffer.st_mode & S_IFDIR) == -1) 7 { 8 cout « "Невозможно получить статистику по файлу." « endl; 9 return (0); Ю } 11 else{ 12 if(StatBuffer.st_mode & S__IFDIR) { 13 return (1); 14 } 15 } 16 return (0) ; 17 } 18 19 2 0 int isRegular(string FileName) 21 { 22 struct stat StatBuffer; 23 24 lstat(FileName.c_str(),&StatBuffer); 25 if((StatBuffer.st_mode & S_IFDIR) == -1) 26 { 27 cout « "Невозможно получить статистику по файлу." « endl; 28 return (0); 29 } 30 else{ 31 if(StatBuffer.st_mode & S_IFREG){ 32 return (1); 33 }
4.9. Управление потоками 167 49 50 \\ return (0); 36 ) 37 38 id depthFirstTraversal(const char *CurrentDir) g ( MR *DirP; до string Temp; и3 string FileName; 44 struct dirent *EntryP; 45 chdir(CurrentDir); 46 cout « "Просмотр каталога: " « CurrentDir « endl; 47 DirP = opendir(CurrentDir); 48 if(DirP == NULL){ cout « "He удается открыть файл." « endl; 51 return; 52 } 53 EntryP = readdir(DirP); 54 while (EntryP != NULL) 55 { 56 Temp.erase(); 57 FileName.erase(); 58 Temp = EntryP->d_name; 59 if((Temp != ".") && (Temp != "..")){ 60 FileName.assign(CurrentDir); 61 FileName.appendd, '/') ; 62 F i 1 eName. append (En tryP - >d__name) ; 63 if(isDirectory(FileName)){ 64 string NewDirectory; 65 NewDirectory = FileName; 66 depthFirstTraversal(NewDirectory.c_str()); 67 } 68 else{ 69 if(isRegular(FileName)){ 70 int Flag; n\ Flag = FileName. f ind (". cpp") ; It if(Flag > 0){ pthread_mutex_lock(&CountMutex); * FileCount++; 7fi pthread_mutex_unlock(&CountMutex); 77 pthread_mutex_lock(&QueueMutex); 78 Text Files, push (Fil eName) ; 7Q pthread_mutex_unlock(&QueueMutex); } } } 80 81 82 83 84 85 86 87 88 89 90 91 } EntryP = readdir(DirP) closedir(DirP); Voi<a * task (void *X)
168 Глава 4. Разбиение С++-программ на множество потоков 92 { 93 char *Directory; 94 Directory = static_cast<char *>(X); 95 depthFirstTraversal(Directory); 96 re turn(NULL); 97 98 } Программа 4.6 содержит поток-"потребитель", который выполняс ных ключевых слов. // Программа 4.6 1 void *keySearch(void *X) 2 { 3 string Tempf Filename; 4 less<string> Comp; 5 6 while(!Keyfile.eof() && Keyfile.good()) 7 { 8 Keyfile >> Temp; 9 if(IKeyfile.eof()){ 10 Keywords.insert(Temp); 11 } 12 } 13 Keyfile.close(); 14 15 while(TextFiles.empty() ) 16 { } 17 18 while(!TextFiles.empty()) 19 { 2 0 pthread__mutex_lock(&QueueMutex) ; 21 Filename = TextFiles.front(); 22 TextFiles.popO ; 23 pthread_mutex__unlock(&QueueMutex) ; 24 Infile. open (Filename. c__str() ) ; 2 5 SearchWords.erase(SearchWords.begin(), 2 6 SearchWords.end()); 27 while(Unfile.eof() && Infile.good()) 28 { 29 Infile » Temp; 3 0 SearchWords.insert(Temp); 31 } 32 33 Infile.close(); 34 i f(includes(SearchWords.begin(),SearchWords.end(), Keywords.begin(),Keywords.end(),Comp)){ 3 5 Outfile « Filename « endl; 3 6 pthread_mutex_lock(&CountMutex); 37 FileCount—; 3 8 pthread__mutex__unlock(&CountMutex) ; 39 FoundCount++; 40 } 41 ) 42 return(NULL); 43 44 }
4.9. Управление потоками 169 П ограмма 4.7 содержит основной поток для потоков модели "изготовитель- 4.7 битель", реализованных в программах 4.5 и 4.6. // программа 1 #include <sys/stat.h> 2 #include <fstream> 3 #include <queue> 4 #include <algorithm> 5 #include <pthread.h> 6 #include <iostream> 7 #include <set> 9 pthread_mutex_t QueueMutex = PTHREAD_MUTEX_INITIALIZER; 10 pthread_mutex_t CountMutex = PTHREAD_MUTEX_INITIALIZER; 11 12 int FileCount = 0; 13 int FoundCount = 0; 14 15 int keySearch (void) ; 16 queue<string> TextFiles; 17 set <string,less<string> >KeyWords; 18 set <string,less<string> >SearchWords; 19 ifstream Infile; 20 of stream Outfile; 21 ifstream Keyfile; 22 string KeywordFile; 23 string OutFilename; 24 pthread_t Threadl; 25 pthread__t Thread2; 26 27 void depthFirstTraversal(const char *CurrentDir); 28 int isDirectory(string FileName); 29 int isRegular(string FileName); 30 31 int main (int argc, char *argv[]) 32 { 33 if(argc != 4) { cerr << "Нужна дополнительная информация." « endl; exit (1); 34 35 36 } 37 38 1q Outfile.open(argv[3],ios::app||ios:rate); ^ Keyfile.open(argv[2]); Pthread_create(&Threadl,NULLftask,argv[1]); Pthread_create(&Thread2,NULL,keySearch,argv[1]); Pthread_join(Threadl,NULL); 44 Pthread^join(Thread2,NULL); 45 Pth^ead_mutex_destroy(&CountMutex) ; 46 pthread-mutex_destroy(&QueueMutex) ; 47 cout « argv[l] « " содержит " « FoundCount 40 <<: " файлов со всеми ключевыми словами." << endl; 49 return(0);
170 Глава 4. Разбиение С++-программ на множество потоков С помощью мьютексов доступ к разделяемой памяти для чтения или записи дац. ных разрешается получить только одному потоку. Для гарантии безопасности работы функций, определенных пользователем, можно использовать и другие механизмы и методы, которые реализуют одну из моделей PRAM: EREW (монопольное чтение и монопольная запись) CREW (параллельное чтение и монопольная запись) ERCW (монопольное чтение и параллельная запись) CRCW (параллельное чтение и параллельная запись) Мьютексы используются для реализации EREW-алгоритмов, которые рассматриваются в главе 5. 4.10. Безопасность использования потоков и библиотек Климан (Klieman), Шах (Shah) и Смаалдерс (Smaalders) утверждали: "Функция или набор функций могут сделать поток безопасным или реентерабельным (повторно входимым), если эти функции могут вызываться не одним, а несколькими потоками без предъявления каких бы то пи было требований к вызывающей части выполнить определенные действия" (1996). При разработке многопоточного приложения программист должен обеспечить безопасность параллельно выполняемых функций. Мы уже обсуждали безопасность функций, определенных пользователем, но без учета того, что приложение часто вызывает функции из системных библиотек или библиотек, созданных сторонними производителями. Одни такие функции и/или библиотеки безопасны для потоков, а другие — нет. Если функция небезопасна, это означает, что в ней используется хотя бы одна статическая переменная, осуществляется доступ к глобальным данным и/или она не является реентерабельной. Известно, что статические переменные поддерживают свои значения между вызовами функции. Если некоторая функция содержит статические переменные, то для ее корректного функционирования требуется считывать (и/или изменять) их значения. Если же к такой функции будут обращаться несколько параллельно выполняемых потоков, возникнут условия "гонок". Если функция модифицирует глобальную переменную, то каждый из нескольких потоков, вызывающих функцию, может попытаться модифицировать эту глобальную переменную. Возникновения условий "гонок" также не миновать, если не синхронизировать множество параллельных доступов к глобальной переменной. Например, несколько параллельных потоков могут выполнять функции, которые устанавливают переменную errno. Для некоторых потоков, предположим, эта функция не может выполниться успешно, и переменная errno устанавливается равной сообщению об ошибке, в то время как другие потоки выполняются успешно. Если реализация компилятора не обеспечивает потоковую безопасность поддержки переменной errno, то какое сообщение получит поток при проверке состояния переменной errno- Блок кода считается реентерабельным, если его невозможно изменить при выполнении. Реентерабельный код исключает возникновение условий "гонок" благодаря отсутствию ссылок на глобальные переменные и модифицируемые статические данные. Следовательно, такой код могут совместно использовать несколько параллельных потоков или процессов без риска создать условия "гонок". Стандарт POSIX опр^ деляет ряд реентерабельных функций. Их легко узнать по наличию "суффикса" _г' присоединяемого к имени функции. Перечислим некоторые из них:
4.10. Безопасность использования потоков и библиотек 171 sterror^r О strtok^r( readdir^rO rand^r О ttyname^f v) V ли функция получает доступ к незащищенным глобальным переменным, содер- атические модифицируемые переменные или нереентерабельна, то такая кыия считается небезопасной для потока. Системные библиотеки или библиоте- созданные сторонними производителями, могут иметь различные версии своих андартных библиотек. Одна версия предназначена для однопоточных приложений, угая _ для многопоточных. Если предполагается разрабатывать многопоточное ппиложение, программист должен использовать многопоточные версии нужной ему библиотеки. Некоторые среды требуют не компоновки многопоточных приложений с многопоточной версией библиотеки, а лишь определения макросов, что позволяет объявить реентерабельные версии функций. Такое приложение будет затем компилироваться как безопасное для выполнения потоков. Во всех ситуациях использовать многопоточные версии функций попросту невозможно. В отдельных случаях многопоточные версии конкретных функций недоступны для данного компилятора или среды. Иногда один интерфейс функции не в состоянии сделать ее безопасной. Кроме того, программист может столкнуться с увеличением числа потоков в среде, которая изначально использовала функции, предназначенные для функционирования в однопоточной среде. В таких условиях обычно используются мьютексы. Например, программа имеет три параллельно выполняемых потока. Два из них, threadl и thread2, параллельно выполняют функцию f uncA (), которая не является безопасной для одновременной работы потоков. Третий поток, thread3, выполняет функцию funcB (). Для решения проблемы, связанной с функцией f uncA (), возможно, достаточно заключить в защитную оболочку мьютекса доступ к ней со стороны потоков threadl и thread2: threadl thread2 thread3 { n { { loc*() lockO funcBO funcA() funcAO } unlock() unlock() } } При реализации таких защитных мер к функции f uncA () в любой момент времени * ет получить доступ только один поток. Но проблемы на этом не исчерпываются. Если ^Нкции ^uncA () и f uncB () небезопасны для выполнения потоками, они могут обе th ИСРПЦиРовать глобальные или статические переменные. И хотя потоки threadl и ead2 используют мьютексы для функции f uncA (), поток thread3 может выполнять Цию f uncB () одновременно с любым из остальных потоков. В такой ситуации впол- rvr Р°ЯТНо возникновение условий "гонок", поскольку функции f uncA () и f uncB () мо- е модифицировать одну и ту же глобальную или статическую переменную. Нии Р°ИллюстРиРУем eu*c один тип условий "гонок", возникающих при использова- в и6лиотеки iostream. Предположим, у нас есть два потока, А и В, которые вы- об-к ^анные в стандартный выходной поток, с out, который представляет собой ттипа ostream. При использовании операторов "»" и "<<" вызываются мето-
172 Глава 4. Разбиение С++-программ на множество потоков ды объекта cout. Вопрос: являются ли эти методы безопасными? Если поток А о правляет сообщение "Мы существа разумные" объекту stdout, а поток В отправляе сообщение "Люди алогичные существа", то не произойдет ли "перемешивание" Вь ходных данных, в результате которого может получиться сообщение вроде такого- " Мы Люди существа алогичные разумные существа"? В некоторых случаях безопасны для потоков функции реализуются как атомные. Атомные функции — это функции, которые, если их выполнение началось, не могут быть прерваны. Если операция "»" для объекта cout реализована как атомная, то подобное "перемешивание" не произойдет. Если есть несколько обращений к оператору "»", то они будут выполнены последовательно. Сначала отобразится сообщение потока А, а затем сообщение потока В или наоборот, хотя они вызвали функцию вывода одновременно. Это — пример преобразования параллельных действий в последовательные, которое обеспечит безопасность выполнения потоков. Но это не единственный способ обезопасить функцию. Если функция не оказывает неблагоприятного эффекта, она может смешивать свои операции. Например, если метод добавляет или удаляет элементы из структуры, которая не отсортирована, и этот метод вызывают два различных потока, то перемешивание их операций не даст неблагоприятного эффекта. Если неизвестно, какие функции из библиотеки являются безопасными, а какие - нет, программист может воспользоваться одним из следующих вариантов действий. • Ограничить использование всех опасных функций одним потоком. • Не использовать безопасные функции вообще. • Собрать все потенциально опасные функции в один набор механизмов синхронизации. Еще один вариант — создать интерфейсные классы для всех опасных функций, которые должны использоваться в многопоточном приложении, т.е. опасные функции инкапсулируются в одном интерфейсном классе. Такой интерфейсный класс может быть скомбинирован с соответствующими объектами синхронизации с помощью наследования или композиции и использован специализированным классом. Такой подход устраняет возможность возникновения условий "гонок". 4.11. Разбиение программы на несколько потоков Выше в этой главе мы рассматривали делегирование работы в соответствии с конкретной стратегией или потоковой моделью. Итак, используются следующие распространенные модели: • делегирование ("управляющий-рабочий"); • сеть с равноправными узлами; • конвейер; • "изготовитель-потребитель". Каждая модель характеризуется собственной декомпозиг^ией работ (Work Breakdow Structure — WBS), которая определяет, кто отвечает за создание потоков и при каки условиях они создаются. В этом разделе мы рассмотрим пример программы для ка* дой модели, использующей функции библиотеки Pthread.
4.11. Разбиение программы на несколько потоков 173 Алл 1 Использование модели делегирования ассмотрели два подхода к реализации модели делегирования при разделении ^ь " на потоки. Вспомним: в модели делегирования один поток (управляющий) соз- програ. потоки (рабочие) и назначает каждому из них задачу. Управляющий поток дает ДР* каЖДому рабочему потоку задачу, которую он должен выполнить, путем зада- ДеЛСГекоторой функции. При одном подходе управляющий поток создает рабочие по- результат запросов, обращенных к системе. Управляющий поток обрабатывает екоторой функции. При одном подходе управляющий поток создает рабочие по- Т° с каждого типа в цикле событий. Как только событие произойдет, будет создан part* ий поток и ему будет назначена задача. Функционирование цикла событий в управ- ющем потоке и создание рабочих потоков продемонстрировано в листинге 4.5. // Листинг 4.5. Подход 1: скелет программы реализации .. модели управляющего и рабочих потоков // (псевдокод) pthread_mutex_t Mutex = PTHREAD_MUTEX_INITIALIZER int AvailableThreads pthread_t Thread [Max_T