Aclaración del concepto de reentrancia

 

En un programa con una única hebra existe un solo flujo de control. Así, el código ejecutado por este tipo de procesos no debe ser reentrante o seguro frente a hebras –thread-safety (Figura ¿-a). El programas multihebrados, las mismas funciones puede ser accedidas concurrentemente por varios flujos de control (ver Figura). Para proteger la integridad de estas funciones, el código de las mismas debe ser reentrante.

 

 
 

 

 

 

 

 

 

 

 

 

 


Reentrancia y seguridad frente a hebras son términos relacionados con la forma en que esta escrito el código, es decir, son propiedades del código no de las hebras o procesos. Pero reentrancia y seguridad frente a hebras son coceptos separados: una función puede ser reentrante, segura frente a hebras, ambas cosas, o ninguna de las dos.

 

Reentrancia

 

Una función reentrante no tiene variables estáticas en sucesivas llamadas, ni retorna un puntero a datos estáticos. Todos los datos son suministrados por el llamador de la función. Una función reentrante no debe llamar a funciones no reentrantes.

            Una función no reentrante puede a menudo, pero no siempre, identificarse por su interfaz externa y su uso. Por ejemplo, la rutina strtok es no reentrante, pues almacena la cadena que debe ser partida en sus elementos. La subrutina ctime también es no reentrante ya que devuelve un puntero a datos estáticos que se sobrescriben en cada llamada.

            Por ejemplo, una función recursiva debe ser reentrante ya que cada instancia de su ejecución debe tener su propia copia de las variables locales para evitar que se corrompa otra instancia. Supongamos la función clásica de cálculo de n! en su versión recursiva:

double power(double x, int exp)
{
if (exp<=0) return(1);
return(x*power(x, exp-1));
}

 

 
 

 

 

 

 

 


Supongamos que modificamos esta función de forma que ahora exp es una variable pública accesible a otras funciones:

double power(double x)
{
if (exp<=0)return(1);
--exp;
return(x*power(x));
}
 
 

 

 

 

 

 

 

 


Ahora la variable es única para varias invocaciones de la función por lo que produciría un cálculo erróneo.

            Una observación, el lenguaje C esta elegantemente orientado hacia la reentrancia, ya que las variables locales se almacenan en un registro de activación de la pila cada vez que se invoca a la función, pero esto no ocurre con todos lo lenguajes.

 

Seguridad frente a hebras

 

Una función segura frente a hebras protege recursos compartidos de accesos concurrentes mediante cerrojos. La seguridad frente a hebras afecta solo a la implementación de una función pero no afecta a su interfaz externa.

            En C, las variables locales son asignadas dinámicamente en la pila. Por tanto, cualquier función que no usa variables estáticas o comparte recursos compartidos es trivialmente segura frente a hebras. Por ejemplo, la siguiente función es segura frente a hebras:

/* Función segura frente a hebras*/
int diff(int x, int y)
{
        int delta;
 
        delta = y - x;
        if (delta < 0)
                delta = -delta;
        return delta;
}

 

 
 

 

 

 

 

 

 

 

 

 


El uso de datos globales no es seguro frente a hebras. Debe ser mantenido por las hebras o encapsulado, de forma que el acceso sea serializado. Una hebra puede leer un código de error correspondiente a un error provocado por otra hebra.

 

Haciendo reentrante una función

 

En la mayoría de los casos, las funciones no reentrantes deben ser sustituidas por funciones con una interfaz modificada para que sean reentrantes. Las funciones no reentrantes no pueden ser utilizadas por varias hebras. Es más, puede ser imposible hacer que una función no reentrante sea segura frente a hebras.

 

a)      Retorno de datos

 

Como hemos indicado, muchas funciones no reentrantes devuelven un puntero a datos estáticos. Para evitarlo, tenemos dos formas:

1.      Retornar los datos dinámicamente asignados. En este caso, es responsabilidad del llamador liberar el almacenamiento. El beneficio esta en que no necesitamos modificar la interfaz. Sin embargo, no esta asegurada la compatibilidad hacia atrás (los programas con una hebra que ya existan y usen la función no liberaran espacio, provocando una fuga de memoria).

2.      Usar el almacenamiento suministrado por el llamador. Este es el método recomendado, aunque necesitamos modificar las interfaces.

 

Por ejemplo, la función strtoupper, que convierte una cadena a mayúsculas, podría implementarse como sigue:

 

 

 

/* Funcción no reentrante*/
 
char *strtoupper(char *string)
{
        static char buffer[MAX_STRING_SIZE];
        int index;
 
        for (index = 0; string[index]; index++)
                buffer[index] = toupper(string[index]);
        buffer[index] = 0
 
        return buffer;
}

 

 
 

 

 

 

 

 

 

 

 

 


