Thread Pool у мобільній розробці — основи, пул потоків і принцип роботи

Автор: IT Sectr Опубліковано: 2026-03-18 Час читання: 11 хв

Thread Pool — це механізм керування потоками, за якого попередньо створений пул потоків перевикористовується для виконання завдань, уникаючи накладних витрат на створення та знищення потоків. У мобільній розробці thread pool використовується для фонових операцій: мережевих запитів, обробки зображень, роботи з базами даних. За даними Google Android Documentation (2025), ExecutorService є рекомендованим способом керування фоновими потоками в Android. В iOS аналогічну роль виконують OperationQueue та GCD DispatchQueue з глобальними concurrent чергами.

Головне

  • Thread Pool — пул перевикористовуваних потоків для виконання фонових завдань без накладних витрат на створення потоків.
  • ExecutorService в Android керує пулом через ThreadPoolExecutor з налаштовуваними параметрами.
  • OperationQueue в iOS інкапсулює thread pool через maxConcurrentOperationCount.
  • Core pool size — мінімальна кількість потоків, завжди готових до виконання завдань.
  • Work queue зберігає завдання, що очікують вільний потік у пулі.

Що таке Thread Pool?

Thread Pool (пул потоків) — це архітектурний патерн, за якого фіксована кількість потоків створюється заздалегідь і перевикористовується для виконання багатьох завдань. Замість створення нового потоку на кожну операцію (що дорого: близько 1 МБ стеку на потік у JVM), завдання поміщаються в чергу та виконуються вільними потоками з пулу. У мобільній розробці thread pool критично важливий для продуктивності — Android та iOS обмежують кількість потоків на застосунок.

Чому Thread Pool важливий у мобільній розробці

Створення потоку — дорога операція: виділення стеку, реєстрація в системі, контекстне перемикання. На мобільних пристроях з обмеженими ресурсами неконтрольоване створення потоків призводить до OOM (OutOfMemoryError) на Android та до троттлінгу на iOS. Thread Pool вирішує обидві проблеми: обмежує максимальну кількість одночасно працюючих потоків і перевикористовує вже створені. Google рекомендує ExecutorService замість raw Thread(), Apple рекомендує OperationQueue замість Thread.

ПараметрБез пулу (raw Thread)З Thread Pool
Створення потокуНа кожне завданняОдин раз при створенні пулу
Максимум потоківНе обмежений (ризик OOM)Обмежений core/max pool size
УтилізаціяНизька (потік вмирає після завдання)Висока (потік перевикористовується)
КеруванняРучне (join, interrupt)Автоматичне (ExecutorService)
Споживання пам'ятіЗростає з кожним завданнямФіксоване

Як працює Thread Pool у мобільній розробці?

Thread pool працює за принципом Producer-Consumer: завдання (Runnable/Callable) поміщаються в блокуючу чергу (BlockingQueue). Потоки з пулу очікують завдання в черзі та забирають їх для виконання. Алгоритм: якщо вільних потоків менше corePoolSize, створюється новий потік. Якщо corePoolSize досягнуто, завдання поміщається в чергу. Якщо черга заповнена і потоків менше maximumPoolSize, створюється додатковий потік. При перевищенні maximumPoolSize завдання відхиляється через RejectedExecutionHandler.

Core Pool Size vs Maximum Pool Size

Core pool size — кількість потоків, які підтримуються в пулі навіть у простої. Maximum pool size — максимальна кількість потоків, яка може бути створена при переповненні черги. Різниця між ними — це додаткові (overflow) потоки, які створюються тимчасово і завершуються після idle-таймауту. На мобільних пристроях рекомендується встановлювати corePoolSize рівним maximumPoolSize для уникнення пікових навантажень від створення потоків.

Work Queue та RejectedExecutionHandler

BlockingQueue зберігає завдання, що очікують виконання. Найпопулярніші реалізації: LinkedBlockingQueue (необмежена), ArrayBlockingQueue (обмежена) та SynchronousQueue (без зберігання — завдання одразу передається потоку). При переповненні черги та пулу спрацьовує RejectedExecutionHandler. Стандартні політики: AbortPolicy (кидає RejectedExecutionException), CallerRunsPolicy (виконує в потоці відправника), DiscardPolicy та DiscardOldestPolicy.

