Por que a criação de um Thread é considerada cara?

180

Os tutoriais de Java dizem que a criação de um Thread é cara. Mas por que exatamente é caro? O que exatamente está acontecendo quando um Java Thread é criado que torna sua criação cara? Estou aceitando a afirmação como verdadeira, mas estou interessado apenas na mecânica da criação de threads na JVM.

Sobrecarga do ciclo de vida do encadeamento. A criação e desmontagem de threads não são gratuitas. A sobrecarga real varia entre plataformas, mas a criação do encadeamento leva tempo, introduzindo latência no processamento de solicitações e requer alguma atividade de processamento pela JVM e pelo SO. Se as solicitações forem frequentes e leves, como na maioria dos aplicativos de servidor, a criação de um novo encadeamento para cada solicitação poderá consumir recursos computacionais significativos.

Da simultaneidade Java na prática
Por Brian Goetz, Tim Peierls, Joshua Bloch, Joseph Bowbeer, David Holmes e Doug Lea
Imprimir ISBN-10: 0-321-34960-1

kachanov
fonte
Não sei o contexto em que os tutoriais que você leu dizem o seguinte: eles implicam que a criação em si é cara ou que "criar um encadeamento" é caro. A diferença que tento mostrar é entre a pura ação de criar o thread (vamos chamá-lo de instanciação ou algo assim), ou o fato de você ter um thread (portanto, usando um thread: obviamente tendo sobrecarga). Qual é reivindicado // sobre qual você deseja perguntar?
Nanne
9
@typoknig - Caro comparado a não criar um novo segmento :)
willcodejavaforfood
possível duplicata de Java criação de thread sobrecarga
Paul Draper
1
poços de rosca para a vitória. não é necessário sempre criar novos threads para tarefas.
Alexander Mills

Respostas:

149

A criação do encadeamento Java é cara porque há bastante trabalho envolvido:

  • Um grande bloco de memória deve ser alocado e inicializado para a pilha de encadeamentos.
  • É necessário fazer chamadas do sistema para criar / registrar o encadeamento nativo no sistema operacional host.
  • Os descritores precisam ser criados, inicializados e adicionados às estruturas de dados internas da JVM.

Também é caro, no sentido de que o encadeamento amarra recursos enquanto estiver vivo; por exemplo, a pilha de encadeamentos, quaisquer objetos acessíveis a partir da pilha, os descritores de encadeamentos da JVM, os descritores de encadeamentos nativos do SO.

Os custos de todas essas coisas são específicos da plataforma, mas não são baratos em nenhuma plataforma Java que eu já tenha encontrado.


Uma pesquisa no Google me encontrou um antigo benchmark que relata uma taxa de criação de threads de ~ 4000 por segundo em um Sun Java 1.4.1 em um Xeon de dois processadores antigos de 2002, executando o Linux antigo de 2002. Uma plataforma mais moderna dará números melhores ... e não posso comentar sobre a metodologia ... mas pelo menos fornece uma estimativa de quanto a criação de encadeamentos provavelmente será cara .

Os testes comparativos de Peter Lawrey indicam que a criação de encadeamentos é significativamente mais rápida atualmente em termos absolutos, mas não está claro quanto disso se deve a melhorias no Java e / ou no sistema operacional ... ou em velocidades mais altas do processador. Mas seus números ainda indicam uma melhoria de mais de 150 vezes se você usar um pool de threads em vez de criar / iniciar um novo thread a cada vez. (E ele afirma que tudo isso é relativo ...)


(O exemplo acima pressupõe "encadeamentos nativos" em vez de "encadeamentos verdes", mas todas as JVMs modernas usam encadeamentos nativos por motivos de desempenho. Encadeamentos verdes são possivelmente mais baratos de criar, mas você paga por ele em outras áreas.)


Pesquisei um pouco para ver como a pilha de um encadeamento Java realmente é alocada. No caso do OpenJDK 6 no Linux, a pilha de encadeamentos é alocada pela chamada para pthread_createcriar o encadeamento nativo. (A JVM não passa pthread_createuma pilha pré-alocada.)

Em seguida, na pthread_createpilha é alocado por uma chamada da mmapseguinte maneira:

mmap(0, attr.__stacksize, 
     PROT_READ|PROT_WRITE|PROT_EXEC, 
     MAP_PRIVATE|MAP_ANONYMOUS, -1, 0)

De acordo com man mmap, o MAP_ANONYMOUSsinalizador faz com que a memória seja inicializada em zero.

Portanto, mesmo que não seja essencial que as novas pilhas de encadeamentos Java sejam zeradas (conforme a especificação da JVM), na prática (pelo menos com o OpenJDK 6 no Linux) elas são zeradas.

