Использование встроенных страниц справки

В Linux существует встроенная система справки, доступная из консоли. Справка содержит страницы руководства по консольным командам, функциям языков программирования, установленным в системе, основным программам и т.д. Как правило, любая программа, предполагающая работу с консолью, инсталлирует свои страницы помощи в формате этой справочной системы.

Получить доступ к справочной системе можно при помощи команды man. В качестве параметра manтребует ключевое слово, для которого запрашивается справка, например:

man gdb

Данный запрос выведет на экран страницы помощи по консольному отладчику gdb. Многие man-руководства насчитывают сотни страниц с подробным описанием всех возможностей конкретных программ. Выход из справочной системы осуществляется посредством клавиши ‘q’.

Однако бывают случаи, когда несколько разделов справки именуются одинаковым образом. К примеру, time – это и команда shell, и библиотечная функция языка Си. Для разрешения подобных конфликтов страницы man разбиты на несколько разделов. На самих man-страницах можно встретить ссылки на другие команды и функции. Как правило, ссылки оформляются в виде команда(раздел). Например, ссылка на команду time будет выглядеть так: time(1). При наличии нескольких одинаково именованных разделов справки, необходимо явно указать команде man раздел, в котором следует искать справочную информацию:

man 1 time

Таким образом, первым параметром идет номер раздела, вторым – команда, для которой запрашивается помощь.

Страницы помощи в Linux обычно разбиваются на следующие разделы:

 

Команды Команды, которые могут быть запущены пользователем из оболочки
Системные вызовы Функции, исполняемые ядром
Библиотечные вызовы Большинство функций libc, таких, как qsort(3)
Специальные файлы Файлы, находящиеся в /dev
Форматы файлов Формат файла /etc/passwd и подобных ему легко читаемых файлов
Игры  
Макропакеты Описание стандартной "раскладки" файловой системы, сетевых протоколов, кодов ASCII и других таблиц, данной страницы документации и др.
Команды управления системой Команды типа mount(8), которые может использовать только root
Процедуры ядра Это устаревший раздел руководства. В то время, когда появилась идея держать документацию о ядре Linux в этом разделе, она была неполной и, по большей части, устаревшей. Существуют значительно более удобные источники информации для разработчиков ядра

 

 

Понятие процессов

 

Процесс – одно из средств организации параллельных вычислений в современных ОС. Под процессом обычно понимают выполняющуюся программу и все ее элементы, включая адресное пространство, глобальные переменные, регистры, стек, открытые файлы и т.д.

Параллельные вычисления подразумевают одновременное исполнение нескольких процессов. Для однопроцессорных систем характерно применение т.н. псевдопараллельного режима, когда операционная система последовательно выделяет для каждого процессора определенное количество квантов процессорного времени, тем самым достигается тот же эффект, что и при одновременной работе процессов.

Для управления процессом, обеспечения синхронизации и взаимодействия нескольких параллельных процессов необходимо знать, как идентифицируется процесс в операционной системе. Идентификатором процесса служит уникальный номер (pid), определяющий его положение в системных таблицах.

Ниже рассмотрим процедуры создания и завершения новых процессов в Linux и Windows.

 

Linux

Новый процесс в Linux создается при помощи системного вызова fork(). Данный вызов создает дубликат процесса-родителя. Выполнение процесса-потомка начинается с оператора, следующего после fork(). В случае успешного выполнения операции функция fork() возвращает родительскому процессу идентификатор созданного процесса-потомка, а самому процессу-потомку – 0; в случае ошибки функция возвращает -1.

Классический пример создания процесса:

pid = fork();

switch( pid ) {

-1: …; // Ошибка

0: …; // Дочерний процесс

default: …; // Родительский процесс

}

Распараллеливание вычислений при помощи дублирования текущего процесса не всегда эффективно с точки зрения программирования. Для запуска произвольных исполняемых файлов существует семейство функций exec. Все функции этого семейства (execv, execve, execl и т.д.) замещают текущий процесс новым образом, загружаемым из указанного исполняемого файла. По этой причине exec-функции не возвращают никакого значения, кроме случая, когда при их запуске происходит ошибка.

Наиболее распространенная схема создания нового процесса в Unix – совместное использование fork() и exec-функций. При этом дубликат родительского процесса, создаваемый fork(), замещается новым модулем.

При выборе конкретной функции необходимо учитывать следующее:

1) Функции, содержащие в названии литеру ‘p’ (execvp и execlp), принимают в качестве аргумента имя запускаемого файла и ищут его в прописанном в окружении процесса пути. Функции без этой литеры нуждаются в указании полного пути к исполняемому файлу.

