

Рис. 7.3. Если между предком и потомком необходимо организовать двунаправленное соединение, создайте дополнительный канал
Глава 7. Распределение нагрузки: многозадачность |
151 |
Ⱦɚɧɧɚɹ ɜɟɪɫɢɹ ɤɧɢɝɢ ɜɵɩɭɳɟɧɚ ɷɥɟɤɬɪɨɧɧɵɦ ɢɡɞɚɬɟɥɶɫɬɜɨɦ %RRNV VKRS Ɋɚɫɩɪɨɫɬɪɚɧɟɧɢɟ ɩɪɨɞɚɠɚ ɩɟɪɟɡɚɩɢɫɶ ɞɚɧɧɨɣ ɤɧɢɝɢ ɢɥɢ ɟɟ ɱɚɫɬɟɣ ɁȺɉɊȿɓȿɇɕ Ɉ ɜɫɟɯ ɧɚɪɭɲɟɧɢɹɯ ɩɪɨɫɶɛɚ ɫɨɨɛɳɚɬɶ ɩɨ ɚɞɪɟɫɭ piracy@books-shop.com
Сигнализация о завершении
В покере есть несколько прямых управляющих команд. В любой момент игрок может сказать "пас" (выйти из текущей игры). Если бы игра происходила на Ди ком Западе, обманывающий игрок мог подвергнуться внезапному исключению из игры. В любом случае брошенные карты забирает сдающий. Точно так же про цесс с помощью сигнала может уведомить программу о своем завершении.
Любое задание в программе должно обрабатывать все полезные сигналы. Всего существует около 30 ти сигналов (два сигнала определяются пользователем). Большинство из них можно игнорировать или не перехватывать, поскольку веро ятность их возникновения ничтожно мала или их обработка не представляет осо бого смысла. Многие сигналы, будучи не перехваченными, приводят к заверше нию программы, а некоторые из этих сигналов важны в многозадачных програм мах. Например, операционная система уведомляет родительскую программу о завершении задания потомка, посылая сигнал SIGCHLD.
Сигнал напоминает запрос на аппаратное прерывание. О нем известно только то, что он произошел. Когда появляется сигнал, задание прекращает выполнять свои действия и переходит к специальной подпрограмме (называемой обработчи! ком сигналов). При получении сигнала SIGCHLD можно вызвать функцию wait(), чтобы провести дополнительную очистку после завершения потомка. Можно пе рехватывать и другие сигналы, в частности, те, которые возникают в случае мате матических ошибок или после нажатия клавиш <Ctrl+C>.
В Linux поддерживаются сигналы двух разновидностей: в стиле System V (однократный сигнал, который возвращается стандартному обработчику, когда система вызывает пользовательский обработчик) и в стиле BSD (посылается об работчику до тех пор, пока не будет явно остановлен). Если сигнал посылается с помощью системного вызова signal(), он будет однократным. Но на это можно не обращать внимания: следует ожидать, что сигнал будет перехватываться мно гократно.
Сброссигнала
Для сброса сигнала некоторые программисты помещают вызов системного обработчика сигна лов непосредственно в тело собственного обработчика. К сожалению, это может привести к возникновению такого состояния, как "гонка", когда сигнал приходит раньше, чем вызывается сбрасывающая его функция.
Вместо функции signal() лучше пользоваться системным вызовом sigaction(), который позволяет лучше контролировать поведение сигнальной подсистемы. Прототип этой функции таков:
iinclude <signal.h>
int sigaction(int sig_num, const struct sigaction *action, const struct sigaction *old);
Первый параметр определяет номер перехватываемого сигнала. Второй пара метр задает способ обработки сигнала. Если последний параметр не равен NULL, будет запомнено последнее выполненное действие. Ниже приведено определение структуры sigaction:
152 |
Часть П. Создание серверных приложений |
www.books-shop.com
struct sigaction
{
/* Указатель на функцию обработки */ void (*sa_handler)(int signum);
/* Специальная функция обратного вызова */ void (*sa_sigaction)(int, siginfo t *, void *);
/* Массив битовых флагов, указывающих, какие сигналы следует игнорировать, находясь в теле обработчика */
sigset_t sajnask;
/* Выполняемое действие */ int sa_flags;
/* (больше не используется должно быть равно 0) */ void (* sa_restorer)(void);
};
Для активизации третьего параметра необходимо, чтобы первое поле (sa_handler) отличалось от указателя на функцию во втором параметре. Если по местить в это поле константу SIG_IGN, программа проигнорирует указанный сиг нал, а если задать константу SIG_DFL, будет восстановлена стандартная процедура обработки.
Чтобы разрешить или запретить каскадирование сигналов (один сигнал пре рывает другой, вследствие чего возникает цепочка вызовов обработчиков), вос пользуйтесь третьим полем структуры, sa mask. Каждый бит (всего их 1024) обо значает разрешение (1) или запрет (0) обработки сигнала. По умолчанию обра ботчик сигнала игнорирует другие аналогичные сигналы. Например, если обрабатывается сигнал SIGCHLD и в это же время завершается другой процесс, по вторный сигнал будет проигнорирован. Подобный режим можно изменить с по мощью флага SA_NOMASK.
Поле sa_flags содержит набор флагов, определяющих поведение обработчика.
Вбольшинстве случаев это поле можно задать равным нулю.
•SA_ONESHOT. Режим System V: сброс сигнала после того, как он перехва
чен.
•SA_RESETHAND. To же, ЧТО И SA_ONESHOT.
•SA_RESTART. Повторный запуск некоторых системных функций, если сиг нал прервал их выполнение. Это позволяет восстанавливать работу таких функций, как, например, accept().
•SA_NOMASK. Разрешить обработку того же самого сигнала во время обра ботки более раннего сигнала.
• SA_NODEFER. То же, что И SA_NOMASK.
• SA_NOCLDSTOP. He уведомлять родительскую программу о прекращении работы дочернего задания (сигнал SIGSTOP, SIGTSTP, SIGTTIN или SIGTTOU). Этот флаг важен для данной главы.
В листинге 7.12 демонстрируется, как перехватывать сигнал SIGFPE (исключительная ситуация в операции с плавающей запятой) и игнорировать сигнал SIGINT (запрос на прерывание от клавиатуры).
Глава 7. Распределение нагрузки: многозадачность |
153 |
www.books-shop.com
Листинг7.12.ОбработчиксигналовSIGFPEиSIGINT
/*** Перехват сигнала SIGFPE и игнорирование сигнала SIGINT ***/
#include<signal.h>
/* Определение обработчика сигналов */ void sig_catcher(int sig)
printf("I caught signal #%d\n", sig);
int main(void)
{ struct sigaction act;
bzero(&act, sizeof(act)); |
|
act.sa_handler = sig_catcher; |
|
sigaction(SIGFPE, act, 0); |
/* перехватываем ошибку в |
|
операции с плавающей запятой */ |
act.sa_handler = SIG_IGN; |
/* игнорируем сигнал */ |
signal(SIGINT,&act,0); |
/* игнорируем сигнал ЛС */ |
/*— тело программы —*/ |
|
}
Потеря сигналов в обработчиках
Если обработчик сигналов выполняется слишком долго, программа может потерять сигналы, ожидающие обработки. В очереди сигналов только одна позиция — когда приходят два сигнала, записывается только один из них. Поэтому старайтесь минимизировать время, затрачиваемое на обработку сигналов.
Серверы и клиенты могут принимать несколько различных сигналов. Чтобы сделать программу более отказоустойчивой, следует обрабатывать все сигналы, от которых потенциально зависит работа программы. (Некоторые сигналы, напри мер SIGFAULT, лучше всего не трогать. Данный сигнал свидетельствует о наличии ошибки в тексте программы или в ее данных. Такую ошибку нельзя исправить.)
Уменьшение текста программы за счет совместного использования обработчиков сигналов
Можно объединить несколько обработчиков сигналов в одной подпрограмме. Распределение обязанностей несложно организовать внутри подпрограммы, так как система передает ей номер сигнала.
Дочернему заданию можно послать любой сигнал. Из командной строки это можно сделать с помощью команды kill. В программе доступен системный вызов kill(). Его прототип выглядит следующим образом:
154 |
Часть II. Создание серверных приложений |
www.books-shop.com

