Actualiza y renderiza en hilos separados

12

Estoy creando un motor de juego 2D simple y quiero actualizar y renderizar los sprites en diferentes hilos, para aprender cómo se hace.

Necesito sincronizar el hilo de actualización y el de render. Actualmente, uso dos banderas atómicas. El flujo de trabajo se parece a:

Thread 1 -------------------------- Thread 2
Update obj ------------------------ wait for swap
Create queue ---------------------- render the queue
Wait for render ------------------- notify render done
Swap render queues ---------------- notify swap done

En esta configuración, limito el FPS del hilo de renderizado al FPS del hilo de actualización. Además, uso sleep()para limitar tanto el renderizado como la actualización del FPS del hilo a 60, por lo que las dos funciones de espera no esperarán mucho tiempo.

El problema es:

El uso promedio de la CPU es de alrededor del 0.1%. A veces sube hasta un 25% (en una PC de cuatro núcleos). Significa que un subproceso está esperando al otro porque la función de espera es un bucle while con una función de prueba y configuración, y un bucle while utilizará todos los recursos de su CPU.

Mi primera pregunta es: ¿hay otra forma de sincronizar los dos hilos? Noté que std::mutex::lockno use la CPU mientras está esperando bloquear un recurso, por lo que no es un ciclo while. ¿Como funciona? No puedo usar std::mutexporque tendré que bloquearlos en un hilo y desbloquearlos en otro hilo.

La otra pregunta es; Dado que el programa se ejecuta siempre a 60 FPS, ¿por qué a veces su uso de CPU salta al 25%, lo que significa que uno de los dos espera mucho? (los dos hilos están limitados a 60 fps, por lo que idealmente no necesitarán mucha sincronización).

Editar: Gracias por todas las respuestas. Primero quiero decir que no comienzo un nuevo hilo en cada cuadro para renderizar. Comienzo tanto la actualización como el bucle de renderizado al principio. Creo que el subprocesamiento múltiple puede ahorrar algo de tiempo: tengo las siguientes funciones: FastAlg () y Alg (). Alg () es mi obj de actualización y obj de render y Fastalg () es mi "cola de envío de render a" renderizador ". En un solo hilo:

Alg() //update 
FastAgl() 
Alg() //render

En dos hilos:

Alg() //update  while Alg() //render last frame
FastAlg() 

Entonces, quizás el multiproceso puede ahorrar el mismo tiempo. (en realidad, en una aplicación matemática simple, sí, donde alg es un algoritmo largo y es más rápido)

Sé que dormir no es una buena idea, aunque nunca he tenido problemas. ¿Será esto mejor?

While(true) 
{
   If(timer.gettimefromlastcall() >= 1/fps)
   Do_update()
}

Pero este será un ciclo while infinito que usará toda la CPU. ¿Puedo usar el modo de suspensión (un número <15) para limitar el uso? De esta manera, se ejecutará, por ejemplo, a 100 fps, y la función de actualización se llamará solo 60 veces por segundo.

Para sincronizar los dos hilos, usaré waitforsingleobject con createSemaphore para poder bloquear y desbloquear diferentes hilos (sin usar un bucle while), ¿no?

Liuka
fuente
55
"No digas que mi multihilo es inútil en este caso, solo quiero aprender cómo hacerlo" . En ese caso, debes aprender las cosas correctamente, es decir (a) no uses el sueño () para controlar el marco raro , nunca , y (b) evite el diseño de subprocesos por componente y evite ejecutar bloqueos, en su lugar, divida el trabajo en tareas y maneje las tareas desde una cola de trabajo.
Damon
1
@Damon (a) sleep () se puede usar como un mecanismo de velocidad de fotogramas y, de hecho, es bastante popular, aunque tengo que aceptar que hay opciones mucho mejores. (b) El usuario aquí quiere separar tanto la actualización como el procesamiento en dos hilos diferentes. Esta es una separación normal en un motor de juego y no es tan "hilo por componente". Ofrece claras ventajas, pero puede traer problemas si se hace incorrectamente.
Alexandre Desbiens
@AlphSpirit: El hecho de que algo sea "común" no significa que no esté mal . Sin siquiera entrar en temporizadores divergentes, la mera granularidad del sueño en al menos un sistema operativo de escritorio popular es razón suficiente, si no su falta de confiabilidad por diseño en cada sistema de consumo existente. Explicar por qué separar la actualización y el procesamiento en dos subprocesos como se describe no es prudente y causa más problemas de lo que vale tomaría demasiado tiempo El objetivo del OP se establece como aprender cómo se hace , que debe ser aprender cómo se hace correctamente . Un montón de artículos sobre el diseño moderno del motor MT.
Damon
@Damon Cuando dije que era popular o común, no quise decir que era correcto. Solo quise decir que fue utilizado por muchas personas. "... aunque tengo que estar de acuerdo en que hay opciones mucho mejores" significa que, de hecho, no es una muy buena forma de sincronizar el tiempo. Perdón por el malentendido.
Alexandre Desbiens
@AlphSpirit: No te preocupes :-) El mundo está lleno de cosas que muchas personas hacen (y no siempre por una buena razón), pero cuando uno comienza a aprender, uno aún debe tratar de evitar los más evidentemente incorrectos.
Damon

