¿Async (launch :: async) en C ++ 11 hace que los grupos de subprocesos sean obsoletos para evitar la costosa creación de subprocesos?

117

Está vagamente relacionado con esta pregunta: ¿std :: thread está agrupado en C ++ 11? . Aunque la pregunta es diferente, la intención es la misma:

Pregunta 1: ¿Todavía tiene sentido usar su propio grupo de subprocesos (o una biblioteca de terceros) para evitar la creación de subprocesos costosa?

La conclusión en la otra pregunta fue que no se puede confiar en std::threadque se agruparán (podría serlo o no). Sin embargo, std::async(launch::async)parece tener una probabilidad mucho mayor de agruparse.

No creo que esté forzado por el estándar, pero en mi humilde opinión, esperaría que todas las buenas implementaciones de C ++ 11 usen la agrupación de subprocesos si la creación de subprocesos es lenta. Solo en plataformas donde es económico crear un nuevo hilo, esperaría que siempre generen un nuevo hilo.

Pregunta 2: Esto es solo lo que pienso, pero no tengo hechos que lo prueben. Bien puedo estar equivocado. ¿Es una suposición fundamentada?

Finalmente, aquí he proporcionado un código de muestra que primero muestra cómo creo que la creación de subprocesos se puede expresar mediante async(launch::async):

Ejemplo 1:

 thread t([]{ f(); });
 // ...
 t.join();

se convierte en

 auto future = async(launch::async, []{ f(); });
 // ...
 future.wait();

Ejemplo 2: disparar y olvidar hilo

 thread([]{ f(); }).detach();

se convierte en

 // a bit clumsy...
 auto dummy = async(launch::async, []{ f(); });

 // ... but I hope soon it can be simplified to
 async(launch::async, []{ f(); });

Pregunta 3: ¿Preferiría las asyncversiones a las threadversiones?


El resto ya no es parte de la pregunta, sino solo para aclarar:

¿Por qué se debe asignar el valor de retorno a una variable ficticia?

Desafortunadamente, el estándar actual de C ++ 11 obliga a capturar el valor de retorno std::async, ya que de lo contrario se ejecuta el destructor, que se bloquea hasta que finaliza la acción. Algunos lo consideran un error en el estándar (por ejemplo, por Herb Sutter).

Este ejemplo de cppreference.com lo ilustra muy bien:

{
  std::async(std::launch::async, []{ f(); });
  std::async(std::launch::async, []{ g(); });  // does not run until f() completes
}

Otra aclaración:

Sé que los grupos de subprocesos pueden tener otros usos legítimos, pero en esta pregunta solo me interesa el aspecto de evitar costosos costos de creación de subprocesos .

Creo que todavía hay situaciones en las que los grupos de subprocesos son muy útiles, especialmente si necesita más control sobre los recursos. Por ejemplo, un servidor puede decidir manejar solo un número fijo de solicitudes simultáneamente para garantizar tiempos de respuesta rápidos y aumentar la previsibilidad del uso de la memoria. Los grupos de subprocesos deberían estar bien, aquí.

Las variables locales de subprocesos también pueden ser un argumento para sus propios grupos de subprocesos, pero no estoy seguro de si es relevante en la práctica:

  • Crear un nuevo hilo con std::threadinicios sin variables locales de hilo inicializadas. Quizás esto no es lo que quieres.
  • En los subprocesos generados por async, no está claro para mí porque el subproceso podría haberse reutilizado. Según tengo entendido, no se garantiza que se restablezcan las variables locales de subprocesos, pero puedo estar equivocado.
  • El uso de sus propios grupos de subprocesos (de tamaño fijo), por otro lado, le brinda un control total si realmente lo necesita.
