Aggiornamento e rendering in thread separati


12

Sto creando un semplice motore di gioco 2D e voglio aggiornare e renderizzare gli sprite in diversi thread, per imparare come è fatto.

Devo sincronizzare il thread di aggiornamento e quello di rendering. Attualmente uso due bandiere atomiche. Il flusso di lavoro è simile al seguente:

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

In questa configurazione, limito l'FPS del thread di rendering all'FPS del thread di aggiornamento. Inoltre, utilizzo sleep()per limitare il rendering e l'aggiornamento dell'FPS del thread a 60, quindi le due funzioni di attesa non attenderanno molto tempo.

Il problema è:

L'utilizzo medio della CPU è di circa lo 0,1%. A volte arriva fino al 25% (in un PC quad core). Significa che un thread è in attesa dell'altro perché la funzione wait è un ciclo while con una funzione test e set e un ciclo while utilizzerà tutte le risorse della CPU.

La mia prima domanda è: c'è un altro modo per sincronizzare i due thread? Ho notato che std::mutex::locknon usano la CPU mentre è in attesa di bloccare una risorsa, quindi non è un ciclo while. Come funziona? Non posso usarlo std::mutexperché dovrò bloccarli in un thread e sbloccarli in un altro thread.

L'altra domanda è; dal momento che il programma funziona sempre a 60 FPS perché a volte il suo utilizzo della CPU passa al 25%, il che significa che una delle due attese è in attesa molto? (i due thread sono entrambi limitati a 60 fps, quindi idealmente non avranno bisogno di molta sincronizzazione).

Modifica: grazie per tutte le risposte. Per prima cosa voglio dire che non inizio un nuovo thread ogni frame per il rendering. Inizio sia l'aggiornamento che il ciclo di rendering all'inizio. Penso che il multithreading possa far risparmiare un po 'di tempo: ho le seguenti funzioni: FastAlg () e Alg (). Alg () è sia il mio Update obj che render obj e Fastalg () è la mia "invia la coda di rendering a" renderer "". In un singolo thread:

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

In due thread:

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

Quindi forse il multithreading può risparmiare allo stesso tempo. (in realtà lo fa in una semplice applicazione matematica, dove alg è un algoritmo lungo e fastalg uno più veloce)

So che dormire non è una buona idea, anche se non ho mai avuto problemi. Sarà meglio?

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

Ma questo sarà un ciclo while infinito che utilizzerà tutta la CPU. Posso usare sleep (un numero <15) per limitare l'utilizzo? In questo modo verrà eseguito, ad esempio, a 100 fps e la funzione di aggiornamento verrà chiamata solo 60 volte al secondo.

Per sincronizzare i due thread userò waitforsingleobject con createSemaphore in modo da poter bloccare e sbloccare in thread diversi (senza usare un ciclo while), vero?


5
"Non dire che il mio multithreading è inutile in questo caso, voglio solo imparare come farlo" - in quel caso dovresti imparare le cose correttamente, cioè (a) non usare sleep () per controllare il frame raro , mai e poi (b) evitare la progettazione thread per componente ed evitare l'esecuzione di lockstep, invece dividere il lavoro in attività e gestire le attività da una coda di lavoro.
Damon,

1
@Damon (a) sleep () può essere usato come meccanismo di frame rate ed è in effetti piuttosto popolare, anche se devo ammettere che ci sono opzioni molto migliori. (b) L'utente qui desidera separare sia l'aggiornamento che il rendering in due thread diversi. Questa è una normale separazione in un motore di gioco e non è così "thread per componente". Dà chiari vantaggi ma può portare problemi se fatto in modo errato.
Alexandre Desbiens,

@AlphSpirit: il fatto che qualcosa sia "comune" non significa che non sia sbagliato . Senza nemmeno entrare in timer divergenti, la semplice granularità del sonno su almeno un popolare sistema operativo desktop è una ragione sufficiente, se non la sua inaffidabilità per progettazione su ogni sistema di consumo esistente. Spiegare perché separare l'aggiornamento e il rendering in due thread come descritto non è saggio e causa più problemi di quanti ne valga la pena. L'obiettivo dell'OP è indicato come apprendere come è fatto , che dovrebbe essere appreso come è stato eseguito correttamente . Numerosi articoli sul moderno design del motore MT in circolazione.
Damon,

