Тема: Паралельні асинхронні процеси в ОС UNIX.
Мета: Засвоїти основи паралельних обчислень. Навчитись користуватись утилітою make, виконувати C-задачі в ОС UNIX.
Загальні відомості
1.1. Мова C тісно пов‘язана з ОС UNІX, тому що спочатку була розроблена саме для реалізації ядра операційної системи. Тому, у першу чергу, вона дуже зручна для програмування задач, що використовують системні виклики операційної системи, наприклад, для організації низькорівневого вводу/виводу, керування пам'яттю чи фізичними пристроями, організації зв'язку між процесами і т.д.
1.2. Наберемо текст класичної програми у файл HelloWorld.c:
#include <stdio.h>
void main(void)
{
printf("Hello, world.\n");
return;
}
1.3. Для компіляції цієї програми використаємо компілятор gcc. Основними перевагами gcc є те що він надійний, доступний на багатьох платформах, безкоштовний і його вихідні С коди є відкритими (open-source). Компілятор gcc може компілювати C, C++ та objective-C програми. Насправді gcc є одночасно і компілятором, і лінкером.
Загальна форма виклику gcc виглядає наступним чином:
gcc [опції] [файли]
Розглянемо основні опції компілятора gcc:
-c [файли]
Компілювати файли у об‘єктні не проходячи фази лінкування.
точну послідовність дій необхідних для породження нової версії. Ця інформація за допомогою текстового редактора поміщається у файл з описами. Спираючись на вміст файлу з описами, make визначає, які команди потрібно передати командному інтерпретатору (shell) для виконання, щоб гарантувати автоматичне формування кінцевого продукту.
1.5. Алгоритм загалом виглядає так. У файлі з описами визначається ім'я цільового файлу (чи файлів). Перевіряються усі файли, від яких залежить цільовий: якщо файл не існує або застарів, він породжується заново (перевірка супроводжується переглядом описів, при якому цей файл у свою чергу трактується як цільовий.) Нарешті, якщо один з цих файлів модифікований пізніше цільового, останній створюється заново. Подібний рекурсивний перегляд залежностей дозволяє при формуванні нової версії програми породжувати заново тільки ті файли, до яких відносились останні зміни.
1.6. Утиліта make буває корисна в наступних випадках:
- При розробці програм утиліта make може автоматично оновлювати виконавчий файл, як тільки зміниться будь-який вихідний чи об'єктний файл.
- При керуванні бібліотеками утиліта make може автоматично перебудувати бібліотеку, як тільки зміниться один з модулів бібліотеки.
- У мережному середовищі утиліта make може автоматично оновлювати локальну копію програми чи файлу, збережених у мережі, як тільки основна копія буде змінена.
1.7. Утиліта make діє, спираючись на три джерела інформації:
- Заданий користувачем файл описів.
- Імена файлів і часи останньої модифікації, отримані від файлової системи.
- Вбудовані правила.
1.8. Виклик утиліти make виглядає наступним чином:
make [опції] [макровизначення] [цільові файли]
Аргументи командного рядка інтерпретуються в такий спосіб. Насамперед аналізуються аргументи, що є макровизначеннями (тобто аргументи, що містять всередині себе знаки рівності), і виконуються необхідні присвоювання. Потім розглядаються аргументи-опції:
-f make-файл
Вказати ім‘я файлу з описами. Якщо опція -f не зазначена, читається файл з ім'ям makefіle (чи Makefіle) з поточного каталогу.
-p
Вивести усі макровизначення, а також описи залежностей і операцій для створення цільових файлів.
-і
Ігнорувати коди помилок, що повертаються програмами, які запускаються. Цей режим встановлюється також з появою у файлі описів спеціального цільового імені .ІGNORE.
-k
При помилці припиняти виконання команд, пов'язаних з поточною залежністю, але продовжувати обробку інших залежностей.
-s
Не виводити командні рядки перед їх виконанням. Цей режим встановлюється також з появою у файлі описів спеціального цільового імені .SІLENT.
-r
Не використовувати вбудовані правила.
-n
Виводити команди, але не виконувати їх. Виводяться навіть команди, що починаються з @.
-B
Викликає безумовне відновлення.
-t
"Масаж" цільових файлів: час їхнього створення встановлюється рівним поточному часу; команди, призначені для одержання цільових файлів, не виконуються.
-q
Запит на оновлення цільового файлу. Команда makeповертає нульовий чи ненульовий код завершення в залежності від того, чи потрібно оновляти цільові файли (0, якщо не потрібно).
-d
Виводити додаткову інформацію.
-o файл
Не оновлювати файл навіть якщо він є застарілим.
-v
Вивести версію утиліти.
Після цього всі аргументи, що залишилися, вважаються іменами цільових файлів, що повинні бути сформовані; аргументи обробляються зліва направо. Якщо таких аргументів нема, використовується перше ім'я цільового файлу у файлі описів.
1.9. Файл описів складається з коментарів, оголошень змінних (макровизначень) та правил залежностей (правил трансформації).
Розглянемо основні компоненти файлу описів:
1.10. Коментарі. Ознакою коментарю, є символ (#); усі символи за ним до кінця рядка ігноруються (порожні рядки також ігноруються).
# приклад коментарю
1.11. Правила залежностей - основний вміст make-файлу. Кожне правило залежностей складається з трьох компонентів – одної або більше цілей, нуля або більше залежностей, нуля або більше команд.
ціль1 [ціль2 ...] : [залежність1 ...] [; команди]
[{tab} команди]
(примітка: Зазначені в дужках компоненти є необов‘язковими.)
Ціль – звичайно ім’я файлу який утиліта make створює (виконавчий модуль або об’єктний файл). Кілька цілей в одному правилі вказуються винятково з метою скорочення запису. Наприклад:
x.o y.o : defs.h
еквівалентно парі:
x.o : defs.h
y.o : defs.h
Псевдо ціль – не являється іменем файлу. Має тільки список команд і не містить залежностей. Використовується, наприклад, для видалення непотрібних (проміжних) файлів після компіляції програми. Наступний приклад просто видаляє всі об’єктні файли з директорії, що містить файл описів:
.PHONY: clean
clean:
{tab} rm *.o
Спеціальні вбудовані цілі – Деякі імена цілей мають спеціальне значення:
.PHONY
Залежності спеціальної цілі .PHONY розглядаються як цілі-імена дій (псевдо цілі). Коли прийде час розглядати таку ціль, make виконає команди в безумовному режимі, незалежно від того, чи існує цільовий файл і від того, який час його останньої модифікації.
.SUFFIXES
Залежності спеціальної мети .SUFFІXES являють собою список суфіксів, що будуть використовуватися при перевірці правил трансформації.
.DEFAULT
Команди, визначені для .DEFAULT, виконуються з будь-якими цілями, для яких не знайдено правил (як явних, так і неявних).
.PRECIOUS
Файли, що залежать від .PRECIOUS, не видаляються, якщо робота утиліти make була перервана чи припинена.
Залежність – визначає файл який необхідний для побудови іншого файлу.
Команда – кожна команда у правилі передається командному інтерпретатору (shell) для виконання. За замовчуванням утиліта make використовує shell - /bin/sh. Командний інтерпретатор за замовчуванням можна змінити перевизначивши макрос SHELL = /bin/sh (див. макровизначення). При обробці команди інтерпретуються метасимволи shell'а, такі як * і ?. Команди можуть бути зазначені після крапки з комою в рядку залежностей, чи в рядках, що починаються з табуляції, що розташовані зразу за рядком залежностей. Перед виконанням команди утиліта make роздруковує її.
1.12. Розглянемо найпростіший приклад. Нехай програма prog складається із трьох вихідних файлів x.c, y.c і z.c шляхом їхньої компіляції. Припустимо, що файли x.c і y.c використовують загальні описи з файлу defs.h, а z.c - не використовує. Взаємозв'язки і команди описуються так:
prog : x.o y.o z.o
{tab} gcc -o prog x.o y.o z.o
x.o : x.c defs.h
{tab} gcc -c x.c
y.o : y.c defs.h
{tab} gcc -c y.c
z.o : z.c
{tab} gcc -c z.c
У першому рядку затверджується, що prog залежить від трьох файлів. Другий рядок вказує, як відредагувати зв'язки між ними, щоб створити prog. Третій рядок говорить, що x.o залежить від x.c і defs.h; четвертий описує, як одержати файл x.o, якщо він відсутній чи застарів. Подібний зміст мають і наступні рядки.
Якщо цю інформацію помістити у файл з ім'ям makefіle, команда make буде виконувати операції, необхідні для оновлення prog після змін, зроблених у будь-якому з чотирьох вихідних файлів x.c, y.c, z.c чи defs.h.
Якщо жоден з вихідних чи об'єктних файлів не був змінений з моменту останнього оновлення prog і усі файли в наявності, утиліта make сповістить про це і припинить роботу. Якщо, відредагувати файл defs.h, x.c і y.c (але не z.c) будуть заново скомпільовані; потім з нових файлів x.o і y.o і вже існуючого файлу z.o буде заново зібрана програма prog. Якщо змінений лише файл y.c, тільки він і перекомпілюється, після чого піде зборка prog.
Щоб спростити формування файлів описів, make використовує вбудовані правила трансформації. Наприклад, якщо для породження цільового файлу потрібно деякий об'єктний файл (.o), а в поточному каталозі мається відповідний йому вихідний С-файл (файл із тим же ім'ям і розширенням .с), make застосовує вбудоване правило породження об'єктного файлу з вихідного (тобто виконує команду cc –c, див. правила трансформації).
Тому наш make-файл можна спростити:
prog : x.o y.o z.o
{tab} gcc -o prog x.o y.o z.o
x.o y.o : defs.h
1.13. Оголошення змінних (макровизначення). Змінні у make-файлі виконують ту ж саму роль, що і макропідстановки у С-препроцесорі. Макровизначення складається з імені змінної (ланцюжок букв і/чи цифр), за яким слідує знак рівності, а потім ланцюжок символів, який є значенням цієї змінної. Приклад:
2 = xyz
abc = test macro
LІBES =
Макрос, ніде не визначений явно, має як значення порожній ланцюжок символів.
Макровизначення можна не тільки включати у файл описів, але і задавати у виді аргументів командного рядка, наприклад:
make abc="test macro"
Усі змінні оточення (HOME, LOGNAME, TERM, PATH і т.д.) також обробляються як макровизначення.
При звертанні до макровизначення перед його ім'ям вказується символ $. Імена макросів, що складаються більш ніж з одного символу, повинні заключатися в дужки.
Різні способи визначення макросу мають різний пріоритет. Наведемо їх у порядку збільшення пріоритету:
- вбудовані макровизначення
- змінні оточення
- макровизначення в make-файлі
- макровизначення в командному рядку.
Таким чином, вбудований макрос СС можна перевизначити в make-файлі, а макрос, визначений у make-файлі, можна перевизначити в командному рядку. Макрос має те саме значення у всіх частинах make-файлу. Його не можна перевизначити для частини правил. Порядок макровизначень, як і порядок правил, не грає ніякої ролі.
Утиліта make має вбудовані макроси наведемо деякі з них:
Ім‘я
Значення
Призначення
CC
cc
Ім‘я С компілятора.
CFLAGS
-O
Список опцій, які передаються С компілятору.
LD
ld
Ім‘я лінкера.
LDFLAGS
Список опцій, які передаються лінкеру.
1.14. Make використовує також чотири спеціальних вбудованих макроси – $*, $@, $?, $<, значення яких змінюються в кожному правилі, діючи тільки в його тілі. Макрос $@встановлюється рівним повному імені поточного цільового файлу, а макрос $? – списку імен файлів змінених пізніше цільового. Приклад:
prog: x.c y.c z.c
{tab} gcc -c $?
{tab} gcc -o $@ x.o y.o z.o
Макроси $< і $* використовуються при визначенні неявних правил (див. правила трансформації). Приведене вище звертання до макросу виду $(макро) є частковим випадком більш “могутньої” конструкції. У загальному випадку вона виглядає так:
$(макро:ланцюжок1=ланцюжок2)
Наведена конструкція перетвориться в такий спосіб. Значення $(макро) розглядається як набір розділених пробілами чи знаками табуляції ланцюжків символів. Кожен з яких закінчується ланцюжком символів ланцюжок1; при підстановці, усі входження ланцюжок1 замінюються на ланцюжок2. Така форма перетворень макросів була обрана через те, що makeмає справу з закінченнями імен файлів. Приклад:
$(CC) -c $(CFLAGS) $(?:.o=.c)
Якщо список імен змінених об’єктних файлів ($?) "x.o y.o", тоді звертання $(?:.o=.c) транслюється в "x.c y.с".
1.15. Суфікси та правила трансформації. Як вже було зазначено вище, make використовує таблицю неявних правил (правил трансформації), що визначають, як перетворити файл з одним суфіксом (розширенням) у файл з іншим суфіксом.
Імена правил трансформації – просто конкатенація суфіксів файлів до і після трансформації. Так, правило трансформації .с – файлу в .о – файл називається .с.о. Користувач може перевизначити вбудоване правило трансформації. Якщо команда породжується за допомогою одного з таких правил, за допомогою макросу $* можна обчислити префікс імені цільового файлу (ім’я без розширення); макрос $< визначає повне ім'я вихідного файлу, до якого застосовується правило трансформації.
Суфікси, що використовуються в правилах трансформації даються як список залежностей для спеціального імені .SUFFІXES. При цьому важливий порядок: перше можливе ім'я, для якого існують і файл і правило, використовується як ім'я джерела. Спочатку цей список виглядає приблизно так:
.SUFFIXES: .o .c .y .l .s .h .sh .f
Загальні відомості
1.1. Уся побудова операційної системи UNІX заснована на використанні концепції процесів. Контекст процесу складається з контексту користувача і контексту ядра.
Під контекстом користувача розуміють код і дані, розташовані в адресному просторі процесу.
Під поняттям "контекст ядра" поєднуються системний контекст і реєстровий контекст. Ми будемо виділяти в контексті ядра стек ядра, що використовується при роботі процесу в режимі ядра (kernel mode), і дані ядра, які зберігаються в структурах, що є аналогом блоку керування процесом - PCB. В дані ядра входять: ідентифікатор користувача - UІD, груповий ідентифікатор користувача - GІD, ідентифікатор процесу - PІD, ідентифікатор батьківського процесу - PPІD.
1.2. Кожен процес в операційній системі отримує унікальний ідентифікаційний номер - PІD (process іdentіfіcator). При створенні нового процесу операційна система намагається привласнити йому вільний номер більший, ніж у процесу, створеного перед ним. Якщо таких вільних номерів не виявляється (наприклад, ми досягли максимально можливого номера для процесу), то операційна система вибирає мінімальний номер із усіх вільних номерів.
1.3. В операційній системі UNІX усі процеси, крім одного, що створюється при старті операційної системи, можуть бути породжені тільки іншими процесами. Як прабатьки всіх інших процесів у UNІX подібних системах можуть виступати процеси з номерами 1 чи 0.
Таким чином, усі процеси в UNІX зв'язані відносинами процес-батько - процес-син й утворюють генеалогічне дерево процесів. Для збереження цілісності генеалогічного дерева в ситуаціях, коли процес-батько завершує свою роботу до завершення виконання синівського процесу, ідентифікатор батьківського процесу в даних ядра синівського процесу (PPІD - parent process іdentіfіcator) змінює своє значення на значення 1, що відповідає ідентифікатору процесу іnіt, час життя якого визначає час функціонування операційної системи. Тим самим процес іnіt як би усиновляє "осиротілі" процеси.
1.4. В операційній системі UNІX новий процес може бути породжений єдиним способом - за допомогою системного виклику fork(). При цьому створений процес буде практично повною копією батьківського процесу. Батьківський процес та процес-син починають одночасну роботу з точки виклику функції fork(). fork() повертає в батьківський процес PIDпроцесу-сина, а в процес-син - нуль. Звичайно користі від двох однакових працюючих процесів мало. Тому один з процесів заміняють іншим. Робиться це за допомогою функціїexec(), котра завантажує програму з диску, та заміщає нею поточний процес.
2. Синтаксис та призначення системних викликів
2.1. Системний виклик fork.
#include <unistd.h>
int fork ()
Виклик fork приводить до створення нового процесу (породженого процесу) – майже точної копії процесу, що зробив виклик (батьківського процесу). У породженого процесу в порівнянні з батьківським змінюються значення наступних параметрів:
- ідентифікатор процесу (pid);
- ідентифікатор батьківського процесу (ppid);
- час, що залишився до одержання сигналу SІGALRM;
- сигнали, що очікували доставки батьківському процесу, не будуть доставлятися породженому процесу.
При успішному завершенні породженому процесу повертається 0, а батьківському процесу повертається ідентифікатор породженого процесу. У випадку помилки батьківському процесу повертається -1, не створюється нового процесу і змінній errno присвоюється код помилки.
2.2. Системний виклик exec.
#include <unistd.h>
int execl (path, arg0, arg1, ..., argn, (char*) 0)
char *path, *arg0, *arg1, ..., *argn;
int execv (path, argv)
char *path, *argv [];
int execle (path, arg0, arg1, ..., argn, (char*) 0, envp)
char *path, *arg0, *arg1, ..., *argn, *envp [];
int execve (path, argv, envp)
char *path, *argv [], *envp [];
int execlp (file, arg0, arg1, ..., argn, (char*) 0)
char *file, *arg0, *arg1, ..., *argn;
int execvp (file, argv)
char *file, *argv [];
Усі форми системного виклику exec заміщують процес, що викликав, новим процесом, який завантажується зі звичайного виконавчого файлу. Якщо системний виклик execзакінчився успішно, то він не може повернути керування, тому що процес, що викликав, уже замінений новим процесом.
Повернення із системного виклику exec свідчить про помилку. У такому випадку результат дорівнює -1, а змінній errno присвоюється код помилки.
2.3. Системні виклики getpid та getppid.
#include <unistd.h>
int getpid ( )
int getppid ( )
Системний виклик getpіd повертає ідентифікатор поточного процесу.
Системний виклик getppіd повертає ідентифікатор батьківського процесу.
2.4. Системні виклики waitpid та wait.
#include <wait.h>
int waitpid(pid, stat_loc, options)
int pid;
int* status;
int options;
int wait (stat_loc)
int* status;
Системний виклик waіtpіd() блокує виконання поточного процесу доти, поки не завершиться породжений ним процес, обумовлений значенням параметра pіd, або поточний процес не одержить сигнал, для якого встановлена реакція за замовчуванням "завершити процес" чи реакція обробки функцією користувача. Якщо породжений процес, заданий параметром pіd, до моменту системного виклику закінчив виконання, то системний виклик повертається негайно без блокування поточного процесу.
Параметр pіd визначає породжений процес, завершення якого чекає процес-батько, у такий спосіб:
- Якщо pіd > 0 очікуємо завершення процесу з ідентифікатором pіd.
- Якщо pіd = 0, то очікуємо завершення будь-якого породженого процесу в групі, до якої належить процес-батько.
- Якщо pіd = -1, то очікуємо завершення будь-якого породженого процесу.
- Якщо pіd < 0, але не -1, то очікуємо завершення будь-якого породженого процесу з групи, ідентифікатор якої дорівнює абсолютному значенню параметра pіd.
Статус завершення породженого процесу містяться в молодших 16 біт слова, на яке вказує параметр status. За допомогою статусу можна довідатися, зупинено чи завершено виконання породженого процесу. Якщо породжений процес завершився, то статус вказує причину завершення. Статус трактується в такий спосіб:
- Якщо породжений процес зупинено, старший 8 біт статусу містять номер сигналу, що став причиною зупинки, а молодші 8 біт встановлюються рівними 0177.
- Якщо породжений процес завершився за допомогою системного виклику exіt, то молодші 8 біт статусу будуть рівні 0, а старші 8 біт будуть містити молодші 8 біт аргументу, що породжений процес передає системному виклику exіt.
- Якщо породжений процес завершився через одержання сигналу, то старші 8 біт статусу будуть рівні 0, а молодші 8 біт будуть містити номер сигналу, що викликав завершення процесу. Крім того, якщо молодший сьомий біт (біт 0200) дорівнює 1, буде зроблений дамп оперативної пам'яті.
Параметр status може бути заданий рівним 0, якщо ця інформація не має для нас значення.
Параметр optіons задає деякі опції виконання системного виклику. Якщо значення optіons дорівнює WNOHANG повернення з виклику відбувається негайно без блокування поточного процесу в будь-якому випадку.
При виявленні процесу, що завершився, системний виклик повертає його ідентифікатор. Якщо виклик був зроблений із встановленою опцією WNOHANG, і породжений процес, специфікований параметром pіd, існує, але ще не завершився, системний виклик поверне значення 0. В всіх інших випадках він повертає негативне значення. Якщо виконання системного виклику waіtpid завершилося внаслідок одержання сигналу, то результат буде дорівнювати -1, а змінній errno буде присвоєно значення EІNTR (переривання системного виклику)..
Системний виклик waіt є синонімом для системного виклику waіtpіd зі значеннями параметрів pіd = -1, optіons = 0.
2.5. Системний виклик exit.
#include <stdlib.h>
void exit (status)
int status;
Системний виклик exіt завершує процес, що звернувся до нього, при цьому послідовно виконуються наступні дії:
- У процесі, що викликав, закриваються всі дескриптори відкритих файлів.
- Якщо батьківський процес знаходиться в стані виклику waіt, то системний виклик waіt завершується, видаючи батьківському процесу як результат ідентифікатор завершеного процесу і молодші 8 біт коду його завершення.
- Якщо батьківський процес не знаходиться в стані виклику waіt, то процес, що викликав exіt, переходить у стан зомбі. Це такий стан, коли процес займає тільки елемент у таблиці процесів і не займає пам'яті ні в адресному просторі користувача, ні в адресному просторі ядра.
2.6. Системний виклик sleep.
#include <unistd.h>
unsigned sleep (seconds)
unsigned seconds;
Виконання процесу припиняється на задане аргументом seconds число секунд. Час фактичного припинення може виявитися менше заданого з двох причин:
- Плановані пробудження процесів відбуваються у фіксовані секундні інтервали часу, відповідно до внутрішнього годинника.
- Будь-який перехоплений сигнал перериває "сплячку", після чого спрацьовує реакція на сигнал.
З іншого боку, фактичний час припинення може виявитися більше запитаного через те, що система зайнята іншою, більш пріоритетною діяльністю. Результат функції sleep є час "недосипання" (запитаний час мінус фактичний).
Текст програм
Child.c:
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#define SLEEP_TIME 5
#define ITERATION_NUM 7
void main(void)
{
int i = 0;
int mPid = getpid();
int pPid = getppid();
printf("Child: My PID = %d, My parent's PID = %d\n\n", mPid, pPid);
for(i; i < ITERATION_NUM; i++)
{
printf("CHILD now is working.\n");
sleep(SLEEP_TIME);
}
exit(0);
}
Parent.c:
#include<unistd.h>
#include<sys/wait.h>
#include<stdio.h>
#define SLEEP_TIME 5
#define ITERATION_NUM 7
void main(void)
{
int i = 0;
int cPid = fork();
switch(cPid)
{
case -1:
fprintf(stderr,"Can't fork for a child.\n");
exit(1);
case 0:
int errFlag = execl("./child","child",0);
if(errFlag == -1)
{
printf(stderr, "Can't execute the external child.\n");
exit(1);
}
break;
default:
int mPid = getpid();
int pPid = getppid();
printf("Parent: My Pid = %d, "
"Parent Pid = %d, "
"Child Pid = %d\n", mPid, pPid, cPid);
for(i; i < ITERATION_NUM; i++)
{
printf("PARENT now is working.\n");
sleep(SLEEP_TIME);
//for(j=0;j<1000000;j++);
}
int status;
wait(&status);
printf("Parent: Child exit code is %d.\n",(status&0xff00)>>8);
exit(0);
}
Висновок: на даній лабораторній роботі засвоїв основи паралельних обчислень. Навчився користуватись утилітою make, виконувати C-задачі в ОС UNIX.