3.3 Herramientas alternativas para la protección de datos compartidos

Herramientas alternativas para la protección de datos compartidos

Si bien los mutex son el mecanismo más general, no son la única opción cuando se trata de proteger datos compartidos; existen alternativas que pueden brindar una protección más adecuada en situaciones específicas.

Un caso particularmente extremo (pero bastante común) es cuando los datos compartidos solo necesitan protección contra el acceso simultáneo durante la inicialización, pero no necesitan sincronización explícita después de eso. Esto puede deberse a que los datos son de solo lectura una vez creados, por lo que no hay posibles problemas de sincronización, o a que la protección necesaria se realiza implícitamente como parte de las operaciones sobre los datos. En cualquier caso, bloquear el mutex después de que se hayan inicializado los datos es simplemente para proteger la inicialización, lo cual es innecesario y afecta innecesariamente el rendimiento. Por este motivo, el estándar C++ proporciona un mecanismo con el único fin de proteger los datos compartidos durante la inicialización.

Proteger los datos compartidos durante la inicialización

Suponga que tiene un recurso compartido que es muy costoso de construir y solo quiere hacerlo cuando realmente lo necesita. Tal vez abra una conexión de base de datos o asigne mucha memoria. La inicialización diferida como esta es común en el código de un solo subproceso: cada operación que solicita un recurso primero verifica si se ha inicializado y, si no, lo inicializa antes de usarlo.

std::shared_ptr<some_resource> resource_ptr;
void foo()
{
    
    
     if(!resource_ptr)
     {
    
    
         resource_ptr.reset(new some_resource); //❶
     }
     resource_ptr->do_something();
}

Si el recurso compartido en sí es seguro para el acceso simultáneo, la única parte que debe protegerse al convertirlo a código multiproceso es la inicialización ❶, pero una conversión ingenua como la del Listado 3.11 hará que los subprocesos que utilizan el recurso generen una serialización innecesaria. Esto se debe a que cada hilo debe esperar a que el mutex compruebe si el recurso se ha inicializado.

//清单3.11 使用互斥元进行线程安全的延迟初始化
#include <memory>
#include <mutex>

struct some_resource
{
    
    
    void do_something()
    {
    
    }
    
};


std::shared_ptr<some_resource> resource_ptr;
std::mutex resource_mutex;
void foo()
{
    
    
    std::unique_lock<std::mutex> lk(resource_mutex); //所有的线程在这里被序列化
    if(!resource_ptr)
    {
    
    
        resource_ptr.reset(new some_resource); //只有初始化需要被保护
    }
    lk.unlock();
    resource_ptr->do_something();
}

int main()
{
    
    
    foo();
}

Este código es común y el problema de la serialización innecesaria es lo suficientemente grande como para que muchas personas hayan intentado encontrar una mejor manera de hacerlo, incluido el infame patrón de bloqueo de doble verificación, leer el puntero por primera vez sin adquirir el bloqueo ❶ (en el código siguiente) y adquiera el bloqueo solo si este puntero es NULL. Una vez que se ha adquirido el bloqueo, se verifica nuevamente el puntero (esta es la segunda parte de verificación) para evitar que otro hilo complete la inicialización entre la primera verificación y este hilo adquiere el bloqueo.

void undefined_behaviour_with_double_checked_locking()
{
    
    
     if(!resource_ptr) //❶
     {
    
       
         std::lock_guard<std::mutex> lk(resource_mutex);
         if(!resource_ptr) //❷
         {
    
    
              resource_ptr.reset(new some_resource); //❸
         }
     }
     resource_ptr->do_something(); //❹
}

Desafortunadamente, este patrón es notorio por una razón. Tiene el potencial de crear una condición de carrera desagradable porque las lecturas ❶ fuera del candado no están sincronizadas con las escrituras ❸ realizadas por otro subproceso dentro del candado. Por lo tanto, esto crea una condición de carrera que cubre no sólo el puntero en sí, sino también el objeto al que apunta. Incluso si un hilo ve el puntero escrito por otro hilo, es posible que no vea la instancia some_resource recién creada, lo que hace que la llamada a do_something()❹ se ejecute con el valor incorrecto. Este es un ejemplo de una condición de carrera, que está definida por el estándar C++ como una carrera de datos y, por lo tanto, tiene un comportamiento indefinido . Así que esto es definitivamente algo que se debe evitar.

