Uma das principais vantagens da Golang (ou Go), linguagem criada pela Google, é a gestão de concorrências, ou seja, a capacidade de rodar múltiplas tarefas ao mesmo tempo.
Toda linguagem moderna possui ferramentas para lidar com concorrência. O diferecial do Go está no fato de que o runtime abstrai a maioria dos detalhes sobre threads e paralelismo para nós, o que torna esse processamento muito mais simples. É o runtime, e não o kernel do sistema operacional, quem define como as goroutines são atribuídas às threads do sistema operacional e como as threads interagem com os núcleos da CPU disponíveis.
O desenvolvedor pode utilizar concorrência (execução intercalada) e paralelismo (execução simultânea) ao mesmo tempo em Go. E pode fazer, inclusive, explicitamente, determinando a propriedade GOMAXPROCS qual o limite de threads simultâneas do programa. Assim o Go pode mapear goroutines em múltiplos núcleos para obter paralelismo real, e máquinas que possuam essa arquitetura no processamento. Por padrão, contudo, a runtime já faz essa abstração para nós.
import (
"runtime"
)
func main() {
runtime.GOMAXPROCS(4) // Permitir até 4 threads para paralelismo
}
Outras linguagens de programação também oferecem ferramentas para concorrência e paralelismo, mas o nível de abstração e simplicidade varia bastante. No Java, por exemplo, temos a API Concurrent (java.util.concurrent) e ferramentas como Thread, ExecutorService e ForkJoinPool para gerenciar concorrência e paralelismo.
No entanto, o desenvolvedor precisa configurar manualmente o pool de threads ou usar ferramentas específicas como o CompletableFuture para simplificar operações assíncronas.
Java também permite execução paralela em máquinas multicore usando pools de threads. Em contrapartida, porém, as threads em Java são mais pesadas porque são mapeadas diretamente para threads do sistema operacional.
Runtime X Kernel
As threads do sistema operacional são gerenciadas pelo kernel do sistema. Isso significa que a criação, destruição, troca de contexto e gerenciamento de threads são tarefas que o kernel executa, introduzindo overhead adicional. Cada thread do sistema operacional consome uma quantidade significativa de memória (normalmente em torno de 1 MB no Java). Quando o sistema alterna entre threads, ele precisa salvar e restaurar os estados do processador (registradores, pilha, etc.), o que é um processo caro.
Já em Go, é o runtime da linguagem quem faz essa gestão. O Go não cria uma thread do sistema operacional para cada goroutine. Em vez disso, o runtime do Go gerencia múltiplas goroutines em um número muito menor de threads do sistema operacional - chamado tecnicamente de M:N scheduling (M goroutines em N threads). Isso permite
milhares de goroutines com o mesmo número de threads sem sobrecarregar o sistema operacional .
E é essa a "graça" da linguagem, fazendo dela a queridinha para gerenciar sistemas distribuídos de alta performance e aplicações de processamento de dados em tempo real.
Porém, entretanto, todavia, é importante ressaltar que qualquer linguagem moderna é capaz de trabalhar com concorrência e paralelismo.
A diferença está na leveza e no custo de processamento.
Desta forma, não precisamos ficar num FlaxFlu de linguagens. Cada linguagem tem sua magia, seus pontos fortes e pontos fracos.
Apenas para mostrar como qualquer linguagem pode se incumbir dessas tarefas, vou exemplificar em Go e em Java como um mesmo programa é codificado, cada um com suas particularidades. A ideia é simples: simular uma tarefa realizada com concorrência e paralelismo e imprimir o tempo de execução e o uso de memória em ambos os casos (os números variam para cada máquina).
Para tornar a comparação mais "isenta", pedi para o chatgpt gerar os códigos, que estão abaixo:
Golang
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
func tarefa(id int) {
// Simula algum processamento leve
time.Sleep(10 * time.Millisecond)
}
func main() {
// Configura a quantidade de tarefas
numTarefas := 100000
// Medindo o tempo de execução
start := time.Now()
var wg sync.WaitGroup
wg.Add(numTarefas)
// Calculando a quantidade de memória usada
var m runtime.MemStats
runtime.ReadMemStats(&m)
initialMemory := m.Sys
// Criando as goroutines para simular o trabalho
for i := 0; i < numTarefas; i++ {
go func(id int) {
defer wg.Done()
tarefa(id)
}(i)
}
wg.Wait() // Espera todas as goroutines terminarem
// Calculando o tempo total de execução e a memória usada
elapsed := time.Since(start)
runtime.ReadMemStats(&m)
finalMemory := m.Sys
// Printando os resultados
fmt.Printf("Tempo de execução: %s\n", elapsed)
fmt.Printf("Memória utilizada: %d bytes\n", finalMemory-initialMemory)
}
Tempo de execução: 141.886206ms
Memória utilizada: 43909120 bytes
Java
import java.util.concurrent.*;
import java.util.*;
public class ConcorrenciaParalelismoJava {
// Função que simula uma tarefa e faz um pequeno processamento
public static void tarefa(int id) throws InterruptedException {
Thread.sleep(10); // Simula um pequeno tempo de processamento
}
public static void main(String[] args) throws InterruptedException, ExecutionException {
// Configura a quantidade de tarefas
int numTarefas = 100000;
// Medindo o tempo de execução
long start = System.nanoTime();
// ExecutorService para gerenciar o pool de threads
ExecutorService executor = Executors.newFixedThreadPool(100);
List<Future<Void>> futures = new ArrayList<>();
// Criando as threads para simular o trabalho
for (int i = 0; i < numTarefas; i++) {
final int tarefaId = i;
futures.add(executor.submit(() -> {
tarefa(tarefaId);
return null;
}));
}
// Espera todas as tarefas terminarem
for (Future<Void> future : futures) {
future.get();
}
executor.shutdown();
// Calculando o tempo total de execução
long elapsed = System.nanoTime() - start;
System.out.println("Tempo de execução: " + (elapsed / 1_000_000) + " ms");
// Medindo o uso de memória (em bytes)
Runtime runtime = Runtime.getRuntime();
long memoryUsed = runtime.totalMemory() - runtime.freeMemory();
System.out.println("Memória utilizada: " + memoryUsed + " bytes");
}
}
Tempo de execução: 10238 ms
Memória utilizada: 106732888 bytes
Enfim, claramente podemos executar exatamente a mesma tarefa nas duas linguagens. Cada uma utilizando suas bibliotecas para os devidos fins. Nota-se que em Go a execução foi mais 98,61% mais rápida e gastou-se 58,86% menos memória.
Mas não existe linguagem melhor que outra.
O que precisamos apenas é entender dos prós e contras de cada uma na hora de escolher qual linguagem pode nos ajudar a resolver os problemas que temos nos nossos projetos. E cada projeto vai ter seu pool de problemas particulares e singulares que precisam ser resolvidos.
Otimização em Java
É possível, claro, usar de estratégias para tentar melhorar o desempenho do código fornecido acima em Java.
Pedi novamente para o chatgpt incorporar algumas cartas na manga no código inicial:
import java.util.concurrent.ForkJoinPool;
import java.util.stream.IntStream;
public class ForkJoinPoolOtimizacao {
public static void tarefa(int id) throws InterruptedException {
Thread.sleep(10); // Simula algum processamento leve
}
public static void main(String[] args) throws InterruptedException {
int numTarefas = 100000;
// Criando um ForkJoinPool personalizado com mais threads
ForkJoinPool pool = new ForkJoinPool(100); // Ajuste o número de threads conforme necessário
// Medindo o tempo de execução
long start = System.nanoTime();
// Submetendo tarefas ao ForkJoinPool
pool.submit(() -> {
IntStream.range(0, numTarefas).parallel().forEach(id -> {
try {
tarefa(id);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}).join();
long elapsed = System.nanoTime() - start;
pool.shutdown();
System.out.println("Tempo de execução: " + (elapsed / 1_000_000) + " ms");
// Medindo o uso de memória
Runtime runtime = Runtime.getRuntime();
long memoryUsed = runtime.totalMemory() - runtime.freeMemory();
System.out.println("Memória utilizada: " + memoryUsed + " bytes");
}
}
Para reduzir o consumo de memória, utilizamos um ForkJoinPool, com um número maior de threads (100) para lidar melhor com a alta simultaneidade. Isso substitui o pool de threads padrão, garantindo que mais tarefas possam ser executadas simultaneamente. Também chamamos submit e join para garantir que todas as tarefas sejam concluídas antes de finalizar o programa.
Com essas mudanças, reduziu-se a alocação de memória em 56,21%:
Tempo de execução: 11877 ms
Memória utilizada: 46733064 bytes
Otimizar esse código é um desafio interessante. Fica o convite para você fazer melhor usando Java, o que é sempre muito possível, já que essa linguagem, sabemos, é maravilhosa independentemente de qualquer detalhe.
Top comments (0)