2) Функции с литерой ‘v’ (execv, execve и execvp) принимают список аргументов как null-терминированный список указателей на строки. Функции с литерой ‘l’ (execl, execle и execlp) принимают этот список, используя механизм указания произвольного числа переменных языка C.

3) Функции с литерой ‘e’ (execve и execle) принимают дополнительный аргумент, массив переменных окружения. Он представляет собой null-терминированный массив указателей на строки, каждая из которых должна представлять собой запись вида “VARIABLE=value”.

Процесс завершается по окончании работы основного потока (main) либо после системного вызова exit().

Получить идентификатор текущего процесса можно при помощи функции getpid(). Идентификатор родительского процесса возвращается функцией getppid().

Родительский процесс должен явно дождаться завершения дочернего при помощи функций wait() или waitpid(). Если родительский процесс завершился раньше дочернего, не вызвав wait(), новым родителем всем его потомкам назначается init, корневой процесс ОС Unix. Если же дочерний процесс завершился, а процесс-родитель не вызвал какую-либо из вышеназванных функций, дочерний процесс становится т.н. «зомби»-процессом. По завершении родительского процесса процессы-«зомби» не могут быть унаследованы, т.к. уже завершены. Несмотря на то, что они ничего не выполняют, они явно присутствуют в системе. Поэтому хорошим тоном в программировании считается контроль жизненного цикла дочерних процессов из родительского, когда разработчик не перекладывает эту задачу на систему.

 

Windows

В Windows создание процессов любого типа реализуется при помощи системной функции CreateProcess(). Функция принимает следующие аргументы:

pszApplicationName – имя исполняемого файла, который должен быть запущен;

pszCommandLine – командная строка, передаваемая новому процессу;

psaProcess – атрибуты защиты процесса;

psaThread – атрибуты защиты потока;

bInheritHandles – признак наследования дескрипторов;

fdwCreate – флаг, определяющий тип создаваемого процесса;

pvEnvironment – указатель на блок памяти, содержащий переменные окружения;

pszCurDir – рабочий каталог нового процесса;

psiStartupInfo – параметры окна нового процесса. Элементы этой структуры должны быть обнулены перед вызовом CreateProcess(), если им не присваивается специальное значение. Поле cb должно быть проинициализировано размером структуры в байтах.

ppiProcInfo – указатель на структуру PROCESS_INFORMATION, в которую будут записаны идентификаторы и дескрипторы нового процесса и основного его потока.

Существует четыре способа явно завершить процесс:

· входная функция первичного потока возвращает управление;

· один из потоков процесса вызывает ExitProcess();

· любой поток любого процесса вызывает TerminateProcess();

· все потоки процесса завершаются.

В Windows нет понятия «зомби»-процесса. Однако в ряде случаев необходимо дождаться окончания выполнения процесса. Делается это при помощи функции WaitForSingleObject() либо же WaitForMultipleObjects().

 

Задание

Задание выполняется в двух вариантах: под Linux и Windows. Необходимо разработать консольное приложение, в котором базовый процесс порождает дочерний. Предусмотреть для каждого процесса свою область вывода, в которую выводится текущее системное время. Под Linux использовать библиотеку ncurses.

 

Лабораторная работа №2

Синхронизация процессов

Цель работы: научиться организовывать синхронизацию нескольких параллельно выполняющихся процессов.

 

При разработке программ, использующих несколько параллельно выполняющихся процессов, могут возникать ситуации, требующие синхронизации вычислений. К примеру, существует понятие состояния гонки (race condition) – в случае ее возникновения корректность работы системы зависит от того, в каком порядке выполняются процессы при доступе в критическую область. Это означает, что должна соблюдаться строгая последовательность выполнения операций. Такое возможно, когда, к примеру, один процесс должен подготовить ресурс к использованию другим процессом.

Иногда процессы одновременно используют некий общий ресурс, доступ к которому не должен перекрываться (например, два процесса выполняют посимвольный вывод текстовых строк в общий файл). В таком случае перед выполнением критической операции процесс должен заблокировать доступ к ресурсу до ее окончания. Только после этого другой процесс может получить доступ к этому ресурсу.

Как правило, для синхронизации процессов используют различные индикаторы занятости ресурса. В общем случае процедура синхронизации состоит из следующих шагов:

· проверить доступность ресурса;

· если ресурс доступен, заблокировать его. В противном случае подождать до разблокирования его другим процессом;