Respuestas:

25

Para un motor 2D simple con sprites, un enfoque de un solo subproceso es perfectamente bueno. Pero dado que desea aprender a hacer subprocesos múltiples, debe aprender a hacerlo correctamente.

No haga

  • Utilice 2 subprocesos que ejecutan más o menos pasos de bloqueo, implementando un comportamiento de subproceso único con varios subprocesos. Tiene el mismo nivel de paralelismo (cero) pero agrega sobrecarga para los cambios de contexto y la sincronización. Además, la lógica es más difícil de asimilar.
  • Use sleeppara controlar la velocidad de fotogramas. Nunca. Si alguien te dice que lo hagas, golpéalo.
    Primero, no todos los monitores funcionan a 60Hz. En segundo lugar, dos temporizadores que funcionan a la misma velocidad corriendo uno al lado del otro siempre se desincronizarán (suelte dos pelotas de ping-pong en una mesa desde la misma altura y escuche). Tercero, por diseño no sleepes ni preciso ni confiable. La granularidad puede ser tan mala como 15,6 ms (de hecho, el valor predeterminado en Windows [1] ), y un marco es de solo 16,6 ms a 60 fps, lo que deja solo 1 ms para todo lo demás. Además, es difícil obtener 16.6 para que sea un múltiplo de 15.6 ... Además, se permite (¡y a veces lo hará!) Regresar solo después de 30 o 50 o 100 ms, o incluso más tiempo.
    sleep
  • Use std::mutexpara notificar a otro hilo. Esto no es para lo que sirve.
  • Suponga que TaskManager es bueno para decirle lo que está sucediendo, especialmente a juzgar por un número como "25% CPU", que podría gastarse en su código, o dentro del controlador de modo de usuario, o en otro lugar.
  • Tener un hilo por componente de alto nivel (por supuesto, hay algunas excepciones).
  • Crear hilos en "tiempos aleatorios", ad hoc, por tarea. Crear subprocesos puede ser sorprendentemente costoso y pueden tomar un tiempo sorprendentemente largo antes de que hagan lo que usted les dijo (¡especialmente si tiene muchas DLL cargadas!).

Hacer

  • Utilice el subprocesamiento múltiple para que las cosas se ejecuten de forma asincrónica tanto como sea posible. La velocidad no es la idea principal de enhebrar, sino hacer las cosas en paralelo (por lo tanto, incluso si tardan más en total, la suma de todos es aún menor).
  • Use la sincronización vertical para limitar la velocidad de fotogramas. Esa es la única forma correcta (y sin fallas) de hacerlo. Si el usuario lo anula en el panel de control del controlador de pantalla ("forzar apagado"), que así sea. Después de todo, es su computadora, no la tuya.
  • Si necesita "marcar" algo a intervalos regulares, use un temporizador . Los temporizadores tienen la ventaja de tener una precisión y fiabilidad mucho mejores en comparación con sleep[2] . Además, un temporizador recurrente representa el tiempo correctamente (incluido el tiempo que transcurre entre ellos) mientras que dormir 16.6 ms (o 16.6 ms menos medido_tiempo_elapsado) no.
  • Ejecute simulaciones físicas que impliquen integración numérica en un paso de tiempo fijo (¡o sus ecuaciones explotarán!), Interpole gráficos entre pasos (esto puede ser una excusa para un hilo por componente separado, pero también se puede hacer sin él).
  • Se utiliza std::mutexpara que solo un hilo acceda a un recurso a la vez ("excluirse mutuamente") y para cumplir con la extraña semántica de std::condition_variable.
  • Evite que los hilos compitan por los recursos. Bloquee tan poco como sea necesario (¡pero nada menos!) Y mantenga los bloqueos solo el tiempo que sea absolutamente necesario.
  • Comparta datos de solo lectura entre subprocesos (sin problemas de caché y sin bloqueo necesario), pero no modifique simultáneamente los datos (necesita sincronización y elimina el caché). Eso incluye modificar datos que están cerca de una ubicación que alguien más podría leer.
  • Use std::condition_variablepara bloquear otro hilo hasta que se cumpla alguna condición. La semántica de std::condition_variablecon ese mutex adicional es ciertamente bastante extraña y retorcida (principalmente por razones históricas heredadas de hilos POSIX), pero una variable de condición es la primitiva correcta para usar para lo que desea.
    En caso de que te parezca std::condition_variabledemasiado extraño para sentirte cómodo con él, también puedes simplemente usar un evento de Windows (un poco más lento) o, si eres valiente, construir tu propio evento simple alrededor de NtKeyedEvents (implica cosas aterradoras de bajo nivel). A medida que usa DirectX, ya está vinculado a Windows de todos modos, por lo que la pérdida de portabilidad no debería ser un problema.
  • Divida el trabajo en tareas de tamaño razonable que son ejecutadas por un grupo de subprocesos de trabajo de tamaño fijo (no más de uno por núcleo, sin contar núcleos hipertronchados). Deje que las tareas de finalización pongan en cola las tareas dependientes (sincronización automática y gratuita). Realice tareas que tengan al menos unos cientos de operaciones no triviales cada una (o una operación de bloqueo duradera como una lectura de disco). Prefiere el acceso contiguo al caché.
  • Crear todos los hilos al inicio del programa.
  • Aproveche las funciones asincrónicas que ofrece el sistema operativo o la API de gráficos para un paralelismo mejor / adicional, no solo en el nivel del programa sino también en el hardware (piense en las transferencias PCIe, el paralelismo CPU-GPU, el disco DMA, etc.).
  • Otras 10.000 cosas que he olvidado mencionar.