Stephen C
fonte
2
@ Raedwald - é a parte de inicialização que é cara. Em algum lugar, algo (por exemplo, o GC ou o SO) zera os bytes antes que o bloco seja transformado em uma pilha de threads. Isso requer ciclos de memória física em hardware típico.
Stephen C
2
"Em algum lugar, algo (por exemplo, o GC ou o SO) zera os bytes". Será? O sistema operacional será necessário se for necessário alocar uma nova página de memória, por motivos de segurança. Mas isso será incomum. E o sistema operacional pode manter um cache de páginas com zero edição (IIRC, Linux faz isso). Por que o GC se incomodaria, considerando que a JVM impedirá que qualquer programa Java leia seu conteúdo? Note que o padrão C malloc()função, que a JVM pode muito bem usar, se não garantir que a memória alocada é zero-ed (presumivelmente para evitar apenas esses problemas de desempenho).
Raedwald
1
stackoverflow.com/questions/2117072/… concorda que "Um fator importante é a memória da pilha alocada para cada encadeamento".
Raedwald
2
@ Raedwald - veja a resposta atualizada para obter informações sobre como a pilha é realmente alocada.
Stephen C
2
É possível (até provável) que as páginas de memória alocadas pela mmap()chamada sejam copiadas na gravação mapeadas para uma página zero, para que sua inicialização ocorra não dentro de mmap()si mesma, mas quando as páginas são gravadas pela primeira vez e, em seguida, apenas uma página em um tempo. Ou seja, quando o encadeamento inicia a execução, com o custo de custo pelo encadeamento criado, em vez do encadeamento do criador.
precisa saber é o seguinte
76

Outros discutiram de onde vêm os custos da segmentação. Esta resposta cobre por que a criação de um encadeamento não é tão cara em comparação com muitas operações, mas relativamente cara em comparação com as alternativas de execução de tarefas, que são relativamente menos caras.

A alternativa mais óbvia para executar uma tarefa em outro thread é executar a tarefa no mesmo thread. Isso é difícil de entender para aqueles que assumem que mais threads são sempre melhores. A lógica é que, se a sobrecarga de adicionar a tarefa a outro encadeamento for maior que o tempo que você economiza, pode ser mais rápido executar a tarefa no encadeamento atual.

Outra alternativa é usar um pool de threads. Um conjunto de encadeamentos pode ser mais eficiente por dois motivos. 1) reutiliza threads já criados. 2) você pode ajustar / controlar o número de threads para garantir o desempenho ideal.

O programa a seguir imprime ....

Time for a task to complete in a new Thread 71.3 us
Time for a task to complete in a thread pool 0.39 us
Time for a task to complete in the same thread 0.08 us
Time for a task to complete in a new Thread 65.4 us
Time for a task to complete in a thread pool 0.37 us
Time for a task to complete in the same thread 0.08 us
Time for a task to complete in a new Thread 61.4 us
Time for a task to complete in a thread pool 0.38 us
Time for a task to complete in the same thread 0.08 us

Este é um teste para uma tarefa trivial que expõe a sobrecarga de cada opção de segmentação. (Esta tarefa de teste é o tipo de tarefa que é realmente melhor executada no encadeamento atual.)

final BlockingQueue<Integer> queue = new LinkedBlockingQueue<Integer>();
Runnable task = new Runnable() {
    @Override
    public void run() {
        queue.add(1);
    }
};

for (int t = 0; t < 3; t++) {
    {
        long start = System.nanoTime();
        int runs = 20000;
        for (int i = 0; i < runs; i++)
            new Thread(task).start();
        for (int i = 0; i < runs; i++)
            queue.take();
        long time = System.nanoTime() - start;
        System.out.printf("Time for a task to complete in a new Thread %.1f us%n", time / runs / 1000.0);
    }
    {
        int threads = Runtime.getRuntime().availableProcessors();
        ExecutorService es = Executors.newFixedThreadPool(threads);
        long start = System.nanoTime();
        int runs = 200000;
        for (int i = 0; i < runs; i++)
            es.execute(task);
        for (int i = 0; i < runs; i++)
            queue.take();
        long time = System.nanoTime() - start;
        System.out.printf("Time for a task to complete in a thread pool %.2f us%n", time / runs / 1000.0);
        es.shutdown();
    }
    {
        long start = System.nanoTime();
        int runs = 200000;
        for (int i = 0; i < runs; i++)
            task.run();
        for (int i = 0; i < runs; i++)
            queue.take();
        long time = System.nanoTime() - start;
        System.out.printf("Time for a task to complete in the same thread %.2f us%n", time / runs / 1000.0);
    }
}
}

Como você pode ver, a criação de um novo encadeamento custa apenas ~ 70 µs. Isso pode ser considerado trivial em muitos casos de uso, se não na maioria. Relativamente falando, é mais caro que as alternativas e, em algumas situações, um pool de threads ou não usar threads é uma solução melhor.