@Damon Quando ho detto che era popolare o comune, non intendevo dire che fosse giusto. Volevo solo dire che era usato da molte persone. "... anche se devo concordare sul fatto che ci sono opzioni molto migliori" significa che non è davvero un ottimo modo per sincronizzare il tempo. Scusa per il fraintendimento.
Alexandre Desbiens,

@AlphSpirit: nessuna preoccupazione :-) Il mondo è pieno di cose che molte persone fanno (e non sempre per una buona ragione), ma quando si inizia a imparare, si dovrebbe comunque cercare di evitare quelle più ovviamente sbagliate.
Damon,

Risposte:


25

Per un semplice motore 2D con sprite, un approccio a thread singolo è perfettamente valido. Ma poiché vuoi imparare a fare il multithreading, dovresti imparare a farlo correttamente.

Non

  • Utilizzare 2 thread che eseguono più o meno lock-step, implementando un comportamento a thread singolo con più thread. Questo ha lo stesso livello di parallelismo (zero) ma aggiunge un sovraccarico per i cambi di contesto e la sincronizzazione. Inoltre, la logica è più difficile da grok.
  • Utilizzare sleepper controllare la frequenza dei fotogrammi. Mai. Se qualcuno te lo dice, colpiscilo.
    Innanzitutto, non tutti i monitor funzionano a 60Hz. In secondo luogo, due timer che ticchettano alla stessa velocità correndo fianco a fianco finiranno sempre per sincronizzarsi (rilasciare due palline da ping pong su un tavolo dalla stessa altezza e ascoltare). In terzo luogo, sleepè in base alla progettazione né accurata né affidabili. La granularità può essere pari a 15,6 ms (in effetti, il valore predefinito su Windows [1] ) e un frame è solo 16,6 ms a 60 fps, il che lascia solo 1 ms per tutto il resto. Inoltre, è difficile ottenere 16.6 per essere un multiplo di 15.6 ...
    Inoltre, sleepè consentito (e talvolta lo farà!) Tornare solo dopo 30 o 50 o 100 ms, o un tempo ancora più lungo.
  • Utilizzare std::mutexper notificare un altro thread. Non è per questo.
  • Supponiamo che TaskManager sia bravo a dirti cosa sta succedendo, specialmente a giudicare da un numero come "CPU al 25%", che potrebbe essere speso nel tuo codice, o all'interno del driver usermode, o da qualche altra parte.
  • Avere un thread per componente di alto livello (ci sono ovviamente alcune eccezioni).
  • Crea discussioni a "orari casuali", ad hoc, per attività. La creazione di thread può essere sorprendentemente costosa e può richiedere molto tempo prima che facciano ciò che gli hai detto (soprattutto se hai caricato molte DLL!).