· выполнить необходимую операцию;

· разблокировать ресурс.

Как в Linux, так и в Windows существует большое количество способов синхронизации процессов. Мы рассмотрим по два способа для каждой ОС.

 

Linux

 

Наиболее распространенными инструментами синхронизации в Linux являются сигналы и семафоры.

 

Сигналы

Сигналы можно интерпретировать как программные прерывания. При получении сигнала выполнение текущей операции прекращается и вызывается функция-обработчик данного сигнала. Сигналы традиционно делятся на ненадежные и надежные. «Ненадежность» сигнала заключается в том, что в некоторых ситуациях процесс может не получить сгенерированный сигнал. Кроме этого, возможности процессов по управлению ненадежными сигналами довольно ограничены.

Одна из проблем ненадежных сигналов заключается в том, что обработчик сигнала сбрасывается в значение по умолчанию всякий раз, как срабатывает сигнал. Пример использования ненадежных сигналов:

int sig_int(); /* Прототип функции-обработчика сигнала */

...

signal(SIGINT, sig_int); /* установка обработчика на сигнал */

...

sig_int()

{

signal(SIGINT, sig_int); /* восстановить обработчик */

... /* обработать сигнал... */

}

 

Данный фрагмент кода будет работать корректно в большинстве случаев, однако возможны ситуации, когда второй сигнал придет в промежуток времени между срабатыванием функции-обработчика и восстановлением обработчика посредством вызова signal. В таком случае будет вызван обработчик по умолчанию, что для SIGINT означает завершение процесса.

Для установки обработчика сигнала в ненадежной модели используется следующая функция:

#include <signal.h>

void (*signal(int signo, void (*func)(int)))(int);

В качестве параметров функция signal принимает номер сигнала (допустимы мнемонические макроопределения SIGINT, SIGUSR1 и т.д.) и указатель на функцию-обработчик, которая должна принимать значение типа int (номер сигнала) и не возвращать ничего. Функция signal возвращает указатель на предыдущий обработчик данного сигнала. В качестве второго параметра signal можно указать константы SIG_IGN (игнорировать данный сигнал) или SIG_DFL (установить значение по умолчанию).

Надежная семантика сигналов подразумевает, что ядро ОС гарантирует, что любой сигнал будет доставлен процессу. При этом процесс имеет возможность указать, какие сигналы он будет перехватывать, а какие игнорировать, т.е. составить т.н. маску сигналов.

Установка обработчика в надежной модели производится следующей функцией:

 

#include <signal.h>

int sigaction(int signo, const struct sigaction *restrict act, struct sigaction *restrict oact);

Данная функция может как устанавливать новый обработчик, так и возвращать старый. Если параметр act не равен NULL, устанавливается новая реакция на сигнал. Если параметр oact не равен NULL, в этот указатель возвращается старый обработчик. Параметр signo – номер сигнала – аналогичен таковому в функции signal.

В случае неудачной операции sigaction возвращает -1, в противном случае – 0.

Рассмотрим теперь структуру sigaction:

 

struct sigaction {

void (*sa_handler)(int);

sigset_t sa_mask;

int sa_flags;

void (*sa_sigaction)(int, siginfo_t *, void *);

};

 

Эта структура содержит следующие поля:

· sa_handler – указатель на функцию-обработчик. Может принимать значения SIG_IGN и SIG_DFL;

· sa_mask – дополнительная маска игнорируемых сигналов, которая добавляется к существующей, прежде чем будет вызвана функция-обработчик сигнала. После возврата обработчиком управления маска возвращается в предыдущее состояние;

· sa_flags – флаги, отвечающие за обработку сигнала. Обычно этот параметр устанавливается в 0;

· sa_sigaction – альтернативный обработчик сигналов. Используется, если выставлен флаг SA_SIGINFO.

 

Для генерации сигналов используются две функции: kill() и raise(). Первая служит для отправки сигнала произвольному процессу, вторая – текущему, т.е. самому себе. Синтаксис их вызова таков:

 

#include <signal.h>

int kill(pid_t pid, int signo);

int raise(int signo);

 

Здесь pid – идентификатор процесса, которому отправляется сигнал, а signo – номер сигнала.

 

Семафоры

Сигналы инициируют срабатывание функций-обработчиков в нужное время, что позволяет реализовать произвольную логику взаимодействия процессов. Семафоры – инструмент, разработанный исключительно для реализации механизма критических секций в межпроцессном взаимодействии (IPC, Inter Process Communication).