Esta función, no reentrante (ni segura frente a hebras), podemos hacerla reentrante utilizando el primer método. La nueva función quedaría de la siguiente forma:

/* Función reentrante (una solución pobre) */
char *strtoupper(char *string)
{
        char *buffer;
        int index;
 
        /* ¡deberíamos realizar comprobacion de error! */
        buffer = malloc(MAX_STRING_SIZE);
 
        for (index = 0; string[index]; index++)
                buffer[index] = toupper(string[index]);
        buffer[index] = 0
 
        return buffer;
}

 

 
 

 

 

 

 

 

 

 

 

 

 

 

 

 


Una solución mejor consiste en modificar la interfaz (segunda solución propuesta anteriormente). El llamador debe suministrar el almacenamiento para las cadenas de entrada y de salida, como en el código ejemplo:

 

 

 

 

/* Función reentrante (una solución mejor) */
 
char *strtoupper_r(char *in_str, char *out_str)
{
        int index;
 
        for (index = 0; in_str[index]; index++)
        out_str[index] = toupper(in_str[index]);
        out_str[index] = 0
 
        return out_str;
}

 

 
 

 

 

 

 

 

 

 

 

 

 


Las subrutinas de la biblioteca estándar de C no reentrantes fueron convertidas a reentrantes utilizando el segundo método. Una discusión de esto podéis encontrarla en http://www.unet.univie.ac.at/aix/aixprggd/genprogc/writing_reentrant_thread_safe_code.htm#PdMfj16manu.

 

b)      Manteniendo los datos entre llamadas sucesivas

 

No debemos mantener datos entre llamadas sucesivas ya que diferentes hebras pueden llamar sucesivamente a la función. Si una función necesita mantener datos entre llamadas sucesivas, tales como un búfer de trabajo o un puntero, estos datos deber ser suministrados por el llamador.

            Considerar el siguiente ejemplo. Una función retorna los caracteres consecutivos en minúscula de una cadena. Esta cadena se suministra solo en la primera llamada, como en la subrutina strok. La función devuelve 0 cuando alcanza el final de la cadena. La función podría implementarse de la siguiente forma no reentrante:

 

/* Función no reentrante */
char lowercase_c(char *string)
{
        static char *buffer;
        static int index;
        char c = 0;
 
        /* almacena la cadena en la primera llamada*/
        if (string != NULL) {
                buffer = string;
                index = 0;
        }
 
        /* busca un character en minuscula */
        for (; c = buffer[index]; index++) {
                if (islower(c)) {
                        index++;
                        break;
                }
        }
        return c;
}

 

 
 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 


Para hacerla reentrante, los datos estáticos, la variable del índice, debe ser mantenido por el llamador. La versión reentrante de la función podría implementarse como muestra el siguiente fragmento de código:

 

 

 

 

 

 

 

 

 

 

/* Función reentrante */
char reentrant_lowercase_c(char *string, int *p_index)
{
        char c = 0;
 
        /* no inicializacion – debe hacerla el llamador */
 
        /* busca un character en minuscula */
        for (; c = string[*p_index]; (*p_index)++) {
                if (islower(c)) {
                        (*p_index)++;
                        break;
                  }
        }
        return c;
}

 

 
 

 

 

 

 

 

 

 


La interfaz de la función cambio y por tanto su uso. El llamador debe suministrar la cadena en cada llamada y debe inicializar el índice a 0 antes de la primera llamada, como en el siguiente fragmento de código:

char *my_string;
char my_char;
int my_index;
...
my_index = 0;
while (my_char = reentrant_lowercase_c(my_string, &my_index)) {
        ...
}

 

 
 

 

 

 

 

 

 

 

 

 


Hacer que una función sea segura frente a hebras

 

En programas multihebrados todas las funciones invocadas por múltiples hebras deben ser seguras frente a hebras. Sin embargo, existe un rodeo para utilizar subrutinas no seguras frente a hebras en programas multihebrados. Observar que las funciones no reentrantes son normalmente no seguras frente a hebras, pero haciéndolas reentrantes a menudo las hace también seguras frente a hebras.

 

a) Bloqueando recursos compartidos

 

Las funciones que utilizan datos estáticos o cualesquiera otros recursos compartidos, tales como archivos o terminales, deben serializar el acceso a esos recursos mediante cerrojos para que sean seguros frente a hebras. Por ejemplo, la siguiente función no es segura frente a hebras:

 

