Revista Tecnología

Singletons en C++. Intentando que sean seguros en hilos (thread safety) II

Publicado el 22 abril 2014 por Gaspar Fernández Moreno @gaspar_fm

 

Singleton thread-safe
Ayer hablábamos de la creación de un sigleton y de que nuestro ejemplo no era “thread safe”, vamos, que en un entorno con varios hilos de ejecución cabe la posibilidad de que el comportamiento esperado no siempre se cumpla.

Ahí encontrábamos diferencias entre C++ (<11) y C++11 ya que esta última revisión incluye tratamiento de threads y algunas cosas más que trataremos aquí.

Lo primero que podemos pensar, es que al traernos la instancia de nuestro singleton se crea una sección crítica, la cuál podemos regular con un mutex, provocando que siempre que vayamos a obtener una instancia de nuestro objeto pasemos por el semáforo, y aunque dos threads quieran pelearse por ver quién crea antes el recurso, sólo uno lo conseguirá finalmente.

A partir de ahora, ya que antes de C++11 no tenemos mutex nativos (ya lo he dicho varias veces, bueno una más, a ver si mejora el SEO

:)
), la función getInstance() quedará así:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
  static Singleton *getInstance()
  {
    static pthread_mutex_t mutex;
    pthread_mutex_lock(&mutex);
    if (instance == NULL)
      instance = new Singleton();
    else
      std::cout << "Getting existing instance"<<std::endl;
    pthread_mutex_unlock(&mutex);
    return instance;
  }

Vemos como bloqueamos el mutex antes de hacer nada y desbloqueamos después de construir el singleton, o después de pasar la condición (instance == NULL), ahora, si varios threads entran a nuestra sección crítica, al no poder estar más de uno dentro de la misma, no pasa nada, porque cuando entre uno de los hilos y haga que instance tenga un determinado valor, el siguiente thread verá que instance no es NULL, y no creará una nueva instancia.

Hasta aquí, si probamos el código creando muchos threads, no tendremos problemas (aunque hay algunas cosas más en tener en cuenta), porque los compiladores, hoy en día son muy listos y porque, en este caso, la biblioteca pthread está bien implementada y no nos dará guerra.

Para probar el código, podemos hacerlo con lo siguiente (no hay nada nuevo, pero vale para copiar y pegar):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
#include <iostream>
#include <pthread.h>
#include <cstdlib>
#include <unistd.h>
using namespace std;
class Singleton
{
public:
  static Singleton *getInstance()
  {
    static pthread_mutex_t mutex;
    pthread_mutex_lock(&mutex);
    if (instance == NULL)
      instance = new Singleton();
    else
      std::cout << "Getting existing instance"<<std::endl;
    pthread_mutex_unlock(&mutex);
    return instance;
  }
protected:
  Singleton()
  {
    std::cout << "Creating singleton" << std::endl;
  }
  virtual ~Singleton()
  {
  }
  Singleton(Singleton const&); 
  Singleton& operator=(Singleton const&);
private:
  static Singleton *instance;
  int x;
};
Singleton* Singleton::instance=NULL;
void *task (void*)
{
  Singleton *s = Singleton::getInstance();
  cout << "Thread con instancia"<<endl;
}
int main()
{
  for (unsigned i=0; i<100; ++i)
    {
      pthread_t thread;
      int rc = pthread_create(&thread, NULL, task, NULL);
    }
  pthread_exit(NULL);
  return 0;
}

Aumentando el rendimiento del Singleton

Una primera optimización que podemos hacer es implementar un DCLP (Double-checked locking pattern o patrón de bloqueo con doble comprobación), el objetivo de esto es que, siempre que hacemos getInstance() pasamos por el mutex, y esto consume ciclos de CPU, pero una vez que ya esté creada la instancia, no hará falta entrar en el mutex más veces, puesto que instance ya tiene un valor. El caso es que cuando ya tengamos la instancia de nuestro singleton, no tenemos por qué bloquear / comprobar / desbloquear, por lo tanto, podemos introducir una comprobación antes de bloquear (en el peor de los casos, gastaremos un acceso a memoria y una comprobación de una variable más), pero en el mejor de los casos (que se producirá normalmente muchas más veces), sólo gastaremos esa comprobación y retornaremos antes de getInstance().

Al final, dejamos la función así:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
  static Singleton *getInstance()
  {
    if (instance == NULL)
      {
    static pthread_mutex_t mutex;
    pthread_mutex_lock(&mutex);
    if (instance == NULL)
      instance = new Singleton();
    else
      std::cout << "Getting existing instance"<<std::endl;
    pthread_mutex_unlock(&mutex);
      }
    return instance;
  }

Otra medida que podemos tomar para aumentar el rendimiento es, en el caso de necesitar la instancia para muchas acciones, hacer una copia de la misma, es decir, copiarnos el puntero en una variable local y realizar las acciones sobre él, en lugar de hacer muchas llamadas seguidas a getInstance().

Problemas que pueden aparecer

Podemos encontrar otro gran problema en la optimización de los compiladores, y de las CPUs modernas, y es que nadie nos garantiza que en

1
      instance = new Singleton();

