- Розщеплення процесів з використанням fork ()
- Малюнок 1. Розщеплені процеси
- Лістинг 1. Код програми, що використовує fork ().
- легковагі потоки
- Малюнок 2. Нові потоки в процесі
- Які недоліки потоків?
- Пара слів про створюваний програмному коді
- Програмний код
- Програма з fork ()
- Лістинг 3. Заголовки і оголошення змінних
- Лістинг 4. Функція, що використовує fork ()
- Лістинг 5. Оброблювач сигналу
- Лістинг 6. Функція main
- потоки
- Лістинг 7. Заголовки і визначення
- Лістинг 8. Потік
- Лістинг 9. Функція main
- висновок
- Ресурси для скачування
Що вигідніше створювати: процеси виконання або потоки виконання?
Припустимо, що під час мозкового штурму або обідньої перерви нам в голову прийшла геніальна ідея нового мережевого сервісу - щось таке, що перевернуло б світ, або, по крайней мере, Інтернет. На жаль, невідомо, з чого потрібно почати реалізацію цієї ідеї. Ми прочитали кілька навчальних посібників з програмування сокетів і дізналися, як написати сервіс як однопроцессной цикл, в якому для управління множинними з'єднаннями використовується виклик select ().
Але описаний вище метод занадто схожий на добровільну багатозадачність - метод, який занадто застарів для використання його на сучасних операційних системах. Безумовно не варто витрачати свій час на контроль стану потоків.
На щастя, UNIX® дозволяє створити дочірні процеси, які будуть виконувати за нас цю роботу. Крім того, сучасні UNIX-системи дозволяють використовувати POSIX-потоки. У цій статті розповідається про те, що таке POSIX-потоки і як їх можна використовувати для одночасного виконання кількох завдань.
Розщеплення процесів з використанням fork ()
До того як в UNIX-системах стали використовуватися потоки і спрощені процеси, для створення множинних процесів (шляхом розщеплення ідентичного їм батьківського процесу) використовувався системний метод fork. Розщеплені процеси виконували такий же програмний код, але володіли своєю власною копією вихідних даних, включаючи відкриті файли, сокети, привілеї безпеки і будь-які інші елементи, які необхідно використовувати.
На перший погляд здається, що даний підхід є чудовим рішенням проблеми, пов'язаної з необхідністю одночасного управління декількома мережевими з'єднаннями. Під час створення нового з'єднання слід розщепити батьківський процес; отриманий дочірній процес і буде працювати з протоколом, забезпечувати передачу даних, а батьківський процес в цей час все також буде чекати нових з'єднань ( малюнок 1 ).
Малюнок 1. Розщеплені процеси
В лістингу 1 показаний код програми, що використовує fork ().
Лістинг 1. Код програми, що використовує fork ().
#include <unistd.h> #include <sys / types.h> int main (int argc, char ** argv) {pid_t child = 0; child = fork (); if (child <0) {/ * сталася помилка! * / Handle_error (); } Else if (child == 0) {/ * батьківський процес. * / Handle_parent_duties (); } Else {/ * дочірній процес. * / Handle_child_duties (); } Return 0; }
Які ж недоліки fork ()?
- Перемикання між потоками може знижувати продуктивність через накладних витрат на переключення контексту (збереження і відновлення регістрів, можливий свопинг програми в пам'ять). Також планувальник системи може обмежувати число активних процесів. Крім того, з певного моменту система може витрачати набагато більше часу на обчислення того, який процес повинен виконуватися, ніж на виконання самих процесів.
- Якщо після виклику fork () залишаться відкритими успадковані файли і сокети, то можуть з'явитися несподівані проблеми з синхронізацією. Можливо, щоб зробити окремі копії файлів або дескрипторів сокетів, потрібно використовувати метод dup (), а це призведе до додаткових накладних витрат.
- Створення копії всіх даних програми може зайняти деякий час. Крім того, при копіюванні даних може бути даремно витрачена пам'ять, яка була необхідна для виконання процесів.
- Якщо дочірній і батьківський процеси повинні взаємодіяти між собою або дочірні процеси повинні взаємодіяти один з одним або використовувати загальні дані, то слід пам'ятати, що між процесами синхронізація в більшості випадків працює дуже повільно.
Тепер, після того як були розглянуті причини, за якими не слід використовувати fork (), варто познайомитися з потоками і розглянути кілька прикладів вихідного коду.
легковагі потоки
Через недоліки fork () UNIX-розробники почали створювати більш спеціалізовані рішення, які у відповідних ситуаціях були б більш оптимальні, ніж застарілий метод fork (). В кінцевому рахунку, були створені легковагі потоки управління. Під процесами тепер розуміється один або декілька потоків виконання, а не просто потік інструкцій.
Коли процес створює новий потік, цей потік є частиною процесу. Для цього потоку стають доступними всі відкриті файли і сокети. І, що ще більш важливо, загальними стають все дані процесу, що робить синхронізацію між потоками простою і ефективною ( малюнок 2 і лістинг 2 ).
Малюнок 2. Нові потоки в процесі
Лістинг 2. Код програми, що реалізує даний підхід
#include <pthread.h> #include <stddef.h> void * thread_function (void * data) {/ * робимо що-небудь корисне. * / Return NULL; } Int main (int argc, char ** argv) {pthread_t threadId = 0; pthread_attr_t attr; pthread_attr_init (& attr); pthread_attr_setdetachstate (& attr, PTHREAD_CREATE_DETACHED); (Void) pthread_create (& threadId, & attr, & thread_function, NULL); / * Вирішуємо інші завдання в той же час, коли працює потік. * / Return 0; }
Як можна помітити, для створення потоку потрібно трохи більше зусиль, ніж при використанні методу fork (), але разом з тим даний підхід надає набагато більше можливостей в плані гнучкості і управління.
Які недоліки потоків?
- Необхідно використовувати потокобезпечна бібліотеки або обережно організовувати виклики функцій, які зберігають проміжний стан між викликами. В UNIX є функції, спеціально призначені для збереження проміжного стану; наприклад функція rand (), яка зберігає внутрішні дані в статичної змінної, і має потокобезпечна аналог rand_r () для систем, які підтримують POSIX-потоки. Потокобезпечна бібліотеки в більшості випадків доступні для повсюдного використання. Тому ніяких проблем не виникне за умови, що ми не будемо забувати про необхідність використання потокобезпечна функцій. А якщо забудемо про це, то додаток буде працювати неадекватно, а помилка буде трудноуловимой.
- Необхідність налагодження взаємного блокування потоків. Дана помилка може бути викликана некоректним використанням методик синхронізації (наприклад, м'ютексів і умовних змінних), і її налагодження може виявитися важким завданням.
- Необхідно позбутися поганих звичок при програмуванні, наприклад, від використання глобальних і статичних переменнихе всередині функцій.
Тепер, коли ми розглянули структури програми з методом fork () і структуру програми з потоками, давайте створимо завершену програму для ілюстрації кожного з підходів.
Пара слів про створюваний програмному коді
Посилання для скачування програмного коду, використовуваного в цій статті, можна знайти в розділі Матеріали для скачування . За допомогою команди File> Import можна імпортувати проекти з вихідним кодом, що використовують make, в Eclipse. Eclipse є чудовою платформою і інтегрованим середовищем розробки з великою кількістю плагінів від сторонніх розробників. Більш детальна інформація про Eclipse приведена в розділі ресурси .
Програмний код
У цьому прикладі буде генеруватися послідовність випадкових чисел. Зазвичай для цього не потрібно використовувати fork () і легковагі потоки, але ці ж прийоми підійдуть для таких завдань:
- довгі операції введення / виводу;
- мережеві служби;
- аналіз інформації;
- криптографія;
- стиснення даних;
Будь-яке завдання, виконання якої складається з декількох кроків, виграє від многопоточности в тому чи іншому вигляді, якщо два або більше таких кроку зможуть виконуватися одночасно.
Крім коду для створення (а потім відкидання) кількох випадкових чисел, обидві програми є "хорошим прикладом" управління дочірніми процесами і потоками.
Програма з fork ()
У цьому підході для того щоб визначити, коли дочірній процес завершує свою роботу, потрібно обробник сигналів. Коли закінчено розщеплення на дочірні процеси, обробник сигналів чекає заплати за працю всіх, хто лишився дочірніх процесів, а потім повертається з власної функції main ().
В лістингу 3 можна побачити стандартні заголовки, необхідні для оголошення функцій і інших ідентифікаторів, використовуваних в даній програмі. При компонуванні не потрібно використовувати ніяких додаткових бібліотек, оскільки використовуються стандартні функції C. Будуть використовуватися 32 дочірніх процесу; тоді з урахуванням батьківського процесу над одним завданням працюватимуть 33 процесу. Крім того, створюється глобальна змінна для обліку кількості виконуються робочих процесів. Ця змінна знадобиться в кінці функції main () для акуратного завершення роботи програми.
Лістинг 3. Заголовки і оголошення змінних
#include <errno.h> #include <signal.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <sys / types.h > #define FORK_WORKERS 32 volatile static int activeWorkers = 0;
Нижче представлена звичайна функція work ( лістинг 4 ). Якщо поданий нижче програмний код не цікавий читачеві, його можна замінити якимось більш корисним програмним кодом. Всі компоненти функції fork_worker (), включаючи створення випадкових чисел за допомогою методу rand_r (), заслуговують на окремий розгляд. Оскільки rand () не є потокобезпечна функцією, в програмному коді замість неї використовується функція rand_r (). Функція rand () підтримує стан системи за допомогою внутрішньої статичної змінної; для багатопотокового додатку даний підхід є помилкою. Для програми, що використовує fork (), наявність подібної статичної змінної не настільки важливо, хоча і є ознакою поганого тону в програмуванні.
Лістинг 4. Функція, що використовує fork ()
void fork_worker (void) {unsigned int randState = 0; int idx = 0; randState = (unsigned int) getpid (); for (idx = 0; idx <(FORK_WORKERS * FORK_WORKERS); idx ++) {int val = rand_r (& randState); if (val> randState) {val ++; } Else {val--; }} Exit (EXIT_SUCCESS); }
Цю невелику функцію викликає обробник сигналів SIGCHLD, який буде створений далі в функції fork (). Менеджер процесів в UNIX посилає сигнал SIGCHLD до батьківського процесу щоразу, коли який-небудь з дочірніх процесів завершує свою роботу. Це дає шанс отримати статус дочірнього процесу в момент виходу за допомогою функції wait (). Завдяки цьому можна не допустити того, щоб завершився дочірній процес став зомбі-процесом. Зомбі - це все, що залишилося від програм після завершення їх виконання - може бути, тільки ідентифікатор процесу і код виходу або додаткова інформація, яку схешіровал менеджер процесів. Якщо не видаляти зомбі-процеси за допомогою функцій wait () і waitpid (), які дозволяють очікувати завершення певного процесу, то таблиця процесів переповниться, і створювати нові процеси буде неможливо.
Обробники сигналів не володіють великою функціональністю, оскільки більшість функцій в них заборонені. На щастя, функція wait () є однією з небагатьох функцій стандартної бібліотеки С, яка обробляє сигнали, і ідеально підходить під завдання, які реалізуються в цій статті.
Якщо батьківський процес не буде чекати завершення виконання дочірніх процесів, він буде блокований функцією wait (). Так як обробник сигналу, показаний в лістингу 5 , Викликається при завершенні виконання дочірнього процесу, то виклик функції wait (), який знаходиться в цьому обробнику, призведе до негайного завершення роботи процесу.
Лістинг 5. Оброблювач сигналу
void worker_exitted (int sig) {int status; activeWorkers--; (Void) wait (& status); }
Після оголошення декількох змінних в лістингу 6 буде показано, як використовувати sigaction () для настройки обробника сигналів таким чином, щоб запобігти появі зомбі-процесів.
Цикл for містить найцікавіший ділянку виконання програми. При виклику fork () батьківський процес розділяється на два ідентичних процесу - сам батьківський процес і дочірній процес. Після розщеплення батьківський процес отримує від fork () ідентифікатор дочірнього процесу, а сам дочірній процес отримує 0. Якщо в момент створення дочірнього процесу відбувається помилка, повертається негативний код помилки.
Оператор if, який слідує за викликом fork (), обробляє можливі помилки. Якщо їх немає, і при цьому поточний процес є дочірнім, то викликається розглянута раніше функція fork_worker (). Якщо виконується батьківський процес, то збільшується лічильник виконуються дочірніх процесів.
Перед виходом з fork () слід переконатися, що немає дочірніх процесів, які були б в змозі зомбі. Тому якщо дочірні процеси все ще виконують будь-яку роботу, виклики wait () будуть блоковані ( лістинг 6 ).
Лістинг 6. Функція main
int main (int argc, char ** argv) {pid_t ppid = 0; int idx = 0; struct sigaction signalHandler; sigset_t mask; (Void) sigemptyset (& mask); signalHandler.sa_handler = worker_exitted; signalHandler.sa_mask = mask; signalHandler.sa_flags = 0; (Void) sigaction (SIGCHLD, & signalHandler, NULL); printf ( "Launching% d child processes ... \ n", FORK_WORKERS); / * Створення дочірнього процесу * / for (idx = 0; idx <FORK_WORKERS; idx ++) {ppid = fork (); if (ppid <0) {printf ( "Error creating child% d:% s \ n", idx, strerror (errno)); } Else if (ppid == 0) {/ * це дочірній процес. * / Fork_worker (); } Else {/ * це батьківський процес. * / Printf ( "Child% d's PID is% d \ n", idx, ppid); activeWorkers ++; }} Sleep (1); / * Очікування, коли завершать роботу дочірні процеси * / printf ( "Waiting for% d children to exit ... \ n", activeWorkers); while (activeWorkers> 0) {int status = 0; if (wait (& status) == -1) {/ * All children have exited already. * / Break; } ActiveWorkers--; if (WIFSIGNALED (status)) {printf ( "Child exitted due to signal% d \ n", WTERMSIG (status)); } Else {printf ( "Child's exit status was% d \ n", WEXITSTATUS (status)); }} Printf ( "Done! \ N"); return EXIT_SUCCESS; }
Цікавим моментом в лістингу 6 є також те, що статус, що повертається wait (), з яким процес завершує своє виконання, не відповідає значенню, що повертається exit () або будь-яким іншим методом завершення роботи процесу. У цьому статусі міститься додаткова інформація, тому для перетворення його до нормального вигляду необхідно використовувати макрос WIFSIGNALED ().
потоки
Незважаючи на те що багатопотокова версія програми виглядає більш складною, насправді вона простіша. API-інтерфейс POSIX-потоків значно більш гнучкий, ніж застарілі функції fork () і wait ().
Як і раніше, потрібно включити кілька системних заголовків файлів і визначити необхідну кількість потоків. Відзначимо також відсутність глобальних змінних.
Також можна визначити EOK, оскільки не всі заголовки errno.h містять визначення значення "no error" ( лістинг 7 ).
Лістинг 7. Заголовки і визначення
#include <errno.h> #include <pthread.h> #include <stdio.h> #include <stdlib.h> #include <unistd.h> #define THREAD_WORKERS 32 #if! defined (EOK) # define EOK 0 #endif
В лістингу 8 приведена функція thread_worker (), яка майже ідентична функції fork_worker (), розглянутої раніше. Буде використовуватися функція rand_r (), тому при генерації випадкових чисел інші потоки Ніяк не переплетуться зі станом системи, яке виходить викликом функції rand (). Для кожної бібліотечної функції існує "_r"-версія, яка зберігає стан системи між своїми викликами.
Лістинг 8. Потік
void * thread_worker (void * data) {unsigned int randState = 0; int idx = 0; randState = (unsigned int) getpid (); for (idx = 0; idx <(THREAD_WORKERS * THREAD_WORKERS); idx ++) {int val = rand_r (& randState); if (val> randState) {val ++; } Else {val--; }} Return NULL; }
У функції main створюються потоки; потім система, перш ніж закінчити свою роботу, чекає, поки потоки завершать своє виконання. В цьому випадку не потрібно створювати обробника сигналів, оскільки потоки не можуть весь час залишатися в стані зомбі.
Виклик функції pthread_create () створює новий потік і потім запускає його на виконання. Ця остання деталь може бути особливо важливою, якщо перед продовженням виконання потоку необхідно встановити додаткові параметри; використовуйте м'ютекс або інші інструменти синхронізації для блокування нового потоку.
Завершальний цикл for ( лістинг 9 ) Перебирає всі ідентифікатори потоків (thread IDs) і використовує виклик методу pthread_join () для очікування завершення закінчення роботи кожного потоку і його виходу. Нічого особливого для отримання значення, з яким потік завершує своє виконання, не потрібно.
Лістинг 9. Функція main
int main (int argc, char ** argv) {pthread_t threadIds [THREAD_WORKERS]; int idx = 0; int retval = 0; printf ( "Creating% d child threads ... \ n", THREAD_WORKERS); / * Створення і запуск дочірнього потоку * / for (idx = 0; idx <THREAD_WORKERS; idx ++) {retval = pthread_create (& (threadIds [idx]), NULL, & thread_worker, NULL); printf ( "Child% d's thread ID is% d \ n", idx, (int) threadIds [idx]); } Sleep (1); / * Очікування до тих пір, поки завершаться дочірні потоки * / for (idx = 0; idx <THREAD_WORKERS; idx ++) {int status = 0; retval = pthread_join (threadIds [idx], (void *) & status); if (retval == 0) {printf ( "Thread% d's exit status was% d \ n", (int) threadIds [idx], status); } Else {printf ( "Error joining thread% d \ n", (int) threadIds [idx]); }} Printf ( "Done! \ N"); return EXIT_SUCCESS; }
Використовуючи потоки, слід бути обережним при роботі з спільно використовуваними ресурсами, наприклад, глобальними змінними та функціями, які зберігають стан системи в статичної змінної. Однак при використанні потоків найбільш правильно використовуються переваги многопроцессорности системи, і можна ділити роботу програми на прості, легковагі потоки виконання.
висновок
Існує одна проблема, з якою доводиться зіткнутися при розробці мережевого сервісу для UNIX: як продовжувати встановлювати вхідні з'єднання і одночасно обслуговувати вже підключилися клієнтів. Як же вирішити цю проблему?
Застарілій підхід Полягає у вікорістанні стандартного UNIX-методу fork () для розщеплення процесса на ідентічні батьківський и дочірні процеси. Батьківський процес может продовжуваті чекати нове з'єднання, в тій годину як дочірній процес буде обслуговувати клієнта. Як тільки дочірній процес буде завершений, батьківський процес буде перерваний сигналом. Якщо не видаляти завершені дочірні процеси, таблиця процесів заповниться зомбі, що унеможливить створення нових процесів.
Багатопотокове програмування є новим віянням для багатьох UNIX-програмістів, але воно пропонує набагато більш витончений спосіб одночасно виконувати кілька завдань. Процес для виконання своїх завдань може створити один або кілька потоків; функція main чекатиме нові сполуки, в той час як потоки без додаткового контролю працюватимуть з клієнтами. Немає необхідності проводити очистку таблиці процесів і обробку сигналів. При виконанні програми не виникне ситуації, пов'язаної з ненавмисним переповненням таблиці процесів, що може перешкодити встановлювати нові з'єднання.
Ресурси для скачування
Схожі тими
Підпішіть мене на ПОВІДОМЛЕННЯ до коментарів
Які недоліки потоків?Як же вирішити цю проблему?