Лекция 16
Теперь мы с вами обсудим некоторые дополнительные возможности по организации управления ходом вычисления в процессах Unix.
Нелокальные переходы
Может получиться так, что возникает необходимость предоставления в процессе возможностей перезапуска каких-то из веточек процесса при возникновении некоторых ситуаций. Предположим, имеется некоторый процесс, который занимается обработкой достаточно больших наборов данных, и будет работать следующим образом:
В начальный момент времени процесс получает набор данных и начинает выполнять вычисления. Известно, что при некоторых наборах данных возможно возникновение внештатных ситуаций, например, переполнения или деления на ноль. Мы бы хотели написать программу, которая бы при возникновении внештатных ситуаций обрабатывала бы их, загружала новые данные, переходила в начальную точку процесса и выполняла бы вычисления с другим набором данных. То есть возникает необходимость в повторном использовании некоторых цепей программы. Для решения такой задачи мы должны уметь делать:
Для решения второй проблемы в Unix имеется две функции, которые обеспечивают нелокальные переходы:
#include <setjmp.h>
int setjmp(jmp_buf env);
Эта функция фиксирует точку обращения к этой функции, то есть в структуре данных, связанных с переменной env сохраняется текущее состояние процесса в точке обращения к setjmp, в том числе состояние стека. При обращении к этой функции она возвращает нулевое значение.
void longjmp(jmp_buf env, int val);
Нелокальный переход. При обращении к longjmp переходит передача управления на точку, атрибуты которой зафиксированы в env.
Если мы сделали setjmp, а затем откуда-то longjmp с той же переменной env, то мы вернемся на обращение к функции setjmp и в качестве кода ответа setjmp получим значение val.
То есть setjmp — это декларация некоторой точки, на которую мы можем затем вернуться с помощью, а longjmp — переход на эту точку, где параметр val задает код ответа setjmp.
Пример:
#include <setjmp.h>
jmp_buf save; /* объявляем глобальный буфер save */
main()
{ int ret;
switch(ret=setjmp(save)){
case 0: printf(“до нелокального перехода\n”);
a();
printf(“после нелокального перехода\n”); /* этот текст никогда не будет напечатан */
default: break;
}
}
a() {longjmp(save,1)};
Рассмотрим функцию main() — в переключателе мы обращаемся к setjmp, она запомнит состояние процесса на тот момент и вернет ноль. После этого мы перейдем по варианту связанному с нулем — печатаем текст и вызываем a(). В a() мы вызываем longjmp(save,1), после этого мы попадем опять на переключатель, но на этот раз переменная ret будет равна единице. Произойдет завершение процесса.
Вообще говоря, это некорректная возможность ОС, так как некорректно входить в блочные структуры не сначала и выходить не через конец. Но такие возможности есть и они полезны.
Как работает длинный переход со стеком? Он не запоминает стек, он запоминает указатель стека и восстанавливает его. Конечно, мы можем смоделировать ситуацию, в которой переход будет работать некорректно, например, вызовем функцию, в ней сделаем setjmp, выйдем из функции, как-то поработаем дальше и попробуем сделать longjmp на функцию, из которой уже вышли. Информация в стеке будет уже потеряна и наш переход приведет к ошибке. Такие ситуации отдаются на откуп программистам.
Нелокальный переход работает в пределах одного процесса.
До этого мы говорили о взаимодействии между родственными процессами (отцом и сыном, детьми одного отца и т.п.). Реально же Unix имеет набор средств, поддерживающих взаимосвязь между произвольными процессами. Один из таких механизмов — система межпроцессного взаимодействия (IPC- interprocess communication). Суть этой системы заключается в следующем — имеется некоторое количество ресурсов, которые называют в системе разделяемыми. К одному и тому же разделяемому ресурсу может быть организован доступ произвольного количества произвольных процессов. При этом возникает некоторая проблема именования ресурсов. Если мы вспомним каналы, то в них за счет наследования нужные файловые дескрипторы были известны и с именованием проблем не возникало.
Но это свойство родственных связей. В системе IPC ситуация совершенно иная — есть некоторый ресурс, в общем случае произвольный, и к этому ресурсу могут добираться все кому не лень — все, кто может именовать этот ресурс. Для именования такого ресурсов в системе предусмотрен механизм генерации так называемых ключей. Суть его в следующем — по некоторым общеизвестным данным (текстовые строки или цифровые наборы) генерируется уникальный ключ, который ассоциируется с разделяемым ресурсом,
соответственно, если мы подтверждаем этот ключ и созданный разделяемый ресурс доступен для моего процесса, то мы можем работать с этим ресурсом.
Следующее концептуальное утверждение — разделяемый ресурс создается некоторым процессом-автором. Это к проблеме первичного возникновения ресурса. Автор определяет основные свойства (размер, например) и права доступа. Права доступа разделяются на три категории — доступ автора, доступ всех процессов, имеющих тот же идентификатор, что и автор, и все остальные.
Система позволяет некоторому процессу создать ресурс, защитить его некоторым ключом и забывать про него. Затем, все те, кто знает ключ, могут работать с этим процессом.
Сразу возникает вопрос — а если сразу трое подошли к ресурсу? То есть очевидна проблема синхронизации доступа к разделяемому ресурсу.
Мы с вами будем рассматривать конкретные средства IPC, которые будем рассматривать далее. А пока отмечу, что IPC поддерживает три разновидности разделяемых ресурсов:
Вот об этом мы будем говорить далее.
Лекция 17
Interprocess Communication
Мы с вами говорили, что далее речь пойдет о разделяемых ресурсах, доступ к которым может осуществляться со стороны произвольных процессов, в общем случае, в произвольном порядке. Эти ресурсы доступны любому процессу, а процессы не обязательно должны быть родственными. При наличии такой схемы возникают две принципиальные проблемы:
Проблемы именования связаны с тем, что родственных связей нет и по наследству передать ничего нельзя.
Если проблема именования решена, то возникает проблема синхронизации доступа — как организовать обмен с ресурсами, чтобы этот обмен был корректным. Если у нас есть, например, ресурс “оперативная память”, то когда один процесс еще не дописал информацию, а другой процесс уже прочитал весь блок, то возникает некорректная ситуация.
Решения этих проблем мы и будем рассматривать.
Проблема именования решается за счет ассоциирования с каждым ресурсом некоторого ключа. В общем случае это целочисленное значение. То есть при создании разделяемого ресурса его автор приписывает ему номер и определяет права доступа к этому ресурсу. После этого любой процесс, который укажет системе, что он хочет общаться с разделяемым ресурсом с ключом N, и обладает необходимыми правами доступа, будет допущен для работы с этим ресурсом.
Однако такое решение не является идеальным, так как вполне возможна коллизия номеров — когда совпадают номера разделяемых ресурсов. В этом случае процессы будут путаться, что неизбежно приведет к ошибкам. Поэтому в системе предусмотрено стандартное средство генерации уникальных ключей. Для генерации уникального ключа используется функция ftok
#include <sys/types.h>
#include <sys/ipc.h>
key_t ftok(char *s, char c);
Суть ее действия — по текстовой строке и символу генерируется уникальное для каждой такой пары значение ключа. После этого сгенеренным ключом можно пользоваться как для создания ресурса, так и для подтверждения использования ресурса. Более того, для исключения коллизий, рекомендуется указывать в качестве параметра указателя на строку путь к некоторому своему файлу. Второй аргумент — символьный, который позволяет создавать некоторые варианты ключа, связанного с этим именем, этот аргумент называется проектом (project). При таком подходе можно добиться отсутствия коллизий.
Давайте посмотрим конкретные средства работы с разделяемыми ресурсами.
Разделяемая память.
Общая схема работы с разделяемыми ресурсами такова — есть некоторый процесс-автор, создающий ресурс с какими-либо параметрами. При создании ресурса разделяемой памяти задаются три параметра — ключ, права доступа и размер области памяти. После создания ресурса к нему могут быть подключены процессы, желающие работать с этой памятью. Соответственно, имеется действие подключения к ресурсу с помощью ключа, который генерируется по тем же правилам, что и ключ для создания ресурса. Понятно, что здесь имеется момент некоторой рассинхронизации, который связан с тем, что потребитель разделяемого ресурса (процесс, который будет работать с ресурсом, но не является его автором) может быть запущен и начать подключаться до запуска автора ресурса. В этой ситуации особого криминала нету, так как имеются функции управления доступом к разделяемому ресурсу, с использованием которых можно установить некоторые опции, определяющие правила работы функций, взаимодействующих с разделяемыми ресурсами. В частности, существует опция, заставляющая процесс дождаться появления ресурса. Это также, может быть, не очень хорошо, например, автор может так и не появиться, но другого выхода нету, это есть некоторые накладные расходы. Вот в общих словах — что есть что.
Давайте рассмотрим те функции, которые предоставляются нам для работы с разделяемыми ресурсами.
Первая функция — создание общей памяти.
int shmget (key_t key, int size, int shmemflg);
key — ключ разделяемой памяти
size — размер раздела памяти, который должен быть создан
shmemflg — флаги
Данная функция возвращает идентификатор ресурса, который ассоциируется с созданным по данному запросу разделяемым ресурсом. То есть в рамках процесса по аналогии с файловыми дескрипторами каждому разделяемому ресурсу определяется его идентификатор. Надо разделять ключ — это общесистемный атрибут, и идентификатор, используя который мы работаем с конкретным разделяемым ресурсом в рамках процесса.
С помощью этой функции можно как создать новый разделяемый ресурс “память” (в этом случае во флагах должен быть указан IPC_CREAT)?, а также можно подключиться к существующему разделяемому ресурсу. Кроме того, в возможных флагах может быть указан флаг IPC_EXECL, он позволяет проверить и подключиться к существующему ресурсу — если ресурс существует, то функция подключает к нему процесс и возвращает код идентификатора, если же ресурс не существует, то функция возвращает -1 и соответствующий код в errno.
Следующая функция — доступ к разделяемой памяти:
char *shmat(int shmid, char *shmaddr, int shmflg);
shmid — идентификатор разделяемого ресурса
shmaddr — адрес, с которого мы хотели бы разместить разделяемую память, при этом, если его значение — адрес, то память будет подключена, начиная с этого адреса, если его значение — нуль, то система сама подберет адрес начала. Также в качестве значений этого аргумента могут быть некоторые предопределенные константы, которые позволяют организовать, в частности выравнивание адреса по странице или началу сегмента памяти.
shmflg — флаги. Они определяют разные режимы доступа, в частности, SHM_RDONLY.
Эта функция возвращает указатель на адрес, начиная с которого будет начинаться запрашиваемая разделяемая память. Если происходит ошибка, то возвращается -1.
Хотелось бы немного поговорить о правах доступа. Они реально могут использоваться и корректно работать не всегда. Так как, если аппаратно не поддерживается закрытие области данных на чтение или на запись, то в этом случае могут возникнуть проблемы с реализацией такого рода флагов. Во-первых, они не будут работать, так как мы получаем указатель и начинаем работать с указателем, как с указателем, и общая схема здесь не предусматривает защиты. Второе, можно программно сделать так, чтобы работали флаги, но тогда мы не сможем указывать произвольный адрес, в этом случае система будет подставлять и возвращать в качестве адрес разделенной памяти некоторые свои адреса, обращение к которым будет создавать заведомо ошибочную ситуацию, возникнет прерывание процесса, во время которого система посмотрит — кто и почему был инициатором некорректного обращения к памяти, и если тот процесс имеет нужные права доступа — система подставит нужные адреса, иначе доступ для процесса будет заблокирован. Это похоже на установку контрольной точки в программе при отладке, когда создавалась заведомо ошибочная ситуация для того, чтобы можно было прервать процесс и оценить его состояние.
Третья функция — открепление разделяемой памяти:
int shmdt(char *shmaddr);
shmaddr — адрес прикрепленной к процессу памяти, который был получен при подключении памяти в начале работы.
Четвертая функция — управление разделяемой памятью:
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
shmid — идентификатор разделяемой памяти
cmd — команда управления. В частности, могут быть: IPC_SET (сменить права доступа и владельца ресурса — для этого надо иметь идентификатор автора данного ресурса или суперпользователя), IPC_STAT (запросить состояние ресурса — в этом случае заполняется информация в структуру, указатель на которую передается третьим параметром, IPC_RMID (уничтожение ресурса — после того, как автор создал процесс — с ним работают процессы, которые подключаются и отключаются, но не уничтожают ресурс, а с помощью данной команды мы уничтожаем ресурс в системе)
Это все, что касается функций управления разделяемой памятью.
Передача сообщений.
Следующим средством взаимодействия процессов в системе IPC — это передача сообщений. Ее суть в следующем: в системе имеется так называемая очередь сообщений, в которой каждое сообщение представляет из себя структуру данных, с которой ассоциирован буфер, содержащий тело сообщения и признак, который называется типом сообщения. Очередь сообщений может быть рассмотрена двояко:
Система IPC позволяет создавать разделяемый ресурс, называемый “очередь сообщений” — таких очередей может быть произвольное количество. По аналогии с разделяемой памятью — мы можем создать очередь, подключиться к ней, послать сообщение, принять сообщение, уничтожить очередь и т.д. Рассмотрим функции работы с очередями сообщений:
Создание очереди сообщений:
int msgget(key_t key, int flags);
В зависимости от флагов при обращении к данной функции либо создается разделяемый ресурс, либо осуществляется подключение к уже существующему.
Отправка сообщения:
int msgsnd( int id, struct msgbuf *buf, int size, int flags);
id — идентификатор очереди сообщения;
struct msgbuf {long type; char mtext[s]} *buf — первое поле — тип сообщения, а второе — указатель на тело сообщения;
size — размер сообщения, здесь указывается размер сообщения, размещенного по указателю buf;
flags — флаги, в частности, флагом может быть константа IPC_NOWAIT. При наличии такого флага будут следующие действия — возможна ситуация, когда буфера, предусмотренные системой под очередь сообщений, переполнены. В этом случае возможны два варианта — процесс будет ожидать освобождения пространства, если не указано IPC_NOWAIT, либо функция вернет -1 (с соответствующим кодом в errno), если было указано IPC_NOWAIT.
Прием сообщения:
int msgrcv( int id, struct msgbuf *buf, int size, long type, int flags);
id — идентификатор очереди;
buf — указатель на буфер, куда будет принято сообщение;
size — размер буфера, в котором будет размещено тело сообщения;
type — если тип равен нулю, то будет принято первое сообщение из сквозной очереди, если тип больше нуля, то в этом случае будет принято первое сообщение из очереди сообщений, связанной с типом, равным значению этого параметра.
flags — флаги, в частности, IPC_NOWAIT, он обеспечит работу запроса без ожидания прихода сообщения, если такого сообщения в момент обращения функции к ресурсу не было, иначе процесс будет ждать.
Управление очередью:
int msgctl( int id, int cmd, struct msgid_dl *buf);
id — идентификатор очереди;
cmd — команда управления, для нас интерес представляет IPC_RMID, которая уничтожит ресурс.
buf — этот параметр будет оставлен без комментария.
Мы описали два средства взаимодействия между процессами.
Что же мы увидели? Понятно, что названия и описания интерфейсов мало понятны. Прежде всего следует заметить то, что как только мы переходим к вопросу взаимодействия процессов, у нас возникает проблема синхронизации. И здесь мы уже видим проблемы, связанные с тем, что после того, как мы поработали с разделяемой памятью или очередью сообщений, в системе может оставаться “хлам”, например, процессы, которые ожидают сообщений, которые в свою очередь не были посланы. Так, если мы обратились к функции получения сообщений с типом, которое вообще не пришло, и если не стоит ключ IPC_NOWAIT, то процесс будет ждать его появления, пока не исчезнет ресурс. Или мы можем забыть уничтожить ресурс (и система никого не поправит) — этот ресурс останется в виде загрязняющего элемента системы.
Когда человек начинает работать с подобными средствами, то он берет на себя ответственность за все последствия, которые могут возникнуть. Это первый набор проблем — системная синхронизация и аккуратность. Вторая проблема — синхронизация данных, когда приемник и передатчик работают синхронно. Заметим, что самый плохой по синхронизации ресурс из рассмотренных нами — разделяемая память. Это означает, что корректная работа с разделяемой памятью не может осуществляться без использования средств синхронизации, и, в частности, некоторым элементом синхронизации может быть очередь сообщений. Например, мы можем записать в память данные и послать сообщение приемнику, что информация поступила в ресурс, после чего приемник, получив сообщение, начинает считывать данные. Также в качестве синхронизирующего средства могут применяться сигналы.
И это главное — не язык интерфейсов, а проблемы, которые могут возникнуть при взаимодействии параллельных процессов.
Лекция 18
Итак, мы к текущему моменту разобрали два механизма взаимодействия процессов в системе IPC: разделяемую память и механизм сообщений.
Давайте попробуем написать программу, в которой первый процесс будет принимать некую текстовую строку и в случае, если некоторая строка начинается с буквы “a”, то эта текстовая строка будет передана процессу A, если “b” — процессу B, если “q”, то сообщение будет передано процессам A и B, и будет осуществлен выход
Основной процесс:
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#include <stdio.h>
struct { long mtype;
char Data[256];
} Message;
int main(void)
{ key_t key;
int msgid;
char str[256];
key = ftok(“/usr/mash”,’S’); /* создаем ключ для работы с ресурсом — ключ уникальный и однозначно определяет доступ к разделяемому ресурсу одного типа, то есть с одним ключом могут быть связаны разделяемые ресурсы памяти, очередь сообщений и семафоров, но две области памяти связаны одним ключом быть не могут */
msgid = msgget(key, 0666 | IPC_CREAT); /* создаем очередь сообщений, 0666 — права доступа к очереди, разрешают всем читать и писать */
for (;;) {
gets(str); /* получаем строку */
strcpy(Message.Data, str); /* копируем ее в буфер сообщения */
switch(str[0]) {
case ‘a’:
case ‘A’: Message.mtype = 1; /* если строка начинается с “a”, то ставим тип сообщения равным единице, это означает, что приемником будет первый процесс */
msgsnd(msgid, (struct msgbuf *) &Message), 1+strlen(str),0); /* отправляем сообщение */
break;
case ‘b’:
case ‘B’: Message.mtype = 2; /* посылаем сообщение второму процессу */
msgsnd(msgid, (struct msgbuf *) &Message), 1+strlen(str),0);
break;
case ‘q’:
case ‘Q’: Message.mtype = 1;
msgsnd(msgid, (struct msgbuf *) &Message), 1+strlen(str),0);
Message.mtype = 2;
msgsnd(msgid, (struct msgbuf *) &Message), 1+strlen(str),0);
sleep(10); /* берем таймаут для гарантии, что все предыдущие сообщения дошли */
msgctl(msgid, IPC_RMID, NULL); /* убиваем разделяемый ресурс */
exit(0); /* завершаем процесс */ }
}
Давайте рассмотрим процесс-приемник. Рассмотрим только процесс A, так как B будет аналогичен, за исключением указания типа сообщений для приема.
proc_A:
...
int main(void)
{ key_t key;
int msgid;
key = ftok(“/usr/mash”, ‘S’); /* получаем ключ к очереди по параметрам, аналогичным процессу-отправителю */
msgid = msgget(key, 0666 | IPC_CREAT); /* создаем или подключаемся к очереди сообщений */
for (;;) {msgrcv(msgid, (struct msgbuf *) (&Message), 256, 1, 0); /* принимаем сообщения с типом 1 */
if (Message.Data[0]==’q’) || (Message.Data[0]==’Q’) break; /* если сообщение начинается с “q” — заканчиваем выполнение процесса-получателя */
printf(“...”);
}
exit(0);
}
Семафоры.
С точки зрения тех проблем, с которыми мы знакомимся — семафоры — законное и существующее понятие. Впервые их ввел достаточно известный ученый Дейкстра. Суть этого объекта заключается в следующем. Семафор — это есть некоторый объект, который имеет целочисленное значениеs, и две операции — P(s) и V(s). P — уменьшает значение семафора на единичку и, если s>=0 после уменьшения процесс продолжает работать, если s<0, то процесс будет приостановлен и встанет в очередь ожидания, связанную с семафором s. Операция V увеличивает семафор на единицу. Если s>0 после увеличения, то процесс продолжает свое выполнение, если s<=0, то разблокируется один из процессов в очереди ожидания. Считается, что операции P и V неделимы, то есть их выполнение не может прерываться.
Также бывают двоичные семафоры, максимальное значение которого — 1. При значении 1 считается, что ни один из процессов не находится в критическом участке. При равенстве 0 — один процесс находится в критическом участке, другой работает нормально. Значение “-1” означает, чтоодин семафор находится в очереди ожидания, а другой — в критическом участке. Двоичные семафоры наиболее часто находили практическое применение в аппаратных реализациях.
Кроме тех вычислительных машин, которые являются однопроцессорными, бывают и многомашинные, многопроцессорные комплексы, для этих комплексов необходимо внесение в систему команд поддержки семафоров.
Это мы рассмотрели семафоры в общем случае. Сейчас же рассмотрим семафоры в системе IPC.
Существует разделяемый ресурс массив семафоров. Система позволяет процессам, работающим с этим ресурсом изменять элементы массива на произвольное число. Система позволяет ожидание процессом обнуления одного или нескольких семафоров. И, наконец, система позволяет уменьшать значение семафоров.
int semget(key_t key, int n, int flags)
Данная функция создает массив размерности n семафоров с заданным ключом и флагами. Функция возвращает идентификатор ресурса или -1, если произошла ошибка.
int semop(int semid, struct sembuf *SOPS, int n)
semid — идентификатор ресурса семафоров;
SOPS — указатель на структуру sembuf;
n — количество указателей на эту структуру, которые передаются функции semop, соответственно в структуре sembuf передается вся информация о необходимом действии;
struct sembuf
{short sem_num; /* номер семафора в массиве семафоров */
short sem_op; /* код операции над семафором */
short sem_flg; /* флаги */ }
Все это интерпретируется следующим образом. Пусть значение семафора с номером sem_num есть число sem_val. Если значение операции semop не равно нулю, то оценивается значение сумма sem_val+semop, если эта сумма больше или равна нулю, то значение данного семафора устанавливается новым, равным сумме предыдущего значения плюс код операции (semop), если эта сумма меньше нуля, то действие процесс будет приостановлено до наступления одного из следующих событий: до тех пор, пока значение этой суммы не станет >=0; придет сигнал (при приходе сигнала процесс снимется с ожидание) при этом semop будет равно “-1”. Если же semop=0, то процесс будет ожидать обнуления значения семафора, при этом, если мы обратились к функции semop c нулевым кодом операции, а значение семафора уже было нуль, то ничего не произойдет. Если значение флага равно нулю, то флаги не используются. Флагов на самом деле нет, но, например, есть флаг IPC_NOWAIT, когда процесс ничего ждать не будет.
Заметим, что мы можем передать n структур и выполнить действия с n семафорами.
Управление массивом семафоров:
int semctl(int semid, int n, int cmd, union semun arg);
semid — идентификатор ресурса
n — номер семафора
cmd — команда (команды над семафорами, в том числе IPC_RMID)
arg — объединение, содержащее информацию о семафорах.