Inicio > algoritmos, C++11, C/C++, Concurrencia, Cosas que damos por hechas, General > Singletons en C++. Intentando que sean seguros en hilos (thread safety) II

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

Singleton thread-safeAyer 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.

Para leer…

C++ and the Perils of Double-Checked Locking
Memory Barrier (Wikipedia)
La Plaga Tux
Double-Checked Locking is fixed in C++11

Foto: Andrew Magill (Flickr CC-by)

  1. Marc Costa
    22 abril, 2014 13:39 | #1
    Usando Google Chrome Google Chrome 34.0.1847.116 en Windows Windows 7

    Hace tiempo que sigo este blog aunque hasta ahora nunca había comentado.

    Lo que me ha hecho decidirme esta vez es que creo que este post (y el anterior y siguientes, supongo) se basan en una premisa defectuosa desde el primer momento.

    Para empezar, un Singleton es un antipatrón de diseño, y aún veo mucho blogs que hablan de él y cantan las mil maravillas del Singleton. Un Singleton no es más que una variable global glorificada, con un par de funciones que “descontrolan” su creación y destrucción y que esconden y complican cualquier intento de descubrir el patrón de uso del recurso. Una variable global debería ser inicializada en un punto muy específico del programa, por el thread responsable de inicializarla (UI thread, loading thread…), tener un patrón de uso muy claro, y ser destruída de la misma forma: en un punto en concreto y por un thread specífico. Creo que dejar al azar la adquisición de recursos (que puede ser potencialmente grande) y el cuándo de esta adquisición es básicamente un error de diseño del programa.

    Segundo punto; el Singleton es un patrón para código singlethreaded. Sólo hay que mirar a la función getInstance() i pensar qué pasaría si en lugar de 2 threads la usaran 4, 8, 16 ó 32 a la vez. Está muy claro que el tiempo de espera para acceder al recurso no va a hacer más que crecer, y si el cálculo para el que se usa el Singleton es relativamente largo, el programa se convierte automáticamente en código série, mientras el resto de threads están malgastando recursos del sistema.

    Para terminar, creo que empieza a ser hora de ir abandonando el paradigma de la Programación Orientada a Objetos, y empezar a diseñar programas para que corran en paralelo y sean escalables a medio plazo como mínimo.

    Sólo dejar una última reflexión: el paralelismo es un problema de datos, no de código. Cuando se paraleliza pensando sólo en código, se complica todo demasiado, sin necesidad alguna.

    Gracias por este espacio donde compartir estas opiniones!
    Un saludo!

    • Gaspar Fernández
      22 abril, 2014 19:19 | #2
      Usando Mozilla Firefox Mozilla Firefox 28.0 en Ubuntu Linux Ubuntu Linux

      Muchas gracias por tu comentario Marc.

      Es un tema delicado y no había publicado nada aún por lo mismo. Hay mucha gente que cree que el Singleton es la caña y otra gente que lo odia a muerte. Tiene un nombre chulo, actúa como variable global, y en muchísimos casos, con poner elementos estáticos en la clase nos sobraría, pero bueno, luego tendríamos que controlar el acceso a los recursos, pero no estaríamos usando este patrón.

      En cuanto al número de los threads que utilizamos en cada punto, eso debe ser controlado en muchos aspectos y no sólo este, muchos sistemas, utilizan multi-thread pero hay recursos que no pueden ser accedidos más de una vez, y habrá acciones que debemos hacer con un cierto orden. De todas formas, esto es para la inicialización, es verdad que el cuándo y dónde es importante en muchos casos, en otros no tanto y lo más seguro que mientras implementemos esto, necesitemos establecer más bloqueos, pero eso ya es cosa de cada uno.

      Me ha gustado que saques el tema de la paralelización, aunque hay bibliotecas que la permiten desde hace ya tiempo, hace poco leí acerca de la revisión C++14 y está contemplado entre otras cosas chulas como acceso a sockets y todo eso, sería una forma de tener todo en el mismo sitio está muy bien.

      De nuevo, gracias por tu comentario

  2. 24 abril, 2014 17:47 | #3
    Usando Mozilla Firefox Mozilla Firefox 28.0 en Ubuntu Linux Ubuntu Linux

    Aun no conocia todas las funcionalidades para threads de C++11, bastante util std::call_once.

    Es importante recalcar que se ha podido introducir threads en el estandar gracias a que C++11 define un modelo de memoria a cumplir por todos los compiladores, para evitar tener los problemas que comentas en el articulo.

    Tambien, en C++11, para no tener que definir constructores protegidos, lo ideal es borrar la definición de los constructores que no quieres permitir, e.g:

    Singleton( Singleton const& ) = delete;
    Singleton& operator=( Singleton const& ) = delete;

  1. abril 22nd, 2014 at 13:03 | #1

Current ye@r *

Top