¿Por qué se cree que crear un hilo es costoso?

180

Los tutoriales de Java dicen que crear un hilo es costoso. Pero, ¿por qué es exactamente caro? ¿Qué sucede exactamente cuando se crea un Java Thread que hace que su creación sea costosa? Estoy tomando la declaración como cierta, pero solo estoy interesado en la mecánica de la creación de subprocesos en JVM.

Subproceso de ciclo de vida superior. La creación de hilos y el desmontaje no son gratuitos. La sobrecarga real varía según las plataformas, pero la creación de subprocesos lleva tiempo, ya que introduce latencia en el procesamiento de solicitudes y requiere cierta actividad de procesamiento por parte de la JVM y el sistema operativo. Si las solicitudes son frecuentes y ligeras, como en la mayoría de las aplicaciones de servidor, la creación de un nuevo subproceso para cada solicitud puede consumir importantes recursos informáticos.

De la concurrencia de Java en la práctica
Por Brian Goetz, Tim Peierls, Joshua Bloch, Joseph Bowbeer, David Holmes, Doug Lea
Imprimir ISBN-10: 0-321-34960-1

kachanov
fuente
No sé el contexto en el que los tutoriales que has leído dicen esto: ¿implican que la creación en sí es costosa o que "crear un hilo" es costoso. La diferencia que trato de mostrar es entre la acción pura de hacer el hilo (llamémoslo instanciarlo o algo), o el hecho de que tienes un hilo (por lo tanto, usar un hilo: obviamente tener sobrecarga). ¿Cuál se reclama // cuál desea preguntar?
Nanne
9
@typoknig - Caro en comparación con NO crear un nuevo hilo :)
willcodejavaforfood
posible duplicado de la sobrecarga de creación de hilos Java
Paul Draper
1
Hilo para la victoria. No es necesario crear siempre nuevos hilos para las tareas.
Alexander Mills

Respuestas:

149

La creación de hilos Java es costosa porque hay un poco de trabajo involucrado:

  • Se debe asignar e inicializar un gran bloque de memoria para la pila de subprocesos.
  • Se deben realizar llamadas al sistema para crear / registrar el hilo nativo con el sistema operativo host.
  • Los descriptores deben crearse, inicializarse y agregarse a las estructuras de datos internas de JVM.

También es costoso en el sentido de que el hilo ata los recursos mientras esté vivo; por ejemplo, la pila de hilos, cualquier objeto accesible desde la pila, los descriptores de hilos JVM, los descriptores de hilos nativos del sistema operativo.

Los costos de todas estas cosas son específicos de la plataforma, pero no son baratos en ninguna plataforma Java que haya encontrado.


Una búsqueda en Google me encontró un viejo punto de referencia que informa una tasa de creación de subprocesos de ~ 4000 por segundo en un Sun Java 1.4.1 en un procesador dual 2002 Xeon con 2002 Linux vintage. Una plataforma más moderna dará mejores números ... y no puedo comentar sobre la metodología ... pero al menos da una pista sobre cuán costosa es la creación de hilos.

La evaluación comparativa de Peter Lawrey indica que la creación de subprocesos es significativamente más rápida en estos días en términos absolutos, pero no está claro cuánto de esto se debe a mejoras en Java y / o el sistema operativo ... o mayores velocidades de procesador. Pero sus números aún indican una mejora de más de 150 veces si usa un grupo de hilos en lugar de crear / comenzar un nuevo hilo cada vez. (Y él señala que todo esto es relativo ...)


(Lo anterior supone "hilos nativos" en lugar de "hilos verdes", pero las JVM modernas usan hilos nativos por razones de rendimiento. Los hilos verdes son posiblemente más baratos de crear, pero se paga en otras áreas).


He cavado un poco para ver cómo se asigna realmente la pila de un hilo de Java. En el caso de OpenJDK 6 en Linux, la pila de subprocesos se asigna por la llamada pthread_createque crea el subproceso nativo. (La JVM no pasa pthread_createuna pila preasignada).

Luego, dentro de pthread_createla pila se asigna mediante una llamada a mmaplo siguiente:

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

Según man mmap, la MAP_ANONYMOUSbandera hace que la memoria se inicialice a cero.

Por lo tanto, aunque no sea esencial que las nuevas pilas de subprocesos de Java estén puestas a cero (según la especificación JVM), en la práctica (al menos con OpenJDK 6 en Linux) están puestas a cero.

