ФОСС-Он-Лайн - Багатопоточне програмування в Linux
Особливості багатопотокового програмування в Linux
-
Системний виклик __clone.
У лінуксі (точніше в pthreads) використовується однорівнева система потоків, так звана N-to-N (або 1-1) реалізація, яка мултеплексує створені користувачем потоки в таку кількість виконуваних потоків ядра. Однак, насправді, потоки в лінуксі є звичайними процесами, що мають свій унікальний pid_t, стек, але розділяють між собою певні складові контексту процесу - пам'ять, таблицю файлових дескрипторів, таблицю обробників сигналів. Такі процеси називаються " полегшеними " (LWP - Light Weight Processes), й у лінуксі їхнього створення ( як й у створення звичайних процесів ) використовується системний виклик __clone(2). Сигнатура цього виклику така:int __clone(int (*fn) (void *arg), void *child_stack, int flags, void *arg)
Молодший байт праметраflagsмістить номер сигналу, що посилається батьківському процесу, коли завершується створений їм нащадок. Параметр також може бути встановлений за допомогою операції побітового або ( ) з наступними костантами, щоб вказати, що саме розділяться між нащадком і батьком:
| CLONE_VM |
ЯкщоCLONE_FSвстановлено, батько та нащадок використовують загальну інформацію про файлову систему. Сюди входить кореневий каталог, поточний каталог та параметр umask процесу. Будь-який виклик chroot(2), chdir(2), or umask(2), виконаний нащадком, або батьком впливає також і на інший процес. ЯкщоCLONE_FSне встановлено, для нащадка створюється копія інформації про файлову систему батька та момент виклику __clone. Дзвінки chroot(2), chdir(2), umask(2)виконані процесами впливають інший процес.
ЯкщоCLONE_FILESвстановлено, батько та нащадок будуть використовувати загальну таблицю файлових дескриторів. Ці дескриптори завжди будуть посилатися на одні й ті самі файли і в батьківському процесі, і в нащадку. Будь-який файловий дескриптор, відкритий в одному процесі, може бути використаний в іншому. Те саме стосується і закриття дескриптора за допомогою close(2) або зміни його флпгів за допомогою fcntl(2). ЕслтCLONE_FILESне встановлений, нащадок отримує копію файлових дескритпорів батька на момент виконання __clone. Усі операції над файловими дескрипторами проводяться процесами незалежно один від одного.
ЯкщоCLONE_SIGHANDвстановлено, батько та нащадок використовують загальну таблицю обробників сигналів. Якщо нащадок чи батько викликають sigaction(2) щоб змінити реакцію сигнал, ці зміни відбуваються й у іншому процесі. Проте обидва процеси мають окремі сигнальні маски. Таким чином, кожен з них може блокувати або розблокувати сигнал, використовуючи sigprocmask(2) не впливаючи на інший процес. ЯкщоCLONE_SIGHANDне встановлено, нащадок отримує копію таблиці обробників синалів на момент виклику __clone. Виклики sigaction(2) виконані пізніше в одному з процесів не впливають на інший.
ЯкщоCLONE_PIDвстановлений, нащадок отримує такий самий ідентифікатор процесу (process ID), що й у батька. Але використання цього прапора не рекомендується, оскільки більшість програм все ще розраховує на унікальність ідентифікаторів процесів. ЯкщоCLONE_PIDне встановлено, нащадок отримує свій унікальний ідентифікатор.
Таким чином, для створення потоку потрібно задатиflagsякCLONE_VM CLONE_FS CLONE_FILES CLONE_SIGHAND.
Системний виклик fork.
Відповідно до POSIX, системний виклик fork(2) створює новий процес, що складається тільки з одного потоку, а саме копії того, що викликав fork(). ( У так званих UI threads є версія fork(), що створює повну копію процесу з усіма потоками). Тут треба не забувати про те, що якщо в іншому потоці якийсь мутекс був заблокований, то він залишиться назавжди заблокований і в новому процесі. Щоб уникнути цього, слід використовувати функцію pthread_atfork(2).
Використання сигналів у багатопотоковому додатку є не дуже гарною ідеєю. Чим менше їх змішувати, тим краще. Тим більше, що в linuxthreads є деякі відхилення від стандарту POSIX. Як відомо потоки створюються за допомогою виклику clone(2), де в параметрах вказується, що потоки мають загальну таблицю обробників сигналів, проте потоки в лінуксі, з погляду ядра - це процеси, а отже кожен сигнал має на меті цілком конкретний ідеифікатор процесу. Спробуйте, наприклад, створити програму з двох потоків, в кожному з потоків запустити інтервальний таймер процесу за допомогою setitimer(2), наприклад, ITIMER_REAL. Після закінчення заданого інтервалу ядро надішле сигнал SIGALRM, який обробиться у вказаному вами обробнику, але особливість тут у тому, що у кожного потоку свій таймер, відповідно SIGALRM буде посилатися обом потокам, але оброблятися одним обробником, і розібратися який власне таймер спрацював не завжди буває просто (можна використовувати ідефікатори процесів, що відповідають працюючим потокам).
Під час написання та налагодження програми у разі виникнення помилки сегментації ядро посилає процесу сигнал SIGSEGV, дією за замовчуванням дляякого є завершення процесу зі створенням core dump – образу пам'яті процесу. Цей файл зручно використовувати для postmorten аналізу, щоб знайти причину помилки. У разі багатопоточного докладання іноді буває так, що core dump не створюється. Причиною цього можуть бути системні обмеження розміру core файлу, встановлювані викликом setrlimit(2). Проте варто замінити обробник SIGSEGV наступним:
Має сенс у працюючій версії заборонити створення core файлу за допомогою установок setrlimit(2), щоб уникнути можливості отримання доступу до будь-якої закритої інформації, що знаходилася в пам'яті процесу.
Облік системних ресурсів.
Тут хочеться нагадати те, про що багато хто забуває працюючи з Posix threads взагалі, а не тільки з Linuxthreads. Йдеться про thread cancelation, тобто. про припинення виконання потоку з іншого потоку за допомогою pthread_cancel(). При цьому часто забувають про стікові об'єкти, а при thread cancelation стек не розкручується. Приклад програми, що містить помилку, наведено нижче. Тут створюється об'єкт класу A (змінна a) на стеку потоку, але в результаті виклику функції pthread_cancel() у початковому (батьківському) потоці th1 завершується, а деструктор об'єкта не викликається.
Виходом є використання динамічної пам'яті з порахунком покажчиків та встановленням cleanup handlers за допомогою pthread_cleanup_push/pop.
Крім цього, мутекс, заблокований потоком, не розблокується автоматично при примусовому завершенні, що може призвести до тупикової ситуації, якщо який-небудь інший потік спробує заблокувати цей же мутекс. Розблокування мутексу також слід "доручити" cleanup hadlers.
У бібліотеці linuxthreads є кілька функцій із суфіксом _np (non portable). pthread_cleanup_push_defer_np(3) pthread_cleanup_pop_restore_np(3) pthread_kill_other_threads_np(3) pthread_mutexattr_setkind_np(3)
Саме по собі використання потоків не завжди призводить до підвищення швидкодії програми, адже якщо у вас не мультипроцесорна система, то про паралельне виконання можна говорити лише з деяким ступенем наближеності.
При створенні додатків з використанням linuxthreads не варто забувати, що кожен потік в лінуксі займає рядок у таблиці процесів. Якщо ви вирішили використовувати сотню або більше потоків, краще спробуйте переробити модель програми. Можливо, постійне перемикання контексту між потоками не призведе до збільшення швидкодії, а навіть і помітно знизить його і збільшить загальне завантаження системи - адже перемикання контексту процесу в ядрі все-таки досить дорога операція.
Варто уникати використання у програмі мутексів типу PTHREAD_MUTEX_RECURSIVE_NP (рекурсивних мутексів), їх використання уповільнює виконання програми, тому краще добре пропрацювати свій код, щоб уникнути повторного блокування вже заблокованого в цьому потоці мутексу.
Найбільш частим серед програмістів-початківців (на мою думку через простоту реалізації) підходом при створенні мережевих додатків є модельодин потік на з'єднання, коли кожне нове мережне з'єднання являє собою синхронний блокуючий сокет, оброблюваний виділеним потоком. Простота цього підходу є також причиною досить частої помилки про високу ефективність цієї моделі. Однак це не так: потік, що заблокувався на сокеті буде просто поїдати час прцессора, не виконуючи жодних корисних функцій і головне забирати час употоки, сокети яких мають дані для зчитування (або можливість для запису). Застереження: якщо у вас комп'ютер з 1000 процесорів, або просто якщо у вас потоків менше ніж процесорів, то ця модель ще в принципі забезпечує достатню продуктивність. Якщо ж ви хочете отримати додаток, здатний обслуговувати тисячі одночасних з'єднань на звичайній машині з одним-двома процесорами, то необхідно в першу чергу відмовитися від використання блокуючих сокетів. При цьому використовується невелика автоматично регульована (у деяких предлах) кількість потоків, кожен з яких працює з рівною кількістю сокетів (наприклад, максимум 64 дескриптори на потік). Вибір сокету здійснюється за допомогою системного дзвінка poll(2). Така модель досить легко портується на інші системи, проте, в лінуксі є можливість не опитувати сокети, а доручити ядру інформувати потік про певну подію на дескріторі сокету - за допомогою команди F_SETSIG виклику fcntl(2) можна встановити сигнал, який при цій події буде відправлятися ядром процесу. Відмінність від існуючої практично у всіх юніксах можливості відправки сигналу SIGIO при встановленому прапорі O_ASYNC(FIOASYNC) полягає в тому, що якщо обробник сигналу буде встановлений за допомогою SA_SIGINFO в sigaction(2), то сокет змінив стан буде вказаний у параметрі si_fd структури siginfo_t. Даний спосіб є нестерпним, однак більш швидким ніж перебір набору структур pollfd в системному виклику poll, тому як перебір цих структур (а він приходить і в ядрі, а потім і в потоці) може займати чималу частку часу, виділеного ядром потоку (time_slice).
До питання продуктивності слід віднести і проблеми, описані в пунктіОблік системних ресурсів., оскількивикористання стекових об'єктів небезпечно з погляду thread cancelation, а використання динамічних об'єктів (виділення/звільнення пам'яті з допомогою new/delete) менш ефективно. Тут можна враховувати те, які функції є точками виходу (cancelation points) - в linuxthreads до таких відносятьсяpthread_join(3),pthread_cond_wait(3),pthread_cond_timedwait(3),pthread_testcancel(3),sem_wait(3),sigwait(3), тобто. можна організувати виконання програми таким чином, що стікові об'єкти вже будуть знищені на момент попадання в cancelation point. Природно, це стосується потоків з PTHREAD_CANCEL_DEFERRED прапором обробки cancelation request, а не PTHREAD_CANCEL_ASYNCHRONOUS.