next up previous contents
Next: Semáforos Up: Procesos Previous: Clasificación de procesos

El nano System

Dado que en Unix un proceso pesado corre en un procesador virtual es posible implementar un sistema de threads que comparten el mismo espacio de direcciones virtuales en un proceso Unix. Un ejemplo de estos sistemas es el nano System (nSystem de ahora en adelante).

En vez de usar la palabra thread --que es difícil de pronunciar en castellano-- usaremos como sinónimo la palabra tarea. Las tareas de nSystem se implementan multiplexando el tiempo de CPU del proceso Unix en tajadas de tiempo que se otorgan por turnos a cada tarea, de la misma forma en que Unix multiplexa el tiempo del procesador real para otorgarlo a cada proceso pesado.

De esta forma varios procesos Unix puede tener cada uno un enjambre de tareas que comparten el mismo espacio de direcciones, pero tareas pertenecientes a procesos distintos no comparten la memoria.

Gestión de tareas en nSystem

Los siguientes procedimientos de nSystem permiten crear, terminar y esperar tareas:

En la figura gif se observa que si una tarea A espera otra tarea B antes de que B haya terminado, A se bloquea hasta que B invoque nExitTask. De la misma forma, si B termina antes de que otra tarea invoque nWaitTask, B no termina --permanece bloqueada-- hasta que alguna tarea lo haga.

  
Figure: Creación, espera y término de tareas en nSystem

Como ejemplo veamos como se calcula un número de Fibonacci usando las tareas de nSystem:

int pfib(int n)
{
  if (n<=1) return 1;
  else
  {
    nTask task1= nEmitTask(pfib, n-1);
    nTask task2= nEmitTask(pfib, n-2);
    return nWaitTask(task1)+nWaitTask(task2);
} }

La ejecución de pfib(6) crea dos tareas que calculan concurrentemente pfib(5) y pfib(4). Como a su vez estos dos procedimientos crean nuevas tareas, al final se crea un árbol de tareas.

Observe que esta solución se introduce sólo con motivos pedagógicos. Ella es sumamente ineficiente como mecanismo para calcular un número de Fibonacci, puesto que el sobrecosto natural introducido por la gestión de un número exponencial de tareas es colosal. Para que esta solución sea eficiente se necesitaría una implementación de nSystem que usara procesadores reales para cada tarea creada, pero en la actualidad esto no es posible en ningún multi-procesador de memoria compartida.

El problema de la sección crítica

Supongamos que en el problema anterior se desea contar el número de tareas que se crean. La solución natural que se viene en mente es introducir una variable global count que se incrementa en cada llamada de pfib:

int count=0;
int pfib(int n)
{
  count++;
  if (...) ...
  else ...
}

Esta solución es errada. En efecto, la instrucción en C count++ se traduce por una secuencia de instrucciones de máquina del tipo:

Load count, Reg  ;  Reg= count
Add  Reg, 1, Reg ;  Reg= Reg+1
Store Reg, count ;  count= Reg

En la figura gif se aprecia cómo la ejecución en paralelo de dos de estas secuencias puede terminar incrementando count sólo en 1. Esta situación es un error porque no se contabiliza una de las invocaciones de pfib.

  
Figure: Ejecución concurrente de una misma sección crítica

En el ejemplo hemos supuesto que ambas tareas se ejecutan en dos procesadores reales. Sin embargo, hemos dicho que nSystem no implementa paralelismo real sino que emula varios procesadores a partir de un solo proceso multiplexando en tajadas el tiempo de CPU. Esta forma de ejecutar procesos también puede resultar en un número errado para count, cuando una tajada de tiempo de CPU se acaba justo cuando un proceso está en medio del incremento de count gif.

Este tipo de problemas es muy común en los procesos que comparten algún recurso, en este caso la variable count. El problema es que la utilización del recurso no es atómica, se necesita una secuencia de instrucciones para completar una operación sobre el recurso, en el ejemplo, la contabilización de una invocación de pfib. El problema es que la ejecución de esta operación no debe llevarse a cabo concurrentemente en más de una tarea en un instante dado, porque el resultado será impredescible.

Una sección crítica es una porción de código que sólo puede ser ejecutada por una tarea a la vez para que funcione correctamente. En el ejemplo la instrucción count++ (es decir su traducción a instrucciones de máquina) es un a sección crítica.

Comunicación entre tareas en nSystem

En nSystem se pueden implementar secciones críticas mediante mensajes. El envío de mensajes en nSystem se logra con los siguientes procedimientos:

Con estos procedimientos el problema de la sección crítica se puede resolver creando una tarea que realiza el conteo de tareas.

int count=0; /* Contador de tareas */

/* Crea la tarea para el conteo y la tarea
 * que calcula concurrentemente Fibonacci(n)
 */
int pfib(int n, int *pcount)
{
  nTask counttask= nEmitTask(CountProc);
  int ret= pfib2(n, counttask);
  int end= TRUE;
  nSend(counttask, (void*)&end);
  *pcount= nWaitTask(counttask);
  return ret;
}