Stephen C
fuente
2
@Raedwald: es la parte de inicialización que es costosa. En algún lugar, algo (por ejemplo, el GC o el sistema operativo) pondrá a cero los bytes antes de que el bloque se convierta en una pila de subprocesos. Eso requiere ciclos de memoria física en hardware típico.
Stephen C
2
"En algún lugar, algo (por ejemplo, el GC o el sistema operativo) pondrá a cero los bytes". ¿Va a? El sistema operativo lo hará si requiere la asignación de una nueva página de memoria, por razones de seguridad. Pero eso será poco común. Y el sistema operativo puede mantener un caché de páginas ya editadas a cero (IIRC, Linux lo hace). ¿Por qué se molestaría el GC, dado que la JVM evitará que cualquier programa Java lea su contenido? Tenga en cuenta que la malloc()función C estándar , que la JVM bien podría usar, no garantiza que la memoria asignada esté en cero (presumiblemente para evitar tales problemas de rendimiento).
Raedwald
1
stackoverflow.com/questions/2117072/… coincide en que "Un factor importante es la memoria de pila asignada a cada subproceso".
Raedwald
2
@Raedwald: vea la respuesta actualizada para obtener información sobre cómo se asigna realmente la pila.
Stephen C
2
Es posible (incluso probable) que las páginas de memoria asignadas por la mmap()llamada estén asignadas de copia a escritura a una página cero, por lo que su inicialización ocurre no dentro de mmap()sí misma, sino cuando las páginas se escriben por primera vez , y luego solo una página en un momento. Es decir, cuando el hilo comienza a ejecutarse, con el costo generado por el hilo creado en lugar del hilo creador.
Raedwald
76

Otros han discutido de dónde provienen los costos de enhebrar. Esta respuesta cubre por qué crear un hilo no es tan costoso en comparación con muchas operaciones, sino relativamente caro en comparación con las alternativas de ejecución de tareas, que son relativamente menos costosas.

La alternativa más obvia para ejecutar una tarea en otro subproceso es ejecutar la tarea en el mismo subproceso. Esto es difícil de entender para aquellos que suponen que más hilos son siempre mejores. La lógica es que si la sobrecarga de agregar la tarea a otro subproceso es mayor que el tiempo que ahorra, puede ser más rápido realizar la tarea en el subproceso actual.

Otra alternativa es usar un grupo de subprocesos. Un grupo de subprocesos puede ser más eficiente por dos razones. 1) reutiliza hilos ya creados. 2) puede ajustar / controlar el número de subprocesos para asegurarse de tener un rendimiento óptimo.

El siguiente programa 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

Esta es una prueba para una tarea trivial que expone la sobrecarga de cada opción de subprocesamiento. (Esta tarea de prueba es el tipo de tarea que en realidad se realiza mejor en el hilo actual).

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 puede ver, crear un nuevo hilo solo cuesta ~ 70 µs. Esto podría considerarse trivial en muchos, si no en la mayoría, de los casos de uso. Relativamente hablando, es más costoso que las alternativas y, en algunas situaciones, un grupo de hilos o no usar hilos es una mejor solución.

Peter Lawrey
fuente
8
Esa es una gran pieza de código allí. Conciso, al punto y muestra claramente su jist.
Nicholas
En el último bloque, creo que el resultado está sesgado, porque en los primeros dos bloques el hilo principal se está eliminando en paralelo a medida que se van poniendo los hilos de trabajo. Sin embargo, en el último bloque, la acción de tomar se realiza en serie, por lo que dilata el valor. Probablemente podría usar queue.clear () y usar un CountDownLatch en su lugar para esperar a que se completen los hilos.
Victor Grazi
@VictorGrazi Supongo que desea recopilar los resultados de forma centralizada. Está haciendo la misma cantidad de trabajo en cola en cada caso. Un cierre de cuenta atrás sería un poco más rápido.
Peter Lawrey
En realidad, ¿por qué no hacer que haga algo consistentemente rápido, como incrementar un contador? soltar toda la cosa BlockingQueue. Verifique el contador al final para evitar que el compilador optimice la operación de incremento
Victor Grazi
@grazi, podría hacerlo en este caso, pero no lo haría en la mayoría de los casos realistas, ya que esperar en un mostrador podría ser ineficiente. Si hicieras eso, la diferencia entre los ejemplos sería aún mayor.
Peter Lawrey
31