primero se asigne el valor de instance y luego se llame al constructor, ni siquiera que luego se desbloquee el mutex. También es cierto que la biblioteca pthread funciona muy bien, y aísla nuestro código (pone memory barriers de pormedio), y no vamos a tener problema con ella, siempre que trabajemos en un unix con esta biblioteca (usando funciones pthread_*). Pero como bibliotecas hay muchas, no está de más advertir sobre esto. Es más, a veces podemos pillar que algún thread, pasa del primer if ( instance == NULL ) y quiere cargar el segundo, pero el semáforo lo ha parado.

Otro problema es que, el valor de las variables cambia sin previo aviso, es decir, para un thread, la variable instance podrá tener un valor, y sin que ocurra nada en la ejecución de ese thread cambie de valor (claro, porque otro thread lo ha cambiado sin que el actual se dé cuenta). Esto no es nada nuevo, pero los compiladores optimizan mucho el código máquina resultante, tanto, que si antes no se aseguraba el orden de las sentencias, ahora tampoco se asegura que lo que en realidad cambie sea la variable que hemos dicho, o en realidad se hace un cambio en un registro que se volcará más tarde a memoria, o si no se hace uso de esa memoria, tal vez nunca llegue.

Todo eso está muy bien, al final lo que conseguimos es que nuestro programa se ejecute más rápido, haciendo que el compilador emplee trucos. Por otro lado, las optimizaciones se pueden desactivar, aunque nosotros queremos que los programas aprovechen al máximo la CPU donde ejecutamos, por lo que está feo no optimizar el ejecutable final. Lo que podemos hacer es obligar a una variable a leer y escribir siempre en memoria, y para ello utilizaremos la palabra clave volatile. volatile, en principio se utilizó para dispositivos hardware mapeados en memoria, para acceder a estos dispositivos utilizábamos direcciones de memoria, y por tanto, los valores de dichas direcciones, podían variar por la cara, sin que el programa controlara dichas variaciones, por tanto forzando el acceso a memoria, siempre que realicemos una operación sobre dicha variable leeremos de nuevo el valor (dejando aparte optimizaciones que no se aplicarían en este caso). Volviendo al tema del multi-hilo, al escribir los valores directamente en su posición de memoria, otro hilo podrá hacer una lectura de la misma y ver que, en efecto, se ha modificado.

Por lo tanto, si creamos instance como volatile, estaría un poco mejor, aunque tenemos que saber también que los compiladores más modernos no tendrán este problema y que en C++11, volatile ha quedado reservado exclusivamente a acceso hardware, por lo que no debemos utilizarlo en esta última revisión del lenguaje. Aunque lo dicho, para versiones anteriores, obtendremos mejores resultados.

C++11

C++11 no tiene problema, y en la inicialización de la variable asegura la seguridad entre hilos de ejecución, por lo que no tenemos que complicarnos la vida (siempre y cuando el compilador sea 100% compatible con las especificaciones):

1
2
3
4
5
  static Singleton *getInstance()
  {
    static Singleton* s = new Singleton();
    return s;
  }

En el caso en el que nuestro compilador no cumpla, tengamos duda, o no podamos determinar dónde compilamos, es seguro utilizar std::call_once y std::unique_ptr para almacenar el puntero a la instancia actual, dejando nuestro Singleton así (fuente Marc Gregoires):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <iostream>
#include <cstdlib>
#include <unistd.h>
#include <mutex>
#include <memory>
class Singleton
{
public:
  static Singleton *getInstance()
  {
    std::call_once(m_onceFlag,
           [] {
             m_instance.reset(new Singleton);
           });
    return m_instance.get();
  }
  virtual ~Singleton()
  {
  }
protected:
  Singleton()
  {
    std::cout << "Creating singleton" << std::endl;
  }
  Singleton(Singleton const&); 
  Singleton& operator=(Singleton const&);
private:
  static std::unique_ptr<Singleton> m_instance;
  static std::once_flag m_onceFlag;
  int x;
};

Una última nota

Si en lugar de devolver un puntero a nuestra instancia (porque como nos hagan un delete se puede liar, aunque si tenemos el destructor privado dará un fallo de compilación), queremos devolver la referencia a la instancia, también podemos, debemos hacer algo como:

1
2
3
4
5
  static Singleton &getInstance()
  {
    static s = new Singleton();
    return s;
  }

para C++11, o

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
  static Singleton &getInstance()
  {
    if (instance == NULL)
      {
    static pthread_mutex_t mutex;
    pthread_mutex_lock(&mutex);
    if (instance == NULL)
      instance = new Singleton();
    else
      std::cout << "Getting existing instance"<<std::endl;
    pthread_mutex_unlock(&mutex);
      }
    return *instance;
  }

para versiones anteriores. Como veis, cambiamos el Singleton* que devuelve getInstance() por Singleton& y en lugar de devolver instance, devolvemos *instance, por lo que seguimos almacenando el puntero internamente, pero devolvemos una referencia a nuestro objeto. Para acceder a dicho Singleton debemos tener cuidado y hacerlo de la siguiente manera:

1
  Singleton& sing = Singleton::getInstance();

así evitamos destrucciones inesperadas.

Foto: Andrew Magill (Flickr CC-by)


Volver a la Portada de Logo Paperblog