Multihilo. Espacios de memoria, código reentrante y código threadsafe.

Introducción

Este artículo sirve de introducción al concepto de espacio de memoria de un proceso para su aplicación al concepto de multihilo de forma que solo da una introducción a los distintos segmentos de memoria y a aspectos como la visibilidad para centrarse en el porque y el cuando de la sincronización en el acceso a datos.

En la mayoría de los sistemas operativos (y para lo que nos ocupa en Windows y en Linux) todo proceso tiene varios segmentos en memoria, en concreto son el segmento de código, el segmento de datos, el segmento de pila y el segmento de heap.

  • El segmento de código define la zona de memoria en la que está ubicado el código del programa ejecutable, es decir, la secuencia de instrucciones de código maquina que describen la secuencia de ejecución el mismo.
  • El segmento de datos define la zona de memoria en la que se ubícan los datos estáticos (variables globales y variables definidas como static en aquellos lenguajes que lo soportan) definidos en el programa. Generalmente esta dividido en datos con valor inicial (aquellos que estan inicializados a un valor) y datos sin valor inicial.
  • El segmento de pila define una zona de memoria que se utilizará como un LIFO (Last In Firs Out, Ultimo en entrar primero en salir) y en la que se almacena datos locales a los procedimientos así como otra información de caracter temporal. Al nivel que nos ocupa es interesante recalcar que las variables locales así como los valores de retorno de las funciones se ubican en la pila.
  • El segmento de heap defien una zona de memoria utilizada para almacenar datos en runtime. Toda reserva de memoria producida por un malloc (o un New o un GetMem) devuelve un puntero a una dirección de memoria ubicada en el Heap.

Varios hilos

¿Como afecta todo lo anterior a la ejecución de distintos hilos en un mismo proceso?

Cuando se crea un hilo se asigna un espacio de memoria propio para las secciones de datos y para las secciones de pila mientras que las secciones de código y heap son compartidas (se utilizan las del proceso que crea la tarea).

Esto es importante para entender que datos debemos proteger en una aplicación multihilo y cuales no es necesario que protejamos. Solo aquellos datos a los que puede darse el caso de acceder desde más de un hilo son los que deben ser protegídos es decir, tan solo los datos ubicados en el heap (datos creados dinámicamente) y en la zona de datos (variables globales) son susceptibles de causarnos quebraderos de cabeza.

El funcionamiento del compilador

Cuando el compilador analiza nuestro programa y genera el código objeto correspondiente tiene una determinada forma de procesar las funciones y almacenar las variables. Al analizar nuestro programa el compilador va rellenando una tabla de simbolos con información sobre las variables y funciones que declaramos. Vamos a ver un ejemplo:

/* Esto son variables globales por lo que iran a la
    sección de datos (concretamente sin valor inicial)
*/

int global1;
int global2;
char *cadena;

/* Los parametros de las funciones se pasan por pila,
    generalmente en orden de forma que la pila contendra
    empezando por la cima : a, b, (ptr)cadena, retval, retdir, ...*/


/* Además, generalmente (dependiendo del compilador) es normal
    que los dos o tres primeros parametros se guarden en EBX, ECX ...
    para mejorar la velocidad de ejecución (siempre se debe procurar
    poner los parametros más usados por la función primero) */

int MiFuncion(int a,b; char *cadena)
{
   /* Esto es una variable estática se almacena
       en el segmento de datos (con valor inicial) */

   static int status = 0;

   // Este puntero es una variable local
   char *local;
   
   /* Pero apunta a una dirección de memoria que hemos
       reservado y que está situada en el heap */

   local = (char *)malloc(5 * sizeof(char))

  if (!strcmp(cadena,"Hola!") )
  {
     cadena[4] = "?";
     if (a==0 && b==0)
       status++;
  }

  return status;
}