En teoría, esto depende de la JVM. En la práctica, cada hilo tiene una cantidad relativamente grande de memoria de pila (256 KB por defecto, creo). Además, los subprocesos se implementan como subprocesos del sistema operativo, por lo que su creación implica una llamada del sistema operativo, es decir, un cambio de contexto.

Tenga en cuenta que "caro" en informática es siempre muy relativo. La creación de subprocesos es muy costosa en relación con la creación de la mayoría de los objetos, pero no es muy costosa en relación con una búsqueda aleatoria de disco duro. No tiene que evitar crear hilos a toda costa, pero crear cientos de ellos por segundo no es un movimiento inteligente. En la mayoría de los casos, si su diseño requiere muchos subprocesos, debe usar un grupo de subprocesos de tamaño limitado.

Michael Borgwardt
fuente
9
Por cierto kb = kilo-bit, kB = kilo byte. Gb = giga bit, GB = giga byte.
Peter Lawrey
@PeterLawrey ¿capitalizamos la 'k' en 'kb' y 'kB', por lo que hay simetría en 'Gb' y 'GB'? Estas cosas me molestan.
Jack
3
@Jack Hay un K= 1024 y k= 1000.;) en.wikipedia.org/wiki/Kibibyte
Peter Lawrey
9

Hay dos tipos de hilos:

  1. Subprocesos adecuados : estas son abstracciones en torno a las instalaciones de subprocesos del sistema operativo subyacente. La creación de subprocesos es, por lo tanto, tan costosa como la del sistema: siempre hay una sobrecarga.

  2. Hilos "verdes" : creados y programados por la JVM, son más baratos, pero no se produce un paralelismo adecuado. Estos se comportan como hilos, pero se ejecutan dentro del hilo JVM en el sistema operativo. No son de uso frecuente, que yo sepa.

El factor más importante que puedo pensar en la sobrecarga de creación de subprocesos es el tamaño de pila que ha definido para sus subprocesos. El tamaño de la pila de subprocesos se puede pasar como parámetro cuando se ejecuta la VM.

Aparte de eso, la creación de subprocesos depende principalmente del sistema operativo e incluso de la implementación de VM.

Ahora, permítame señalar algo: crear hilos es costoso si planea disparar 2000 hilos por segundo, cada segundo de su tiempo de ejecución. La JVM no está diseñada para manejar eso . Si tendrá un par de trabajadores estables que no serán despedidos y asesinados una y otra vez, relájese.

slezica
fuente
19
"... un par de trabajadores estables que no serán despedidos ni asesinados ..." ¿Por qué comencé a pensar en las condiciones del lugar de trabajo? :-)
Stephen C
6

La creación Threadsrequiere la asignación de una buena cantidad de memoria, ya que tiene que hacer no una, sino dos nuevas pilas (una para el código Java, una para el código nativo). El uso de Executors / Thread Pools puede evitar la sobrecarga, reutilizando hilos para múltiples tareas para Executor .

Philip JF
fuente
@Raedwald, ¿cuál es el jvm que usa pilas separadas?
bestsss
1
Philip JP dice 2 pilas.
Raedwald
Hasta donde yo sé, todas las JVM asignan dos pilas por hilo. Es útil para la recolección de elementos no utilizados para tratar el código Java (incluso cuando está JIT) de manera diferente a la conversión libre c.
Philip JF
@Philip JF ¿Puedes dar más detalles? ¿Qué quiere decir con 2 pilas, una para el código Java y otra para el código nativo? ¿Qué hace?
Gurinder
"Hasta donde yo sé, todas las JVM asignan dos pilas por subproceso". - Nunca he visto ninguna evidencia que respalde esto. Quizás esté malinterpretando la verdadera naturaleza del opstack en la especificación JVM. (Es una forma de modelar el comportamiento de los códigos de bytes, no es algo que deba usarse en tiempo de ejecución para ejecutarlos)
Stephen C
1

Obviamente, el quid de la cuestión es qué significa "caro".

Un hilo necesita crear una pila e inicializar la pila en función del método de ejecución.

Necesita configurar estructuras de estado de control, es decir, en qué estado se puede ejecutar, esperar, etc.

Probablemente haya una buena cantidad de sincronización en torno a la configuración de estas cosas.

MeBigFatGuy
fuente