El Comité de Estándares de C++ también encontró que este es un escenario importante, por lo que la Biblioteca Estándar de C++ proporciona std::once_flag y std::call_once para manejar esta situación. En lugar de bloquear el mutex y verificar explícitamente el puntero, cada subproceso puede usar std::call_once, y cuando std::call_once regrese, el puntero será inicializado por algún subproceso (de manera totalmente sincrónica), por lo que es seguro. El uso de std::call_once generalmente tiene una sobrecarga menor que el uso explícito de un mutex, especialmente cuando se ha completado la inicialización, por lo que std::call_once debe usarse primero cuando cumple con la funcionalidad requerida. El siguiente ejemplo muestra la misma operación que el Listado 3.11, modificada para usar std::call_once. En este caso, la inicialización se realiza llamando a una función, pero la inicialización podría realizarse con la misma facilidad a través de una instancia de clase con un operador de llamada de función. Como la mayoría de las funciones de la biblioteca estándar que aceptan funciones o afirmaciones como argumentos, std::call_once puede funcionar con cualquier función u objeto invocable.

std::shared_ptr<some_resource> resource_ptr;
std::once_flag resource_flag; //❶

void int_resource()
{
    
    
    resource_ptr.reset(new some_resource);
}
void foo()
{
    
    
    std::call_once(resource_flag, init_resource); //初始化会被正好调用一次
    resource_ptr->do_something();
}

En este ejemplo, std::once_flag❶ y los datos inicializados son objetos con ámbito de espacio de nombres, pero std::call_once() se puede usar fácilmente para la inicialización diferida de miembros de la clase, como se muestra en el Listado 3.12.

//清单3.12 使用std::call_one的线程安全的类成员延迟初始化
#include <mutex>

struct connection_info
{
    
    };

struct data_packet
{
    
    };

struct connection_handle
{
    
    
    void send_data(data_packet const&)
    {
    
    }
    data_packet receive_data()
    {
    
    
        return data_packet();
    }
};

struct remote_connection_manager
{
    
    
    connection_handle open(connection_info const&)
    {
    
    
        return connection_handle();
    }
} connection_manager;


class X
{
    
    
private:
    connection_info connection_details;
    connection_handle connection;
    std::once_flag connection_init_flag;

    void open_connection()
    {
    
    
        connection=connection_manager.open(connection_details);
    }
public:
    X(connection_info const& connection_details_):
        connection_details(connection_details_)
    {
    
    }
    void send_data(data_packet const& data) //❶
    {
    
    
        std::call_once(connection_init_flag,&X::open_connection,this); //❷
        connection.send_data(data);
    }
    data_packet receive_data() //❸
    {
    
    
        std::call_once(connection_init_flag,&X::open_connection,this);
        return connection.receive_data();
    }
};

int main()
{
    
    }

En este ejemplo, la inicialización se realiza mediante la primera llamada a enviar_datos()❶ o mediante la primera llamada a recibir_datos(). Utilice la función miembro open_connection() para inicializar los datos y también debe pasar este puntero a la función. Al igual que otras funciones en la biblioteca estándar que aceptan objetos invocables, como los constructores de std::thread y std::bind(), esto se logra pasando un argumento adicional a std::call_once() ❷ .

Vale la pena señalar que las instancias de std::mutex y std::once_flag no se pueden copiar ni mover, por lo que si desea usarlas como miembros de clase como este, debe definir explícitamente las funciones especiales que necesita.

Un escenario en el que puede haber una condición de carrera durante el proceso de inicialización es declarar variables locales como estáticas. Se define que la inicialización de dicha variable ocurre cuando el control de tiempo pasa por primera vez sobre su declaración. Para varios subprocesos que llaman a la función, esto significa que puede haber una condición de carrera para definir "primera vez". En muchos compiladores anteriores a C++11, esta condición de carrera es problemática en la práctica, porque varios subprocesos pueden pensar que son los primeros e intentar inicializar la variable, o los subprocesos pueden estar inicializándose. Se intentó usarlo mientras se iniciaba. Otro hilo pero aún no se ha completado. En C++11, este problema está resuelto. La inicialización está definida para ocurrir solo en un subproceso, y otros subprocesos no pueden continuar hasta que se complete la inicialización, por lo que la condición de carrera es solo sobre qué subproceso realizará la inicialización, y nada más. Esto se puede utilizar como reemplazo de std::call_once donde se requiere una única instancia global.

