Донецкий техникум промышленной автоматики

Правильна многопоточность: «Так» - плавності, «ні» - блокувань!

  1. Зміст статті Відомо, що додатки бувають однопоточні і багатопотокові. Single thread софт кодіть...
  2. Concurrent Queue
  3. висновок

Зміст статті

Відомо, що додатки бувають однопоточні і багатопотокові. Single thread софт кодіть легко і приємно: розробнику не треба замислюватися про синхронізацію доступу, блокування, взаємодії між нитками і так далі. У багатопотокової середовищі всі ці проблеми часто стають кошмаром для програміста, особливо якщо досвіду роботи з тред у нього ще не було. Щоб полегшити собі життя в майбутньому і зробити multi thread додаток надійним і продуктивним, потрібно заздалегідь продумати, як буде влаштована робота з потоками. Ця стаття допоможе твоєму мозку побачити правильний напрямок!

Асинхронне програмування набирає обертів. Користувачі люблять, коли софт виконує всі дії практично моментально, а UI працює плавно і без лагів. Як би не росла продуктивність заліза, але відповідати таким високим вимогам можна тільки за допомогою тредов і асинхронности, а це, в свою чергу, створює безліч дрібних і не дуже проблем, які доведеться вирішувати звичайним програмістам.

Для того щоб зрозуміти, що за підводні камені таїть в собі робота в багатопотокової середовищі, потрібно поглянути на наступний код:

Singlethread код int Foo () {int res; // Щось довго робимо і повертаємо результат retrun res; } // В основному потоці викликаємо Foo auto x = Foo (); // ...

У нас є функція Foo, яка виконує деякі дії і повертає результат. У головному потоці програми ми запускаємо її, отримуємо результат роботи і йдемо робити далі свої справи. Все добре, за винятком того, що виконання Foo займає досить тривалий час і її виклик в GUI-потоці призведе до замерзання всього інтерфейсу.

Це погано, дуже погано. Сучасний користувач такого не переживе. Тому ми, трохи подумавши, вирішуємо винести дії, що виконуються в нашій функції, в окремий потік. GUI НЕ залипне, а Foo спокійно відпрацює в своєму тред.

Асинхронний виклик функції

З виходом C ++ 11 жити стало простіше. Тепер для створення свого тред не треба використовувати складні API Майкрософт або викликати застарілу _beginthread. У новому стандарті з'явилася нативна підтримка роботи з потоками. Зокрема, зараз нас цікавить клас std :: thread, який є не чим іншим, як STL поданням потоків. Працювати з ним - одне задоволення, для запуску свого коду достатньо лише передати його в конструктор std :: thread у вигляді функціонального об'єкта, і можна насолоджуватися результатом.

Варто також відзначити, що ми можемо дочекатися, коли потік закінчить свою роботу. Для цього нам знадобиться метод thread :: join, який якраз і служить для цих цілей. А можна і зовсім не чекати, зробивши thread :: detach. Наш попередній однопотоковий приклад може бути перетворений в multi thread всього лише додаванням одного рядка коду.

Нить за допомогою std :: thread // ... auto thread = std :: thread (Foo); // Foo виконується в окремому потоці // ...

Здавалося б, все добре. Ми запустили Foo в окремому потоці, і, поки вона відпрацьовує, ми спокійно займаємося своїми справами, не змушуючи основний потік чекати завершення тривалої операції. Але є одне але. Ми забули, що Foo повертає якийсь результат, який, як не дивно, нам потрібен. Найсміливіші можуть спробувати зберігати результат в якусь глобальну змінну, але це не наш метод - занадто непрофесійно.

Спеціально для таких випадків в STL є чудові std :: async і std :: future - шаблонна функція і клас, які дозволяють запустити код асинхронно і отримати за запитом результат його роботи. Якщо переписати попередній приклад з використанням нових примітивів, то ми отримаємо приблизно наступне:

Пробуємо std :: async і std :: future // ... std :: future <int> f = std :: async (std :: launch :: async, Foo); // Працюємо далі в основному потоці // Коли нам потрібно, отримуємо результат роботи Foo auto x = f.get ();

У std :: async ми передали прапор std :: launch :: async, який означає, що код треба запустити в окремому потоці, а також нашу функцію Foo. В результаті ми отримуємо об'єкт std :: future. Після чого ми знову продовжуємо займатися своїми справами і, коли нам це знадобиться, звертаємося за результатом виконання Foo до змінної f, викликаючи метод future :: get.

Після чого ми знову продовжуємо займатися своїми справами і, коли нам це знадобиться, звертаємося за результатом виконання Foo до змінної f, викликаючи метод future :: get

Приклад використання std :: thread

Виглядає все ідеально, але досвідчений програміст напевно запитає: «А що буде, якщо на момент виклику future :: get функція Foo ще не встигне повернути результат своїх дій?» А буде те, що головний потік зупиниться на виклик get до тих пір, поки асинхронний код не завершиться.

Таким чином, при використанні std :: future головний потік може блокуватися, а може і ні. У підсумку ми отримаємо нерегулярні лаги нашого GUI. Наша мета - повністю уникнути таких блокувань і домогтися того, щоб головний тред працював швидко і без фризів.

Concurrent Queue

У прикладі з future :: get ми фактично використовували м'ютекс. Під час спроби отримання значення з std :: future код шаблонного класу перевіряв, чи закінчив свою роботу потік, запущений за допомогою std :: async, і якщо немає, то очікував його завершення. Для того щоб один потік ніколи не чекав, поки відпрацює інший, розумні програмісти придумали потокобезпечна чергу.

Будь-кодер знає такі структури даних, як вектор, масив, стек і так далі. Черга - це одна з різновидів контейнерів, що працює за принципом FIFO (First In First Out). Thread-safe чергу відрізняється від звичайної тим, що додавати і видаляти елементи можна з різних потоків і при цьому не боятися, що ми одночасно спробуємо записати або видалити що-небудь з черги, тим самим з великою часткою ймовірності отримавши падіння програми або, що ще гірше, невизначене поведінку.

Concurrent queue можна використовувати для безпечного виконання коду в окремому потоці. Виглядає це приблизно так: в потокобезпечна чергу ми кладемо функціональний об'єкт, а в цей час в робочому потоці крутиться нескінченний цикл, який на кожній ітерації звертається до черги, дістає з неї переданий їй код і виконує його. Щоб краще зрозуміти, можна поглянути на код:

Реалізація потокобезпечна черзі команд class WorkQueue {public: typedef std :: function <void (void)> CallItem; WorkQueue (): done (false), thread ([=] () {while (! Done) queue.pop () ();}) {} void PushBack (CallItem workItem) {queue.push (workItem); } // ... private: concurrent_queue <CallItem> queue; bool done; std :: thread thread; };

Тепер наш код виконується в черзі, в іншому потоці. Більш того, ми можемо відправити на виконання не тільки Foo, а й інші функції, які виконуються дуже довго. При цьому ми не будемо кожен раз створювати окремий thread для кожної функції.

При цьому ми не будемо кожен раз створювати окремий thread для кожної функції

Документація шаблонного класу std :: feature

Але ми знову забули про яке значення. Одним із способів вирішення цієї проблеми буде зворотний виклик. Ми повинні передавати в робочу чергу не тільки код, що вимагає виконання, а й callback-функцію, яка буде викликана обчисленим значенням в якості параметра.

Callback для повернення значення class WorkQueue {public: typedef std :: function <int (void)> WorkItem; typedef std :: function <void (int)> CallbackItem; WorkQueue (): done (false), thread ([=] () {while (! Done) queue.pop () ();}) {} void PushBack (WorkItem workItem, CallbackItem callback) {queue.push ([= ] () {auto res = workItem (); callback (res);}); } // ... private: typedef std :: function <void (void)> CallItem; concurrent_queue <CallItem> queue; bool done; std :: thread thread; };

Але тут слід пам'ятати, що зворотний виклик буде зроблений в робочому потоці, а не в клієнтському, тому заздалегідь слід подбати про безпечну передачу значення. Якщо все додаток побудовано на основі архітектури потокобезпечна черг, тобто у кожного об'єкта є своя чергу команд, то рішення даної проблеми стає очевидним - ми просто будемо доручати виконання зворотного виклику черзі, в якій працює об'єкт, що запитав у нас виконання Foo.

Ще один варіант використання concurrent queue

На словах це звучить досить просто, але на практиці доведеться вирішувати безліч проблем, які так чи інакше пов'язані з багатопоточність. Так як все відбувається асинхронно, то цілком може бути, що після закінчення виконання будь-якого коду об'єкт, що запитав це виконання, вже не буде існувати і, повертаючи йому значення обчислень, ми можемо зламати всю програму. Такі нюанси треба враховувати з самого початку, щоб на етапі проектування виключити всі подібні ситуації.

висновок

Асинхронне програмування нині в тренді. Призначені для користувача інтерфейси iOS і Windows Phone працюють плавно і без лагів якраз через те, що в їх основу закладені принципи, що дозволяють уникати блокувань потоків в очікуванні результатів роботи тих чи інших тривалих дій. І чим далі, тим більш яскраво буде виражено рух в сторону асинхронности роботи ПО.