Peter Lawrey
fonte
8
Esse é um ótimo código lá. Conciso, direto ao ponto e exibe claramente seu jist.
Nicholas
No último bloco, acredito que o resultado seja distorcido, porque nos dois primeiros blocos o thread principal está removendo em paralelo, enquanto os threads de trabalho estão colocando. No entanto, no último bloco, a ação de executar é executada em série, portanto está dilatando o valor. Você provavelmente poderia usar o queue.clear () e usar um CountDownLatch para aguardar a conclusão dos encadeamentos.
Victor Grazi 9/09
@VictorGrazi Suponho que você deseja coletar os resultados centralmente. Está executando a mesma quantidade de trabalho de enfileiramento em cada caso. Uma trava de contagem regressiva seria um pouco mais rápida.
Peter Lawrey
Na verdade, por que não apenas fazer algo consistentemente rápido, como incrementar um contador; descarte toda a coisa BlockingQueue. Verifique o contador no final para impedir que o compilador otimize a operação de incremento
Victor Grazi 10/10
@grazi, você poderia fazer isso nesse caso, mas na maioria dos casos não seria realista, pois esperar no balcão pode ser ineficiente. Se você fizesse isso, a diferença entre os exemplos seria ainda maior.
Peter Peterrey
31

Em teoria, isso depende da JVM. Na prática, todo encadeamento possui uma quantidade relativamente grande de memória da pilha (256 KB por padrão, eu acho). Além disso, os threads são implementados como threads do SO, portanto, criá-los envolve uma chamada do SO, ou seja, uma opção de contexto.

Realize que "caro" em computação é sempre muito relativo. A criação de threads é muito cara em relação à criação da maioria dos objetos, mas não muito cara em relação a uma busca aleatória no disco rígido. Você não precisa evitar criar threads a todo custo, mas criar centenas deles por segundo não é uma jogada inteligente. Na maioria dos casos, se seu design exigir muitos encadeamentos, você deverá usar um conjunto de encadeamentos de tamanho limitado.

Michael Borgwardt
fonte
9
Entre kb = kilo-bit, kB = kilo byte. Gb = bit giga, GB = byte giga.
Peter Lawrey
@ PeterLawrey capitalizamos o 'k' em 'kb' e 'kB', para que haja simetria para 'Gb' e 'GB'? Essas coisas me incomodam.
Jack Jack
3
@Jack Há um K= 1024 e k= 1000.;) en.wikipedia.org/wiki/Kibibyte
Peter Lawrey
9

Existem dois tipos de threads:

  1. Threads apropriados : são abstrações em torno dos recursos de encadeamento do sistema operacional subjacente. A criação de threads é, portanto, tão cara quanto a do sistema - sempre há uma sobrecarga.

  2. Threads "verdes" : criados e programados pela JVM, são mais baratos, mas não ocorrem paralelismos adequados. Eles se comportam como encadeamentos, mas são executados no encadeamento da JVM no SO. Eles não são usados ​​com frequência, que eu saiba.

O maior fator que consigo pensar na sobrecarga de criação de threads é o tamanho da pilha que você definiu para os threads. O tamanho da pilha do encadeamento pode ser passado como parâmetro ao executar a VM.

Fora isso, a criação de encadeamentos depende principalmente do sistema operacional e até da implementação da VM.

Agora, deixe-me apontar uma coisa: criar threads é caro se você planeja disparar 2000 threads por segundo, a cada segundo de seu tempo de execução. A JVM não foi projetada para lidar com isso . Se você tiver alguns trabalhadores estáveis ​​que não serão demitidos e mortos repetidamente, relaxe.

slezica
fonte
19
"... alguns trabalhadores estáveis ​​que não serão demitidos e mortos ..." Por que comecei a pensar nas condições do local de trabalho? :-)
Stephen C
6

A criação Threadsrequer a alocação de uma quantidade razoável de memória, pois é necessário criar não uma, mas duas novas pilhas (uma para código java e outra para código nativo). O uso de Executores / Pools de Threads pode evitar a sobrecarga, reutilizando threads para várias tarefas do Executor .

Philip JF
fonte
@ Raedwald, qual é a jvm que usa pilhas separadas?
bestsss
1
Philip JP diz 2 pilhas.
precisa
Até onde eu sei, todas as JVMs alocam duas pilhas por encadeamento. É útil para a coleta de lixo tratar o código Java (mesmo quando JITed) de forma diferente da conversão livre c.
Philip JF
@Philip JF Você pode por favor elaborar? O que você quer dizer com 2 pilhas, uma para código Java e outra para código nativo? O que isso faz?
Gurinder #
"Até onde eu sei, todas as JVMs alocam duas pilhas por encadeamento." - Eu nunca vi nenhuma evidência que pudesse apoiar isso. Talvez você esteja entendendo mal a verdadeira natureza do opstack na especificação da JVM. (É uma maneira de modelar o comportamento de bytecodes, não é algo que precisa ser usado em tempo de execução para executá-los.)
Stephen C
1

Obviamente, o cerne da questão é o que significa 'caro'.

Um encadeamento precisa criar uma pilha e inicializar a pilha com base no método run.

Ele precisa configurar estruturas de status de controle, ou seja, em que estado está executando, aguardando etc.

Provavelmente há muita sincronização em torno da configuração dessas coisas.

MeBigFatGuy
fonte