class my_class;
my_class& get_my_class_instance()
{
    
    
     static my_class instance; //❶初始化保证线程是安全的
     return instance;
}

Varios subprocesos pueden continuar llamando de forma segura a get_my_class_instance()❶ sin preocuparse por las condiciones de carrera durante la inicialización.

Proteger los datos utilizados sólo para la inicialización es un caso especial del escenario más general de estructuras de datos que rara vez se actualizan. La mayoría de las veces, dicha estructura de datos es de solo lectura y varios subprocesos pueden leerla simultáneamente sin preocupaciones, pero ocasionalmente es posible que sea necesario actualizar la estructura de datos. Lo que se necesita aquí es un mecanismo de protección que reconozca este hecho.

Proteja las estructuras de datos que rara vez se actualizan

Supongamos que hay una tabla que se usa para almacenar un caché de entradas DNS, que se usa para resolver nombres de dominio en las direcciones P correspondientes. A menudo, una entrada DNS determinada permanecerá sin cambios durante mucho tiempo; en muchos casos, las entradas DNS permanecerán sin cambios durante años. Aunque es posible que se agreguen nuevas entradas a la tabla de vez en cuando a medida que los usuarios visitan diferentes sitios web, estos datos permanecerán esencialmente sin cambios durante toda su vida. Es importante comprobar periódicamente la validez de las entradas de la caché, pero actualizarlas sólo si los detalles realmente han cambiado.

Aunque las actualizaciones son raras, todavía ocurren, y si se puede acceder a este caché desde varios subprocesos, debe protegerse adecuadamente durante las actualizaciones para garantizar que todos los subprocesos no vean daños al leer la estructura de datos del caché.

En ausencia de una estructura de datos dedicada que cumpla totalmente con su uso previsto y esté diseñada específicamente para actualizaciones y lecturas simultáneas, dichas actualizaciones requieren que el subproceso que realiza la actualización tenga acceso exclusivo a la estructura de datos hasta que complete la operación. Una vez que se completa la actualización, la estructura de datos es segura para el acceso simultáneo de varios subprocesos. Por lo tanto, usar std::mutex para proteger la estructura de datos es demasiado pesimista, porque eliminará la posibilidad de lectura simultánea de la estructura de datos cuando la estructura de datos no se modifica. Lo que necesitamos es otro mutex. Este nuevo mutex a menudo se denomina mutex de lector-escritor porque permite dos usos diferentes: acceso exclusivo o uso compartido mediante un único subproceso de "escritura" y uso simultáneo mediante acceso a múltiples subprocesos de "lectura".

La nueva biblioteca estándar de C++ no proporciona directamente dicho mutex, aunque se ha propuesto al comité de estándares. Dado que esta sugerencia no fue aceptada, los ejemplos de esta sección utilizan la implementación proporcionada por la biblioteca Boost, que se basa en esta sugerencia. Como verá más adelante, el uso de un mutex de este tipo no es una panacea y el rendimiento depende de la cantidad de procesadores y de la carga de trabajo relativa de los subprocesos de lectura y actualización. Por lo tanto, es importante analizar el rendimiento del código en el sistema de destino para garantizar que la complejidad adicional tenga beneficios reales.

Puede utilizar una instancia de boost::shared_mutex para lograr la sincronización en lugar de una instancia de std::mutex. Para operaciones de actualización, std::lock_guard<boost::shared_mutex>y std::unique_lock<boost::shared_mutex>se puede utilizar para bloquear, reemplazando la especialización std::mutex correspondiente. Esto garantiza un acceso exclusivo, tal como lo hace std::mutex. Los subprocesos que no necesitan actualizar la estructura de datos pueden utilizarla boost::shared_lock<boost::shared_mutex>para obtener acceso compartido. Esto es exactamente lo mismo que std::unique_lock, excepto que varios subprocesos pueden tener bloqueos compartidos en el mismo boost::share_mutex al mismo tiempo. La única restricción es que si algún hilo posee un bloqueo compartido, el hilo que intenta adquirir el bloqueo exclusivo será bloqueado hasta que todos los demás hilos revoquen sus bloqueos. Del mismo modo, si algún hilo tiene un bloqueo exclusivo, ningún otro hilo puede adquirir el bloqueo compartido. .lock o bloqueo exclusivo hasta que el primer hilo revoque su bloqueo.