kotlin
// Створення Thread Pool в Android
val threadPool = ThreadPoolExecutor(
    corePoolSize = 2,        // Мінімум 2 потоки
    maximumPoolSize = 4,     // Максимум 4 потоки
    keepAliveTime = 30L,     // Час життя overflow-потоку
    unit = TimeUnit.SECONDS,
    workQueue = LinkedBlockingQueue<Runnable>(16),
    threadFactory = Executors.defaultThreadFactory(),
    handler = ThreadPoolExecutor.CallerRunsPolicy()
)

// Відправка завдань
threadPool.execute {
    val result = api.fetchData()
    runOnUiThread { showData(result) }
}

// Завершення пулу
threadPool.shutdown()
// Очікування завершення всіх завдань
threadPool.awaitTermination(10, TimeUnit.SECONDS)

Thread Pool в Android: ExecutorService

Android надає кілька реалізацій thread pool через java.util.concurrent. Executors — фабрика з готовими конфігураціями: newFixedThreadPool(n) (фіксований пул), newCachedThreadPool() (необмежений, потоки створюються за потреби), newSingleThreadExecutor() (один потік — послідовне виконання). Для мобільних проєктів рекомендується newFixedThreadPool з розумним лімітом (2-4 потоки), оскільки cached пул може створити занадто багато потоків.

ThreadPoolExecutor в Android

ThreadPoolExecutor (TPE) — повна реалізація ExecutorService з налаштовуваними параметрами. На Android TPE використовується всередині AsyncTask, IntentService та JobIntentService. Параметри corePoolSize, maximumPoolSize, keepAliveTime, BlockingQueue та RejectedExecutionHandler дозволяють тонко налаштувати поведінку пулу. Рекомендації для Android: corePoolSize = кількість ядер CPU - 1 (для IO-bound завдань) або кількість ядер (для CPU-bound завдань). Для типових застосунків — 2-4 потоки.

kotlin
// Готові конфігурації Executors
// 1. Фіксований пул на 3 потоки
val fixedPool = Executors.newFixedThreadPool(3)

// 2. Кешований пул (не рекомендується для мобільних)
val cachedPool = Executors.newCachedThreadPool()

// 3. Одиночний потік (серіалізація)
val singlePool = Executors.newSingleThreadExecutor()

// 4. Планувальник (періодичні завдання)
val scheduler = Executors.newScheduledThreadPool(2)

// Використання з Callable та Future
val future: Future<String> = fixedPool.submit(Callable {
    "Result: ${api.call()}"
})
// Отримання результату (блокує потік)
val result = future.get(5, TimeUnit.SECONDS)

// Завершення пулу
fixedPool.shutdownNow()

CoroutineDispatcher як thread pool

Kotlin корутини надають CoroutineDispatcher — абстракцію, аналогічну thread pool. Dispatchers.IO використовує пул з 64 потоків (обмежених). Dispatchers.Default — пул, рівний кількості ядер CPU. CoroutineDispatcher не потребує ручного shutdown і керується автоматично. Для тонкого налаштування створіть власний ExecutorCoroutineDispatcher через Executors.newFixedThreadPool(2).asCoroutineDispatcher(). Корутини не замінюють thread pool, а обгортають його.

Thread Pool в iOS: OperationQueue та GCD

iOS надає два основні механізми для керування thread pool: OperationQueue (високорівневий API на основі GCD) та GCD DispatchQueue (низькорівневий C-API). OperationQueue інкапсулює thread pool через властивість maxConcurrentOperationCount. За замовчуванням OperationQueue використовує system-defined maximum (залежить від завантаження системи). DispatchQueue.global() надає concurrent чергу з системним пулом потоків.

OperationQueue та maxConcurrentOperationCount

OperationQueue керує пулом потоків через maxConcurrentOperationCount. Значення 1 створює serial чергу (аналог single thread pool). Значення більше 1 — concurrent пул із зазначеним лімітом. За замовчуванням maxConcurrentOperationCount = NSOperationQueueDefaultMaxConcurrentOperationCount (системний оптимум, зазвичай 4-8 потоків). Operation підтримує залежності, пріоритети та скасування. Кожна операція виконується на будь-якому вільному потоці з системного пулу.

swift
// OperationQueue з пулом на 3 потоки
let queue = OperationQueue()
queue.maxConcurrentOperationCount = 3
queue.qualityOfService = .utility

// Створення операцій
let operation1 = BlockOperation {
    let data = fetchData(from: url1)
    DispatchQueue.main.async { updateUI(data) }
}

let operation2 = BlockOperation {
    let data = fetchData(from: url2)
    DispatchQueue.main.async { updateUI(data) }
}

// Залежність: operation2 чекає на operation1
operation2.addDependency(operation1)

// Додавання в чергу
queue.addOperations([operation1, operation2], waitUntilFinished: false)