/* Realiza el conteo de tareas */
int CountProc()
{
  int end= FALSE;
  int count=0;

  do
  {
    nTask sender;
    int *msg= (int*)nReceive(&sender, -1);
    end= *msg;
    count++;
    nReply(sender, 0);
  }
    while (end==FALSE);

  return count-1;
}

/* Calcula Concurrentemente Fibonacci(n) */
int pfib2(int n, nTask counttask)
{
  int end= FALSE;
  nSend(counttask, (void*)&end);

  if (n<=1) return 1;
  else
  {
    nTask task1=nEmitTask(pfib,n-1,counttask);
    nTask task2=nEmitTask(pfib,n-2,counttask);
    return nWaitTask(task1)+nWaitTask(task2);
} }

Esta solución resuelve efectivamente el problema de la sección crítica, puesto que la única tarea que incrementa el contador es counttask, y ésta se ejecuta estrictamente en secuencia. En el fondo, la solución funciona porque el contador ya no es una variable de acceso compartido. Aunque todas las tareas tienen acceso a este contador, sólo una tarea lo accesa verdaderamente.

Es importante destacar que el siguiente código podría no funcionar.

int *msg= (int*)nReceive(&sender, -1);
nReply(sender, 0);
end= *msg;

En efecto, al hacer nReply el emisor puede continuar y retornar de pfib2 y por lo tanto destruir la variable end que contiene el mensaje, antes de que se ejecute end=*msg;. Esto entregaría resultados impredescibles.

El paradigma que hay detrás de esta solución es el de cliente/servidor. Se crea una tarea servidor ( counttask) que da el servicio de conteo, mientras que fibtask y las demás tareas que se crean para calcular Fibonacci son los clientes.

Nótese que al introducir este servidor, ni siquiera con infinitos procesadores la solución sería eficiente puesto que se establece un cuello de botella en el servidor. En efecto el servidor de conteo es estrictamente secuencial. Las dos tareas que se crean concurrentemente tienen que ser atendidas secuencialmente por el servidor.

Bibliotecas de Unix y nSystem

Muchos procedimientos de biblioteca de Unix pueden continuar usándose en nSystem, sin embargo gran cantidad de procedimientos de biblioteca usan variables globales cuyo acceso se convierte en una sección crítica en nSystem. El código original de Unix no implementa ningún mecanismo de control que garantice que una sola tarea está invocando ese procedimiento, puesto que Unix estándar no ofrece threads. Por lo tanto los procedimientos de biblioteca de Unix que usan y modifican variables globales no pueden ser invocados directamente desde nSystem. Para llamar estos procedimientos en nSystem hay que implementar un mecanismo de control de acceso entre tareas.

Otros procedimientos de nSystem

Durante el lanzamiento de nSystem se realizan algunas inicializaciones y luego se invoca el procedimiento nMain, el que debe ser suministrado por el programador. Éste procedimiento contiene el código correspondiente a la primera tarea del programador. Desde ahí se pueden crear todas las tareas que sean necesarias para realizar el trabajo. El encabezado para este procedimiento debe ser:

int nMain(int argc, char *argv[])
{
   ...
}

El retorno de nMain hace que todas las tareas pendientes terminen y por lo tanto es equivalente a llamar nExitSystem. Es importante por lo tanto evitar que nMain se termine antes de tiempo.

Los siguientes procedimientos son parte de nSystem y pueden ser invocados desde cualquier tarea de nSystem.

Par'ametros para las tareas:

Entrada y Salida:

Los siguientes procedimientos son equivalentes a open, close, read y write en UNIX. Sus parámetros son los mismos. Los ``nano'' procedimientos sólo bloquean la tarea que los invoca, el resto de las tareas puede continuar.

Servicios Misceláneos:

Buffering usando tareas

Veamos como se resuelve el problema del buffering usando tareas. Este mismo problema se resolverá en el próximo capítulo usando interrupciones y canales.

nTask bufftask= nEmitTask(BuffServ);

void ReadBuff(int *pbuff)
{
  nSend(bufftask, (void*)pbuff);
}

void BuffServ()
{
  char pbuff2[512];

  for(;;)
  {
    nTask clienttask;
    char *pbuff;

    nRead(0, pbuff2, 512);
    pbuff= (char*)nReceive(&clienttask,-1);
    bcopy(pbuff, pbuff2, 512);
    nReply(clienttask, 0);
} }

Esta solución permite efectivamente superponer la lectura de un dispositivo (por el descriptor Unix número 0). En efecto, una vez que el servidor BuffServ entrega un bloque de datos, el cliente (la aplicación) procesa este bloque al mismo tiempo que el servidor ya está leyendo el próximo bloque. Con suerte cuando el servidor vuelva a recibir un mensaje pidiendo un nuevo bloque, ese bloque ya estará disponible.

Observe que esta solución también necesita interrupciones para poder implementarse, sólo que esta vez la gestión de las interrupciones la realiza nSystem y no el programador de la aplicación.



next up previous contents
Next: Semáforos Up: Procesos Previous: Clasificación de procesos



José M. Piquer
Fri Apr 9 15:57:37 CLT 1999