Threads, uma ferramenta que ajuda e que se torna indispensável nos desenvolvimentos de soluções modernas e com alto desempenho. Independente da linguagem, o recurso de fazer tarefas em paralelo é algo que tem um grande apelo. Mas obviamente tem a famosa frase do tio Ben: "Com grandes poderes vêm grandes responsabilidades". Como essa solução pode ser usada da melhor maneira, visando performance, melhor uso de recursos e saúde da aplicação? Primeiro é necessário entender os conceitos básicos desse tema.
O que é uma "thread"?
Threads são unidades básicas de execução de um processo em um sistema operacional. Eles permitem que um programa execute múltiplas operações simultaneamente dentro do mesmo processo. Cada thread compartilha o mesmo espaço de memória do processo principal, mas pode executar de forma independente, o que é útil para tarefas que podem ser realizadas em paralelo, como operações de entrada/saída(I/O), cálculos complexos, ou atualizações de interface de usuário.
Em muitos sistemas, threads são gerenciados pelo sistema operacional, que aloca tempo de CPU para cada thread e gerencia a troca de contexto entre eles. Em linguagens de programação como Java, Python, e C++, existem bibliotecas e frameworks que facilitam a criação e o gerenciamento de threads.
Por que usar threads?
Threads são usadas principalmente para melhorar a eficiência e a capacidade de resposta de um programa. Os motivos para usar threads, principalmente focando em backend são:
Paralelismo: Threads permitem que você execute múltiplas operações simultaneamente, aproveitando melhor os recursos de CPU disponíveis, especialmente em sistemas com múltiplos núcleos.
Desempenho: Em operações de I/O, como leitura e escrita de arquivos ou comunicação de rede, threads podem ajudar a melhorar o desempenho ao permitir que o programa continue executando outras tarefas enquanto aguarda a conclusão dessas operações.
Modularidade: Threads podem ser usadas para dividir um programa em partes menores e mais gerenciáveis, cada uma executando uma tarefa específica.
No entanto, é importante gerenciar threads com cuidado, pois o uso incorreto pode levar a problemas como condições de corrida, deadlocks e dificuldades de depuração. Para um melhor gerenciamento das mesmas é usada uma solução de pool de threads.
O que é um pool de threads e por que preciso dele?
Um pool de threads é um padrão de design de software que envolve a criação e gerenciamento de um conjunto de threads que podem ser reutilizados para executar tarefas. Em vez de criar e destruir threads repetidamente para cada tarefa, um pool de threads mantém um número fixo de threads prontos para executar tarefas conforme necessário. Isso pode melhorar significativamente o desempenho de aplicações que precisam lidar com muitas tarefas simultâneas. Os pontos positivos em usar um pool de threads são:
Desempenho Melhorado: Criar e destruir threads é uma operação custosa em termos de recursos. Um pool de threads minimiza esse custo, reutilizando threads existentes.
Gerenciamento de Recursos: Controla o número de threads em execução, evitando a criação excessiva de threads que podem sobrecarregar o sistema.
Facilidade de Uso: Simplifica o gerenciamento de threads, permitindo que os desenvolvedores foquem na lógica da aplicação em vez de no gerenciamento de threads.
Escalabilidade: Ajuda a escalar aplicações para lidar com um grande número de tarefas simultâneas de forma eficiente.
Como definir o limite de threads no pool
Ok, é claro que tenho que criar um pool de threads para melhor utilizar esse recurso, mas uma pergunta que vem rapidamente é: "Quantas threads devem conter no pool?". Seguindo uma lógica básica, quanto mais melhor, certo? Se tudo pode ser feito em paralelo, logo, será feito, pois vai ser mais rápido. Sendo assim é melhor não limitar o número de threads, ou então colocar um número alto, para que que isso não seja uma preocupação. Correto?
É uma afirmação justa, então vamos testar. O código para esse teste foi feito em Kotlin apenas por familiaridade e facilidade de escrever os exemplos. Esse ponto é agnóstico a linguagens.
Foram feitos 4 exemplos explorando diferentes naturezas de sistema. O exemplo 1 e 2 foram feitos para utilizar o CPU, fazer muitas contas, ou seja, ter processamento massivo. O exemplo 3 é focado em I/O, sendo o exemplo uma leitura de um arquivo e por fim, no exemplo 4 é uma situação de chamadas de APIs em paralelo, também focando em I/O. Todos usaram pools com diferentes tamanhos, sendo eles respectivamente com 1, 2, 4, 8, 16, 32, 50, 100 e 500 threads. Todos processos ocorrem mais do que 500 vezes.
Exemplo 1 - Código que calcula quantos números primos existem entre 1 e 100000
import kotlinx.coroutines.*
import kotlin.math.sqrt
import kotlin.system.measureTimeMillis
fun isPrime(number: Int): Boolean {
if (number <= 1) return false
for (i in 2..sqrt(number.toDouble()).toInt()) {
if (number % i == 0) return false
}
return true
}
fun countPrimesInRange(start: Int, end: Int): Int {
var count = 0
for (i in start..end) {
if (isPrime(i)) {
count++
}
}
return count
}
@OptIn(DelicateCoroutinesApi::class)
fun main() = runBlocking {
val rangeStart = 1
val rangeEnd = 100_000
val numberOfThreadsList = listOf(1, 2, 4, 8, 16, 32, 50, 100, 500)
for (numberOfThreads in numberOfThreadsList) {
val customDispatcher = newFixedThreadPoolContext(numberOfThreads, "customPool")
val chunkSize = (rangeEnd - rangeStart + 1) / numberOfThreads
val timeTaken = measureTimeMillis {
val jobs = mutableListOf<Deferred<Int>>()
for (i in 0 until numberOfThreads) {
val start = rangeStart + i * chunkSize
val end = if (i == numberOfThreads - 1) rangeEnd else start + chunkSize - 1
jobs.add(async(customDispatcher) { countPrimesInRange(start, end) })
}
val totalPrimes = jobs.awaitAll().sum()
println("Total de números primos encontrados com $numberOfThreads threads: $totalPrimes")
}
println("Tempo levado com $numberOfThreads threads: $timeTaken ms")
customDispatcher.close()
}
}
Saída do console do exemplo 1
Total de números primos encontrados com 1 threads: 9592
Tempo levado com 1 threads: 42 ms
Total de números primos encontrados com 2 threads: 9592
Tempo levado com 2 threads: 17 ms
Total de números primos encontrados com 4 threads: 9592
Tempo levado com 4 threads: 8 ms
Total de números primos encontrados com 8 threads: 9592
Tempo levado com 8 threads: 8 ms
Total de números primos encontrados com 16 threads: 9592
Tempo levado com 16 threads: 16 ms
Total de números primos encontrados com 32 threads: 9592
Tempo levado com 32 threads: 12 ms
Total de números primos encontrados com 50 threads: 9592
Tempo levado com 50 threads: 19 ms
Total de números primos encontrados com 100 threads: 9592
Tempo levado com 100 threads: 36 ms
Total de números primos encontrados com 500 threads: 9592
Tempo levado com 500 threads: 148 ms
Exemplo 2 - Código que calcula o 30º número na escala fibonacci
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.newFixedThreadPoolContext
import kotlinx.coroutines.runBlocking
import kotlin.system.measureTimeMillis
fun fibonacci(n: Int): Long {
return if (n <= 1) n.toLong() else fibonacci(n - 1) + fibonacci(n - 2)
}
@OptIn(DelicateCoroutinesApi::class)
fun main() = runBlocking {
val numberOfThreadsList = listOf(1, 2, 4, 8, 16, 32, 50, 100, 500)
for (numberOfThreads in numberOfThreadsList) {
val customDispatcher = newFixedThreadPoolContext(numberOfThreads, "customPool")
val numbersToCalculate = mutableListOf<Int>()
for (i in 1..1000) {
numbersToCalculate.add(30)
}
val timeTaken = measureTimeMillis {
val jobs = numbersToCalculate.map { number ->
launch(customDispatcher) {
fibonacci(number)
}
}
jobs.forEach { it.join() }
}
println("Tempo levado com $numberOfThreads threads: $timeTaken ms")
customDispatcher.close()
}
}
Saída do console do exemplo 2
Tempo levado com 1 threads: 4884 ms
Tempo levado com 2 threads: 2910 ms
Tempo levado com 4 threads: 1660 ms
Tempo levado com 8 threads: 1204 ms
Tempo levado com 16 threads: 1279 ms
Tempo levado com 32 threads: 1260 ms
Tempo levado com 50 threads: 1364 ms
Tempo levado com 100 threads: 1400 ms
Tempo levado com 500 threads: 1475 ms
Exemplo 3 - Código que lê um arquivo de números randômicos e os soma ao final da leitura
import kotlinx.coroutines.*
import java.io.File
import kotlin.system.measureTimeMillis
@OptIn(DelicateCoroutinesApi::class)
fun main() = runBlocking {
val file = File("numeros_aleatorios.txt")
if (!file.exists()) {
println("Arquivo não encontrado!")
return@runBlocking
}
val numberOfThreadsList = listOf(1, 2, 4, 8, 16, 32, 50, 100, 500)
for (numberOfThreads in numberOfThreadsList) {
val customDispatcher = newFixedThreadPoolContext(numberOfThreads, "customPool")
val timeTaken = measureTimeMillis {
val jobs = mutableListOf<Deferred<Int>>()
file.useLines { lines ->
lines.forEach { line ->
jobs.add(async(customDispatcher) {
processLine(line)
})
}
}
val totalSum = jobs.awaitAll().sum()
println("Total da soma com $numberOfThreads threads: $totalSum")
}
println("Tempo levado com $numberOfThreads threads: $timeTaken ms")
customDispatcher.close()
}
}
fun processLine(line: String): Int {
return line.toInt() + 10
}
Saída do console do exemplo 3
Total da soma de 1201 linhas com 1 threads: 60192
Tempo levado com 1 threads: 97 ms
Total da soma de 1201 linhas com 2 threads: 60192
Tempo levado com 2 threads: 28 ms
Total da soma de 1201 linhas com 4 threads: 60192
Tempo levado com 4 threads: 30 ms
Total da soma de 1201 linhas com 8 threads: 60192
Tempo levado com 8 threads: 26 ms
Total da soma de 1201 linhas com 16 threads: 60192
Tempo levado com 16 threads: 33 ms
Total da soma de 1201 linhas com 32 threads: 60192
Tempo levado com 32 threads: 35 ms
Total da soma de 1201 linhas com 50 threads: 60192
Tempo levado com 50 threads: 44 ms
Total da soma de 1201 linhas com 100 threads: 60192
Tempo levado com 100 threads: 66 ms
Total da soma de 1201 linhas com 500 threads: 60192
Tempo levado com 500 threads: 297 ms
Exemplo 4 - Código que chama uma API 500 vezes
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.request.*
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.newFixedThreadPoolContext
import kotlinx.coroutines.runBlocking
import kotlin.system.measureTimeMillis
@OptIn(DelicateCoroutinesApi::class)
fun main() = runBlocking {
val client = HttpClient(CIO)
try {
val numberOfThreadsList = listOf(1, 2, 4, 8, 16, 32, 50, 100, 500)
for (numberOfThreads in numberOfThreadsList) {
val customDispatcher = newFixedThreadPoolContext(numberOfThreads, "customPool")
val timeTaken = measureTimeMillis {
repeat(500) {
val jobs = launch(customDispatcher) { client.get("http://127.0.0.1:5000/example") }
jobs.join()
}
}
println("Tempo levado com $numberOfThreads threads: $timeTaken ms")
customDispatcher.close()
}
} catch (e: Exception) {
println("Erro ao conectar à API: ${e.message}")
} finally {
client.close()
}
}
Saída do console do exemplo 4
Tempo levado com 1 threads: 7104 ms
Tempo levado com 2 threads: 4793 ms
Tempo levado com 4 threads: 4170 ms
Tempo levado com 8 threads: 4310 ms
Tempo levado com 16 threads: 4028 ms
Tempo levado com 32 threads: 4089 ms
Tempo levado com 50 threads: 4066 ms
Tempo levado com 100 threads: 3978 ms
Tempo levado com 500 threads: 3777 ms
Os exemplos 1 ao 3 tem um comportamento em comum, todos eles ficam mais performáticos até 8 threads, depois voltam a subir no tempo de processamento, mas não o exemplo 4, o que então isso mostra? Não é interessante usar sempre o maior número possível de threads?
A resposta simples e rápida é não.
O processador da minha máquina tem 8 cores, ou seja, ele consegue fazer 8 tarefas ao mesmo tempo, mais do que isso o tempo aumenta pois o tempo de gerenciar os estados de cada thread acaba degradando a performance.
Ok, isso responde do exemplo 1 ao 3, mas e o exemplo 4? Qual o motivo da performance melhorar quanto mais threads são lançadas?
Simples, pois é uma integração, a maquina não tem processamento, ela basicamente espera uma resposta, ela fica "dormindo" até a resposta chegar, então sim, aqui o número de threads pode ser maior. Mas cuidado, não significa que pode ser o maior número possível, threads causam exaustão de recursos, usá-las indiscriminadamente tem um efeito reverso que vai afetar a saúde como um todo do serviço.
Sendo assim, para definir o número de threads que seu pool terá, a maneira mais fácil e segura é separar a natureza da tarefa que será feita. Sendo elas separadas em duas:
Tarefas que não exigem processamento:
Quando o tipo de tarefa não exige processamento, podem ser criadas mais threads do que os cores de processadores da máquina. Isso acontece por não ser necessário processar a informação para a completude da thread, basicamente as threads com essa natureza, em sua maioria, esperam respostas de integrações, como por exemplo a escrita em um BD ou a resposta de uma API.Tarefas que exigem processamento:
Quando a solução tem um processamento, ou seja, que a máquina efetivamente está tendo trabalho, o número máximo de threads deve ser o número de cores do processador da máquina. Isso pelo motivo de um core de processador ser incapaz de fazer mais de uma coisa ao mesmo tempo. Por exemplo, se o processador em que a solução roda tem 4 cores, então seu pool de threads deve ser do tamanho dos cores do seu processador, um pool de 4 threads.
Conclusão
Primeiro ponto a se definir quando se pensa em pool de threads não é nem necessariamente o número que limitará o tamanho do mesmo, mas sim a natureza da tarefa executada. As threads ajudam e muito na performance dos serviços, mas devem ser usadas da melhor maneira para que ela não tenha o efeito contrário e degrade a performance, ou ainda pior, faça que o serviço inteiro tenha a saúde afetada. Fica claro que pools menores acabam favorecendo as tarefas com muito uso de processamento, as tarefas CPU bounded em outras palavras. Caso não tenha certeza se a solução em que as threads serão usadas tem um comportamento em que o processamento será usado massivamente, peque pela precaução, limite seu pool ao número de processadores da máquina, acredite, vai lhe poupar muita dor de cabeça.
Top comments (0)