// Скасування всіх операцій
queue.cancelAllOperations()

GCD DispatchQueue як thread pool

DispatchQueue — це thread pool від Apple. Concurrent черга (qos: .utility) використовує системний пул потоків, оптимізований для поточного завантаження пристрою. Різні QoS (userInteractive, userInitiated, utility, background) відображаються на різні пули з різним пріоритетом. DispatchGroup дозволяє синхронізувати кілька завдань. DispatchWorkItem підтримує скасування та qualityOfService. Для тонкого контролю створюйте власні concurrent черги через DispatchQueue(label: qos: attributes: .concurrent).

swift
// GCD DispatchQueue як thread pool
let customQueue = DispatchQueue(
    label: "com.app.background",
    qos: .utility,
    attributes: .concurrent,
    autoreleaseFrequency: .workItem
)

// Відправка завдань у пул
customQueue.async { self.processFile(file1) }
customQueue.async { self.processFile(file2) }

// DispatchGroup для синхронізації
let group = DispatchGroup()
let pool = DispatchQueue.global(qos: .utility)

pool.async(group: group) { fetchData() }
pool.async(group: group) { processImage() }

group.notify(queue: .main) {
    self.showResult() // Обидва завдання завершено
}

// Обмеження concurrency через semaphore
let semaphore = DispatchSemaphore(value: 3)
for url in urls {
    pool.async {
        semaphore.wait()
        download(url)
        semaphore.signal()
    }
}

Параметри налаштування Thread Pool

Конфігурація thread pool безпосередньо впливає на продуктивність застосунку. Невірні параметри призводять до недоутилізації CPU (занадто мало потоків) або перевантаження системи (занадто багато). Для мобільних застосунків оптимальні значення відрізняються від серверних через обмежені ресурси та енергоспоживання. Основні параметри: corePoolSize, maxPoolSize, queue capacity та keepAliveTime.

Розрахунок оптимального розміру пулу

Формула для IO-bound завдань: corePoolSize = кількість ядер CPU × 2 (потоки очікують введення-виведення). Для CPU-bound завдань: corePoolSize = кількість ядер CPU (потоки постійно завантажені обчисленнями). На сучасних мобільних пристроях (6-8 ядер) це дає 6-8 потоків для CPU-bound і 12-16 для IO-bound. Практичні тести показують, що для типового мобільного застосунку 3-4 потоки оптимальні — більше потоків збільшують енергоспоживання без приросту продуктивності.

Queue Capacity та поведінка при переповненні

Розмір черги завдань (work queue) визначає, скільки завдань може очікувати виконання. Необмежена черга (LinkedBlockingQueue без ліміту) може призвести до OOM при швидкому надходженні завдань. Обмежена черга (ArrayBlockingQueue з фіксованим розміром) відхиляє завдання при переповненні. Для мобільних застосунків рекомендується ArrayBlockingQueue з ємністю 16-32 завдань. CallerRunsPolicy — найкращий RejectedExecutionHandler для мобільних: він сповільнює відправника (pressure back) замість втрати завдання.

ПараметрРекомендація для мобільнихОбґрунтування
corePoolSize2-4Обмежені ресурси мобільного пристрою
maxPoolSizecorePoolSize (або +1-2)Уникнути пікових навантажень на створення потоків
keepAliveTime15-30 секундШвидке звільнення пам'яті, але без частого створення
Queue capacity16-32Баланс між буферизацією та OOM-ризиком
HandlerCallerRunsPolicyBackpressure без втрати завдань

Типові помилки при роботі з пулом потоків

Розробники мобільних застосунків часто допускають помилки при використанні thread pool, які призводять до падінь, витоків пам'яті та нестабільної роботи. Найчастіші: невиклик shutdown() для ExecutorService, створення нового пулу на кожну операцію, занадто великий пул, deadlock між завданнями, використання CachedThreadPool на Android.

Deadlock в Thread Pool

Deadlock виникає, коли завдання в пулі очікує результат іншого завдання з того ж пулу, але всі потоки зайняті очікуванням. Приклад: завдання А відправляє завдання Б в той же пул і викликає future.get() — якщо пул вичерпаний, завдання А чекає на завдання Б, а завдання Б не може виконатися, оскільки немає вільних потоків. Рішення: використовуйте окремі пули для різних рівнів завдань або async callback замість блокуючого .get().

kotlin
// Deadlock в Thread Pool
val pool = Executors.newFixedThreadPool(1)

