Procesos
Índice
Concepto
El proceso
Estado de un proceso
Bloque de control de procesos
Hilos
Planificación de procesos
Colas de planificación
Planificación de CPU
Cambio de contexto
Operaciones sobre procesos
Creación de procesos
Terminación de proceso
Jerarquía de procesos en Android
Comunicación entre procesos
IPC en sistemas de memoria compartida
IPC en sistemas de pase de mensajes
Nombrado
Sincronización
Buffering
Ejemplos de sistemas de comunicación entre procesos (IPC)
Memoria compartida POSIX
Pasaje de mensajes de Mach
Windows
Pipes o tuberías
Tuberías ordinarias (Pipes)
Tuberías nombradas
Comunicación en Sistemas Cliente - Servidor
Sockets
Llamadas a procedimientos remotos (RPC)
RPC en Android
Ejercicios prácticos
Procesos
Las primeras computadoras permitían que solo se ejecutara un programa a la vez. Este
programa tenía el control completo del sistema y tenía acceso a todos los recursos del
sistema. Por el contrario, los sistemas informáticos contemporáneos permiten que se
carguen múltiples programas en la memoria y se ejecuten al mismo tiempo. Esta evolución
requirió un control más firme y una mayor compartimentación de los distintos programas, y
estas necesidades dieron lugar a la noción de proceso, que es un programa en ejecución.
Un proceso es la unidad de trabajo en un sistema informático moderno.
Cuanto más complejo es el sistema operativo, más se espera que haga en beneficio de
sus usuarios, aunque su principal preocupación es la ejecución de los programas de
usuario, también debe tener cuidado de varias tareas del sistema que se realizan mejor en
el espacio del usuario, en lugar de dentro del núcleo. Por lo tanto, un sistema consiste en
una colección de procesos, algunos ejecutan código de usuario, otros ejecutan código del
sistema operativo. Potencialmente, todos estos procesos pueden ejecutarse
simultáneamente, con la CPU (o CPUs) multiplexada entre ellos. A continuación, veremos
sobre qué son los procesos, cómo se representan en un sistema operativo y cómo
funcionan.
Objetivos
● Identificar los componentes separados de un proceso e ilustrar cómo están
representados y planificados en un sistema operativo.
● Describir cómo se crean y terminan los procesos en un sistema operativo, incluido el
desarrollo de programas utilizando las llamadas del sistema apropiadas que realizan
estas operaciones.
● Describir y contrastar la comunicación entre procesos usando memoria compartida y
pase de mensajes.
● Diseñar programas que usen pipes (tuberías) y memoria compartida POSIX para
realizar la comunicación entre procesos.
● Describir la comunicación cliente-servidor usando sockets y llamadas a
procedimientos remotos.
● Diseñar módulos del núcleo que interactúen con el sistema operativo Linux.
Concepto
Una cuestión que surge en discusiones de sistemas operativos es como llamar a las
actividades de la CPU. Las primeras computadoras eran sistemas batch que ejecutaban
trabajos, seguidos por los emergentes sistemas de tiempo compartido que corrían
programas de usuario, o tareas. Aún en sistemas de un solo usuario, un usuario puede ser
capaz de correr varios programas a la vez: un procesador de texto, un navegador y un
programa de correo electrónico. Inclusive, si una computadora puede ejecutar solo un
programa a la vez, como un dispositivo embebido, que no soporta multitarea, el sistema
operativo puede necesitar realizar sus propias tareas programadas, como gestión de
memoria. En muchos aspectos, todas esas actividades son similares, y las llamamos
procesos.
El proceso
Informalmente, como mencionamos antes, un proceso es un programa en ejecución. El
estado de la actividad actual de un proceso es representado por el valor del contador del
programa y los contenidos de los registros del procesador. La distribución de memoria de
un proceso es dividido en múltiples secciones, y mostrado en la figura 1. Esas secciones
incluyen:
● Sección de texto - el código ejecutable
● Sección de dato - variables globales
● Sección de cabecera - memoria qué es asignada dinámicamente durante la
ejecución de un programa
● Sección de la pila - almacén de datos temporal durante invocación a funciones
tales como parámetros de la función direcciones de retorno y variables locales.
Note que el tamaño de las secciones de texto y datos son fijos, debido a que sus
tamaños no cambian durante la ejecución del programa. Sin embargo, las secciones de pila
y cabecera pueden crecer o achicarse dinámicamente durante la ejecución del programa.
Cada vez que una función es llamada, un registro de activación conteniendo parámetros
de la función, variables locales, y la direcciones de retorno es colocado en la pila; cuando el
control es devuelto desde la función, el registro activación es tomado de la pila.
Similarmente, la cabecera crecerá cuando se asigne memoria dinámicamente, y se
contraerá cuando la memoria sea devuelta al sistema. Las secciones de pila y cabecera
crecen una hacia la otra. El sistema operativo debe asegurarse que no se superpongan
entre sí.
Enfatizamos que un programa por sí mismo no es un proceso. Un programa es una
entidad pasiva, tal como un archivo conteniendo una lista de instrucciones almacenadas en
disco (comúnmente llamada archivo ejecutable). En contraste, un proceso es una entidad
activa, con un contador de programa especificando la siguiente instrucción a ejecutar y un
conjunto de recursos asociados. Un programa pasa a ser un proceso cuando un archivo
ejecutable es cargado en la memoria. Dos técnicas comunes para cargar un archivo
ejecutable es a través de un doble clic en un icono que lo representa o ingresando su
nombre en la línea de comandos.
A pesar de que dos procesos pueden estar asociados con el mismo programa, son
considerados dos secuencia de ejecución separadas. Por ejemplo, varios usuarios pueden
estar corriendo diferentes copias de un programa de correo electrónico, o el mismo usuario
puede invocar muchas copias de un navegador. Cada uno de ellos es un proceso separado;
y a pesar de que las secciones de texto son equivalentes, las secciones de datos, cabecera
y pila varían. un proceso a su vez puede disparar otros procesos durante su ejecución.
Estado de un proceso
A medida que un proceso se ejecuta, cambia su estado. El estado de un proceso es
definido en parte por la actividad actual del proceso. Un proceso puede estar en uno de los
siguientes estados:
● Nuevo. El proceso está siendo creado.
● Ejecutando. Las instrucciones están siendo ejecutadas.
● Esperando. El proceso está esperando por la ocurrencia de un evento (tal como la
finalización de una operación de entrada-salida o recepción de una señal)
● Listo. el proceso está esperando para ser asignado a un procesador.
● Terminado. El proceso ha finalizado su ejecución.
Los nombres son arbitrarios y varían a través de los sistemas operativos. Los estados
que vemos están en todos los sistemas. Ciertos sistemas operativos también definen otros
estados intermedios. Es importante considerar que solamente un proceso puede ejecutarse
en un núcleo de un procesador en un momento determinado. Muchos procesos pueden
estar listos o en espera. El diagrama de estado correspondiente a esos estados es
presentado en la figura 2.
Bloque de control de procesos
Cada proceso está representado en el sistema operativo por un bloque de control de
proceso (PCB) - también llamado un bloque de control de tareas. Un PCB se muestra en la
figura 3. Contiene muchas piezas de información asociadas con un proceso, incluyendo:
● Estado del proceso. El estado puede ser nuevo, listo, ejecutando, esperando,
detenido u otros.
● Contador de programa. El contador indica la dirección de la próxima instrucción
que será ejecutada para el proceso.
● Registros de CPU. Los registros varían en número y tipo dependiendo de la
arquitectura de la computadora. Incluyen acumuladores, registros índices, punteros
a pilas, y registros de propósito general, además de cualquier información de
condición del código. Junto con el contador del programa, este estado debe ser
almacenado cuando ocurre una interrupción, para permitir que el proceso continúe
correctamente cuándo es reprogramado para su ejecución.
● Información de planificación de CPU. Esta información incluye prioridad del
proceso, punteros a colas de planificación, y cualquier otro parámetro de
planificación.
● Información de gestión de memoria. Esta información puede incluir ítems como el
valor de los registros base y límite, tablas de página, tablas de segmento,
dependiendo del sistema de memoria usado por el sistema operativo.
● Información contable. Esta información incluye la cantidad de tiempo real de CPU
usado, límites de tiempos, número de procesos y otros.
● Información de estado de entrada-salida. Esta información incluye la lista de
dispositivos entrada/salida asignados al proceso, lista de archivos abiertos y otros.
En resumen, el PCB sirve como un repositorio de todo dato necesario para iniciar o
reiniciar un proceso, junto con información de contabilización.
Hilos
El modelo de procesos discutido hasta aquí, implica que un proceso es un programa que
corre un solo hilo de ejecución. Por ejemplo, cuando un proceso está corriendo un
procesador de texto, un solo hilo de instrucciones se está ejecutando. Este único hilo de
control permite que el proceso realice sólo una tarea a la vez. Así, el usuario no puede
simultáneamente escribir caracteres y correr el corrector ortográfico. Los sistemas
operativos más modernos han extendido el concepto de proceso para permitir que un
proceso tenga múltiples hilos de ejecución y así realizar más de una tarea a la vez. Esta
característica es especialmente beneficiosa en sistemas multinúcleos, donde múltiples hilos
pueden ejecutar en paralelo. Un procesador de texto multihilo podría por ejemplo asignar un
hilo para administrar el ingreso del usuario, mientras que otro ejecuta el corrector
ortográfico. En sistemas que soportan hilos, el PCB es expandido para incluir información
para cada hilo.
Planificación de procesos
Para un sistema con un solo núcleo nunca habrá más de un proceso corriendo a la vez,
mientras que en sistemas multinúcleos pueden correr múltiples procesos a la vez. Si hay
más procesos que núcleos los procesos excedentes tendrán que esperar hasta que un
núcleo sea liberado y pueda ser replanificado. El número de procesos actualmente en
memoria es conocido como grado de multiprogramación.
El balance de objetivos de la multiprogramación y del tiempo compartido también
requiere tomar en cuenta el comportamiento general de un proceso. En general la mayoría
de los procesos pueden ser descritos como enlazados a I/O o enlazados a CPU. Un
proceso enlazado a I/O es uno que invierte más de su tiempo haciendo entrada-salida que
cálculos. Un proceso enlazado a CPU, en contraste, genera requerimientos de
entrada-salida con muy poca frecuencia, usando más de su tiempo para hacer cálculos.
Colas de planificación
A medida que los procesos ingresan al sistema se colocan en la cola de listos dónde
estarán a la espera para ejecutar en un núcleo de CPU. Esta cola generalmente es
almacenada como una lista enlazada; la cabecera de la cola listos contiene punteros al
primer PCB en la lista, y cada PCB incluye un puntero que apunta al próximo PCB en la
cola.
El sistema también incluye otras colas. Cuando un proceso es asignado a un núcleo de
CPU, se ejecuta por un tiempo y eventualmente termina, es interrumpido, o espera por la
ocurrencia de un evento particular, tal como un requerimiento de entrada-salida.
Supongamos que el proceso hace un requerimiento de entrada-salida a un dispositivo como
un disco. Debido a que los dispositivos corren significativamente más lento que los
procesadores, el proceso tendrá que esperar para que la entrada-salida se realice. Los
procesos que están esperando por eventos, tal como entrada-salida, son colocados en la
cola de espera (Figura 4).
Una representación común de planificación de procesos es un diagrama de encolado tal
como el de la figura 5. Dos tipos de cola están presentes: la cola listo y un conjunto de colas
de espera. El círculo representa los recursos que sirven las colas, y las flechas indican el
flujo de los procesos en el sistema.
Un proceso nuevo es inicialmente colocado en la cola listo. Espera ahí hasta que sea
seleccionado para su ejecución, o despachado. Una vez que al proceso se le asigna un
núcleo y está ejecutando, pueden ocurrir varios eventos:
● El proceso podría emitir un requerimiento de entrada-salida y entonces ser colocado
en una cola de espera de entrada-salida.
● El proceso podría crear un proceso hijo y entonces ser colocado en una cola de
espera mientras que espera la terminación del hijo.
● El proceso podría ser removido forzosamente del núcleo, como resultado de una
interrupción o que haya finalizado su ventana de tiempo, y será colocado en la cola
listo.
Para los primeros dos casos, el proceso eventualmente cambia del estado esperando al
estado listo y entonces vuelve a la cola listo. Un proceso continúa este ciclo hasta que
termina, en ese momento es removido de todas las colas y su PCB y recursos son liberados
y devueltos al sistema.
Planificación de CPU
Un proceso migra entre colas de procesos listos y varias colas de procesos en espera a
través de su vida. El rol del planificador de CPU es seleccionar entre procesos que están en
colas de procesos listos para ejecutar y asignar un núcleo de CPU a uno de ellos. El
planificador de CPU debe seleccionar un nuevo proceso para la CPU frecuentemente. Un
proceso restringido por entrada salida puede ejecutarse por solamente unos pocos
milisegundos antes de esperar por un requerimiento de entrada salida. A pesar de que un
proceso restringido por CPU requerirá un núcleo por más tiempo, es improbable que el
planificador garantice el núcleo al proceso por un período extendido. En su lugar,
probablemente sea removido de la CPU y seleccionado otro proceso para su ejecución. Por
lo tanto, el planificador de CPU ejecuta al menos una vez cada 100 milisegundos, o quizá
con más frecuencia.
Algunos sistemas operativos tienen una forma intermedia de planificación, conocida
como swapping, cuya idea es que a veces pueda ser ventajoso remover un proceso de
memoria y así reducir el grado de multiprogramación. Luego, el proceso puede ser
introducido a la memoria, y su ejecución puede continuar dónde quedó. Este esquema,
conocido como swapping, debido a que el proceso puede ser intercambiado desde la
memoria al disco, donde su estado actual es resguardado, y luego intercambiado desde el
disco a la memoria, donde su estado es restaurado. La técnica de swapping es típicamente
necesaria cuando la memoria está siendo usada a niveles críticos y debe ser liberada.
Cambio de contexto
Como mencionamos antes, las interrupciones causan que el sistema operativo cambie la
CPU de su tarea actual y ejecute rutinas del núcleo. Estas operaciones ocurren
frecuentemente en sistemas de propósito general. Cuando una interrupción ocurre, el
sistema necesita almacenar el contexto actual del proceso ejecutándose en la CPU de
modo que pueda ser restaurado cuando sea seleccionado para ejecutar nuevamente. El
contexto está representado en el bloque de control de procesos (PCB) del proceso. Incluye
el valor de los registros de CPU, el estado del proceso, e información de administración de
memoria. De manera genérica, lo que ocurre es el almacenamiento del estado actual del
núcleo de la CPU, esté en modo núcleo o usuario, y luego se restaura el estado para
continuar las operaciones.
El intercambio del núcleo de la CPU a otro proceso requiere realizar un
almacenamiento del estado del proceso actual y una restauración del estado de un proceso
diferente. Esta tarea es conocida como cambio de contexto y está ilustrada en la figura 6.
Cuando ocurre un cambio de contexto, el núcleo almacena el contexto del proceso viejo en
su bloque de control de proceso (PCB) y luego carga al contexto almacenado del nuevo
proceso que fue elegido o planificado para ejecutarse. El tiempo del cambio de contexto es
pura sobrecarga, debido que el sistema no hace trabajo útil mientras que ocurre el
intercambio. La velocidad intercambio varía de máquina en máquina, dependiendo de la
velocidad de memoria, los
números de registro que deben ser copiados, y la existencia de instrucciones especiales
(tales como una simple instrucción para cargar y almacenar todos los registros de una sola
vez). Típicamente las velocidades en los cambios de contextos son de varios
microsegundos.
Los tiempos de cambio de contexto son altamente dependiente de soporte en
hardware. Por ejemplo, algunos procesadores proveen múltiples conjuntos de registros. Un
cambio de contexto aquí simplemente requiere cambiar el puntero al conjunto de registro
actual. Por supuesto, si hay más procesos activos que conjuntos de registros, el sistema
recurre a la copia de registros hacia y desde la memoria, como siempre.
Operaciones sobre procesos
Los procesos en la mayoría de los sistemas se pueden ejecutar simultáneamente y se
pueden crear y eliminar de forma dinámica. Por lo tanto, estos sistemas deben proporcionar
un mecanismo para la creación y terminación de procesos. En esta sección, exploramos los
mecanismos involucrados en la creación de procesos e ilustramos la creación de procesos
en sistemas UNIX y Windows.
Creación de procesos
Durante la ejecución, un proceso puede crear varios procesos nuevos. Como mencionamos
anteriormente, el proceso creador es llamado proceso padre, y el proceso creado es
llamado proceso hijo. Cada uno de esos procesos puede a su vez crear otros procesos,
formando un árbol de procesos.
La mayoría de los sistemas operativos, incluyendo UNIX, Linux, y Windows, identifican
procesos de acuerdo a un identificador de procesos único (o pid), el cual es típicamente un
número entero. El pid provee un valor único para cada proceso en el sistema, y puede ser
usado como un índice para acceder a los atributos de un proceso dentro del núcleo.
La figura 7 ilustra un árbol de proceso típico para el sistema operativo Linux, mostrando
el nombre de cada proceso y su pid. El proceso systemd (que posee el pid 1) sirve como el
proceso padre de todos los procesos de usuario, y es el primer proceso de usuario creado
cuando el sistema inicia. Una vez que el sistema ha iniciado, el proceso systemd crea
procesos que proveen servicios adicionales, tales como un servidor de impresión o web, un
servidor ssh y similares. En la figura 7, vemos dos hijos de systemd - logind y sshd. El
proceso logind es responsable de administrar clientes que se identifican directamente en el
sistema. En este ejemplo, un cliente se ha identificado y está usando el shell bash, al cual
se le ha asignado el pid 8416. Usando la interfaz de línea de comando bash, este usuario
ha creado el proceso ps como también el editor vim. El proceso sshd es responsable de
administrar clientes que se conectan al sistema usando ssh (secure shell).
En sistemas UNIX y Linux, podemos obtener una lista de procesos usando el comando
ps. Por ejemplo, el comando
ps -el
listará información completa para todos los procesos actualmente activos en el sistema. Un
árbol de proceso similar al mostrado en la figura 7 puede ser construido siguiendo
recursivamente los procesos padre. (Además, Linux provee el comando pstree, que muestra
un árbol de todos los procesos en el sistema)
En general, cuando un proceso crea un proceso hijo, este proceso necesitará ciertos
recursos (tiempo de CPU, memoria, archivos, dispositivos de E/S) para cumplir su tarea. Un
proceso hijo puede ser capaz de obtener sus recursos directamente del sistema operativo, o
puede estar restringido a un subconjunto de los recursos del proceso padre. El padre puede
tener que distribuir sus recursos entre sus hijos, o puede ser capaz de compartir algunos
recursos (tal como memoria o archivos) entre varios de sus hijos. Restringir un proceso hijo
a un subconjunto de los recursos del padre evita que cualquier proceso sobrecargue el
sistema creando demasiados procesos hijo.
Además de proveer varios recursos físicos y lógicos, el proceso padre puede pasar
datos inicializados al proceso hijo. Por ejemplo, considere un proceso cuya función es
mostrar el contenido de un archivo, digamos hw1.c, en la pantalla de la terminal. Cuando el
proceso es creado, obtendrá, como entrada de su padre, el nombre del archivo hw1.c.
usando el nombre del archivo, lo abrirá y escribirá el contenido. Puede también obtener el
nombre del dispositivo de salida. Alternativamente, algunos sistemas operativos pasan
recursos a los procesos hijos. En tal caso, el nuevo proceso puedo tener dos archivos
abiertos, hw1.c y el dispositivo terminal, y puede transferir simplemente el dato entre las
dos.
Cuando un proceso crea otro nuevo, existen dos posibilidades para ejecución:
1. El padre continúa ejecutando concurrentemente con sus hijos
2. El padre espera hasta que alguno o todos sus hijos hayan terminado
hay también dos posibilidades para el espacio de direcciones para el nuevo proceso:
1. El proceso hijo es una copia del proceso padre (tiene el mismo programa y datos
que el padre)
2. El hijo tiene un nuevo programa cargado en él
Para ilustrar estas diferencias, consideremos primero el sistema operativo UNIX. En
UNIX, como hemos visto, cada proceso es identificado por su identificador de proceso, qué
es un entero único. Un nuevo proceso es creado por la llamada al sistema fork(). El
nuevo proceso consiste de una copia del espacio direcciones del proceso original. Este
mecanismo permite al proceso padre comunicarse fácilmente con su hijo. Ambos procesos,
padre e hijo, continúan la ejecución en la instrucción después de la sentencia fork(), con
una diferencia: el código de retorno para el fork() es cero para el nuevo proceso (hijo),
mientras que el identificador de proceso del hijo es devuelto al padre (valor mayor a cero).
Después de la llamada a fork(), uno de los dos procesos típicamente usa la llamada
al sistema exec() para reemplazar el espacio de memoria del proceso con un programa
nuevo. La llamada al sistema exec() carga un archivo binario en memoria (destruyendo la
imagen de memoria del programa conteniendo la llamada al sistema exec()) e inicia su
ejecución. De esta manera, los dos procesos son capaces de comunicarse e ir por caminos
separados. El padre puede entonces crear más hijos; o, si no tiene nada que hacer,
mientras que el hijo ejecuta, puede emitir una llamada al sistema wait() para moverse, él
mismo, fuera de la cola de procesos listos hasta la terminación del proceso hijo. Debido a
que la llamada exec() sobrescribe el espacio de direcciones del proceso con un nuevo
programa, exec() no retorna al control a menos que ocurra un error.
El programa C mostrado en la figura 8 ilustra las llamadas al sistema UNIX previamente
descritas. Ahora tenemos dos procesos diferentes corriendo copias del mismo programa. La
única diferencia es que el valor de la variable pid para el proceso hijo es cero, mientras que
para el padre, es un valor entero mayor que cero (de hecho, es el actual pid del proceso
hijo). El proceso hijo hereda privilegios y atributos de planificación de su padre, como
también ciertos recursos, como archivos abiertos. El proceso hijo entonces sobreescribe su
espacio de direcciones con el comando UNIX /bin/ls (usado para obtener la lista de un
directorio) usando la llamada al sistema execlp(). El padre espera a que el proceso hijo
finalice con la llamada al sistema wait(). Cuando el hijo termina (ya sea implícitamente o
explícitamente invocando a exit()), el proceso padre continúa desde la llamada a
wait(), y finaliza usando la llamada al sistema exit(). Ilustrado en la figura 9.
Por supuesto, no se puede prevenir que el hijo no invoque a exec() y en su lugar
continuar ejecutando una copia del proceso padre. En este escenario, el proceso padre y el
hijo son procesos concurrentes ejecutando el mismo conjunto de instrucciones. Debido a
que el hijo es una copia del padre, cada proceso tiene su propia copia de los mismos datos.
Como otro ejemplo, consideraremos a continuación la creación de procesos en
Windows. Los procesos son creados en Windows usando la función CreateProcess(), el
cual es similar a fork() en que un padre crea un nuevo proceso hijo. Sin embargo,
mientras fork() tiene al proceso hijo heredando el espacio direcciones de su padre,
CreateProcess() requiere cargar un programa especificado en espacio direcciones del
proceso hijo durante su creación. Además, mientras que a fork() no se le pasa
parámetros, CreateProcess() espera no menos de 10.
El programa C mostrado en la figura 10 ilustra la función CreateProcess(), el cual
crea un proceso hijo que carga la aplicación [Link]. Optamos por la mayoría de los
valores por defecto de los 10 parámetros pasados a CreateProcess().
Los dos parámetros pasados a la función CreateProcess() son instancias de las
estructuras STARTUPINFO y PROCESS_INFORMATION. STARTUPINFO especifica
muchas propiedades del nuevo proceso, tal como el tamaño y la apariencia de la ventana
y manejadores para la entrada y salida estándar de archivos. La estructura
PROCESS_INFORMATION contiene un manejador y los identificadores para el proceso e
hilo creados recientemente. Invocamos a la función ZeroMemory() para asignar memoria
para cada una de esas estructuras antes de proceder con CreateProcess().
Los primeros dos parámetros pasados a CreateProcess() son el nombre de la
aplicación y los parámetros de línea de comando. Si el nombre de la aplicación es NULL
(como en este caso), el parámetro de línea de comando especifica la aplicación a cargar.
En este caso, estamos cargando la aplicación [Link]. Más allá de los dos
parámetros iniciales, usamos los parámetros por defecto para heredar procesos y
manejadores de hilos como también especificamos que no habrá banderas de creación.
También usamos el bloque de ambiente existente del padre y el directorio de inicio. Por
último, proveemos dos punteros a STARTUPINFO y PROCESS_INFORMATION creados al
comienzo del programa. En la figura 8, el proceso padre espera para que el hijo complete
invocando la llamada al sistema wait(). El equivalente de esto en Windows es
WaitForSingleObject(), igual se le pasa un manejador del proceso hijo, [Link], y
espera a que finalice este proceso. Una vez que el proceso hijo termina, el control vuelve de
la función WaitForSingleObject() al proceso padre.
Terminación de proceso
Un proceso termina cuando finaliza de ejecutar la última sentencia y solicita al sistema
operativo para que lo borré usando la llamada al sistema exit(). En este punto, el proceso
puede retornar un valor de estado (típicamente un entero) a su proceso padre que está en
espera (vía la llamada al sistema wait()). Todos los recursos del proceso, incluyendo
memoria virtual y física, archivos abiertos, buffers de entrada salida, son liberados y
reclamados por el sistema operativo.
La terminación también puede ocurrir en otras circunstancias. Un proceso puede causar
la terminación de otro proceso vía una llamada apropiada sistema (por ejemplo,
TerminateProcess() en Windows). Usualmente, tales llamadas pueden ser invocadas
solamente por el padre del proceso qué será terminado. De otra manera, un usuario o
aplicación, podría arbitrariamente matar otro proceso de usuario. Note que un padre
necesita saber la identidad de sus hijos para finalizarlos. Así, cuando un proceso crea otro
nuevo, la identidad del proceso recientemente creado es pasada a su padre.
Un padre puede terminar la ejecución de uno de sus hijos por una variedad de razones,
tales como estas:
● El hijo se ha excedido en el uso de alguno de los recursos que se le asignó (para
determinar si esto ha ocurrido, el padre debe tener un mecanismo para inspeccionar
el estado de sus hijos).
● La tarea asignada al hijo ya no es requerida.
● El padre está finalizando, y el sistema operativo no permite que un hijo continúe si
sus padres finalizan.
Algunos sistemas no permiten que un hijo exista si sus padres han terminado. En tales
sistemas, si un proceso termina (normal o anormalmente), entonces todos sus hijos deben
también ser finalizados. Este fenómeno, referido como terminación en cascada, es
normalmente iniciado por el sistema operativo.
Para ilustrar la ejecución y terminación de procesos, considere que, en Linux y UNIX,
podemos terminar un proceso usando en la llamada al sistema exit(), proveyendo un
estado de salida como parámetro.
De hecho, en terminaciones normales, exit() será llamado directa o indirectamente,
como la librería de ejecución de C (qué es agregada a los archivos ejecutables UNIX)
incluirá una llamada a exit() por defecto.
Un proceso padre puede esperar la terminación de un hijo usando wait(). A la
llamada a wait() se le pasa un parámetro que permite al padre obtener el estado de
terminación del hijo. Esta llamada también retorna el identificador de proceso del hijo
terminado de modo que el padre pueda saber cuál de sus hijos ha terminado:
Cuando un proceso termina, sus recursos son reclamados por el sistema operativo. Sin
embargo, su entrada en la tabla procesos debe permanecer allí hasta la llamada a wait()
del padre, debido a que la tabla de proceso contiene el estado de salida del proceso. Un
proceso que fue terminado, pero cuyo padre aún no ha llamado a wait(), es conocido
como proceso zombie. Todos los procesos cambian a este estado cuando terminan, pero
generalmente lo hacen por un tiempo muy breve. Una vez que el padre llama a wait(), el
identificador de proceso del proceso zombi y su entrada en la tabla de procesos es liberado.
Ahora considere que ocurre si un padre no invoca a wait() y termina, por lo tanto,
dejando sus procesos hijos como huérfanos. Sistemas UNIX tradicionales identifican este
escenario asignado al proceso init como nuevo padre de los procesos huérfanos. El
proceso init periódicamente invoca a wait(), permitiendo que el estado de salida de un
proceso huérfano sea tomado y liberado junto a la entrada correspondiente en la tabla de
procesos.
Jerarquía de procesos en Android
Debido a la restricción de recursos tales como memoria limitada, los sistemas operativos de
móviles pueden necesitar terminar procesos existentes para reclamar recursos limitados del
sistema. En lugar de terminar un proceso arbitrariamente, Android tiene identificado una
jerarquía de importancias para procesos, y cuando el sistema debe terminar un proceso
para recuperar recursos y asignarlos a uno nuevo, o más importante, termina el proceso de
acuerdo a su orden de importancia. De más a menos importante, la jerarquía de procesos
se clasifica como sigue:
● Procesos del frente. Los procesos visibles actualmente en la pantalla, representan
la aplicación con la cual el usuario está interactuando.
● Proceso visible. Un proceso que no está visible directamente en el frente pero está
realizando una actividad que un proceso de enfrente necesita (el proceso realizando
una actividad cuyo estado es mostrado en un proceso de enfrente)
● Proceso servicio. Un proceso que es similar a un proceso del fondo pero está
realizando una actividad que es evidente para el usuario (tal como streaming de
música)
● Proceso del fondo. Un proceso que puede estar realizando una actividad pero no
es evidente para el usuario.
● Proceso vacío. Un proceso que no contiene componentes activos asociados con
alguna aplicación.
Si se necesitan recursos del sistema, Android primero terminará procesos vacíos, luego
procesos del fondo, y así hasta encontrar un proceso que pueda eliminar y liberar recursos.
Los procesos son asignados a un ranking de importancia, y Android intenta asignar un
proceso con el ranking más alto posible. Por ejemplo, si un proceso está proveyendo un
servicio y es visible, se le asignará la clasificación visible más importante.
Comunicación entre procesos
Los procesos que se ejecutan concurrentemente en un sistema operativo pueden ser
procesos independientes o cooperativos. Un proceso es independiente si no comparte
datos con ningún otro proceso ejecutándose en el sistema. Un proceso es cooperativo si
puede afectar o ser afectado por otros procesos ejecutándose en el sistema. Claramente,
cualquier proceso que comparte datos con otros, es un proceso cooperativo.
Hay varias razones para proveer un ambiente que permita la cooperación entre
procesos:
● Compartir información. Debido que varias aplicaciones pueden estar interesadas
en la misma pieza de información (por ejemplo, para copiar y pegar), debemos
proveer un ambiente que permita acceso concurrente a esa información.
● Acelerar el cálculo. Si deseamos que una tarea particular se ejecute más rápido,
debemos dividirla en subtareas, cada una de las cuales estará ejecutando en
paralelo con las otras. Esto acelerará el proceso de cálculo, pero este beneficio
puede ser obtenido solamente si la computadora tiene múltiples núcleos de
procesamiento.
● Modularidad. Podemos desear construir el sistema de una manera modular,
dividiendo las funciones del sistema en procesos separados o hilos.
Los procesos cooperativos requieren un mecanismo de comunicación entre procesos
(IPC) que les permita el intercambio de datos. Esto es, enviar y/o recibir datos entre ellos.
Hay dos modelos fundamentales de comunicación entre procesos: memoria compartida y
pasaje de mensajes. En el modelo de memoria compartida, se establece una región de
memoria qué es compartida por los procesos cooperativos. Los procesos pueden entonces
intercambiar información leyendo y escribiendo datos en la región compartida. En el modelo
de pasaje de mensajes, la comunicación toma lugar a través del intercambio de mensajes
entre los procesos cooperativos. Los modelos de comunicación son comparados en la figura
11.
Ambos modelos mencionados son comunes en los sistemas operativos, y muchos
sistemas implementan los dos. Pasaje de mensajes es útil para intercambiar pequeñas
cantidades de datos, debido a que no se necesita evitar conflictos. Pasaje de mensajes
también es fácil de implementar en un sistema distribuido que comparte memoria. (A pesar
de que hay sistemas que proveen memoria compartida distribuida, no la consideraremos en
esta asignatura). La memoria compartida puede ser más rápida que el pasaje de mensajes,
debido a que los sistemas de pase de mensajes son típicamente implementados usando
llamadas al sistema y así requiere tareas que consumen más tiempo debido a la
intervención del núcleo. En sistemas de memoria compartida, las llamadas al sistema son
requeridas solamente para establecer las regiones de memoria compartida. Una vez que se
establece la región compartida, todos los accesos son tratados como accesos a memoria
rutinarias, y no se requiere asistencia por parte del núcleo.
IPC en sistemas de memoria compartida
La comunicación entre procesos usando memoria compartida requiere procesos de
comunicación para establecer una región de memoria compartida. Típicamente, una región
de memoria compartida reside en el espacio de direcciones del proceso que crea el
segmento de memoria compartida. Los demás procesos, que deseen comunicarse usando
este segmento compartido, deben asignarlo a su espacio de direcciones. Recordemos que
normalmente el sistema operativo trata de prevenir que un proceso acceda a la memoria de
otro proceso. Memoria compartida requiere que dos o más procesos acepten remover esta
restricción, permitiendo el intercambio de información leyendo y/o escribiendo datos en el
área compartida. La forma del dato y la localización son determinados por los procesos y no
está bajo el control del sistema operativo. Los procesos también son responsables de
asegurar que no escriban en la misma localización simultáneamente.
Para ilustrar el concepto de procesos cooperativos, consideraremos el problema del
productor consumidor, el cual es un paradigma conocido de procesos cooperativos. Un
proceso productor produce información qué es consumida por un proceso consumidor. Por
ejemplo, un compilador puede producir código ensamblador que es consumido por un
ensamblador. El ensamblador, a su vez, puede producir módulos objetos que son
consumidos por el cargador. El problema del productor - consumidor también provee una
metáfora útil para el paradigma cliente-servidor. Generalmente pensamos en un servidor
como un productor y un cliente como un consumidor. Por ejemplo, un servidor web produce
contenido web tal como archivos html e imágenes, los cuales son consumidos por el cliente,
un navegador web, que requiere el recurso.
Una forma de solucionar el problema del productor consumidor es usando memoria
compartida. Para que los procesos productores y consumidores corran concurrentemente,
debemos tener un buffer de elementos que pueden ser llenados por el productor y vaciados
por el consumidor. Este buffer estará en una región de memoria qué es compartida por los
procesos productor y consumidor. El productor y el consumidor deben estar sincronizados,
de modo que el consumidor no trate de consumir un elemento que aún no ha sido
producido.
Dos tipos de buffers pueden ser usados. Buffer ilimitado, no impone límites en el
tamaño del buffer. El consumidor puede tener que esperar por elementos nuevos, pero el
productor puede producir siempre elementos nuevos. Un buffer limitado tiene un tamaño
fijo. En este caso, el consumidor debe esperar si el buffer está vacío, y el productor debe
esperar si el buffer está lleno.
Veamos más de cerca como un buffer limitado ilustra la comunicación entre procesos
usando memoria compartida. Las siguientes variables residen en una región de memoria
compartida por el productor y el consumidor.
El buffer compartido es implementado por un arreglo circular con dos punteros lógicos:
in y out. La variable in apunta a la próxima posición libre en el buffer; out apunta a la
primera posición llena en el buffer. El buffer está vacío cuando in == out; el buffer está lleno
cuando ((in + 1) % BUFFER_SIZE) == out.
El código para el proceso productor es mostrado en la figura 12, y el código del proceso
consumidor mostrado en la figura 13. El productor tiene una variable local next_produced
en la cual el nuevo ítem producido es almacenado. El proceso consumidor tiene una
variable local next_consumed donde es almacenado el ítem qué será consumido.
Este esquema permite al menos BUFFER_SIZE - 1 elementos en el buffer al mismo
tiempo.
Un problema que esta figura no identifica se refiere al hecho en el que tanto el proceso
productor como el proceso consumidor intenten acceder al buffer compartido de manera
simultánea. Más adelante discutiremos la sincronización entre procesos cooperativos.
IPC en sistemas de pase de mensajes
Anteriormente mostramos cómo dos procesos cooperativos pueden comunicarse en un
ambiente de memoria compartida. El esquema requiere que los procesos compartan una
región de memoria y que el código para acceder y manipular la memoria compartida sea
escrita explícitamente por el programador de la aplicación. Otra manera de obtener el
mismo efecto es que el sistema operativo proporcione los medios para que los procesos
cooperativos puedan comunicarse entre sí a través de mensajes.
El pase de mensajes provee un mecanismo que permite que los procesos se comuniquen y
sincronicen sus acciones sin compartir el mismo espacio de direcciones. Es particularmente
útil en un ambiente distribuido, donde los procesos comunicados pueden residir en
diferentes computadoras conectadas por una red. Por ejemplo, un programa de chat a
través de internet podría ser diseñado de modo que los participantes del chat se
comuniquen entre sí intercambiando mensajes.
El mecanismo de pasaje de mensajes provee al menos dos operaciones:
Los mensajes enviados por un proceso pueden ser de tamaño fijo o variable. Si se envían
mensajes de tamaño fijo, la implementación a nivel de sistemas sería más simple. Esta
restricción, sin embargo, hace la tarea de programación más difícil. Por otro lado,mensajes
de tamaño variable requieren implementaciones a nivel de sistema más complejas, pero la
programación es más simple.
Si los procesos P y Q desean comunicarse, deben poder enviarse mensajes entre sí:
debe existir un enlace de comunicación entre ellos. Este enlace puede ser implementado de
varias formas. Aquí nos enfocaremos en la implementación lógica del enlace y no la
implementación física. Hay varios métodos para implementar lógicamente un enlace y las
operaciones send y receive:
● Comunicación directa o indirecta
● Comunicacion sincrona y asincrona
● Buffering automático o explícito
Nombrado
Los proceso que desean comunicarse deben tener una manera de referirse el uno al otro.
Pueden usar comunicación directa o indirecta.
Bajo comunicación directa, cada proceso que desea comunicarse debe nombrar
explícitamente al recipiente o emisor de la comunicación. En este esquema las primitivas
send() y receive() se definen así:
● send(P, mensaje) - Envía un mensaje al proceso P.
● receive(Q, mensaje) - Recibe un mensaje desde el proceso Q.
Un enlace de comunicación en este esquema tiene las siguientes propiedades:
● Un enlace es establecido automáticamente entre cada par de procesos que desean
comunicarse. El proceso necesita saber solamente la identidad del otro para
comunicarse.
● Un enlace está asociado con sólo dos procesos.
● Entre cada par de procesos existe un solo enlace.
Este esquema exhibe simetría en el direccionamiento; es decir, tanto el proceso emisor
como el receptor deben nombrar al otro para comunicarse. Una variante de este esquema
emplea direccionamiento asimétrico. Aquí solamente el emisor nombra al recipiente; el
recipiente no necesita nombrar al emisor. En este esquema las primitivas send() y
receive() se definen como sigue:
● send(P, mensaje) - Envía un mensaje al proceso P.
● receive(id, mensaje) - Recibe un mensaje de cualquier proceso. La variable id se
establece al nombre del proceso con el cual la comunicación toma lugar.
La desventaja en ambos esquemas (simétrico y asimétrico) es la modularidad limitada
de las definiciones de los procesos resultantes. Cambiar el identificador de un proceso
puede necesitar examinar la definición de los demás procesos. Todas las referencias del
identificador viejo deben ser encontradas, de modo que puedan ser modificadas al nuevo.
En general, cualquier técnica de codificación fija, donde los indicadores deben ser
establecidos explícitamente, son menos deseables que las técnicas que involucran
indirección.
Con comunicación indirecta, los mensajes son enviados y recibidos desde puertos o
casillas de correo. Una casilla de correo puede ser vista de manera abstracta como un
objeto en el cual los mensajes pueden ser depositados por procesos y desde la cual los
mensajes pueden ser obtenidos. Cada casilla de correo tiene una identificación única. Por
ejemplo, las colas de mensajes POSIX usan un valor entero para identificar una casilla de
correo. Un proceso puede comunicarse con otro proceso a través de un número de casilla,
pero dos procesos pueden comunicarse solamente si tienen una casilla de correo
compartida. Las primitivas send() y receive() se definen de la siguiente manera:
● send(A, mensaje) - Envía un mensaje a la casilla A.
● receive(A, mensaje) - Recibe un mensaje desde la casilla A.
En este esquema un enlace de comunicación tiene las siguientes propiedades:
● Un enlace es establecido entre un par de procesos solamente si ambos miembros
del enlace tienen una casilla compartida.
● Un enlace puede estar asociado con más de dos procesos.
● Entre cada par de procesos comunicados, puede existir varios enlaces, donde cada
enlace corresponde a una casilla de correo.
Supongamos que los procesos P1, P2 y P3 comparten la casilla de mensajes A. El
proceso P1 envía un mensaje a la casilla A, los procesos P2 y P3 ejecutan receive()
desde A. ¿Cuál proceso recibirá el mensaje enviado por P1?. La respuesta depende del
método que elegimos:
● Permitir que un enlace se asocie con dos procesos como máximo.
● Permitir al menos que un proceso a la vez ejecute la operación receive()
● Permitir que el sistema seleccione arbitrariamente qué proceso recibirá el mensaje
(es decir, P2 o P3, pero no ambos, recibirán el mensaje). El sistema puede definir un
algoritmo para seleccionar qué proceso recibirá el mensaje (por ejemplo, round
robin, donde los procesos se turnan para recibir mensajes). El sistema puede
identificar el receptor para el emisor
Una casilla de correo puede ser propiedad de un proceso o del sistema operativo. Si la
casilla es propiedad de un proceso (es decir, es parte del espacio de direcciones del
proceso), entonces distinguimos entre el propietario (el cual puede solamente recibir
mensaje a través de esta casilla) y el usuario (el cual puede solamente enviar mensaje a la
casilla). Debido que cada casilla tiene un solo propietario, no habrá confusión acerca de qué
proceso debería recibir un mensaje enviado a esta casilla. Cuando un proceso propietario
de una casilla finaliza, la casilla desaparece. Cualquier proceso que envíe mensajes a esta
casilla posteriormente, será notificado de la inexistencia de la casilla.
Sin embargo, una casilla qué es propiedad del sistema operativo tiene una existencia
propia. Es independiente y no está asignada a ningún proceso particular. El sistema
operativo entonces debe proveer un mecanismo que permita que un proceso haga lo
siguiente:
● Crear una nueva casilla.
● Enviar y recibir mensajes a través de la casilla.
● Borrar una casilla.
El proceso que crea una nueva casilla es su propietario por defecto. Inicialmente, el
propietario es el único proceso que puede recibir mensajes a través de esta casilla. Sin
embargo, la propiedad y los privilegios pueden ser pasados a otros procesos a través de
llamadas al sistema apropiadas. Por supuesto, esta provisión no debería resultar en
múltiples receptores para cada casilla.
Sincronización
La comunicación entre procesos toma lugar a través de las llamadas a las primitivas
send() y receive(). Hay diferentes opciones de diseño para implementar cada
primitiva. El pase de mensaje puede ser bloqueante o no bloqueante - también conocido
como sincrono y asincrono.
● Emisión bloqueante. El proceso emisor es bloqueado hasta que el mensaje es
recibido por el receptor o por la casilla de correo.
● Emisión no bloqueante. El proceso emisor envía el mensaje y continúa su operación.
● Recepción bloqueante. El receptor se bloquea hasta que un mensaje esté
disponible.
● Recepción no bloqueante. El receptor devuelve un mensaje válido o null.
Es posible diferentes combinaciones de send() y receive(). Cuando ambas son
bloqueantes tenemos una cita entre el receptor y el emisor. La solución para el problema del
productor consumidor pasa a ser simple cuando usamos primitivas bloqueantes. El
productor meramente invoca la llamada send() bloqueante y espera hasta que el mensaje
sea distribuido al receptor o a la casilla. De la misma forma, cuando el consumidor invoca a
receive(), se bloquea hasta que exista un mensaje disponible. Esto es ilustrado en las
figuras 14 y 15.
Buffering
Si la comunicación es directa o indirecta, los mensajes intercambiados por los procesos
residen en una cola temporal. Básicamente, esas colas pueden ser implementadas de tres
formas:
● Capacidad cero. La cola tiene una longitud máxima de cero; la cola no puede tener
ningún mensaje esperando en ella. En este caso, el emisor debe bloquearse hasta
que el recipiente reciba el mensaje.
● Capacidad limitada. La cola tiene una longitud finita de n; así, al menos n mensajes
puede residir en ella. Si la cola no está llena cuando un mensaje es enviado, el
mensaje es colocado en la cola, y el emisor puede continuar su ejecución sin
esperar. La capacidad de la cola es finita. Si la cola está completa, el emisor debe
bloquearse hasta que se genere espacio en la cola.
● Capacidad ilimitada. La longitud de la cola es potencialmente infinita; así, cualquier
cantidad de mensajes pueden depositarse en ella. El emisor nunca se bloquea.
El caso de capacidad cero es a veces referido como sistema de mensajes sin buffering.
Los otros casos son referidos como sistemas con buffering automático.
Ejemplos de sistemas de comunicación entre procesos (IPC)
Exploraremos cuatro sistemas IPC distintos. Primero API POSIX para memoria compartida,
luego pase de mensajes en el sistema operativo Mach. Luego presentamos IPC de
Windows, el cual, interesantemente usa memoria compartida como mecanismo para
proveer ciertos tipo de pase de mensajes. Concluimos con pipe, uno de los primeros
mecanismos de IPC en sistemas UNIX.
Memoria compartida POSIX
Varios mecanismos de IPC están disponibles para sistemas POSIX , incluyendo memoria
compartida y pase de mensajes.
La memoria compartida POSIX está organizada usando archivos de mapeo de
memoria, el cual asocia una región de memoria compartida con un archivo. Un proceso
debe primero crear un objeto de memoria compartida usando la llamada al sistema
shm_open() cómo sigue:
El primer parámetro especifica el nombre del objeto de memoria compartida. Los procesos
que desean acceder a esta memoria compartida deben referirse al objeto por este nombre.
Los siguientes parámetros especifican que el objeto de memoria compartida será creado si
no existe aún (O_CREAT) y que el objeto será abierto para lectura y escritura (O_RDWR). El
último parámetro establece los permisos de acceso al archivo que corresponde al objeto de
memoria compartida. Una llamada exitosa shm_open() retorna un entero que corresponde
al descriptor del archivo para el objeto de memoria compartida.
Una vez que el objeto está establecido, la función ftruncate() es usada para
configurar el tamaño del objeto en bytes. La llamada
establece el tamaño del objeto a 4096 bytes.
Finalmente la función mmap() establece un archivo de mapeo de memoria conteniendo
el objeto de memoria compartida. También retorna un puntero al archivo de memoria
mapeada, usado para acceder al objeto de memoria compartida.
Los programas mostrados en la figura 16 y 17 usan el modelo del productor consumidor
en la implementación de memoria compartida. El productor establece un objeto de memoria
compartida y escribe a la memoria compartida, y el consumidor lee desde la memoria
compartida.
El productor mostrado en la figura 16 crea un objeto de memoria compartida llamado OS y
escribe la cadena “Hello World!” a la memoria compartida. El programa mapea un objeto de
memoria compartida del tamaño especificado y permite la escritura al objeto. La bandera
MAP_SHARED específica que los cambios al objeto de memoria compartida serán visibles a
todos los procesos que estén compartiendo el objeto. Note que escribimos al objeto de
memoria compartida llamando a la función sprintf() y escribimos la cadena formateada
al puntero ptr. Después de cada escritura, debemos incrementar el puntero en el número de
bytes escritos.
El proceso consumidor mostrado en la figura 17, lee y muestra el contenido de la memoria
compartida. El consumidor también invoca a la función shm_unlink(), la cual remueve el
segmento de memoria compartida después que ha accedido a él.
Pasaje de mensajes de Mach
Mach fue diseñado especialmente para sistemas distribuidos, pero ha demostrado también
ser útil para sistemas móviles y de escritorio, cómo se puede evidenciar en su inclusión en
los sistemas operativos macOS e iOS.
El núcleo de Mach soporta la creación y destrucción de múltiples tareas, qué son
similares a procesos pero tienen múltiples hilos de control y algunos recursos asociados a
ellas. La mayoría de la comunicación en Mach, incluyendo comunicación entre tareas, es a
través de mensajes. Los mensajes se envían hacia, y son recibidos desde, casillas de
mensajes las cuales son llamados puertos en Mach. Los puertos son finitos en tamaño y
unidireccionales; para comunicación en dos vías, un mensaje se envía a un puerto, y una
respuesta es enviada a un puerto de respuesta separado. Cada puerto puede tener
múltiples emisores, pero solamente un receptor. Mach usa puertos para representar
recursos tales como tareas, hilos, memoria, y procesadores, mientras que el pasaje de
mensaje provee un enfoque orientado a objetos para interactuar con los recursos y servicios
del sistema. El pase de mensajes puede ocurrir entre dos puertos cualquiera en el mismo
host, o sobre hosts separados en un sistema distribuido.
Asociado con cada puerto, hay una colección de derechos de puerto que identifican las
capacidades necesarias para que una tarea interactúe con ese puerto. Por ejemplo, para
que una tarea reciba un mensaje desde un puerto, debe tener la capacidad
MACH_PORT_RIGHT_RECEIVE para el puerto. La tarea que crea un puerto es la
propietaria del mismo, y por lo tanto, es la única tarea que puede recibir mensajes de ese
puerto. Un propietario de un puerto puede también manipular las capacidades del puerto.
Esto es comúnmente de hecho cuando se establecen puertos de respuesta. Por ejemplo,
asumamos que la tarea T1 es dueña del puerto P1, y envía un mensaje al puerto P2 qué es
propiedad de la tarea T2. Si T1 espera recibir una respuesta de T2, debe garantizar a T2 el
derecho MACH_PORT_RIGHT_SEND para el puerto P1. La propiedad de los derechos de un
puerto es a nivel de tarea, lo que significa que todos los hilos que pertenecen a la misma
tarea comparten los mismos derechos del puerto. Así, dos hilos perteneciendo a la misma
tarea pueden fácilmente comunicarse entre sí, intercambiando mensajes a través del puerto
asociado con la tarea.
Cuando se crea una tarea, también se crean dos puertos especiales, el puerto de la
tarea en sí y el puerto de notificación. El núcleo tiene que recibir derechos para el puerto de
la tarea, lo que permitirá que una tarea envíe mensajes al núcleo. El núcleo puede enviar
notificaciones de eventos al puerto de notificaciones de la tarea (por lo cual la tarea tiene
que recibir derechos).
La función mach_port_allocate() crea un nuevo puerto y asigna espacio para sus
colas de mensajes. También identifica los derechos del puerto. Cada derecho de puerto
representa un nombre para ese puerto, y un puerto puede solamente ser accedido vía un
derecho. Los nombres de puertos son valores enteros simples y se comportan similar a los
descriptores de archivos en UNIX. El siguiente ejemplo ilustra la creación de un puerto
usando esta API:
Cada tarea también tiene acceso a un puerto de arranque, que permite a una tarea
registrar un puerto creado por ella con un servidor de arranque del sistema. Una vez que
el puerto ha sido registrado con el servidor de arranque, otras tareas pueden buscar el
puerto en este registro y obtener derechos para enviar mensajes al puerto.
La cola asociada con cada puerto es finita en tamaño, e inicialmente está vacía. A
medida que los mensajes son enviados al puerto, son copiados a la cola. Todos los
mensajes son distribuidos de manera confiable y tienen la misma prioridad. Mach garantiza
que múltiples mensajes del mismo emisor son encolados usando orden FIFO, pero no
garantiza un orden absoluto. Por ejemplo, mensajes de dos emisores pueden ser encolados
en cualquier orden.
Los mensajes Mach contienen los siguientes campos:
● Un encabezado del mensaje de tamaño fijo conteniendo metadatos acerca del
mensaje, incluyendo el tamaño del mensaje como también los puertos fuente y
destino del mismo.
● Y un cuerpo de tamaño variable que contiene el dato.
Los mensajes pueden ser simples o complejos. Un mensaje simple contiene datos
ordinarios de usuario, no estructurados y no es interpretado por el núcleo. Un mensaje
complejo puede contener punteros a ubicaciones de memoria conteniendo datos (conocido
como dato fuera de línea) o también puede ser usado para transferir derechos de puerto a
otra tarea. Los punteros de datos fuera de línea son especialmente útiles cuando un
mensaje debe pasar grandes cantidades de datos. Un mensaje simple requiere copiar y
empaquetar datos en el mensaje; la transmisión de datos fuera de línea requiere solamente
un puntero que refiere a la ubicación de memoria donde está almacenado el dato.
La función mach_msg() es el estándar para enviar y recibir mensajes. El valor de uno
de los parámetros de la función, MACH_SEND_MSG o MACH_RCV_MSG, indica si es una
operación de envío o recepción. A continuación veremos como es usado cuando una tarea
cliente envía un mensaje a una tarea servidor. Asumamos que hay dos puertos, cliente y
servidor, asociados con las tareas cliente y servidor respectivamente. El código de la figura
18 muestra la tarea cliente construyendo un encabezado y enviando un mensaje al servidor,
como también la tarea servidora recibiendo el mensaje enviado desde el cliente.
La función mach_msg() es invocada por los programas de usuario para realizar el
pase de mensajes. mach_msg() invoca la función mach_msg_trap(), que es una
llamada al sistema al núcleo Mach. Dentro del núcleo Mach, mach_msg_trap() llama a la
función mach_msg_overwrite_trap(), que maneja el pase del mensaje real.
Las operaciones de envío y recepción en sí mismas son flexibles. Por ejemplo, cuándo un
mensaje es enviado a un puerto, su cola puede estar llena. Si no está llena, el mensaje es
copiado a la cola y la tarea emisora continúa. Si la cola está llena, el emisor tiene varias
opciones (especificadas a través de parámetros a la función mach_msg()):
1. Esperar indefinidamente hasta que haya espacio en la cola.
2. Esperar al menos cierta cantidad especificada de milisegundos.
3. No esperar y retornar inmediatamente.
4. Cachear temporalmente un mensaje. Aquí el mensaje se le pasa al sistema
operativo para que lo tenga, a pesar de que la cola a la cual el mensaje está siendo
enviadas esté llena. Solamente un mensaje para una cola llena puede estar
pendiente en cualquier momento para un hilo emisor.
La última opción es significativa para tareas servidoras. Después de finalizar un
requerimiento, un servidor puede necesitar enviar una respuesta a la tarea que ha requerido
el servicio, pero también debe continuar con otros requerimientos de servicios, a pesar de
que el puerto de respuesta para el cliente esté lleno.
El mayor problema con los sistemas de mensajes es generalmente la baja en la
performance causada por la copia de los mensajes desde el puerto emisor al puerto
receptor. El sistema de mensajes de Mach intenta evitar operaciones de copia usando
técnicas de administración de memoria virtual. Esencialmente Mach mapea el espacio de
direcciones conteniendo el mensaje emisor al espacio de direcciones del receptor. Por lo
tanto, el mensaje en sí mismo, nunca es copiado ya que el receptor y el emisor tienen
acceso a la misma memoria. Esta técnica de administración de mensajes provee una gran
mejora en la performance, pero funciona solamente para mensajes intrasistema.
Windows
El sistema operativo Windows es un ejemplo de diseño moderno que emplea modularidad
para incrementar funcionalidad y decrementar el tiempo necesario para implementar nuevas
características. Windows provee soporte para muchos ambientes operativos o subsistemas.
Los programas de aplicación se comunican con los subsistemas a través del pase de
mensajes. Por lo tanto, los programas de aplicación son considerados clientes de
subsistemas servidores.
La facilidad de pase de mensajes en Windows es llamada a procedimiento local
avanzado (ALPC - advanced local procedure call). Es usada para comunicación entre dos
procesos en una misma máquina. Es similar a llamada a procedimiento remoto estándar
(RPC - remote procedure call) qué es usada ampliamente, pero está optimizado para
Windows. Igual que Mach, Windows usa objetos puerto para establecer y mantener
conexión entre dos procesos. Windows usa dos tipos de puertos: puertos de conexión y
puertos de comunicación.
Los procesos servidores publican objetos puerto de conexión qué están visibles para
todos los procesos. Cuando un cliente desea servicios de un subsistema, abre un
manejador para el objeto puerto de conexión del servidor y envía un requerimiento de
conexión al puerto. El servidor entonces crea un canal y devuelve un manejador al cliente.
El canal consiste de un par de puertos de comunicación privados: uno para mensajes
cliente - servidor, y el otro para mensajes servidor - cliente. Además, los canales de
comunicación admiten un mecanismo de devolución de llamada que permite al cliente y al
servidor aceptar solicitudes cuando normalmente esperarían una respuesta.
Cuando es creado un canal ALPC, se elige una de las tres técnicas de pase de
mensajes:
1. Para pequeños mensajes (hasta 256 bytes), se usa la cola de mensaje del puerto
como almacén intermedio, y los mensajes son copiados de un proceso a otro.
2. Para el pase de mensajes grandes, se hace a través de objetos sección, los cuales
son una regiones de memoria compartida asociada con el canal.
3. Cuando la cantidad de datos es muy grande para entrar en un objeto sección, se
dispone de una API y permite al proceso servidor leer y escribir directamente en el
espacio direcciones del cliente.
El cliente tiene que decidir, cuando configura el canal, si necesitará enviar un mensaje
grande. Si determina que desea enviar un mensaje grande, solicita la creación de un objeto
sección. Igualmente, si el servidor decide que la respuesta será grande, crea un objeto
sección. Para que el objeto sección pueda ser utilizado, se envía un pequeño mensaje
conteniendo un puntero e información de tamaño del objeto sección. Este método es más
complicado que el primero, pero evita el copiado de datos. La estructura de llamadas a
procedimientos locales avanzados en Windows está ilustrada en la figura 19.
Es importante notar que la facilidad ALPS en Windows no es parte de la API de
Windows, y por ello, no está disponible para programadores de aplicaciones. En su lugar,
las aplicaciones usando API de Windows invocan llamadas a procedimientos remotos
estándar.
Pipes o tuberías
Una tubería actúa como un conducto permitiendo a dos procesos comunicarse. Las tuberías
fueron uno de los primeros mecanismos de comunicación entre procesos en los primeros
sistemas UNIX. Típicamente proveen una forma simple de comunicación entre dos
procesos, a pesar de tener algunas limitaciones. En la implementación de una tubería, se
deben considerar cuatro cuestiones:
1. ¿La tubería permite la comunicación bidireccional, o es unidireccional?
2. Si se permite comunicación en dos vías, es half duplex (el dato puede recorrer
solamente en un sentido a la vez) o full duplex (el dato recorre en ambas direcciones
al mismo tiempo)?
3. ¿Debe existir una relación (tal como padre e hijo) entre los procesos que se
comunican?
4. Puede la tubería comunicarse sobre una red, o los procesos comunicantes deben
residir en la misma máquina?
a continuación exploraremos dos tipos comunes de tuberías usados tanto en Windows
como en UNIX: tuberías ordinarias y tuberías nombradas.
Tuberías ordinarias (Pipes)
Las tuberías ordinarias permiten que dos procesos se comuniquen de la manera
productor consumidor estándar: el productor escribe en un extremo del tubo (el extremo de
escritura) y el consumidor lee desde el otro extremo (extremo de lectura). Como resultado,
los tubos ordinarios son unidireccionales, permitiendo solamente comunicación en un
sentido. Si se requiere comunicación en dos sentidos, se deben usar dos tubos, con cada
tubo enviando datos en direcciones diferentes. A continuación veremos la construcción de
tubos ordinarios en sistemas Windows y UNIX. En ambos programas de ejemplo, un
proceso escribe el mensaje de bienvenida al tubo, mientras que el otro proceso lee este
mensaje desde el tubo.
En sistemas UNIX, los tubos ordinarios se construyen usando la función
esta función crea un tubo que es accedido a través del descriptor de archivo int fd[]:
fd[0] es el extremo de lectura del tubo, y fd[1] es el extremo de escritura. UNIX trata un
tubo como un tipo de archivo especial. Así, los tubos pueden ser accedidos usando las
llamadas al sistema comunes read() y write().
Un tubo ordinario no puede ser accedido desde afuera del proceso que lo crea. Un
padre crea un tubo y lo usa para comunicarse con los procesos hijos que él crea a través de
la llamada al sistema fork(). Recordemos que un hijo hereda archivos abiertos de su
padre. Debido a que un tubo es un tipo especial de archivo, el hijo hereda el tubo del
proceso padre. La figura 20 ilustra la relación del descriptor de archivo en el arreglo fd con
los
procesos padre e hijo. Como se puede observar, toda operación de escritura por parte del
proceso padre en el extremo de escritura del tubo fb[1] puede ser leído por el proceso hijo
en su extremo de lectura fd[0] del tubo.
En el programa UNIX mostrado en la figura 21 el proceso padre crea un tubo y envía
una llamada fork() creando el proceso hijo. Lo que ocurre después del fork() depende
de cómo fluye el dato a través del tubo. En este ejemplo, el padre escribe al tubo, y el hijo
lee desde él. Es importante considerar que el proceso padre y el proceso hijo inicialmente
cierran los extremos no usados de la tubería. A pesar de que el programa mostrado en la
figura no requiere esta acción, es un paso importante para asegurar que un proceso
leyendo de la tubería pueda detectar el final de archivo (read() retorna cero) cuando el
escritor ha cerrado su extremo del tubo.
Tuberías ordinarias en sistemas Windows son denominadas tuberías anónimas, y se
comportan similarmente a su contraparte en UNIX: son unidireccionales y emplean
relaciones padre e hijo entre los procesos comunicantes. Además, lectura y escritura a la
tubería puede ser realizado con funciones ordinarias ReadFile() y WriteFile(). La
API de Windows para crear tubería es la función CreatePipe(), a la cual se le pasan
cuatro parámetros. Los parámetros proveen manejadores separados para (1) leer y (2)
escribir al tubo, también una (3) instancia de la estructura STARTUPINFO, que se usa para
especificar qué atributos de la tubería hereda el proceso hijo. Por último, (4) puede
especificarse el tamaño del tubo (en bytes).
La figura 23 ilustra un proceso padre creando un tubo anónimo para comunicarse con
su hijo. Distinto a UNIX, en el cual un proceso hijo hereda automáticamente un tubo
heredado por su padre, Windows requiere al programador que especifique qué atributos
heredará el proceso hijo. Esto se realiza primero inicializando la estructura
SECURITY_ATTRIBUTES para permitir que los manejadores sean heredados y luego
redirigir los manejadores de la entrada y salida estándar del proceso hijo, al manejador de
lectura y escritura del tubo. Debido a que el hijo leerá del tubo, el padre debe dirigir la
entrada estándar del proceso hijo al manejador de lectura del tubo. Por último, debido a que
el tubo es half duplex, es necesario prohibir al hijo de heredar el extremo de escritura del
tubo. El
programa que crea el proceso hijo es similar al programa de la figura 10, excepto que el
quinto parámetro es establecido a TRUE, indicando que el proceso hijo hereda manejadores
designados de su padre. Antes de escribir al tubo, el padre primero cierra su extremo no
usado de lectura. El proceso hijo que le dé del tubo de mostrar la figura 25. Antes de leer
desde el tubo, este programa obtiene el manejador de lectura para el tubo invocando a
GetStdHandle().
Observemos que los tubos ordinarios requieren una relación padre-hijo entre los
procesos que se comunican tanto en sistemas UNIX como en Windows. Esto significa que
estos tubos pueden ser usados solamente para comunicación entre procesos en una misma
máquina.
Tuberías nombradas
Las tuberías ordinarias proveen un mecanismo simple para que los procesos se
comuniquen entre sí. Sin embargo, las tuberías de este tipo existen solamente mientras que
los procesos se comunican. Tanto en UNIX como en Windows, una vez que los procesos
han finalizado la comunicación y finalizan, la tubería también deja de existir.
Las tuberías nombradas proveen una herramienta de comunicación mucho más
poderosa. La comunicación puede ser bidireccional, y no se requiere una relación padre e
hijo. Una vez que se establece la tubería nombrada, varios procesos pueden usarla para
comunicarse. De hecho, en un escenario típico, una tubería nombrada tiene varios
escritores. Adicionalmente, las tuberías nombradas continúan su existencia aún después
que los procesos que se comunican han finalizado. Tanto UNIX como Windows soportan
tubería nombradas, a pesar de que los detalles de implementación difieren mucho. A
continuación exploraremos tuberías nombradas en cada uno de estos sistemas.
Tuberías nombradas son referidas cómo FIFO en sistemas UNIX. Una vez creadas,
aparecen como archivos comunes en el sistema de archivos. Un FIFO es creado con la
llamada al sistema mkfifo() y manipulado con las llamadas al sistema comunes cómo
open(), read(), write() y close(). Continúa existiendo hasta que es explícitamente
eliminado del sistema de archivos. Si bien, FIFO permite la comunicación bidireccional,
solamente la transmisión half duplex es permitida. Si el dato debe recorrer en ambas
direcciones, se usan dos FIFOS. Además, los procesos de comunicación deben residir en la
misma máquina. Si se necesita comunicación entre máquinas, debe usarse sockets.
Los tubos nombrados en Windows proveen un mecanismo de comunicación mucho
más potente que su contraparte UNIX. Permiten comunicación full dúplex, y los procesos
que se comunican pueden residir en la misma máquina en máquina distintas. Además, en
un FIFO de UNIX sólo se pueden transmitir datos orientado a bytes, mientras que el sistema
Windows permite datos orientados a mensajes o bytes. Los tubos nombrados son creados
con la función CreateNamedPipe(), y un cliente puede conectarse a una tubería
nombrado usando la función ConnectNamedPipe(). La comunicación sobre el tubo
nombrado puede ser realizada usando las funciones ReadFile() y WriteFile().
Comunicación en Sistemas Cliente - Servidor
Anteriormente hemos descripto cómo los procesos pueden comunicarse usando técnicas de
memoria compartida y pasaje de mensajes. Esas técnicas también pueden ser usadas para
comunicación en sistemas cliente-servidor. A continuación exploraremos otras dos
estrategias para comunicación en sistemas cliente servidor: sockets y llamadas a
procedimientos remotos (RPC). Cómo veremos en la cobertura de rpc, no solamente es útil
para la comunicación cliente servidor, sino que también Android lo utiliza como una forma
de comunicación entre procesos que corren en un mismo sistema.
Sockets
Un socket es definido como un extremo para una comunicación. Un par de procesos qué se
comunican sobre una red emplean un par de sockets, uno para cada proceso. Un socket es
identificado por una dirección IP unida a un número de puerto. En general, los sockets usan
una arquitectura cliente-servidor. El servidor espera por requerimientos entrantes de
clientes, escuchando a un puerto especificado. Una vez que un requerimiento es recibido, el
servidor acepta una conexión desde el socket cliente para completar la conexión. Los
servidores implementan servicios específicos (cómo SSH, FTP y HTTP) escuchando a
puertos conocidos (un servidor SSH escucha al puerto 22; un servidor FTP escucha al
puerto 21; y un servidor web, o HTTP, escucha al puerto 80). Todos los puertos debajo del
1024 son considerados conocidos y se usan para implementar servicios estándares.
Cuando un cliente inicia un requerimiento para una conexión, se le asigna un puerto por
parte de la computadora host. Este puerto tiene un número arbitrario mayor que 1024. Por
ejemplo, si un cliente en el host X con la dirección IP [Link] desea establecer una
conexión con un servidor web (que está escuchando al puerto 80) en la dirección IP
[Link], el host X puede ser asignado al puerto 1625. La conexión consistirá de un par
de sockets: ([Link]:1625) en el host X, y ([Link]:80) en el servidor web. Esta
situación es ilustrada en la figura 26. Los paquetes recorriendo entre los hosts son
distribuidos a los procesos apropiados basados en el número de puerto destino.
Todas las conexiones deben ser únicas. Por lo tanto, si otro proceso, también en el host
X, desea establecer otra conexión con el mismo servidor web, debería ser asignado a un
número de puerto mayor que el 1024 y no igual al 1625. Esto asegura que todas las
conexiones consisten de un par único de sockets.
A pesar de que la mayoría de los programas de ejemplo usan C, ilustraremos sockets
usando Java, debido que provee una interfaz mucho más fácil para sockets y tiene una
librería muy rica para utilidades de networking.
Java provee tres tipos diferentes de sockets: Los sockets Connection-oriented (TCP)
son implementados con la clase Socket. Los sockets Connectionless (UDP) usan la clase
DatagramSocket. Finalmente, la clase MulticastSocket es una subclase de la clase
DatagramSocket. Un socket multicast permite que el dato sea enviado a múltiples
recipientes.
Nuestro ejemplo describe un servidor de fecha que usa sockets Connection-oriented
TCP. La operación permite que los clientes requieran la fecha y hora actual al servidor. El
servidor escucha el puerto 6013, a pesar de que el puerto podría ser ser asignado a
cualquier número arbitrario, no utilizado, superior a 1024. Cuando se recibe una conexión, el
servidor retorna la fecha y hora al cliente.
El servidor de tiempo se muestra en la figura 27. El servidor crea un ServerSocket
que especifica que escuchará al puerto 6013. Comienza a escuchar al puerto con el método
accept(). El server se bloquea en el método accept() esperando a que un cliente haga
un requerimiento. Cuando una conexión es requerida, accept() devuelve un socket que el
servidor puede usar para comunicarse con el cliente.
Los detalles de cómo el servidor se comunica con el socket están a continuación. El
servidor, primero establece un objeto PrinterWriter que usará para comunicarse con el
cliente. Un objeto PrintWriter permite que el servidor escriba al socket usando los
métodos print() y println() a la salida. El proceso servidor envía la fecha al cliente,
llamando al método println(). Una vez que ha sido escrita la fecha al socket, el servidor
cierra el socket para el cliente y continúa escuchando por más requerimientos.
Un cliente se comunica con el servidor creando un socket y conectando al puerto al cual
escucha el servidor. Implementamos el cliente en el programa Java mostrado en la figura
28. El cliente crea un socket y requiere una conexión con el servidor en la dirección IP
[Link] en el puerto 6013. Una vez que se establece la conexión, el cliente puede leer
desde el socket usando una instrucción normal de entrada salida. Después de que ha
recibido la fecha del servidor, el cliente cierra el socket y sale. La dirección IP [Link] es
una dirección IP especial conocida como loopback. Cuando una computadora se refiere a
esta dirección IP, se refiere a sí misma. Este mecanismo permite que un cliente y un
servidor en el mismo host se comuniquen usando el protocolo TCP/IP. La dirección de
loopback puede ser reemplazada con la dirección IP de otro host corriendo el servidor de
fechas.
La comunicación usando sockets, a pesar de que es común y eficiente, es considerada
una forma de comunicación de bajo nivel entre procesos distribuidos. Una razón es que los
sockets permiten solamente el intercambio de flujos no estructurados de bytes entre los
hilos que se comunican. Es responsabilidad del cliente o servidor imponer una estructura
sobre el dato. A continuación veremos un método de comunicación de más alto nivel:
llamadas a procedimiento remoto (RPC)
Llamadas a procedimientos remotos (RPC)
Una de las formas más comunes de servicio remoto es el paradigma RPC o llamada a
procedimiento remoto, qué fue diseñado como una manera de abstraer mecanismos de
llamadas a procedimientos para su uso entre sistemas con conexiones de red. Es similar a
muchos aspectos al mecanismo IPC o comunicación entre procesos descripto
anteriormente y usualmente está construido sobre el mismo. Aquí, sin embargo, debido a
que estamos tratando por un ambiente en el cual los procesos se ejecutan en sistemas
separados, debemos usar un esquema de comunicación basado en mensaje para proveer
servicio remoto.
A diferencia de los mensajes IPC, los mensajes intercambiados en comunicaciones
RPC están bien estructurados y no son simples paquetes de datos. Cada mensaje está
dirigido a un demonio RPC escuchando a un puerto en el sistema remoto, y cada uno
contiene un identificador especificando la función a ejecutar y los parámetros para pasar a
la función. La función es ejecutada cuando es requerida, y cualquier salida es enviada al
proceso que requiere el servicio en un mensaje separado.
Un puerto en este contexto es simplemente un número incluido al inicio de un paquete
de mensaje. Mientras que un sistema normalmente tiene una sola dirección de red, puede
tener muchos puertos dentro de la dirección para diferenciar entre servicios de red que
soporta. Si un proceso remoto necesita un servicio, direcciona un mensaje al puerto
apropiado. Por ejemplo, si un sistema desea permitir que otros sistemas sean capaces de
listar sus usuarios actuales, podría tener un demonio soportando un servicio RPC asignado
a un puerto, por ejemplo el puerto 3027. Cualquier sistema remoto podría obtener la
información necesaria (esto es, la lista de usuarios actuales) enviando un mensaje RPC al
puerto 3027 en el servidor. El dato debería ser recibido en un mensaje de respuesta.
La semántica de RPC permite a un cliente invocar un procedimiento en un host remoto
como si lo invocara localmente. El sistema RPC oculta los detalles que permite que la
comunicación tome el lugar proveyendo un código auxiliar (stub) en el lado del cliente.
Típicamente, existe un stub para cada procedimiento remoto separado. Cuando el cliente
invoca un procedimiento remoto, el sistema RPC llama al stub apropiado, pasándole los
parámetros provistos para el procedimiento remoto. El stub localiza el puerto en el servidor
y marshalls (empaqueta) los parámetros. El stub entonces transmite un mensaje al servidor
usando pasaje de mensajes. Un stub similar en el lado del servidor recibe este mensaje e
invoca el procedimiento en el servidor. Si es necesario, los valores de retorno son pasados
al cliente usando la misma técnica. En sistemas Windows, el stub es compilado a partir de
una especificación escrita en el lenguaje definición de interfase de Microsoft (MIDL -
Microsoft Interface Definition Language), el cual es usado para definir las interfaces entre el
cliente y el servidor.
El empaquetado de parámetros debe resolver el problema relacionado a las diferencias
en la representación interna del dato en el cliente y el servidor. Considere la representación
de un entero de 32 bits. Algunos sistemas (conocidos como big-endian) almacenan el byte
más significativo primero, mientras que otros sistemas (conocidos como little-endian)
almacenan el byte menos significativo primero. Ninguna de las opciones es "mejor" per se;
más bien, la elección es arbitraria dentro de una arquitectura de computadora. Para resolver
diferencias como estas, muchos sistemas RPC definen una representación de datos
independiente de la máquina. Una representación como ésta es conocida como
representación de datos externa (XDR - eXternal Data Representation). En el cliente, el
empaquetado de parámetros involucra la conversión de datos dependiente de la máquina
en XDR antes que sea enviado el servidor. En el servidor,el dato XDR es desempaquetado
y convertido a una representación dependiente de la máquina en el servidor.
Otro tema importante involucra la semántica de una llamada. Mientras que llamadas a
procedimientos locales fallan en circunstancias extremas, RPC también puede fallar, o ser
duplicado y ejecutado más de una vez, como resultado de errores en las redes. Una forma
de abordar este problema es que el sistema operativo se asegure de que se actúe sobre el
mensaje exactamente una vez, en lugar de al menos una vez. La mayoría de las llamadas
a procedimientos locales tiene la funcionalidad exactamente una vez, pero es más difícil de
implementar.
Primero, consideraremos “al menos una vez”. Esta semántica puede ser implementada
asignando una estampa de tiempo a cada mensaje. El servidor debe mantener un historial
de todos las estampas de tiempo de los mensajes que ya ha procesado o un historial lo
suficientemente largo para asegurarse que los mensajes repetidos sean detectados. Los
mensajes entrantes que ya tengan una estampa de tiempo en el historial serán ignorados.
El cliente puede entonces enviar un mensaje una o varias veces y estará asegurado que
será ejecutado solamente una vez.
Para “exactamente una vez”, necesitamos eliminar el riesgo de que el servidor nunca
reciba el requerimiento. Para satisfacer esto, el servidor debe implementar el protocolo “al
menos una vez” descripto anteriormente, pero también, debe acusar o reconocer al cliente
que la llamada RPC fue recibida y ejecutada. Esos mensajes ACK son comunes en las
redes. El cliente debe reenviar las llamadas RPC periódicamente hasta que reciba un ACK
para esa llamada.
Aún queda otro tema importante referido a la comunicación entre un servidor y cliente.
Con las llamadas a procedimientos estándar, se lleva a cabo alguna forma de enlace
durante el tiempo de enlace, carga o ejecución, de modo que el nombre de una llamada a
procedimiento se reemplaza por la dirección de memoria de la llamada a procedimiento. El
esquema RPC requiere un enlace similar del puerto del cliente y el servidor, pero como
hace un cliente para saber los números de puertos en el servidor?. Ningún sistema tiene
información acerca del otro, debido a que no comparten memoria.
Dos enfoques son comunes. Primero, la información de enlazado puede estar
predeterminada, en la forma de direcciones de puertos fijos. En tiempo de compilación, una
llamada RPC tiene un número de puerto fijo asociado con él. Una vez que un programa es
compilado, el servidor no puede cambiar el número de puerto del servicio requerido.
Segundo, el enlazado puede ser hecho dinámicamente por un mecanismo de cita
(rendezvous). Típicamente, un sistema operativo provee un demonio de citas (también
denominado matchmaker) en un puerto RPC fijo. Un cliente envía un mensaje conteniendo
el nombre del RPC al demonio de citas, requiriendo la dirección del puerto RPC que
necesita ejecutar. El número de puerto es devuelto, y las llamadas RPC pueden ser
enviadas a este puerto hasta que el proceso termina (o el servidor cae). Este método
requiere la sobrecarga extra de un requerimiento inicial, pero es más flexible que el primer
enfoque. La figura 29 muestra un ejemplo de interacción.
El esquema RPC es útil en implementar un sistema de archivos distribuido. El sistema
puede ser implementado como un conjunto de demonios y clientes RPC. Los mensajes son
direccionados al puerto del sistema de archivos distribuido en un servidor en el cual las
operaciones de archivos toman lugar. El mensaje contiene la operación de disco a realizar.
La operación de disco podría ser read(), write(), rename(), delete() o status(),
correspondiendose con las llamadas al sistema usuales relacionadas a gestión de archivos.
El mensaje de retorno contiene cualquier dato resultante de esa llamada, que es ejecutado
por el demonio DFS en lugar del cliente. Por ejemplo, un mensaje podría contener una
solicitud para transferir un archivo entero a un cliente o estar limitado a la solicitud de un
bloque. En el último caso, pueden ser necesarias varias solicitudes si debe transferirse un
archivo entero.
RPC en Android
A pesar de que RPC típicamente está asociado con la computación cliente servidor en
sistemas distribuidos, también es usado como una forma de de IPC entre procesos
corriendo en un mismo sistema. El sistema operativo Android tiene un rico conjunto de
mecanismos IPC contenidos en su framework de enlace, incluyendo RPC que permite que
un proceso requiera servicio de otro proceso.
Android define un componente de aplicación como un bloque de construcción básico
que provee utilidades a una aplicación Android, y una aplicación puede combinar múltiples
componentes de aplicación para proveer funcionalidad a otra aplicación. Tal componente de
aplicación es un servicio, que no posee interfaces pero corre en el fondo mientras que
ejecuta largas operaciones o realiza trabajos para procesos remotos. Los ejemplos de
servicios incluyen reproducir música en segundo plano y recuperar datos a través de una
conexión de red en nombre de otro proceso, evitando así que el otro proceso se bloquee
mientras se descargan los datos. Cuando una aplicación cliente invoca al método
bindservice() de un servicio, el servicio es “enlazado” a la aplicación y pasa a estar
disponible para proveer comunicación cliente servidor usando pasaje de mensajes o RPC.
Servicio enlazado debe extender la clase Service de Android e implementar el método
onBind(), qué es invocado cuando un cliente llama al método bindService(). En el
caso de pasaje de mensajes, el método onBind() retorna un servicio Messenger, qué es
usado para enviar mensajes desde el cliente al servicio. El servicio Messenger es de una
sola vía; si el servicio debe enviar una respuesta al cliente, el cliente también debe proveer
un servicio Messenger, que es contenido en el campo replyTo del objeto Message enviado
el servicio. El servicio puede entonces enviar mensajes al cliente.
Para proveer RPC, el método onBind() debe retornar una interfase representando los
métodos en el objeto remoto que los clientes usan para interactuar con el servicio. Esta
interfaz está escrita en sintaxis Java regular y usa el lenguaje definición de interfaces de
Android (AIDL - Android Interface Definition Language) para crear archivos stub, qué sirven
como la interfaz cliente para el servicio remoto.
A continuación, mostramos brevemente el proceso requerido para proveer un servicio
remoto genérico llamado remoteMethod() usando AIDL y el servicio de enlace. La
interfase para el servicio remoto se asemeja a la siguiente:
Este archivo está escrito como [Link]. El kit de desarrollo de Android lo
usará para generar una interfase .java desde el archivo .aidl, así como un código auxiliar
(stub) que sirve como interfaz RPC para este servicio. El servidor debe implementar la
interfase generada por el archivo .aidl, y la implementación de interfaces será llamada
cuando el cliente invoque el remoteMethod().
Cuando un cliente llama bindService(), el método onBind() es invocado en el
servidor, y retorna el stub para el objeto RemoteService al cliente. El cliente puede entonces
invocar al método remoto de la siguiente manera:
Internamente, el framework enlazador de Android maneja el marshalling de los
parámetros, transfiriendo parámetros marshaled entre procesos, e invocando la
implementación necesaria del servicio, también enviando cualquier valor de retorno de
vuelta al proceso cliente.
Ejercicios prácticos
1. Usando el programa de la figura 30, explique cuál sería la salida en la LÍNEA A.
2. Incluyendo el proceso padre inicial, ¿Cuántos procesos son creados por el programa
en la figura 31?.
3. Las versiones originales del sistema operativo para móviles de Apple, iOS, no
proveían el concepto de procesamiento concurrente. Describa las tres mayores
complicaciones que el procesamiento concurrente agrega a un sistema operativo.
4. Algunos sistemas de computadoras proveen múltiples conjuntos de registros.
Describa qué sucede cuando ocurre un cambio de contexto si el nuevo contexto ha
sido cargado en uno de los conjuntos de registros. ¿Qué sucede si el nuevo contexto
está en la memoria en lugar de en uno de los conjuntos de registros y todos los
conjuntos de registros están en uso?.
5. Cuando un proceso crea un nuevo proceso usando una llamada a fork(), cual de
los siguientes estados es compartido entre el proceso padre y los procesos hijos?.
a. Stack - Pila
b. Heap - Montón
c. Segmentos de memoria compartida
6. Considerando la semántica “exactamente uno” con respecto al mecanismo RPC. El
algoritmo para implementar esta semántica se ejecuta correctamente aún si el
mensaje ACK enviado al cliente se pierde debido a problemas de red?. Describa la
secuencia de mensajes, y discuta si se preserva la semántica “exactamente uno”.
7. Asumiendo que un sistema distribuido es susceptible a fallas en el servidor. Qué
mecanismos se requieren para garantizar la semántica “exactamente uno” en la
ejecución de RPCs?.
8. Describa las acciones realizadas por el núcleo de un sistema operativo para realizar
un cambio de contexto entre procesos.
9. Construya un árbol de procesos similar al de la figura 7. Para obtener información de
procesos en UNIX o Linux, use el comando ps -ael. Use el comando man ps para
obtener más información sobre el comando ps. El administrador de tareas de
Windows no provee el ID de procesos padres, pero la herramienta monitor de
procesos, disponible desde [Link] provee una herramienta para
procesar árboles de procesos.
10. Explique el rol del proceso init (systemd) en UNIX y Linux respecto a la terminación
de procesos.
11. Incluyendo el proceso padre inicial, ¿Cuántos procesos son creados por el programa
en la figura 32?.
12. Explique las circunstancias bajo las cuales la línea de código printf(“LINE J”)
marcada en la figura 33 es ejecutada?.
13. Usando el programa en la figura 34, identifique los valores de pid en las líneas A, B,
C y D. (Asuma que el valor actual de pids del padre e hijo son 2600 y 2603,
respectivamente).
14. Dar un ejemplo de una situación en la cual un tubo ordinario es más apto que tubos
nombrados y un ejemplo de una situación en la cual el uso de tubos nombrados sea
mejor que usar tubos ordinarios.
15. Considere el mecanismo RPC. Describa las consecuencias indeseables que podrían
surgir de no forzar las semánticas de “al menos una vez” o “exactamente una vez”.
Describa usos posibles para un mecanismo que no garantiza ninguna.
16. Usando el programa mostrado en la figura 35, explique qué salidas emitirá en las
líneas X e Y.
17. Cuáles serán los beneficios y desventajas de cada caso?. Considere tanto a nivel de
sistema como a nivel de programador.
a. Comunicación sincrónica y asincrónica.
b. Buffering automático y explícito.
c. Envío por copia o referencia.
d. Mensajes de tamaño fijo y tamaño variable.