DEV Community

Cover image for Concorrência e paralelismo: o Golang performa melhor que o Java nesse quesito?
Ana Carolina Cortez
Ana Carolina Cortez

Posted on

Concorrência e paralelismo: o Golang performa melhor que o Java nesse quesito?

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
}

Enter fullscreen mode Exit fullscreen mode

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)
}

Enter fullscreen mode Exit fullscreen mode

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");
    }
}

Enter fullscreen mode Exit fullscreen mode

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");
    }
}

Enter fullscreen mode Exit fullscreen mode

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)