Les commentaires sur l'article " Comment dormir correctement et incorrectement " m'ont inspiré pour écrire cet article.
Cet article se concentrera sur le développement d'applications multi-threads, l'applicabilité du verrouillage sans verrou à certains cas survenus lors des travaux sur LAppS , sur la fonction nanosleep et la violence sur le planificateur de tâches.
NB: C++ Linux, POSIX.1-2008 a ( ).
En général, tout est assez désordonné, j'espère que le fil conducteur de la présentation sera clair. Si vous êtes intéressé, je demande un chat.
Un logiciel orienté événement attend toujours quelque chose. Qu'il s'agisse d'une interface graphique ou d'un serveur réseau, ils attendent tous les événements: entrée clavier, événements souris, paquets de données arrivant sur le réseau. Mais tous les logiciels attendent différemment. Les systèmes sans verrouillage ne doivent pas attendre du tout. Au moins, l'utilisation d'algorithmes sans verrouillage devrait se produire là où vous n'avez pas besoin d'attendre, et même nuisible. Mais nous parlons de systèmes compétitifs (multithread), et curieusement, des algorithmes sans verrouillage attendent également. Oui, ils ne bloquent pas l'exécution des threads parallèles, mais ils attendent eux-mêmes l'opportunité de faire quelque chose sans bloquer.
LAppS utilise très activement les mutex et les sémaphores. En même temps, il n'y a pas de sémaphores dans la norme C ++. Le mécanisme est très important et pratique, mais C ++ devrait fonctionner sur des systèmes qui ne prennent pas en charge les sémaphores, et donc les sémaphores ne sont pas inclus dans la norme. De plus, si j'utilise des sémaphores parce qu'ils sont pratiques, alors des mutex parce que je le dois.
Le comportement du mutex dans le cas de lock concurrentiel (), comme sem_wait () sous Linux, place le thread en attente à la fin de la file d'attente du planificateur de tâches, et lorsqu'il est en haut, la vérification est répétée sans retourner au pays utilisateur, le thread est replacé dans la file d'attente si l'événement attendu ne s'est pas encore produit. Ceci est un point très important.
Et j'ai décidé de vérifier si je peux refuser les sémaphores std :: mutex et POSIX, en les émulant avec std :: atomic, en transférant la charge principalement vers l'espace utilisateur. En fait, a échoué, mais d'abord les choses.
Tout d'abord, j'ai plusieurs sections dans lesquelles ces expériences pourraient être utiles:
- verrous dans LibreSSL (cas 1);
- blocage lors du transfert de paquets reçus de charge utile vers des applications Lua (cas 2);
- En attente d'événements de charge utile prêts à être traités par les applications Lua (cas 3).
Commençons par les verrous non bloquants. Écrivons notre mutex en utilisant l'atomique, comme le montrent certains discours de H. Sutter (il n'y a donc pas de code d'origine, de mémoire et donc le code ne coïncide pas avec les 100% d'origine, et dans Satter ce code était lié à la progression de C ++ 20, il y a donc des différences). Et malgré la simplicité de ce code, il comporte des pièges.
#include <atomic> #include <pthread.h> namespace test { class mutex { private: std::atomic<pthread_t> mLock; public: explicit mutex():mLock{0} { } mutex(const mutex&)=delete; mutex(mutex&)=delete; void lock() { pthread_t locked_by=0;
Contrairement à std :: mutex :: unlock (), le comportement de test :: mutex: unlock () lors de la tentative de déverrouillage à partir d'un autre thread est déterministe. Une exception sera levée. C'est bon, bien que non conforme au comportement standard. Et qu'est-ce qui est mauvais dans cette classe? La mauvaise nouvelle est que la méthode test :: mutex: lock () consommera sans vergogne les ressources CPU dans les quotas de temps alloués au thread, pour tenter de reprendre le mutex qu'un autre thread possède déjà. C'est-à-dire une boucle dans test :: mutex: lock () sera un gaspillage de ressources CPU. Quelles sont nos options pour surmonter cette situation?
Nous pouvons utiliser sched_yield () (comme suggéré dans l'un des commentaires sur l'article ci-dessus). C'est aussi simple que ça? Premièrement, pour utiliser sched_yield (), il est nécessaire que les threads d'exécution utilisent les politiques SCHED_RR, SCHED_FIFO pour leur priorisation dans le planificateur de tâches. Sinon, appeler sched_yield () serait un gaspillage de ressources CPU. Deuxièmement, un appel très fréquent à sched_yield () augmentera toujours la consommation du processeur. De plus, l'utilisation de stratégies en temps réel dans votre application, et à condition qu'il n'y ait aucune autre application en temps réel dans le système, limitera la file d'attente du planificateur avec la stratégie sélectionnée à vos threads uniquement. Il semblerait que ce soit bon! Non, pas bon. L'ensemble du système deviendra moins réactif, car occupé avec tâche prioritaire. CFQ sera dans le stylo. Mais il existe d'autres threads dans l'application, et très souvent une situation se produit lorsque le thread qui a capturé le mutex est placé à la fin de la file d'attente (le quota a expiré), et le thread qui attend que le mutex soit libéré juste devant lui. Dans mes expériences (cas 2), cette méthode a donné à peu près les mêmes résultats (3,8% de moins) que std :: mutex, mais le système est moins réactif et la consommation CPU est augmentée de 5% à 7%.
Vous pouvez essayer de changer test :: mutex :: lock () comme ceci (également mauvais):
void lock() { pthread_t locked_by=0; while(!mLock.compare_exchange_strong(locked_by,pthread_self())) { static thread_local const struct timespec pause{0,4};
Ici, vous pouvez expérimenter la durée du sommeil en nanosecondes, les retards de 4 ns étaient optimaux pour mon processeur et la baisse de performances par rapport à std :: mutex dans le même cas 2 était de 1,2%. Pas le fait que le nanosommeil dormait 4 ns. En fait, ou plus (dans le cas général) ou moins (en cas d'interruption). La baisse (!) De la consommation CPU était de 12% -20%. C'est-à-dire c'était un rêve si sain.
OpenSSL et LibreSSL ont deux fonctions qui configurent des rappels à bloquer lors de l'utilisation de ces bibliothèques dans un environnement multithread. Cela ressemble à ceci:
// callback void openssl_crypt_locking_function_callback(int mode, int n, const char* file, const int line) { static std::vector<std::mutex> locks(CRYPTO_num_locks()); if(n>=static_cast<int>(locks.size())) { abort(); } if(mode & CRYPTO_LOCK) locks[n].lock(); else locks[n].unlock(); } // callback-a CRYPTO_set_locking_callback(openssl_crypt_locking_function_callback); // id CRYPTO_set_id_callback(pthread_self);
Et maintenant, le pire, c'est que l'utilisation du test :: mutex mutex ci-dessus dans LibreSSL réduit les performances de LAppS de près de 2 fois. De plus, quelle que soit l'option (boucle d'attente vide, sched_yield (), nanosleep ()).
En général, nous supprimons le cas 2 et le cas 1, et restons avec std :: mutex.
Passons aux sémaphores. Il existe de nombreux exemples d'implémentation de sémaphores à l'aide de std :: condition_variable. Ils utilisent tous également std :: mutex. Et ces simulateurs de sémaphore sont plus lents (selon mes tests) que les sémaphores du système POSIX.
Par conséquent, nous allons faire un sémaphore sur les atomes:
class semaphore { private: std::atomic<bool> mayRun; mutable std::atomic<int64_t> counter; public: explicit semaphore() : mayRun{true},counter{0} { } semaphore(const semaphore&)=delete; semaphore(semaphore&)=delete; const bool post() const { ++counter; return mayRun.load(); } const bool try_wait() { if(mayRun.load()) { if(counter.fetch_sub(1)>0) return true; else { ++counter; return false; } }else{ throw std::system_error(ENOENT,std::system_category(),"Semaphore is destroyed"); } } void wait() { while(!try_wait()) { static thread_local const struct timespec pause{0,4}; nanosleep(&pause,nullptr); } } void destroy() { mayRun.store(false); } const int64_t decrimentOn(const size_t value) { if(mayRun.load()) { return counter.fetch_sub(value); }else{ throw std::system_error(ENOENT,std::system_category(),"Semaphore is destroyed"); } } ~semaphore() { destroy(); } };
Oh, ce sémaphore est beaucoup plus rapide que le sémaphore système. Le résultat d'un test séparé de ce sémaphore avec un fournisseur et 20 consamers:
OS semaphores test. Started 20 threads waiting on a semaphore Thread(OS): wakes: 500321 Thread(OS): wakes: 500473 Thread(OS): wakes: 501504 Thread(OS): wakes: 502337 Thread(OS): wakes: 498324 Thread(OS): wakes: 502755 Thread(OS): wakes: 500212 Thread(OS): wakes: 498579 Thread(OS): wakes: 499504 Thread(OS): wakes: 500228 Thread(OS): wakes: 499696 Thread(OS): wakes: 501978 Thread(OS): wakes: 498617 Thread(OS): wakes: 502238 Thread(OS): wakes: 497797 Thread(OS): wakes: 498089 Thread(OS): wakes: 499292 Thread(OS): wakes: 498011 Thread(OS): wakes: 498749 Thread(OS): wakes: 501296 OS semaphores test. 10000000 of posts for 20 waiting threads have taken 9924 milliseconds OS semaphores test. Post latency: 0.9924ns ======================================= AtomicEmu semaphores test. Started 20 threads waiting on a semaphore Thread(EmuAtomic) wakes: 492748 Thread(EmuAtomic) wakes: 546860 Thread(EmuAtomic) wakes: 479375 Thread(EmuAtomic) wakes: 534676 Thread(EmuAtomic) wakes: 501014 Thread(EmuAtomic) wakes: 528220 Thread(EmuAtomic) wakes: 496783 Thread(EmuAtomic) wakes: 467563 Thread(EmuAtomic) wakes: 608086 Thread(EmuAtomic) wakes: 489825 Thread(EmuAtomic) wakes: 479799 Thread(EmuAtomic) wakes: 539634 Thread(EmuAtomic) wakes: 479559 Thread(EmuAtomic) wakes: 495377 Thread(EmuAtomic) wakes: 454759 Thread(EmuAtomic) wakes: 482375 Thread(EmuAtomic) wakes: 512442 Thread(EmuAtomic) wakes: 453303 Thread(EmuAtomic) wakes: 480227 Thread(EmuAtomic) wakes: 477375 AtomicEmu semaphores test. 10000000 of posts for 20 waiting threads have taken 341 milliseconds AtomicEmu semaphores test. Post latency: 0.0341ns
Ce sémaphore avec un post presque gratuit (), 29 fois plus rapide que celui du système, est également très rapide pour réveiller les threads qui l'attendent: 29325 réveils par milliseconde, contre 1007 réveils par milliseconde du système. Il a un comportement déterministe avec un sémaphore détruit ou un sémaphore destructible. Et naturellement, segfault lorsque vous essayez d'utiliser un déjà détruit.
(¹) En fait, tant de fois en une milliseconde, un flux ne peut pas être retardé et réveillé par le planificateur. Parce que post () ne bloque pas, dans ce test synthétique, wait () se retrouve très souvent dans une situation où vous n'avez pas besoin de dormir. Dans le même temps, au moins 7 threads en parallèle lisent la valeur du sémaphore.
Mais son utilisation dans le cas 3 dans LAppS entraîne des pertes de performances indépendamment du temps de sommeil. Il se réveille trop souvent pour vérifier, et les événements dans LAppS arrivent beaucoup plus lentement (latence réseau, latence côté client générant la charge, etc.). Et vérifier moins souvent signifie également perdre des performances.
De plus, l'utilisation du sommeil dans de tels cas et d'une manière similaire est complètement nuisible, car sur un autre matériel, les résultats peuvent s'avérer complètement différents (comme dans le cas de la pause des instructions d'assembleur), et pour chaque modèle de CPU, vous devez également sélectionner le temps de retard.
L'avantage d'un mutex et d'un sémaphore système est que le thread d'exécution ne se réveille pas jusqu'à ce qu'un événement (déverrouillage du mutex ou incrémentation du sémaphore) se produise. Les cycles CPU supplémentaires ne sont pas gaspillés - profit.
En général, tout de ce mal, la désactivation des iptables sur mon système donne de 12% (avec TLS) à 30% (sans TLS) un gain de performance ...