Семафор представляет собой глобальный системный счетчик, теоретически доступный всем процессам. При этом операции выполняются не над одним семафором, а над набором семафоров (semaphore set).

Создать набор семафоров можно следующей функцией:

 

#include <sys/sem.h>

int semget(key_t key, int nsems, int flag);

 

Данная функция принимает следующие параметры:

· key – уникальный ключ, идентифицирующий набор семафоров. Имеет тип длинное целое (long int) и обычно генерируется при помощью функции ftok();

· nsems – количество семафоров в наборе;

· flag – флаг, устанавливающий права доступа к набору семафоров.

 

Функция возвращает идентификатор набора семафоров в случае успешной операции и – 1 при ошибке. При этом данная функция может как создавать новый набор семафоров (flag должен содержать значение IPC_CREAT), так и получать доступ к уже существующему. Для открытия существующего набора семафоров необходимо указать ключ key, идентифицирующий созданный ранее набор, при этом параметр nsems обычно устанавливается в 0.

Операции с семафорами производятся при помощи двух основных функций: semop() и semctl().

Функция semop() работает следующим образом:

 

#include <sys/sem.h>

int semop(int semid, struct sembuf semoparray[], size_t nops);

 

Здесь semid – идентификатор набора семафоров, над которым производится операция; semoparray – массив структур типа sembuf, описывающих конкретные операции (если операция одна, массив представляет собой указатель на структуру); nops – количество операций (элементов в массиве).

 

Рассмотрим структуру sembuf:

struct sembuf {

unsigned short sem_num;

short sem_op;

short sem_flg;

};

 

Здесь sem_num – номер семафора в наборе, над которым производится операция (начиная с нуля), sem_op – сама операция. При этом действие этого параметра зависит от принимаемого им значения:

· sem_op > 0: значение параметра прибавляется к текущему значению счетчика семафора;

· sem_op = 0: операция «дождаться нуля». Процесс переводится в состояние ожидания, пока значение семафора не станет равным нулю;

· sem_op < 0: модуль значения параметра отнимается от текущего значения счетчика семафора, если при этом результат не становится меньше нуля. В противном случае процесс переводится в состояние ожидания, пока такая операция не станет возможна, т.е. пока значение счетчика не станет больше либо равным модулю sem_op.

При этом, если параметр sem_flg установлен в значение IPC_NOWAIT, процесс в состояние ожидания не переводится, а во всех соответствующих случаях функция semop() возвращает значение -1 и устанавливает переменную errno в значение EAGAIN.

Состояние ожидания процесса прерывается не только установкой семафора в соответствующее состояние, но также в следующих случаях:

· при удалении набора семафоров, вызвавших блокировку процесса;

· при получении процессом сигнала, на который установлен обработчик.

 

Функция semctl() предоставляет расширенный спектр операций над семафорами, позволяя получать статус семафора, получать и устанавливать значение счетчика семафора, удалять набор семафоров и т.д. В общем случае для решения задач межпроцессного взаимодействия достаточно функции semop(), а semctl() обычно используется для удаления набора семафоров, когда в них отпадает надобность. Делать это необходимо, потому что семафоры являются глобальными объектами ядра и сохраняются в системе и после завершения процесса.

 

Windows

 

Для синхронизации процессов в Windows обычно используются события и семафоры.

 

События

Один из самых распространенных механизмов межпроцессной синхронизации в Windows – события. Они не являются полным аналогом unix-сигналов, представляя собой бинарные флаги, имеющие два потенциальных состояния: сигнальное и несигнальное. Событие может быть одним из двух типов: со сбросом вручную и с автосбросом.

Для создания события используется функция CreateEvent():

HANDLE CreateEvent(PSECURITY_ATTRIBUTES psa, BOOL fManualReset, BOOL fInitialState, PCTSTR pszName);

Функция принимает следующие параметры:

· psa – атрибуты защиты;

· fManualReset – TRUE, если событие сбрасывается вручную, FALSE в противном случае;

· fInitialState – начальное состояние события (TRUE – сигнальное, FALSE - несигнальное);

· pszName – уникальное имя-идентификатор события.

 

Получить доступ к событию из другого процесса можно одним из следующих способов:

· вызвать функцию CreateEvent() с тем же именем, которое было присвоено событию первым процессом;

· унаследовать дескриптор;

· применить функцию DuplicateHandle();

· вызвать функцию OpenEvent() с указанием имени существующего события.

 

Управлять состоянием события позволяют функции: SetEvent() – переводит событие в сигнальное состояние, и ResetEvent() – в несигнальное. Для ожидания события используется функция WaitForSingleObject(). Если событие создано с флагом автосброса, то при успешном окончании ожидания событие автоматически переведется в несигнальное состояние.

 

