Tengo un programa 'servidor' que actualiza muchas listas enlazadas en la memoria compartida en respuesta a eventos externos. Quiero que los programas de los clientes noten una actualización en cualquiera de las listas lo más rápido posible (latencia más baja). El servidor marca el nodo de la lista vinculada state_
como FILLED
una vez que se completan sus datos y su siguiente puntero se ha establecido en una ubicación válida. Hasta entonces, es state_
es NOT_FILLED_YET
. Estoy usando barreras de memoria para asegurarme de que los clientes no vean el state_
como FILLED
antes de que los datos estén realmente listos (y parece que funcionan, nunca veo datos corruptos). Además, state_
es volátil para garantizar que el compilador no levante la comprobación del bucle del cliente.De estos 3 métodos para leer listas vinculadas de la memoria compartida, ¿por qué es el tercero más rápido?
Manteniendo el código del servidor exactamente igual, he encontrado 3 métodos diferentes para que el cliente escanee las listas enlazadas para ver los cambios. La pregunta es: ¿por qué es el 3er método más rápido?
Método 1: Round robin sobre todas las listas enlazadas (llamados 'canales') de manera continua, buscando para ver si todos los nodos han cambiado a 'LLENO':
void method_one()
{
std::vector<Data*> channel_cursors;
for(ChannelList::iterator i = channel_list.begin(); i != channel_list.end(); ++i)
{
Data* current_item = static_cast<Data*>(i->get(segment)->tail_.get(segment));
channel_cursors.push_back(current_item);
}
while(true)
{
for(std::size_t i = 0; i < channel_list.size(); ++i)
{
Data* current_item = channel_cursors[i];
ACQUIRE_MEMORY_BARRIER;
if(current_item->state_ == NOT_FILLED_YET) {
continue;
}
log_latency(current_item->tv_sec_, current_item->tv_usec_);
channel_cursors[i] = static_cast<Data*>(current_item->next_.get(segment));
}
}
}
Método 1 dio una latencia muy baja cuando el entonces el número de canales era pequeño Pero cuando el número de canales creció (250K +), se volvió muy lento debido a la repetición de todos los canales. Así que lo intenté ...
Método 2: Dé a cada lista vinculada una ID. Mantenga una 'lista de actualización' por separado. Cada vez que se actualiza una de las listas enlazadas, inserte su ID en la lista de actualizaciones. Ahora solo tenemos que controlar la lista de actualizaciones individual y verificar las identificaciones que obtenemos de ella.
void method_two()
{
std::vector<Data*> channel_cursors;
for(ChannelList::iterator i = channel_list.begin(); i != channel_list.end(); ++i)
{
Data* current_item = static_cast<Data*>(i->get(segment)->tail_.get(segment));
channel_cursors.push_back(current_item);
}
UpdateID* update_cursor = static_cast<UpdateID*>(update_channel.tail_.get(segment));
while(true)
{
ACQUIRE_MEMORY_BARRIER;
if(update_cursor->state_ == NOT_FILLED_YET) {
continue;
}
::uint32_t update_id = update_cursor->list_id_;
Data* current_item = channel_cursors[update_id];
if(current_item->state_ == NOT_FILLED_YET) {
std::cerr << "This should never print." << std::endl; // it doesn't
continue;
}
log_latency(current_item->tv_sec_, current_item->tv_usec_);
channel_cursors[update_id] = static_cast<Data*>(current_item->next_.get(segment));
update_cursor = static_cast<UpdateID*>(update_cursor->next_.get(segment));
}
}
Método 2 dio TERRIBLE latencia. Mientras que el Método 1 podría dar menos de 10us de latencia, el Método 2 inexplicablemente a menudo daría una latencia de 8 ms. Al usar gettimeofday, parece que el cambio en update_cursor-> state_ fue muy lento para propocionar desde la vista del servidor hasta la del cliente (estoy en un cuadro multinúcleo, así que supongo que la demora se debe a la memoria caché). Así que probé un enfoque híbrido ...
Método 3: conserve la lista de actualizaciones. Pero recorra todos los canales continuamente, y dentro de cada iteración, compruebe si la lista de actualización se ha actualizado. Si es así, ve con el número que aparece en él. Si no lo ha hecho, verifique el canal en el que hemos iterado actualmente.
void method_three()
{
std::vector<Data*> channel_cursors;
for(ChannelList::iterator i = channel_list.begin(); i != channel_list.end(); ++i)
{
Data* current_item = static_cast<Data*>(i->get(segment)->tail_.get(segment));
channel_cursors.push_back(current_item);
}
UpdateID* update_cursor = static_cast<UpdateID*>(update_channel.tail_.get(segment));
while(true)
{
for(std::size_t i = 0; i < channel_list.size(); ++i)
{
std::size_t idx = i;
ACQUIRE_MEMORY_BARRIER;
if(update_cursor->state_ != NOT_FILLED_YET) {
//std::cerr << "Found via update" << std::endl;
i--;
idx = update_cursor->list_id_;
update_cursor = static_cast<UpdateID*>(update_cursor->next_.get(segment));
}
Data* current_item = channel_cursors[idx];
ACQUIRE_MEMORY_BARRIER;
if(current_item->state_ == NOT_FILLED_YET) {
continue;
}
found_an_update = true;
log_latency(current_item->tv_sec_, current_item->tv_usec_);
channel_cursors[idx] = static_cast<Data*>(current_item->next_.get(segment));
}
}
}
La latencia de este método era tan bueno como el método 1, pero a escala en un gran número de canales. El problema es que no tengo idea de por qué. Solo para lanzar una llave inglesa en las cosas: si descomiento la parte 'encontrado a través de la actualización', se imprime entre TODOS LOS MENSAJES DE REGISTRO DE LETENCIA. ¡Lo que significa que las cosas solo se encuentran en la lista de actualizaciones! Así que no entiendo cómo este método puede ser más rápido que el método 2.
El, código compilables completa (requiere GCC-e impulsar 1.41) que genera cadenas aleatorias como datos de prueba está en: http://pastebin.com/0kuzm3Uf
Actualización: Los 3 métodos son efectivamente spinlocking hasta que ocurra una actualización. La diferencia radica en cuánto tardan en darse cuenta de que se ha producido la actualización. Ellos todos continuamente gravan el procesador, por lo que no explica la diferencia de velocidad. Estoy probando en una máquina de 4 núcleos sin nada más que se ejecute, por lo que el servidor y el cliente no tienen nada con lo que competir. Incluso he hecho una versión del código donde las actualizaciones señalan una condición y hacen que los clientes esperen con la condición, esto no ayudó a la latencia de ninguno de los métodos.
Update2: A pesar de que hay 3 métodos, solo he intentado 1 a la vez, por lo que solo 1 servidor y 1 cliente compiten por el miembro state_.
Sí, es para evitar condiciones de carrera. Se suponía que también estaba en el método 2, he editado mi publicación para corregir eso (los tiempos son los mismos). Los procesadores modernos (no solo el compilador) son libres de reordenar tiendas y cargas entre hilos, por lo que un hilo puede establecer A y B, pero otro hilo puede 'ver' que B se configuró antes de ver que A se configuró. El servidor llena los datos del nodo, hace un RELEASE_MEMORY_BARRIER, luego establece state_ en FILLED. Combinado con el cliente que realiza ACQUIRE_MEMORY_BARRIER, esto garantiza que el cliente nunca perciba state_ como FILLED antes de que se completen los datos del nodo. –
Debo añadir que esta es la primera vez que uso barreras de memoria, y así es como pienso * que se supone para trabajar;) Si sigue el enlace pastebin, el primer archivo, list.H define RELEASE_MEMORY_BARRIER y ACQUIRE_MEMORY_BARRIER para primitivas documentadas aquí: http://gcc.gnu.org/onlinedocs/gcc-4.1.2/gcc/Atomic-Builtins .html –
Lamento el spam; p He editado mi publicación (ver la parte inferior) para abordar el martilleo del procesador. –