“Sem bloqueio ou sem bloqueio, eis a questão” ou “Sono saudável é pior que rabanete amargo”

Os comentários no artigo " Como dormir corretamente e incorretamente " me inspiraram a escrever este artigo.


Este artigo se concentrará no desenvolvimento de aplicativos multiencadeados, na aplicabilidade do lock-free em alguns casos decorrentes do processo de trabalho no LAppS , na função nanosleep e na violência contra o agendador de tarefas.


NB:      C++  Linux,       POSIX.1-2008  a (    ). 

Em geral, tudo está muito confuso, espero que a linha de pensamento da apresentação seja clara. Se estiver interessado, peço um gato.


O software orientado a eventos está sempre esperando por algo. Seja uma GUI ou um servidor de rede, eles estão aguardando eventos: entrada do teclado, eventos do mouse, pacote de dados que chega pela rede. Mas todo o software espera de maneira diferente. Os sistemas sem bloqueio não precisam esperar nada. Pelo menos o uso de algoritmos sem bloqueio deve ocorrer onde você não precisa esperar e até prejudicial. Mas estamos falando de sistemas competitivos (multiencadeados) e, curiosamente, algoritmos sem bloqueio também estão aguardando. Sim, eles não bloqueiam a execução de encadeamentos paralelos, mas eles mesmos estão aguardando a oportunidade de fazer algo sem bloquear.


O LAppS usa mutexes e semáforos muito ativamente. Ao mesmo tempo, não há semáforos no padrão C ++. O mecanismo é muito importante e conveniente, mas o C ++ deve funcionar em sistemas que não possuem suporte a semáforos e, portanto, os semáforos não estão incluídos no padrão. Além disso, se eu usar semáforos, porque eles são convenientes, então mutexes, porque eu preciso.


O comportamento do mutex no caso de lock competitivo (), como sem_wait () no Linux, coloca o thread em espera no final da fila do planejador de tarefas e, quando está no topo, a verificação é repetida sem retornar à área do usuário, o thread é colocado de volta na fila se o evento esperado ainda não ocorreu. Este é um ponto muito importante.


E decidi verificar se posso recusar os semáforos std :: mutex e POSIX, emulando-os com std :: atomic, transferindo a carga principalmente para a terra do usuário. Na verdade falhou, mas as primeiras coisas primeiro.


Em primeiro lugar, tenho várias seções nas quais esses experimentos podem ser úteis:


  • bloqueios no LibreSSL (caso 1);
  • bloqueio ao transferir pacotes recebidos da carga útil para aplicativos Lua (caso 2);
  • Aguardando eventos de carga útil prontos para serem processados ​​pelos aplicativos Lua (caso 3).

Vamos começar com bloqueios sem bloqueio. Vamos escrever nosso mutex usando atomics, conforme mostrado em alguns discursos de H. Sutter (portanto, não há código original da memória e, portanto, o código não coincide com o 100% original, e no Satter esse código estava relacionado ao progresso do C ++ 20, portanto, existem diferenças). E, apesar da simplicidade desse código, existem armadilhas nele.


 #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; //  C++20     , .. compare_exchange_strong          while(!mLock.compare_exchange_strong(locked_by,pthread_self())) { locked_by=0; //      } } void unlock() { pthread_t current=pthread_self(); if(!mLock.compare_exchange_strong(current,0)) { throw std::system_error(EACCES, std::system_category(), "An attempt to unlock the mutex owned by other thread"); } } const bool try_lock() { pthread_t unused=0; return mLock.compare_exchange_strong(unused,pthread_self()); } }; } 

Ao contrário de std :: mutex :: unlock (), o comportamento de test :: mutex: unlock () ao tentar desbloquear de outro encadeamento é determinístico. Uma exceção será lançada. Isso é bom, embora não seja consistente com o comportamento padrão. E o que há de ruim nessa classe? A má notícia é que o método test :: mutex: lock () consome descaradamente recursos da CPU em cotas de tempo alocadas ao encadeamento, na tentativa de assumir o mutex que outro encadeamento já possui. I.e. um loop em test :: mutex: lock () será um desperdício de recursos da CPU. Quais são as nossas opções para superar essa situação?