#include <sys/types.h> #include <signal.h>
int kill(pid_t PID, int sig_num);
Детальное описание параметров этой функции можно найти в интерактивном справочном руководстве. По сути, программа вызывает функцию kill(), указывая идентификатор задания и сигнал, который следует ему послать.
Получение данных от потомка
Вернемся к игре в покер. Когда игра заканчивается или последний из оппо нентов говорит "пас", сдающий должен собрать колоду и заново ее перетасовать. Некоторые игроки могут потребовать посмотреть карты победителя, прежде чем он заберет себе выигрыш. Точно так же родительская программа должна прове рять результаты завершения каждого из потомков.
По коду завершения программа может определить, успешно ли выполнилось дочернее задание. Когда процесс завершается, он всегда возвращает целое число со знаком. В то же время поток может вернуть абстрактный объект типа void*. В этом случае необходимо соблюдать осторожность, чтобы не произошло потери данных. Не следует возвращать объект, созданный на основании значений стеко вых переменных. Лучше передавать данные через кучу или глобальную перемен ную.
Обгоняя время: исключающие
семафоры и гонки
Сила, заключенная в потоках, очень привлекательна. Если правильно управ лять ими, можно заставить программу выполняться быстрее и реже "зависать". Тем не менее есть один подводный камень — соперничество за право обладания ресурсами. Когда два потока одновременно обновляют одни и те же данные, они почти наверняка будут повреждены. Отлаживать такие потоки можно часами. Ниже рассматриваются вопросы, связанные с одновременным доступом к ресур сам.
Гонки за ресурсами
Возможно, вам знакомо состояние гонки, в котором оказываются два потока, пытающиеся сохранить свои данные. Ранее в этой главе рассматривался пример, когда гонка возникала при сбросе сигнала. Критической секцией называется раз дел программы, где происходит "столкновение" потоков. Рассмотрим листинги 7.13 и 7.14, предполагая, что оба потока выполняются одновременно.
Листинг 7.13. Состояние гонки в потоке 1
/****************************************************************/
/*** |
Пример гонки, в которой два потока соперничают |
***/ |
/*** |
за право доступа к массиву queue |
***/ |
Глава 7. Распределение нагрузки: многозадачность |
155 |
www.books-shop.com
int queue[lO]; |
|
|
int in, out, empty; |
|
|
/************** поток 1 **************/ |
|
|
/* Чтение данных из очереди */ |
|
|
if ( |
!empty ) /* избегаем чтения пустой очереди */ |
|
{ |
int val = queue[out]; |
|
out++; |
|
|
if ( out >= sizeof(queue) ) |
|
|
|
out = 0; /* начинаем заново */ |
|
empty = (out == in); |
|
|
} |
|
|
Листинг 7.14. Состояние гонки в потоке 2 |
|
|
/*** |
Пример гонки, в которой два потока соперничают |
***/ |
/*** |
за право доступа к массиву queue |
***./ |
int queue[10];
int in, out, empty;
/************** поток 2 **************/
/* Запись данных в очередь */
if ( Jempty && out != in ) /* избегаем переполнения очереди */
{queue [in] = 50;
if ( in >= sizeof (queue) )
in = 0; /* начинаем заново */
empty = (out == in);
}
Обратите внимание на то, что приращение индексных переменных происходит после обращения к очереди. Все кажется правильным, если предположить, что потоки выполняются параллельно, строка за строкой. К сожалению, это происхо дит очень редко. Что если первый поток отстает от второго на несколько строк? Поток 2 мог выполнить проверку переменной empty как раз перед тем, как по ток 1 сбросил значение переменной out. Возникнет проблема, так как перемен ная out никогда не станет равной переменной in.
Другая проблема возникнет в случае переключения между потоками (если они выполняются не строго параллельно, а в многозадачной среде). Переключение заданий может произойти сразу после завершения операции out++ в потоке 1. При этом существует вероятность, что поток 2 получит неправильные значения переменных out и empty, так как не все проверки были завершены.
В этом примере две подпрограммы соперничают за четыре ресурса: перемен ные queue, in, out и empty.
Исключающий семафор
Работать с критическими секциями можно, блокируя другие процессы при об ращении к ним. Подобный процесс называется взаимным исключением (кто пер
156 |
Часть Ц. Создание серверных приложений |
www.books-shop.com
вый захватил ресурс, тот блокирует остальных) или сериализацией (разрешение одновременного доступа к ресурсу только для одного задания). Эта методика по зволяет предотвращать повреждение данных в критических секциях. Исключаю! щий семафор — это флаг, разрешающий или запрещающий монопольный доступ к ресурсу. Если флаг сброшен (семафор опущен), поток может войти в критиче скую секцию. Если флаг установлен (семафор поднят), поток блокируется до тех пор, пока доступ не будет разрешен.
Существуют две методики блокировки: грубая и точная. В первом случае, ко гда программа входит в критическую секцию, блокируются все остальные выпол няемые задания. При этом может отключаться режим квантований времени. С данной методикой связаны две большие проблемы: во первых, блокируются за дания, не относящиеся к текущей программе, и, во вторых, она не поддержива ется в многопроцессорных системах.
Точная блокировка применяется по отношению к ресурсам, а не заданиям. Поток запрашивает доступ к общему ресурсу. Если он не используется, поток за хватывает его в свое распоряжение. Если оказывается, что ресурс уже зарезерви рован, поток блокируется, ожидая освобождения ресурса.
В библиотеке Pthreads имеется множество средств управления потоками. Су ществуют также функции работы с исключающими семафорами. Использовать их очень просто (листинг 7.15).
Листинг 7.15. Пример исключающего семафора
/****************************************************************/
/*** Создание глобального исключающего семафора ***/
/г***************************************************************/ pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
/* Начало критической секции */ pthread_mutex_lock(&mutex);
/*— Работа с критическими данными —*/
pthread_mutex_unlock(&mutex); /* Конец критической секции */
Полный текст этого примера содержится на Web узле в файле thread mutex.c. Параметр mutex является семафором, блокирующим доступ к секции. Он может быть инициализирован тремя способами.
•Быстрый (по умолчанию) — PTHREAD_MUTEX INITIALIZER. Выполняется простая проверка наличия блокировки. Если один и тот же поток попы тается дважды заблокировать исключающий семафор, возникнет тупи ковая ситуация (взаимоблокировка).
•Рекурсивный — PTHREAD_RECURSIVE_MUTEX_INITIALIZER_NP. Проверяется, не блокирует ли владелец повторно тот же самый исключающий семафор. Если это так, включается счетчик (считающий семафор), определяющий число блокировок. Исключающий семафор должен быть разблокирован столько раз, сколько было сделано блокировок.
Глава 7. Распределение нагрузки: многозадачность |
157 |
www.books-shop.com
• С проверкой ошибок — PTHREAD_ERRORCHECK_MUTEX_INITIALIZER_NP. Прове ряется, тот ли самый поток пытается разблокировать исключающий се мафор, что и поток, который заблокировал его. Если это другой поток, возвращается ошибка и блокировка не снимается.
В библиотеке имеется дополнительная функция pthread_mutex_trylock(), кото рая запрашивает блокировку семафора. Если она невозможна, возвращается ошибка EBUSY.
Как избежать проблем с критическими секциями
Старайтесь, чтобы в критической секции выполнялись операции лишь над нужными ресурсами. В частности, не следует вызывать функции ввода вывода или манипулировать другими данными, если только этого совершенно невозможно избежать. При необходимости можно скопировать данные в локальные переменные и обработать их за пределами секции. Помните, что включение операций ввода вывода в критические секции может привести к возникновению взаимоблокировок.
Проблемы с исключающими семафорами в
библиотеке Pthreads
При работе с библиотекой Pthreads следует помнить о некоторых ограничени ях. Во первых, исключающий семафор не содержит ссылку на блокируемую об ласть памяти. Он представляет собой всего лишь флаг. Поэтому существует воз можность, что два разных потока используют один и тот же семафор для блоки рования несвязанных данных. Это не опасно — не произойдет ни повреждения данных, ни взаимоблокировки. Просто можно блокировать поток тогда, когда в этом нет никакой необходимости.
Во вторых, рассмотрим ситуацию, когда несколько потоков работают с боль шим массивом данных, например с таблицей. Ее ячейки не связаны друг с дру гом, и потоки работают в разных сегментах таблицы. Необходимо блокировать не всю таблицу, а лишь отдельные ее секции (зонная блокировка). Библиотека Pthreads не позволяет определить, в каком месте ресурса работает поток.
Наконец, может потребоваться определить приоритеты доступа. В некоторых случаях вполне допускается одновременное чтение данных несколькими потока ми (нежесткая блокировка). Но ни один из потоков не может осуществлять за пись данных. В библиотеке Pthreads возможен только монопольный доступ — вы либо владеете ресурсом, либо нет.
Предотвращение взаимоблокировки
Представьте двух детей, играющих одними и теми же игрушками. Каждый ре бенок видит игрушку другого и хочет ее, но не желает отдавать свою. В програм мировании это называется взаимоблокировкой.
Создавая потоки, обязательно выявляйте критические секции и возможные конфликты ресурсов. Обнаружив критические данные, определите, кому и когда они могут понадобиться. Может оказаться, что два ресурса должны быть забло
158 |
Часть IL Создание серверных приложений |
www.books-shop.com
кированы, прежде чем работа продолжится. Если проявить невнимательность,
возникнет |
взаимоблокировка. |
Рассмотрим следующий пример. |
|
Поток 1 |
Поток 2 |
1.Блокирует семафор Funds_Mutex_1.
2.Блокирует семафор Funds_Mutex_2.
3.Используя семафор Funds_Mutex_2, изменяет семафор Funds_Mutex_1.
4.Разблокирует семафор Funds_Mutex_2.
5.Разблокирует семафор Funds_Mutex_1.
1.Блокирует семафор Funds_Mutex_2. 2. Блокирует семафор Funds_Mutex_1.
3.Используя семафор Funds_Mutex_1, изменяет семафор Funds_Mutex_2.
4.Разблокирует семафор Funds Mutex_2.
5.Разблокирует семафор Funds Mutex_1.
Взаимоблокировка в потоке 2 произойдет на втором этапе. Она возникает из за того, что оба потока ожидают ресурсов друг друга. Ниже перечислены правила, которые позволяют снизить вероятность возникновения взаимоблокировок:
•наименование ресурсов по группам — идентифицируйте взаимосвязан ные группы ресурсов и присвойте соответствующим исключающим се мафорам сходные имена (например, Funds_Mutex_l и Funds_Mutex_2);
•правильный порядок блокировки — блокируйте ресурсы по номерам от наименьшего к наибольшему;
•правильный порядок разблокирования — разблокируйте ресурсы по но мерам от наибольшего к наименьшему.
Если придерживаться этих правил, можно избежать утомительного процесса отладки, который требуется для поиска взаимоблокировки.
Управление дочерними заданиями и
задания зомби
Итак, мы создали несколько потоков и процессов. Всеми дочерними задания ми можно управлять. Вопрос в том, как это делать. Выше уже говорилось о том, что взаимодействовать с дочерними заданиями можно посредством сигналов, пе редаваемых данных и возвращаемых значений.
Приоритеты и планирование дочерних зада
ний
Можно понизить приоритет дочернего задания, чтобы другие задания получи ли больше процессорного времени. Для этого предназначены системные вызовы getpriority() и setpriority() (они доступны только заданиям, имеющим приви легии пользователя root).
В отличие от процессов, практически не имеющих контроля над своими до черними заданиями, для потоков можно изменять алгоритм планирования, а так
Глава 7. Распределение нагрузки: многозадачность |
159 |
www.books-shop.com
же отказываться от владения ими. В Linux применяется алгоритм приоритетного кругового обслуживания. В библиотеке Pthreads поддерживаются три алгоритма:
•обычный — аналогичен алгоритму планирования в Linux (принят по умолчанию);
•круговой — планировщик игнорирует значение приоритета, и каждый поток получает свою долю времени, пока не завершится (этот алгоритм применяется в системах реального времени);
•FIFO — планировщик помешает каждый поток в очередь и выполняет его до тех пор, пока он не завершится (этот алгоритм также применяет ся в системах реального времени).
Уничтожение зомби: очистка после заверше
ния
Возможно, от внимания читателей ускользнул тот факт, что в некоторых из рассмотренных примеров могут появляться процессы зомби. Это не триллер "Хеллоуин", а настоящий кошмар для любого системного администратора. Появившись в таблице процессов, зомби не исчезают вплоть до перезагрузки компьютера.
Если вы любите риск и не знаете, что такое зомби, попробуйте создать его в своей системе. Не делайте этого, если не имеете возможности перегрузиться. Зом би не причиняют вреда системе, но занимают ценные ресурсы (память и место в таблице процессов). Запустите многозадачный эхо сервер (текст имеется на Web узле), подключитесь к нему и введите команду "bye". Затем закройте соединение. Теперь введите команду ps aux | grep <имя пользователя> (заменив пара метр своим пользовательским именем). В полученном списке будет присутство вать задание, имеющее статус Z (зомби). Обычно его можно уничтожить, уничто жив предка (эхо сервер).
Когда процесс завершается, он возвращает целочисленный код. Это значение сигнализирует об успешном окончании работы или о наличии ошибки. Обычно родительское задание дожидается завершения потомка с помощью системного вызова wait(). Данная функция принимает от потомка код завершения и передает его родительской программе. Если забыть вызвать эту функцию, дочерний про цесс перейдет в режим бесконечного ожидания.
Появление зомби
Предок должен заботиться обо всех своих потомках. Но он не всегда это делает. Если родитель ский процесс завершился, оставив после себя дочерние процессы, ждущие подтверждения о за вершении, управление над ними принимает программа init. В ее обязанности входит планиро вание, выполнение и завершение процессов, однако она не всегда справляется с последней ча стью задачи. В этом случае в таблице процессов появляются:зомби. Их нельзя удалить даже с помощью команды kill. (Можно попробовать выполнить команду init s или init 1 для очи стки таблицы процессов, но нет гарантии, что она будет работать.)
В отличие от процессов, создаваемых с помощью системного вызова fork() или _clone(), библиотека Pthreads позволяет отказаться от владения потоком
(отсоединить его). Отсоединив поток, можно продолжить выполнение програм
160 |
Часть //. Создание серверных приложений |
www.books-shop.com