Los comentarios sobre el artículo " Cómo dormir correcta e incorrectamente " me inspiraron a escribir este artículo.
Este artículo se centrará en el desarrollo de aplicaciones de subprocesos múltiples, la aplicabilidad de sin bloqueo en algunos casos que surgieron durante el trabajo en LAppS , en la función de nano sueño y la violencia en el programador de tareas.
NB: C++ Linux, POSIX.1-2008 a ( ).
En general, todo es bastante desordenado, espero que el tren de pensamiento en la presentación sea claro. Si está interesado, entonces pido un gato.
El software orientado a eventos siempre está esperando algo. Ya sea una GUI o un servidor de red, están esperando cualquier evento: entrada del teclado, eventos del mouse, paquete de datos que llegue a través de la red. Pero todo el software espera de manera diferente. Los sistemas sin bloqueo no tienen que esperar en absoluto. Al menos el uso de algoritmos sin bloqueo debe ocurrir donde no es necesario esperar, e incluso perjudicial. Pero estamos hablando de sistemas competitivos (multiproceso) y, curiosamente, los algoritmos sin bloqueo también están esperando. Sí, no bloquean la ejecución de subprocesos paralelos, pero ellos mismos esperan la oportunidad de hacer algo sin bloquear.
LAppS usa mutexes y semáforos de manera muy activa. Al mismo tiempo, no hay semáforos en el estándar C ++. El mecanismo es muy importante y conveniente, pero C ++ debería funcionar en sistemas que no tienen soporte de semáforos y, por lo tanto, los semáforos no están incluidos en el estándar. Además, si uso semáforos porque son convenientes, mutexes porque tengo que hacerlo.
El comportamiento del mutex en el caso del bloqueo competitivo (), como sem_wait () en Linux, coloca el hilo de espera al final de la cola del programador de tareas, y cuando está en la parte superior, la verificación se repite sin volver al usuario, el hilo se vuelve a poner en la cola si el evento esperado aún no ha ocurrido. Este es un punto muy importante.
Y decidí verificar si puedo rechazar los semáforos std :: mutex y POSIX, emulándolos con std :: atomic, transfiriendo la carga principalmente a tierra de usuario. En realidad falló, pero lo primero es lo primero.
En primer lugar, tengo varias secciones en las que estos experimentos podrían ser útiles:
- se bloquea en LibreSSL (caso 1);
- bloqueo al transferir paquetes de carga recibida a aplicaciones Lua (caso 2);
- Esperando eventos de carga útil listos para ser procesados por aplicaciones Lua (caso 3).
Comencemos con bloqueos sin bloqueo. Escribamos nuestro mutex usando atómicos, como se muestra en algunos discursos de H. Sutter (por lo tanto, no hay código original de la memoria y, por lo tanto, el código no coincide con el 100% original, y en Satter este código estaba relacionado con el progreso de C ++ 20, por lo tanto hay diferencias). Y a pesar de la simplicidad de este código, hay dificultades en él.
#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;
A diferencia de std :: mutex :: unlock (), el comportamiento de test :: mutex: unlock () cuando se intenta desbloquear desde otro hilo es determinista. Se lanzará una excepción. Esto es bueno, aunque no es consistente con el comportamiento estándar. ¿Y qué hay de malo en esta clase? La mala noticia es que el método test :: mutex: lock () consumirá descaradamente los recursos de la CPU en las cuotas de tiempo asignadas al subproceso, en un intento de asumir el mutex que otro subproceso ya posee. Es decir un bucle en test :: mutex: lock () será un desperdicio de recursos de la CPU. ¿Cuáles son nuestras opciones para superar esta situación?
Podemos usar sched_yield () (como se sugiere en uno de los comentarios en el artículo anterior). ¿Es así de simple? En primer lugar, para usar sched_yield (), es necesario que los subprocesos de ejecución usen las políticas SCHED_RR, SCHED_FIFO para su priorización en el programador de tareas. De lo contrario, llamar a sched_yield () sería un desperdicio de recursos de la CPU. En segundo lugar, una llamada muy frecuente a sched_yield () seguirá aumentando el consumo de CPU. Además, el uso de políticas en tiempo real en su aplicación, y siempre que no haya otras aplicaciones en tiempo real en el sistema, limitará la cola del planificador con la política seleccionada solo a sus hilos. Parece que esto es bueno! No, no esta bien. Todo el sistema será menos receptivo, porque ocupado con tarea prioritaria. CFQ estará en la pluma. Pero hay otros hilos en la aplicación, y muy a menudo surge una situación cuando el hilo que ha capturado el mutex se coloca al final de la cola (la cuota ha expirado), y el hilo que está esperando que se libere el mutex justo en frente de él. En mis experimentos (caso 2), este método arrojó aproximadamente los mismos resultados (3.8% peores) que std :: mutex, pero el sistema responde menos y el consumo de CPU aumenta en un 5% -7%.
Puede intentar cambiar test :: mutex :: lock () de esta manera (también está mal):
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};
Aquí puede experimentar con la duración del sueño en nanosegundos, los retrasos de 4ns fueron óptimos para mi CPU y la caída del rendimiento en relación con std :: mutex en el mismo caso 2 fue del 1.2%. No es el hecho de que nanosleep durmió 4ns. De hecho, o más (en el caso general) o menos (si se interrumpe). La caída (!) En el consumo de CPU fue del 12% -20%. Es decir Fue un sueño tan saludable.
OpenSSL y LibreSSL tienen dos funciones que configuran devoluciones de llamada para bloquear cuando se usan estas bibliotecas en un entorno multiproceso. Se ve así:
// 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);
Y ahora lo peor es que el uso de la prueba anterior :: mutex mutex en LibreSSL reduce el rendimiento de LAppS en casi 2 veces. Además, independientemente de la opción (ciclo de espera vacío, sched_yield (), nanosleep ()).
En general, eliminamos el caso 2 y el caso 1, y nos quedamos con std :: mutex.
Pasemos a los semáforos. Hay muchos ejemplos de cómo implementar semáforos usando std :: condition_variable. Todos usan std :: mutex también. Y tales simuladores de semáforos son más lentos (según mis pruebas) que los semáforos del sistema POSIX.
Por lo tanto, haremos un semáforo en los á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(); } };
Oh, este semáforo es muchas veces más rápido que el semáforo del sistema. El resultado de una prueba separada de este semáforo con un proveedor y 20 consumidores:
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
Este semáforo con publicación casi gratuita (), que es 29 veces más rápido que el sistema, también es muy rápido al despertar los hilos que lo esperan: 29325 activaciones por milisegundo, frente a 1007 activaciones por milisegundo del sistema. Tiene un comportamiento determinista con un semáforo destruido o un semáforo destructible. Y, naturalmente, segfault cuando intentas usar uno ya destruido.
(¹) En realidad, el programador no puede retrasar ni despertar tantas veces en un milisegundo una secuencia. Porque post () no está bloqueando, en esta prueba sintética, wait () a menudo se encuentra en una situación en la que no necesita dormir. Al mismo tiempo, al menos 7 hilos en paralelo leen el valor del semáforo.
Pero usarlo en el caso 3 en LAppS conduce a pérdidas de rendimiento independientemente del tiempo de sueño. Se despierta con demasiada frecuencia para verificar, y los eventos en LAppS llegan mucho más lento (latencia de red, latencia del lado del cliente que genera la carga, etc.). Y comprobar con menos frecuencia también significa perder rendimiento.
Además, el uso del sueño en tales casos y de manera similar es completamente dañino, porque en otro hardware, los resultados pueden ser completamente diferentes (como en el caso de la pausa de instrucciones del ensamblador), y para cada modelo de CPU, también debe seleccionar el tiempo de retraso.
La ventaja de un mutex y un semáforo del sistema es que el hilo de ejecución no se activa hasta que ocurre un evento (desbloqueo del mutex o incremento del semáforo). Los ciclos de CPU adicionales no se desperdician: ganancias.
En general, todo, desde este malvado, deshabilitar iptables en mi sistema da del 12% (con TLS) al 30% (sin TLS) una ganancia de rendimiento ...