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:
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:
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:
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:
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:
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:
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:
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:
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:
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.
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.
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.