El Listado 3.13 muestra un caché DNS simple como se describió anteriormente, usando std::map para guardar los datos del caché y boost::share_mutex para protección.

//清单3.13 使用boost::share_mutex保护数据结构
#include <map>
#include <string>
#include <mutex>
#include <boost/thread/shared_mutex.hpp>

class dns_entry
{
    
    };

class dns_cache
{
    
    
    std::map<std::string,dns_entry> entries;
    boost::shared_mutex entry_mutex;
public:
    dns_entry find_entry(std::string const& domain)
    {
    
    
        boost::shared_lock<boost::shared_mutex> lk(entry_mutex); //❶
        std::map<std::string,dns_entry>::const_iterator const it=
            entries.find(domain);
        return (it==entries.end())?dns_entry():it->second;
    }
    void update_or_add_entry(std::string const& domain,
                             dns_entry const& dns_details)
    {
    
    
        std::lock_guard<boost::shared_mutex> lk(entry_mutex); //❷
        entries[domain]=dns_details;
    }
};

int main()
{
    
    }

En el Listado 3.13, find_entry() usa una boost::share_lock<>instancia para protegerlo para acceso compartido de solo lectura: varios subprocesos pueden llamar a find_entry() simultáneamente sin ningún problema. update_or_add_entry(), por otro lado, utiliza una std::lock_guard<>instancia que proporciona acceso exclusivo mientras se actualiza la tabla; no solo se bloquea la actualización de otros subprocesos durante la llamada a update_or_add_entry(), sino que el subproceso que llama a find_entry() también está bloqueado.

bloqueo recursivo

Cuando se usa std::mutex, es un error que un subproceso intente bloquear un mutex que ya posee, e intentar hacerlo resultará en un comportamiento indefinido . Sin embargo, en algunos casos es deseable que un subproceso vuelva a adquirir el mismo mutex varias veces sin liberarlo primero. Para ello, la biblioteca estándar de C++ proporciona std::recursive_mutex. Es como std::mutex, excepto que puedes adquirir múltiples bloqueos en una sola instancia en el mismo hilo. Debes liberar todos los bloqueos antes de que otro subproceso pueda bloquear el mutex, por lo que si llamas a lock() tres veces, también debes llamar a unlock() tres veces. Úselo correctamente std::lock_guard<std::recursive_mutex>y std::unique_lock<std::recursive_mutex>será atendido por usted.

La mayoría de las veces, si cree que necesita un mutex recursivo, es posible que deba cambiar su diseño. Los mutex recursivos se utilizan a menudo cuando una clase está diseñada para que varios subprocesos accedan simultáneamente, por lo que tiene un mutex para proteger los datos de los miembros. Cada función miembro pública bloquea el mutex, hace su trabajo y luego desbloquea el mutex. Sin embargo, a veces es deseable que una función miembro pública llame a otra función como parte de su operación. En este caso, la segunda función miembro también intentará bloquear el mutex, provocando un comportamiento indefinido. La solución cruda es cambiar el mutex a un mutex recursivo. Esto permitirá que el bloqueo del mutex en la segunda función miembro tenga éxito y la función continúe.

Sin embargo, no se recomienda dicho uso , ya que puede dar lugar a pensamientos descuidados y a un diseño deficiente. En particular, los invariantes de clase generalmente se corrompen mientras se mantiene el bloqueo, lo que significa que la segunda función miembro debe funcionar incluso si se llama con el invariante corrupto. Por lo general, es mejor extraer una nueva función miembro privada que se llama desde ambas funciones miembro y que no bloquea el mutex (cree que el mutex ya está bloqueado). Luego, puede pensar en las circunstancias bajo las cuales se puede llamar a esta nueva función y el estado de los datos en esas circunstancias.

Supongo que te gusta

Origin blog.csdn.net/qq_36314864/article/details/132202824
Recomendado
Clasificación