Jusqu'à présent, j'utilisais std::queue
dans mon projet. J'ai mesuré le temps moyen nécessaire à une opération spécifique sur cette file d'attente.
Les temps ont été mesurés sur 2 machines: Mon Ubuntu local VM et un serveur distant . En utilisant std::queue
, la moyenne était presque identique sur les deux machines: ~ 750 microsecondes.
Ensuite, j'ai "mis à jour" le std::queue
à boost::lockfree::spsc_queue
, afin de pouvoir me débarrasser des mutex protégeant la file d'attente. Sur mon VM locale, je pouvais voir un gain de performances énorme, la moyenne est maintenant de 200 microsecondes. Sur la machine distante cependant, la moyenne est passée à 800 microsecondes, ce qui est plus lent qu’avant.
J'ai d'abord pensé que c'était peut-être parce que la machine distante pouvait ne pas supporter l'implémentation sans verrouillage:
De la page Boost.Lockfree:
Tout le matériel ne prend pas en charge le même jeu d'instructions atomiques. S'il n'est pas disponible dans le matériel, il peut être émulé dans un logiciel à l'aide de protections. Cependant, cela a l'inconvénient évident de perdre la propriété sans verrouillage.
Pour savoir si ces instructions sont prises en charge, boost::lockfree::queue
dispose d'une méthode appelée bool is_lock_free(void) const;
. Cependant, boost::lockfree::spsc_queue
n’a pas une fonction comme celle-ci, ce qui implique pour moi qu’il ne dépend pas du matériel, c’est toujours sans blocage - sur n’importe quelle machine.
Quelle pourrait être la raison de la perte de performance?
// c++11 compiler and boost library required
#include <iostream>
#include <cstdlib>
#include <chrono>
#include <async>
#include <thread>
/* Using blocking queue:
* #include <mutex>
* #include <queue>
*/
#include <boost/lockfree/spsc_queue.hpp>
boost::lockfree::spsc_queue<int, boost::lockfree::capacity<1024>> queue;
/* Using blocking queue:
* std::queue<int> queue;
* std::mutex mutex;
*/
int main()
{
auto producer = std::async(std::launch::async, [queue /*,mutex*/]()
{
// Producing data in a random interval
while(true)
{
/* Using the blocking queue, the mutex must be locked here.
* mutex.lock();
*/
// Push random int (0-9999)
queue.Push(std::Rand() % 10000);
/* Using the blocking queue, the mutex must be unlocked here.
* mutex.unlock();
*/
// Sleep for random duration (0-999 microseconds)
std::this_thread::sleep_for(std::chrono::microseconds(Rand() % 1000));
}
}
auto consumer = std::async(std::launch::async, [queue /*,mutex*/]()
{
// Example operation on the queue.
// Checks if 1234 was generated by the producer, returns if found.
while(true)
{
/* Using the blocking queue, the mutex must be locked here.
* mutex.lock();
*/
int value;
while(queue.pop(value)
{
if(value == 1234)
return;
}
/* Using the blocking queue, the mutex must be unlocked here.
* mutex.unlock();
*/
// Sleep for 100 microseconds
std::this_thread::sleep_for(std::chrono::microseconds(100));
}
}
consumer.get();
std::cout << "1234 was generated!" << std::endl;
return 0;
}
Les algorithmes sans verrou fonctionnent généralement moins bien que les algorithmes basés sur des verrous. C'est une des principales raisons pour lesquelles ils ne sont pas utilisés aussi souvent.
Le problème des algorithmes sans verrouillage est qu'ils maximisent les conflits en permettant aux threads en conflit de continuer à s'affronter. Les verrous évitent les conflits en dé-programmant les threads en conflit. Les algorithmes sans verrouillage, en première approximation, ne doivent être utilisés que lorsqu'il n'est pas possible de décaler les threads en conflit. Cela ne s'applique que rarement au code au niveau de l'application.
Permettez-moi de vous donner une hypothèse très extrême. Imaginez que quatre threads s'exécutent sur un processeur dual-core moderne et typique. Les threads A1 et A2 manipulent la collection A. Les threads B1 et B2 manipulent la collection B.
Premièrement, imaginons que la collection utilise des verrous. Cela signifie que si les threads A1 et A2 (ou B1 et B2) essaient de s'exécuter en même temps, l'un d'eux sera bloqué par le verrou. Donc, très rapidement, un thread A et un thread B seront en cours d'exécution. Ces threads fonctionneront très rapidement et ne contesteront pas. Chaque fois que les threads tentent de s'affronter, le thread en conflit sera désorganisé. Yay.
Maintenant, imaginez que la collection n’utilise aucun verrou. Les threads A1 et A2 peuvent maintenant être exécutés simultanément. Cela provoquera une controverse constante. Les lignes de cache pour la collection vont ping-pong entre les deux cœurs. Les bus inter-noyaux peuvent être saturés. La performance sera terrible.
Encore une fois, c'est très exagéré. Mais vous avez l'idée. Vous voulez éviter les conflits, ne pas en souffrir autant que possible.
Cependant, lancez à nouveau cette expérience de pensée, où A1 et A2 sont les seuls threads du système entier. Maintenant, la collection sans verrou est probablement meilleure (bien que vous puissiez trouver qu’il est préférable d’avoir un seul thread dans ce cas!).
Presque tous les programmeurs passent par une phase où ils pensent que les verrous sont incorrects et en évitant que les verrous accélèrent le code. Finalement, ils réalisent que c'est conflit qui ralentit le processus et que les verrous, utilisés correctement, minimisent les conflits.
Je ne peux pas dire que la file d'attente boost lockfree est plus lente dans tous les cas possibles. D'après mon expérience, le Push (const T & item) essaie de faire une copie. Si vous construisez des objets tmp et que vous poussez sur la file d'attente, vous êtes frappé par un glissement de performances. Je pense que la bibliothèque a juste besoin de la version surchargée Push (T && item) pour rendre les objets mobiles plus efficaces. Avant l'ajout de la nouvelle fonction, vous devrez peut-être utiliser des pointeurs, le type brut ou les types intelligents proposés après C++ 11. C'est un aspect assez limité de la file d'attente, et je n'utilise que la file d'attente lockfree varie rarement.