Fare

  • Usa il multithreading per far funzionare le cose in modo asincrono il più possibile. La velocità non è l'idea principale del threading, ma fare cose in parallelo (quindi anche se impiegano più tempo del tutto, la somma di tutto è ancora meno).
  • Utilizzare la sincronizzazione verticale per limitare la frequenza dei fotogrammi. Questo è l'unico modo corretto (e non mancante) per farlo. Se l'utente ti sovrascrive nel pannello di controllo del driver dello schermo ("force off"), allora così sia. Dopotutto è il suo computer, non il tuo.
  • Se devi "spuntare" qualcosa a intervalli regolari, usa un timer . I timer hanno il vantaggio di avere una precisione e affidabilità molto migliori rispetto a sleep[2] . Inoltre, un timer ricorrente tiene conto del tempo correttamente (incluso il tempo che passa tra) mentre dormendo per 16.6ms (o 16.6ms meno misura_tempo_caduto) no.
  • Esegui simulazioni fisiche che implicano l'integrazione numerica in una fase temporale fissa (o le tue equazioni esploderanno!), Interpola la grafica tra le fasi (questa può essere una scusa per un thread separato per componente, ma può anche essere fatto senza).
  • Utilizzare std::mutexper consentire a un solo thread di accedere a una risorsa alla volta ("escludersi a vicenda") e rispettare la strana semantica di std::condition_variable.
  • Evita che i thread competano per le risorse. Bloccare quanto basta (ma non meno!) E tenere i blocchi solo per il tempo assolutamente necessario.
  • Condividere i dati di sola lettura tra i thread (nessun problema di cache e nessun blocco necessario), ma non modificare contemporaneamente i dati (necessita di sincronizzazione e uccide la cache). Ciò include la modifica dei dati nelle vicinanze di una posizione che potrebbe essere letta da qualcun altro.
  • Utilizzare std::condition_variableper bloccare un altro thread fino a quando alcune condizioni sono vere. La semantica di std::condition_variablecon quel mutex extra è certamente piuttosto strana e distorta (principalmente per ragioni storiche ereditate dai thread POSIX), ma una variabile di condizione è la primitiva corretta da usare per quello che vuoi.
    Nel caso in cui ti trovi std::condition_variabletroppo strano per sentirti a tuo agio, puoi anche semplicemente usare un evento di Windows (leggermente più lento) invece o, se sei coraggioso, costruire il tuo semplice evento attorno a NtKeyedEvents (coinvolge cose spaventose di basso livello). Mentre usi DirectX, sei già legato a Windows, quindi la perdita della portabilità non dovrebbe essere un grosso problema.
  • Suddividere il lavoro in attività di dimensioni ragionevoli che vengono eseguite da un pool di thread di lavoro di dimensioni fisse (non più di uno per core, senza contare i core hyperthreaded). Consenti alle attività di finitura di accodare le attività dipendenti (sincronizzazione automatica gratuita). Esegui attività che hanno almeno alcune centinaia di operazioni non banali ciascuna (o un'operazione di blocco prolungata come una lettura del disco). Preferisci l'accesso contiguo alla cache.
  • Crea tutti i thread all'avvio del programma.
  • Sfrutta le funzioni asincrone offerte dal sistema operativo o dall'API grafica per un parallelismo migliore / aggiuntivo, non solo a livello di programma ma anche sull'hardware (pensa a trasferimenti PCIe, parallelismo CPU-GPU, DMA disco, ecc.).
  • 10.000 altre cose che ho dimenticato di menzionare.


[1] Sì, puoi impostare la frequenza dello scheduler su 1ms, ma questo è disapprovato poiché provoca molti più cambi di contesto e consuma molta più energia (in un mondo in cui sempre più dispositivi sono dispositivi mobili). Inoltre non è una soluzione poiché non rende il sonno più affidabile.
[2] Un timer aumenterà la priorità del thread, che gli consentirà di interrompere un altro thread di uguale priorità di livello medio ed essere programmato per primo, che è un comportamento quasi-RT. Naturalmente non è vero RT, ma si avvicina molto. Svegliarsi dal sonno significa semplicemente che il thread diventa pronto per essere programmato in qualsiasi momento, quando ciò può essere.


Puoi spiegare perché non dovresti "Avere un thread per componente di alto livello"? Vuoi dire che non si dovrebbe avere la fisica e il missaggio audio in due thread separati? Non vedo alcun motivo per non farlo.
Elviss Strazdins,

3

Non sono sicuro di ciò che desideri ottenere limitando l'FPS dell'aggiornamento e del rendering entrambi a 60. Se li limiti allo stesso valore, avresti potuto semplicemente inserirli nello stesso thread.

L'obiettivo quando si separano Update e Render in thread diversi è quello di avere entrambi "quasi" indipendenti l'uno dall'altro, in modo che la GPU possa eseguire il rendering di 500 FPS e la logica di aggiornamento continui a 60 FPS. In questo modo non si ottiene un aumento delle prestazioni molto elevato.

Ma hai detto che volevi solo sapere come funziona, e va bene. In C ++, un mutex è un oggetto speciale che viene utilizzato per bloccare l'accesso a determinate risorse per altri thread. In altre parole, si utilizza un mutex per rendere accessibili i dati sensibili da un solo thread alla volta. Per fare ciò, è abbastanza semplice:

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

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

EDIT : assicurati che il tuo mutex sia di classe o di file, come nel link indicato, altrimenti ogni thread creerà il suo mutex e non otterrai nulla.

Il primo thread per bloccare il mutex avrà accesso al codice all'interno. Se un secondo thread tenta di chiamare la funzione lock (), si bloccherà fino a quando il primo thread non lo sblocca. Quindi un mutex è una funzione di blocco, a differenza di un ciclo while. Le funzioni di blocco non stresseranno la CPU.


E come funziona il blocco?
Liuka,

Quando il secondo thread chiamerà lock (), attenderà pazientemente che il primo thread sblocchi il mutex e continuerà sulla riga successiva dopo (in questo esempio, le cose sensibili). EDIT: Il secondo thread bloccherà quindi il mutex per se stesso.
Alexandre Desbiens,


1
Usa std::lock_guardo simili, non .lock()/ .unlock(). RAII non è solo per la gestione della memoria!
bcrist
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.