Семафоры

Второй рассматриваемый тип синхронизирующих объектов в Windows – семафоры. Принципиальных различий между семафорами в Unix и Windows не так много. Основное различие для программиста заключается в инструментальных средствах управления семафорами.

В Windows семафор создается функцией CreateSemaphore():

 

HANDLE CreateSemaphore( PSECURITY_ATTRIBUTE psa, LONG lInitialCount, LONG lMaximumCount, PCTRTR pszName);

Функция принимает следующие параметры:

· psa – атрибуты защиты;

· fManualReset – TRUE, если событие сбрасывается вручную, FALSE – в противном случае;

· lInitialCount – начальное значение счетчика семафора;

· lMaxCount – максимальное значение счетчика семафора;

· pszName – уникальное имя-идентификатор события.

 

Очевидно, что в отличие от Linux, Windows оперирует не наборами, а отдельными семафорами. Унаследовать созданный семафор можно теми же методами, что и событие. Для открытия семафора можно использовать функцию OpenSemaphore().

При вызове функции ReleaseSemaphore() значение счетчика ресурсов увеличивается. При этом его можно изменить не только на 1, как это обычно делается, но и на любое другое значение.

Дождаться освобождения ресурса (ненулевого состояния семафора) можно при помощи функций WaitForSingleObject(), WaitForMultipleObjects(), MsgWaitForMultipleObjects(). При этом счетчик ресурсов автоматически будет уменьшен на единицу.

 

Задание

Задание выполняется в двух вариантах: под Linux и Windows. Необходимо разработать многопроцессное приложение. Исходный процесс является управляющим, принимает поток ввода с клавиатуры и контролирует дочерние процессы. По нажатию клавиши ‘+’ добавляется новый процесс, ‘-’ – удаляется последний добавленный, ‘q’ – программа завершается. Каждый дочерний процесс посимвольно выводит на экран в вечном цикле свою уникальную строку. При этом операция вывода строки должна быть атомарной, т.е. процесс вывода должен быть синхронизирован таким образом, чтобы строки на экране не перемешивались. В качестве метода синхронизации следует использовать сигналы/события.

 

 

Лабораторная работа №3

Взаимодействие процессов

 

Цель работы: научиться осуществлять передачу произвольных данных между параллельно выполняющимися процессами.

 

Взаимодействие нескольких параллельных процессов часто вызывает необходимость обмена данными между ними. При этом возникает следующая проблема: каждый процесс имеет собственное адресное пространство, при этом переменные одного процесса недоступны другим процессам. Обычно для передачи данных между процессами используют глобальные объекты ядра, выделение общих сегментов памяти, сокеты, каналы и т.д. Для каждой из рассматриваемых операционных систем существует большое количество методов и способов решения данной проблемы, поэтому подробно остановимся на наиболее часто используемых.

 

Linux

 

Чаще всего для передачи данных между процессами в Linux используются каналы (pipes) и сегменты разделяемой памяти.

 

Каналы

Канал представляет собой специальный файл, связывающий два процесса. Таким образом, передача информации через канал сводится к записи в него данных в одном процессе и чтению из него в другом. Ранние Unix-системы не поддерживали полнодуплексную передачу данных, т.е. чтение и запись одним процессом через один канал; для обеспечения двусторонней связи приходилось создавать два канала. Несмотря на то, что практически все современные системы такую возможность предоставляют при разработке приложений необходимо предусматривать наличие только полудуплексной передачи данных.

Канал создается системной функцией pipe():

#include <unistd.h>

int pipe(int filedes[2]);

Функции надо передать массив filedes из двух целочисленных элементов, первый из которых будет проинициализирован файловым дескриптором для чтения, а второй – для записи.

Если канал создан успешно, функция pipe() возвратит 0. В противном случае получим – 1 .

Дескрипторы можно использовать напрямую функциями read() и write(), а можно преобразовать к стандартному файловому потоку FILE* функцией fdopen(). Второй вариант позволяет использовать высокоуровневые функции форматированного ввода-вывода, такие как fprintf() и fgets().

Необходимо учитывать также, что передача данных через каналы возможна только для процессов, имеющих общего предка. Обычно канал создается до вызова fork() и затем используется в обоих процессах-клонах.

Существует правило, согласно которому неиспользуемый конец канала должен быть закрыт. Так, процесс-писатель закрывает дескриптор чтения (и наоборот).