В наших примерах будем отталкиваться от стандартной модели «ввод-обработка-вывод». В наиболее общем случае одна часть этой модели ответственна за получение откуда-либо входных данных, другая часть — за обработку этих данных и преобразование их в некоторые выходные данные (или управляющие воздействия), третья часть — за отправку полученных выходных данных куда надо.
Несколько процессов
Давайте, во-первых, осмыслим, что мы будем иметь в случае нескольких однопоточных процессов. Для нашей модели у нас было бы три процесса — процесс «ввода», процесс «обработки» и процесс «вывода»:
Система 1: Несколько операций, несколько процессов.
В таком виде наша модель в высшей степени абстрактна, но и в такой же степени «слабо связана». Процесс «ввода» не имеет никакой реальной связи ни с процессом «обработки», ни с процессом «вывода» — он просто отвечает за сбор входных данных и передачу их как-нибудь на следующий этап («этап обработки»).
Мы могли бы сказать то же самое о процессах «обработки» и «вывода» — они также не имеют никакой реальной связи друг с другом. Также здесь предполагается, что обмен данными («ввод — обработка» и «обработка — вывод») осуществляется по некоторому стандартному протоколу (например, через программные каналы, очереди сообщений POSIX, обмен сообщениями QNX/Neutrino — что угодно).
Несколько процессов с разделяемой памятью
В зависимости от объема потока данных, мы можем пожелать оптимизировать характер связей. Самый простой путь состоит в том, чтобы связать три процесса «теснее». Попробуем теперь вместо использования универсального протокола соединения выбрать схему с разделяемой памятью (на диаграмме толстые стрелки указывают потоки данных; тонкие стрелки — потоки управления):
Система 2: Несколько операций, буферы разделяемой памяти между процессами.
В данной схеме мы «подтянули» связь так, чтобы в результате обеспечить более быстрый и более эффективный обмен данными. В то же время, мы здесь по-прежнему можем применять универсальный протокол для передачи «управляющей» информации, поскольку предполагается, что по сравнению с потоком данных ее не так много.
Несколько потоков
Система с наиболее тесными связями представлена на следующей схеме:
Система 3: Несколько операций, несколько потоков.
Здесь мы наблюдаем один процесс с тремя потоками. Все три потока неявно разделяют области данных. Обмен управляющей информацией может быть реализован аналогично предыдущим примерам или с помощью ряда примитивов синхронизации потоков (мы уже имели дело с мутексами, барьерами и семафорами — скоро рассмотрим и другие).
Сравнение
Давайте теперь сравним эти три метода по ряду критериев и взвесим все «за» и «против».
В системе 1 связь была самой слабой. Это имеет то преимущество, что каждый из трех процессов может быть легко (то есть при помощи командной строки, в противоположность перекомпиляции/переработке) заменен другим модулем. Это следует из самой природы модели, потому что «единицей модульности» здесь является сам функциональный модуль. Система 1 является также единственной, которая из всех трех может быть распределена по узлам сети QNX/Neutrino. Поскольку информационные связи здесь абстрагированы до некоторого универсального протокола, очевидно, что эти три процесса могут быть выполнены на любой машине в сети. Это может быть очень мощным фактором масштабируемости в Вашем проекте — вам может понадобиться расширить свою сеть до сотен узлов, либо разделенных географически, либо как-то иначе — например, для совместимости с другими аппаратными средствами.
Однако, как только мы переходим к применению разделяемой памяти, мы теряем способность распределять модули по сети. QNX/Neutrino не поддерживает распределенные объекты разделяемой памяти. Таким образом, в Системе 2 мы реально ограничили себя выполнением всех трех процессов на одной и той же машине. Мы не потеряли способность легкой замены или исключения модулей, потому что модули все еще представляют собой отдельные процессы, управляемые командной строкой. Но мы добавили ограничение, в соответствии с которым все заменяемые компоненты должны соответствовать модели с разделяемой памятью.
В системе 3 мы теряем все отмеченные ранее проектные возможности. Мы определенно не можем выполнять различные потоки одного процесса на различных узлах (хотя при этом мы можем выполнять их на различных процессорах в SMP-системе). Также мы потеряли наши возможности переконфигурации — теперь нам обязательно понадобится механизм явного доопределения, который из алгоритмов «ввода», «обработки» и «вывода» мы должны использовать (эту проблему можно решить с помощью разделяемых объектов, также известных как динамические библиотеки — DLL).
Так почему же я должен проектировать свою систему, используя многопоточность, как в Системе 3? Почему бы мне для обеспечения максимальной универсальности не выбрать Систему 1?
Ну, даже при том, что Система 3 является наиболее ригидной, она, скорее всего, окажется самой быстродействующей. В ней не будет переключений контекста между потоками в различных процессах, мне не придется настраивать разделяемую память, а также применять абстрактные методы синхронизации типа программных каналов, очередей сообщений POSIX или обмен сообщениями QNX/Neutrino для обеспечения доставки данных или управляющей информации — я смогу использовать базовые примитивы синхронизации потоков на уровне ядра. Другим преимуществом является то, что при запуске системы, состоящей из одного процесса (с тремя потоками), я могу быть уверен, что все, что мне понадобится далее, уже загружено с носителя (то есть потом не выяснится что-то типа «Опа! А нужного-то драйвера на диске и нету...») И, наконец, Система 3 также, скорее всего, будет наиболее компактной, потому что не придется использовать три отдельных копии информации, характерной для процессов (например, дескрипторы файлов).
Мораль: знайте, какое решение сулит какие выгоды и какие потери, и применяйте то, что будет оптимальным для вашего конкретного проекта.
Дополнительно о синхронизации
Мы уже обсудили:
• мутексы;
• семафоры;
• барьеры.
Давайте теперь завершим нашу дискуссию о синхронизации, обсудив следующее:
• блокировки чтения/записи (reader/writer locks);
• ждущие блокировки (sleepons);
• условные переменные (condition variables);
• дополнительные сервисы QNX/QNX/Neutrino.
Блокировки чтения/записи применяются точно в соответствии с их названием: несколько «читателей» могут использовать ресурс в отсутствие «писателей», или один «писатель» может использовать ресурс в отсутствие «читателей» и других «писателей».
Эта ситуация возникает достаточно часто для того, чтобы создать отдельный примитив синхронизации специально для этих целей.
У вас будет часто возникать ситуация разделения структуры данных группой потоков. Очевидно, что в любой момент времени только один поток может записывать данные в эту структуру. Если бы запись велась более чем одним потоком одновременно, одни потоки могли бы записать свои данные поверх данных других потоков. Для предотвращения таких ситуаций поток-«писатель» должен эксклюзивно получить блокировку чтения/записи («rwlock»), обозначив этим, что он и только он имеет доступ к структуре данных. Заметьте, что это исключительное право доступа «строго контролируется на добровольных началах» — обеспечение того, чтобы все потоки, которые пользуются указанной областью данных, синхронизировались с использованием блокировок чтения/ записи, зависит только от вас.
С «читателями» ситуация противоположная. Поскольку считывание области данных — неразрушающая операция, любое число потоков может считывать данные (даже если ту же часть данных в этот момент считывает другой поток). Сложным моментом здесь является то, что никто не должен производить запись в область данных, из которой в этот момент ведется чтение. В противном случае, считывающие потоки могут быть «введены в заблуждение» — например, поток мог бы считать часть данных, затем быть вытесненным потоком-«писателем» затем возобновиться и продолжить считывание данных, но уже обновленных! Это может закончиться нарушением целостности данных.
Давайте рассмотрим вызовы, которые вы могли бы использовать при применении блокировок чтения/записи.
Первые два вызова используются для инициализации внутренних областей памяти для rwlock-блокировок (чтения/записи):
int pthread_rwlock_init(pthread_rwlock_t *lock,
const pthread_rwlockattr_t *attr);