/* Función no segura frente a hebras */
int increment_counter()
{
        static int counter = 0;
        counter++;
        return counter;
}

 

 
 

 

 

 

 

 

 

Para que sea segura frente a hebras, la variable estática contador necesita protección mediante un cerrojo estático, como en el siguiente fragmento en seudo-código:

/* Seudo-código de function segura frente a hebras */
int increment_counter();
{
        static int counter = 0;
        static lock_type counter_lock = LOCK_INITIALIZER;
 
        lock(counter_lock);
        counter++;
        unlock(counter_lock);
        return counter;
}

 

 
 

 

 

 

 

 

 

 

 

 

 


En un programa multihebrado que utilice una biblioteca de hebras, debemos utilizar mutex para serializar el acceso a los recursos compartidos. Las bibliotecas independientes podría trabajar fuera del contexto de las hebras y así utilizar otra clase de cerrojos.

 

b) Un rodeo para funciones no seguras frente a hebras

 

Es posible, dando un rodeo, utilizar funciones no seguras frente a hebras onvocandolas desde varia hebras. Esto puede ser útil cuando utilizamos una biblioteca no segura frente a hebras en un programa multihebrado, bien para pruebas o mientras esperamos la versión multihebrada de la biblioteca. El rodeo produce cierta sobrecarga, dado que consiste en serializar la función completa o incluso un grupo de funciones.

 

/* ¡Esto es seudo-código! */
 
lock(library_lock);
library_call();
unlock(library_lock);
 
lock(library_lock);
x = library_var;
unlock(library_lock);

 

 
 

 

 

 

 

 

 

 

 

 


Esta solución crea cuellos de botella dado que sólo una hebra puede acceder a cualquier parte de la biblioteca en un instante dado. La solución sólo es aceptable si la biblioteca es accedida muy pocas veces, o como un rodeo inicial rápidamente implementado.

 

 

/* ¡Esto es seudo-código! */
 
lock(library_moduleA_lock);
library_moduleA_call();
unlock(library_moduleA_lock);
 
lock(library_moduleB_lock);
x = library_moduleB_var;
unlock(library_moduleB_lock);

 

 
 

 

 

 

 

 

 

 

 


Esta solución es más complicada de implementar que la primera, pero mejora el rendimiento.

 

Dado que este rodeo debe utilizarse solo en programas de aplicación, no en bibliotecas, se pueden usar mutex para bloquear la biblioteca.

 

Reentrancia y sistema operativo

 

Es evidente que un sistema operativo multitarea debe ser reentrante ya que una función del mismo puede ser invocada bien por diferentes procesos de usuario o por un proceso y los manejadores de interrupciones.

            De aquí, que a nivel de sistema operativo una forma de hacer fragementos de código reentrantes es desahibilitar la interrupciones al inicio de la función y habilitarlas después de finalizar. Como vemos, esto permite que el fragmento de código no sea invocado por un manejador de interrupciones. Pero hay que tener presente que deshabilitar las interrupciones provoca un aumento de la latencia del sistema, es decir, una disminución de la capacidad para responder a eventos externos de forma correcta en el tiempo. Una forma más inteligente, pero más complicada desde el punto de vista de codificación es el uso de cerrojos.

            Por ello, es imprescindible implementar el código del sistema operativo de forma que sea reentrante. Aquí juega un papel importante la pila kernel que se utiliza para almacenar las variables locales a un proceso para permitir la ejecución de otro.

            Dado que el kernel del sistema operativo mantiene gran cantidad de datos globales al sistema, hay fragmentos de código (donde se manipulan estos datos) que no pueden hacerse reentrantes. Por ello, necesitamos proteger estos datos globales mendiantes cerrojo o bien mediante la no-apropiatividad del kernel.

            La principal razón por la que MS-DOS no se utiliza para multitarea es que el código de la BIOS y del propio DOS no son reentrantes. Por esta misma razón, los sistemas realmente multitarea no utilizan las rutinas de la BIOS (salvo para cosas muy concretas).

 

 

Bibliografía

 

-          “Writing Reentrant and Thread-Safe Code”, en http://www.unet.univie.ac.at/aix/aixprggd/genprogc/writing_reentrant_thread_safe_code.htm.

-          Reentrancy” en http://www.ganssle.com/articles/areentra.htm.

-          Art of Aseembly: Chapter Eighteen-3”, en http://courses.ece.uiuc.edu/ece390/books/artofasm/CH18/CH18-3.html sobre los problemas de reentrancia en DOS y la BIOS.

-          “Developing Threads-Safe Library” en http://www.cs.arizona.edu/computer.help/policy/DIGITAL_unix/AA-PS30D-TET1_html/peg13.html.