Philipp Claßen
fuente
8
"Sin embargo, std::async(launch::async)parece tener una probabilidad mucho mayor de ser agrupada". No, creo std::async(launch::async | launch::deferred)que se puede agrupar. Con solo launch::asyncla tarea se supone que debe iniciarse en un nuevo hilo independientemente de qué otras tareas se estén ejecutando. Con la política launch::async | launch::deferred, la implementación puede elegir qué política, pero lo más importante es retrasar la elección de qué política. Es decir, puede esperar hasta que un subproceso en un grupo de subprocesos esté disponible y luego elegir la política asincrónica.
bames53
2
Hasta donde yo sé, solo VC ++ usa un grupo de subprocesos con std::async(). Todavía tengo curiosidad por ver cómo admiten destructores thread_local no triviales en un grupo de subprocesos.
bames53
2
@ bames53 Pasé por libstdc ++ que viene con gcc 4.7.2 y descubrí que si la política de lanzamiento no es exactamente, launch::async entonces la trata como si fuera única launch::deferredy nunca la ejecuta de forma asincrónica, por lo que, en efecto, esa versión de libstdc ++ "elige" utilizar siempre diferido a menos que se fuerce de otra manera.
doug65536
3
@ doug65536 Mi punto sobre los destructores thread_local fue que la destrucción en la salida del hilo no es del todo correcta cuando se usan grupos de hilos. Cuando una tarea se ejecuta de forma asincrónica, se ejecuta 'como si estuviera en un nuevo hilo', de acuerdo con la especificación, lo que significa que cada tarea asíncrona obtiene sus propios objetos thread_local. Una implementación basada en un grupo de subprocesos debe tener especial cuidado para garantizar que las tareas que comparten el mismo subproceso de respaldo aún se comporten como si tuvieran sus propios objetos thread_local. Considere este programa: pastebin.com/9nWUT40h
bames53
2
@ bames53 Usar "como en un nuevo hilo" en la especificación fue un gran error en mi opinión. std::asyncpodría haber sido algo hermoso para el rendimiento; podría haber sido el sistema estándar de ejecución de tareas de ejecución corta, naturalmente respaldado por un grupo de subprocesos. En este momento, es solo una std::threadcon algunas tonterías agregadas para que la función del hilo pueda devolver un valor. Ah, y agregaron una funcionalidad "diferida" redundante que se superpone std::functioncompletamente al trabajo .
doug65536

Respuestas:

54

Pregunta 1 :

Cambié esto del original porque el original estaba mal. Tenía la impresión de que la creación de subprocesos de Linux era muy barata y, después de probar, determiné que la sobrecarga de la llamada de función en un subproceso nuevo frente a uno normal es enorme. La sobrecarga para crear un hilo para manejar una llamada de función es algo así como 10000 o más veces más lenta que una llamada de función simple. Entonces, si está emitiendo muchas llamadas a funciones pequeñas, un grupo de subprocesos podría ser una buena idea.

Es bastante evidente que la biblioteca C ++ estándar que se envía con g ++ no tiene grupos de subprocesos. Pero definitivamente puedo ver un caso para ellos. Incluso con la sobrecarga de tener que empujar la llamada a través de algún tipo de cola entre subprocesos, probablemente sería más barato que iniciar un nuevo subproceso. Y el estándar lo permite.

En mi humilde opinión, la gente del kernel de Linux debería trabajar para hacer que la creación de hilos sea más barata de lo que es actualmente. Pero, la biblioteca estándar de C ++ también debería considerar el uso de pool para implementar launch::async | launch::deferred.

Y el OP es correcto, usar ::std::threadpara lanzar un hilo, por supuesto, fuerza la creación de un nuevo hilo en lugar de usar uno de un grupo. Entonces ::std::async(::std::launch::async, ...)se prefiere.

Pregunta 2 :

Sí, básicamente esto lanza un hilo 'implícitamente'. Pero realmente, todavía es bastante obvio lo que está sucediendo. Así que no creo que la palabra implícitamente sea una palabra particularmente buena.