Podemos usar sched_yield () (conforme sugerido em um dos comentários no artigo acima). Isso é simples? Primeiramente, para usar o sched_yield (), é necessário que os encadeamentos de execução usem as políticas SCHED_RR, SCHED_FIFO para priorização no agendador de tarefas. Caso contrário, chamar sched_yield () seria um desperdício de recursos da CPU. Em segundo lugar, uma chamada muito frequente para sched_yield () ainda aumentará o consumo da CPU. Além disso, o uso de políticas em tempo real no seu aplicativo, e desde que não haja outros aplicativos em tempo real no sistema, limitará a fila do agendador com a política selecionada a apenas seus encadeamentos. Parece que isso é bom! Não, não é bom. Todo o sistema se tornará menos responsivo, porque ocupado com a tarefa prioritária. O CFQ estará na caneta. Mas existem outros encadeamentos no aplicativo e, muitas vezes, surge uma situação em que o encadeamento que capturou o mutex é colocado no final da fila (a cota expirou) e o encadeamento que aguarda o lançamento do mutex bem na frente dele. Nos meus experimentos (caso 2), esse método deu os mesmos resultados (3,8% piores) que std :: mutex, mas o sistema é menos responsivo e o consumo de CPU é aumentado em 5% a 7%.


Você pode tentar alterar test :: mutex :: lock () assim (também é ruim):


 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}; // -      nanosleep(&pause,nullptr); locked_by=0; } } 

Aqui você pode experimentar a duração do sono em nanossegundos, os atrasos de 4ns foram ótimos para minha CPU e a queda de desempenho em relação ao std :: mutex no mesmo caso 2 foi de 1,2%. Não é o fato de que o nanosleep dormiu 4ns. De fato, ou mais (no caso geral) ou menos (se interrompido). A queda (!) No consumo de CPU foi de 12% a 20%. I.e. foi um sonho tão saudável.


O OpenSSL e o LibreSSL têm duas funções que configuram retornos de chamada para bloquear ao usar essas bibliotecas em um ambiente multithread. É assim:


 //  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); 

E agora o pior é que o uso do teste acima :: mutex mutex no LibreSSL reduz o desempenho do LAppS em quase 2 vezes. Além disso, independentemente da opção (loop de espera vazio, sched_yield (), nanosleep ()).


Em geral, excluímos o caso 2 e o caso 1 e permanecemos com std :: mutex.


Vamos para semáforos. Existem muitos exemplos de como implementar semáforos usando std :: condition_variable. Todos eles usam std :: mutex também. E esses simuladores de semáforo são mais lentos (de acordo com meus testes) do que os semáforos do sistema POSIX.


Portanto, criaremos um semáforo nos átomos:


  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(); } }; 

Ah, esse semáforo é muitas vezes mais rápido que o semáforo do sistema. O resultado de um teste separado desse semáforo com um provedor e 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 

Esse semáforo com post quase gratuito (), que é 29 vezes mais rápido que o sistema, também é muito rápido ao ativar os threads que o esperam: 29325 despertares por milissegundo, contra 1007 despertadores por milissegundo do sistema. Possui comportamento determinístico com um semáforo destruído ou um semáforo destrutível. E, naturalmente, segfault ao tentar usar um já destruído.


(¹) Na verdade, muitas vezes em um milissegundo um fluxo não pode ser atrasado e despertado pelo planejador. Porque O post () não está bloqueando; nesse teste sintético, o wait () geralmente se encontra em uma situação em que você não precisa dormir. Ao mesmo tempo, pelo menos 7 threads em paralelo leem o valor do semáforo.


Mas usá-lo no caso 3 do LAppS leva a perdas de desempenho, independentemente do tempo de sono. Ele acorda com muita frequência para verificar e os eventos no LAppS chegam muito mais lentamente (latência da rede, latência do lado do cliente gerando a carga, etc.). E verificar menos frequentemente significa também perder desempenho.


Além disso, o uso do sono nesses casos e de maneira semelhante é completamente prejudicial, porque em outro hardware, os resultados podem ser completamente diferentes (como no caso da pausa nas instruções do assembler) e, para cada modelo de CPU, você também precisa selecionar o tempo de atraso.


A vantagem de um mutex e semáforo do sistema é que o encadeamento de execução não é ativado até que ocorra um evento (desbloqueando o mutex ou incrementando o semáforo). Ciclos extras de CPU não são desperdiçados - lucro.


Em geral, tudo, desde este mal, desabilitar o iptables no meu sistema dá de 12% (com TLS) a 30% (sem TLS) um ganho de desempenho ...

Source: https://habr.com/ru/post/pt428087/


All Articles