[1] Sí, puede establecer la velocidad del planificador en 1 ms, pero esto está mal visto ya que causa muchos más cambios de contexto y consume mucha más energía (en un mundo donde cada vez más dispositivos son dispositivos móviles). Tampoco es una solución ya que todavía no hace que el sueño sea más confiable.
[2] Un temporizador aumentará la prioridad del hilo, lo que le permitirá interrumpir otro hilo de igual prioridad a mitad del cuántico y se programará primero, lo cual es un comportamiento cuasi-RT. Por supuesto, no es verdadero RT, pero se acerca mucho. Despertar del sueño simplemente significa que el hilo está listo para ser programado en algún momento, siempre que sea posible.

Damon
fuente
¿Puede explicar por qué no debería "tener un hilo por componente de alto nivel"? ¿Quiere decir que uno no debería tener física y mezcla de audio en dos hilos separados? No veo ninguna razón para no hacerlo.
Elviss Strazdins
3

No estoy seguro de lo que quiere lograr al limitar el FPS de la actualización y el renderizado a 60. Si los limita al mismo valor, podría haberlos puesto en el mismo hilo.

El objetivo al separar Update y Render en diferentes subprocesos es tener ambos "casi" independientes el uno del otro, de modo que la GPU pueda generar 500 FPS y la lógica de Actualización todavía vaya a 60 FPS. No logras una ganancia de rendimiento muy alta al hacerlo.

Pero dijiste que solo querías saber cómo funciona, y está bien. En C ++, un mutex es un objeto especial que se usa para bloquear el acceso a ciertos recursos para otros subprocesos. En otras palabras, usa un mutex para hacer que los datos sensibles sean accesibles por un solo hilo a la vez. Para hacerlo, es bastante simple:

std::mutex mutex;
mutex.lock();
// Do sensible stuff here...
mutex.unlock();

Fuente: http://en.cppreference.com/w/cpp/thread/mutex

EDITAR : asegúrese de que su mutex sea de clase o de todo el archivo, como en el enlace proporcionado, de lo contrario, cada hilo creará su propio mutex y no logrará nada.

El primer subproceso para bloquear el mutex tendrá acceso al código interno. Si un segundo hilo intenta llamar a la función lock (), se bloqueará hasta que el primer hilo lo desbloquee. Entonces, un mutex es una función de bloqueo, a diferencia de un ciclo while. Las funciones de bloqueo no ejercerán presión sobre la CPU.

Alexandre Desbiens
fuente
¿Y cómo funciona el bloque?
Liuka
Cuando el segundo subproceso llamará a lock (), esperará pacientemente a que el primer subproceso desbloquee el mutex y continuará en la siguiente línea después (en este ejemplo, lo sensible). EDITAR: el segundo subproceso bloqueará el mutex por sí mismo.
Alexandre Desbiens
1
Uso std::lock_guardo similar, no .lock()/ .unlock(). ¡RAII no es solo para la administración de memoria!
bcrist