void main()
{
   /* Las variables locales se guardan en la pila */
   int local1 = 0;
   int local2 = 0;

   /* Cadena (puntero) esta almacenada en el segmento
       de datos sin valor inicial pero apuntará a memoria
       ubicada en el segmento de heap */

   cadena = (char *)malloc(5 * sizeof(char));
   strcpy(cadena,'Hola!');

   MiFuncion(local1,local2,cadena)
}

El código anterior es un ejemplo de una función muy sencilla llamada MiFuncion que compara la cadena con "Hola!" y si es igual sustituye el "!" por un "?". Está comentada la ubicación en la que se guardarán cada una de las variables definidas.

Código reentrante y código threadsafe

Decimos que una determinada función (o método) es threadsafe cuando se puede ejecutar de forma concurrente por varias tareas. Esto implica que, para los accesos a toda información estática (común) se realizan las operaciones de sincronización necesarias para garantizar el acceso coherente al recurso dado.

Decimos que una determinada rutina es reentrante cuando soporta la reentrada en el código en cuestión mientras este esta ejecutando, es decir, es capaz de funcionar correctamente cuando, en mitad de su ejecución por algún motivo (como por ejemplo una interrupción) esta se detiene en mitad de proceso y desde algún otro punto se invoca de nuevo la rutina, reentrando en esta, ejecutandola y restaurando la ejecución en el punto en que se interrumpió en un principio.

Una función es reentrante si:

  • No se modifica a si misma (no modifíca su propio código). Esto es lo más normal en la gran mayoría de los programas.
  • No hace uso de memoria estática (situada en el heap o en el segmento de datos)
  • No realiza llamadas a funciones no reentrantes

Si una función cumple esos parametros entonces es reentrante (lo cual no significa que una función no pueda ser reentrante cumpliendo otros requisitos. Cualquier función que garantice la reentrada de código (por ejemplo realizando el acceso a variables globales de forma atómica) es reentrante.

El código reentrante puede a veces confundirse con el concepto de código treadsafe (seguro para tareas), principalmente por que suele ser normal que el código reentrante sea tambien threadsafe, sin embargo son conceptos distintos.

int DoTheMath()
{
  static int lastCall = 0;

  lastCall++;
}

void ThreadSafeNonReentrantFunc()
{
   lock(myLocker);
   // Hacemos lo que tengamos que hacer de forma segura
   globalStatus := DoTheMath();
   unlock(myLocker);
}

Si observamos la función anterior podemos ver claramente que es threadsafe (puesto que se produce exclusión mútua mediante un mutex de forma que la variable global siempre permance protegida (y la función lock es atómica).

Ahora vamos a la parte en la que está el truco, ¿la funcion es reentrante?, no. Para que una función sea reentrante debe cumplir que se pueda interrumpir en cualquier momento, reentrar en ella, acabar su ejecución y seguir ejecutando donde fue interrumpida.

Si durante la ejecución del código de anterior llegase una interrupción o una señal capturada (por ejemplo) que en su código de tratamiento realizara una llamada a esa mismo código podrían pasar dos cosas:

  • Que la función lock este implementada de forma que si se realiza una llamada a lock cuando ya se ha realizado una llamada el proceso llamante se quede bloqueado independientemente de si ya poseía el mutex. En este caso el programa se bloquearía (puesto que ya se ha hecho un lock sobre la variable).
  • Que la función lock no bloquee el proceso si este ya es dueño del lock por lo que la ejecución funcionaría correctamente pero ejecutaría la función DoTheMath() que no es reentrante puesto que usa un dato estático (por ejemplo la señal de la que hablabamos antes podría llegar en medio de la ejecución de lastCall++.

En cualquiera de los dos casos estamos ante una función threadsafe (si se llama a la función desde distintos hilos esta garantizada la exclusión mútua en la ejecución de la función DoTheMath y el acceso a las variables globales) pero que no es reentrante. En muchos casos sin embargo tranformar una función reentrante la transforma también en threadsafe.

8
Average: 8 (8 votes)
Your rating: None