Tampoco estoy convencido de que obligarte a esperar una devolución antes de la destrucción sea necesariamente un error. No sé si debería usar la asyncllamada para crear subprocesos 'daemon' que no se espera que regresen. Y si se espera que regresen, no está bien ignorar las excepciones.

Pregunta 3 :

Personalmente, me gusta que los lanzamientos de hilos sean explícitos. Valoro mucho las islas donde puede garantizar el acceso en serie. De lo contrario, terminará con un estado mutable en el que siempre tendrá que envolver un mutex en algún lugar y recordar usarlo.

Me gustó mucho más el modelo de cola de trabajo que el modelo 'futuro' porque hay 'islas de serie' por ahí para que pueda manejar de manera más efectiva el estado mutable.

Pero en realidad, depende exactamente de lo que estés haciendo.

Prueba de rendimiento

Entonces, probé el rendimiento de varios métodos para llamar cosas y obtuve estos números en un sistema de 8 núcleos (AMD Ryzen 7 2700X) que ejecuta Fedora 29 compilado con clang versión 7.0.1 y libc ++ (no libstdc ++):

   Do nothing calls per second:   35365257                                      
        Empty calls per second:   35210682                                      
   New thread calls per second:      62356                                      
 Async launch calls per second:      68869                                      
Worker thread calls per second:     970415                                      

Y nativo, en mi MacBook Pro de 15 "(Intel (R) Core (TM) i7-7820HQ CPU @ 2.90GHz) con Apple LLVM version 10.0.0 (clang-1000.10.44.4)OSX 10.13.6, obtengo esto:

   Do nothing calls per second:   22078079
        Empty calls per second:   21847547
   New thread calls per second:      43326
 Async launch calls per second:      58684
Worker thread calls per second:    2053775

Para el subproceso de trabajo, inicié un subproceso, luego usé una cola sin bloqueo para enviar solicitudes a otro subproceso y luego esperé a que se enviara una respuesta "Listo".

El "No hacer nada" es solo para probar la parte superior del arnés de prueba.

Está claro que la sobrecarga de lanzar un hilo es enorme. E incluso el subproceso de trabajo con la cola entre subprocesos ralentiza las cosas en un factor de 20 aproximadamente en Fedora 25 en una máquina virtual, y en aproximadamente 8 en OS X nativo.

Creé un proyecto de Bitbucket con el código que usé para la prueba de rendimiento. Se puede encontrar aquí: https://bitbucket.org/omnifarious/launch_thread_performance

De todo género
fuente
3
Estoy de acuerdo con el modelo de cola de trabajo, sin embargo, esto requiere tener un modelo de "canalización" que puede no ser aplicable a todos los usos del acceso concurrente.
Matthieu M.
1
Me parece que las plantillas de expresión (para operadores) podrían usarse para componer los resultados, para las llamadas a funciones necesitaría un método de llamada , supongo, pero debido a las sobrecargas podría ser un poco más difícil.
Matthieu M.
3
"muy barato" es relativo a su experiencia. Considero que la sobrecarga de creación de subprocesos de Linux es sustancial para mi uso.
Jeff
1
@Jeff: pensé que era mucho más barato de lo que es. Actualicé mi respuesta hace un tiempo para reflejar una prueba que hice para descubrir el costo real.
Omnifarious
4
En la primera parte, está subestimando un poco cuánto se debe hacer para crear una amenaza y qué poco se debe hacer para llamar a una función. La llamada y devolución de una función son unas pocas instrucciones de CPU que manipulan algunos bytes en la parte superior de la pila. La creación de una amenaza significa: 1. asignar una pila, 2. realizar una llamada al sistema, 3. crear estructuras de datos en el kernel y vincularlas, capturar bloqueos en el camino, 4. esperar a que el programador ejecute el hilo, 5. cambiar contexto al hilo. Cada uno de estos pasos en sí mismo toma mucho más tiempo que las llamadas a funciones más complejas.
cmaster - reinstalar a monica