// Завдання А чекає на завдання Б — deadlock!
val futureA = pool.submit {
    // Це завдання ніколи не виконається
    val futureB = pool.submit { 42 }
    futureB.get() // Блокується назавжди
}

// Виправлення: окремі пули
val workerPool = Executors.newFixedThreadPool(2)
val callbackPool = Executors.newSingleThreadExecutor()

workerPool.submit {
    callbackPool.submit {
        // Виконується в окремому пулі — deadlock неможливий
    }
}

// Або використовуйте CompletableFuture
workerPool.submit {
    CompletableFuture
        .supplyAsync { 42 }
        .thenAccept { result ->
            println(result)
        }
}

Незавершений пул та витоки

ExecutorService, створений в Activity, повинен бути завершений в onDestroy(). Якщо цього не зробити, потоки будуть висіти в пам'яті навіть після знищення Activity. Рішення: зберігайте пул в Application scope або ViewModel, а не в Activity. Для корутин використовуйте viewModelScope або lifecycleScope. Якщо пул створено всередині Activity, обов'язково викличте pool.shutdown() в onDestroy(). Для тестування використовуйте es.shutdownNow() для негайної зупинки.

Часті запитання

Чим Thread Pool відрізняється від звичайного потоку?

Thread Pool перевикористовує вже створені потоки для виконання багатьох завдань. Звичайний потік (raw Thread) створюється, виконує одне завдання та знищується. Створення потоку займає близько 1 МБ пам'яті та ~1 мс часу. Thread Pool зменшує накладні витрати, обмежує максимальну кількість потоків та надає API для керування (shutdown, awaitTermination).

Скільки потоків має бути в пулі для мобільного застосунку?

Для типового мобільного застосунку оптимально 2-4 потоки. Для CPU-bound завдань — кількість ядер CPU. Для IO-bound завдань — кількість ядер × 2. Більша кількість потоків збільшує енергоспоживання та контекстне перемикання без приросту продуктивності. На Android використовуйте Process.availableProcessors() для визначення ядер. На iOS — ProcessInfo.processInfo.processorCount.

Що таке CachedThreadPool і чому він небезпечний на Android?

CachedThreadPool створює потоки за потреби та перевикористовує існуючі. Проблема: він не обмежує максимальну кількість потоків. Якщо 100 завдань прийдуть одночасно, буде створено 100 потоків. Це призводить до OOM на Android (кожен потік ~1 МБ). Використовуйте newFixedThreadPool(n) з явним лімітом. CachedThreadPool допустимий лише для короткочасних burst-завдань з гарантією невеликого обсягу.

Чи потрібно викликати shutdown() для ExecutorService?

Так, якщо пул не належить керованому контейнеру (як корутини). shutdown() зупиняє прийом нових завдань і завершує потоки після виконання поточних. Без shutdown() потоки висять у пам'яті, застосунок не завершується. Для Activity викликайте в onDestroy(). Для ViewModel використовуйте coroutineScope. Завершення пулу — обов'язкова частина керування ресурсами, аналогічна закриттю Cursor або InputStream.

Чи є OperationQueue та DispatchQueue thread pool?

Так, OperationQueue та DispatchQueue — це thread pool, що надаються iOS. OperationQueue обмежує concurrency через maxConcurrentOperationCount. DispatchQueue.global() використовує системний пул потоків без прямого контролю. На відміну від Java ThreadPoolExecutor, ви не керуєте corePoolSize або queue capacity — система оптимізує пул автоматично під поточне навантаження та енергоспоживання пристрою.

Підсумки

  • Thread Pool — пул перевикористовуваних потоків для фонових завдань, що знижує накладні витрати на створення потоків.
  • Android використовує ThreadPoolExecutor та Executors.newFixedThreadPool(n) з явним обмеженням розміру пулу.
  • iOS надає OperationQueue з maxConcurrentOperationCount та GCD DispatchQueue з QoS-пулами.
  • Core pool size — мінімальна кількість потоків; maximum pool size — максимальна при переповненні черги.
  • Deadlock в пулі виникає при блокуванні завдання, що очікує інше завдання з того ж пулу.
  • CallerRunsPolicy кращий для мобільних застосунків — він сповільнює відправника без втрати завдань.
  • Для типового мобільного застосунку оптимальний розмір пулу — 2-4 потоки із закриттям через shutdown().

Ми розробимо мобільний застосунок під ключ

IT Sectr створює застосунки для iOS та Android для стартапів і бізнесу з 2017 року. Ми проконсультуємо вас і запропонуємо найкраще рішення.

Обговорити проект

Читайте також