UT1. Programación multiproceso
En esta primer unidad vamos a estudiar los conceptos básicos de la programación multiproceso, que nos permitirán entender cómo funcionan los sistemas operativos modernos y cómo podemos aprovechar sus capacidades para mejorar el rendimiento y la eficiencia de nuestras aplicaciones.
Antes de comenzar, es importantes tener claros algunos conceptos fundamentales.
Conceptos básicos
- Programa: Es un conjunto de instrucciones escritas en un lenguaje de programación que una computadora puede interpretar y ejecutar para realizar una tarea específica.
- Proceso: Es una instancia en ejecución de un programa. Cada proceso tiene su propio espacio de memoria y recursos del sistema, lo que le permite funcionar de manera independiente de otros procesos.
- Hilo: Es la unidad más pequeña de ejecución dentro de un proceso. Un proceso puede contener múltiples hilos que comparten el mismo espacio de memoria y recursos del proceso, lo que permite una mayor eficiencia en la ejecución de tareas concurrentes.
Programación concurrente
La programación concurrente es un paradigma de programación que permite la ejecución simultánea de múltiples tareas o procesos. Esto es especialmente útil en sistemas operativos modernos, donde múltiples aplicaciones y servicios pueden estar ejecutándose al mismo tiempo. La programación concurrente puede mejorar el rendimiento y la eficiencia de las aplicaciones al permitir que varias tareas se realicen al mismo tiempo, aprovechando mejor los recursos del sistema.
Nota
Dichas tareas se ejecutan en un único procesador, pero se van alternando en la ejecución, de forma que parece que se ejecutan a la vez. A este tipo de técnica empleada por el sistema operativo se le llama multiprogramación.
Cuando el sistema operativo cuenta con varios procesadores, se puede aprovechar la capacidad de procesamiento paralelo, permitiendo que múltiples tareas se ejecuten realmente al mismo tiempo.
Programación paralela y distribuida
La programación paralela es un enfoque de programación que permite la ejecución simultánea de múltiples tareas o procesos en múltiples núcleos de un procesador o en múltiples procesadores. Esto puede mejorar significativamente el rendimiento de las aplicaciones que requieren un alto nivel de procesamiento, como el procesamiento de datos, la simulación y renderizado.
La programación distribuida, por otro lado, es un enfoque de programación que permite la ejecución de tareas o procesos en múltiples computadoras conectadas en red. Esto puede mejorar la escalabilidad y la disponibilidad de las aplicaciones, permitiendo que se ejecuten en un entorno distribuido y tolerante a fallos.
Ejecutables. Procesos. Servicios.
Un ejecutable es un archivo que contiene un programa que puede ser ejecutado por una computadora. Los ejecutables pueden ser archivos binarios o scripts, y pueden ser creados en diferentes lenguajes de programación. Cuando se ejecuta un archivo ejecutable, el sistema operativo crea un proceso para gestionar su ejecución.
Un proceso, como hemos visto, es una instancia en ejecución de un programa. Los procesos pueden comunicarse entre sí a través de mecanismos como pipes, sockets y memoria compartida. Ademas, los procesos pueden encontrarse en diferentes estados:
- Nuevo: El proceso se está creando.
- Preparado: El proceso está preparado para ejecutarse, pero aún no ha sido asignado a un procesador.
- En ejecución: El proceso está siendo ejecutado por un procesador.
- Bloqueado: El proceso está esperando por un recurso, como la entrada/salida o la finalización de otro proceso.
- Terminado: El proceso ha finalizado su ejecución.
Existen también otros estados adicionales que pueden variar según el sistema operativo.
Dentro de un proceso, pueden existir múltiples hilos de ejecución que permiten la ejecución concurrente de tareas dentro del mismo proceso. Los hilos comparten el mismo espacio de memoria y recursos del proceso, lo que permite una mayor eficiencia en la ejecución de tareas concurrentes.
Un servicio es un programa que se ejecuta en segundo plano y proporciona funcionalidades específicas al sistema operativo o a otras aplicaciones. Los servicios pueden ser iniciados automáticamente al arrancar el sistema operativo y pueden ejecutarse de manera continua, esperando por solicitudes o eventos para realizar su tarea.
Gestión de procesos
La gestión de procesos es una tarea fundamental del sistema operativo, que se encarga de crear, planificar y finalizar los procesos. La gestión de procesos incluye varias actividades, como la creación de procesos, la planificación de procesos, la comunicación entre procesos y la finalización de procesos.
Colas de procesos
Los procesos se gestionan a través de colas que representan los diferentes estados en los que pueden encontrarse. Las colas más comunes son:
- Cola de nuevos: Contiene los procesos que están siendo creados.
- Cola de preparados: Contiene los procesos que están listos para ejecutarse.
- Cola de ejecución: Contiene el proceso que está siendo ejecutado por un procesador.
- Cola de bloqueados: Contiene los procesos que están esperando por un recurso.
- Cola de terminados: Contiene los procesos que han finalizado su ejecución.
Planificación de procesos
La planificación de procesos es el proceso mediante el cual el sistema operativo decide qué proceso se ejecutará en un procesador en un momento dado. Existen diferentes algoritmos de planificación, como:
- FIFO (First In, First Out): El primer proceso en llegar es el primero en ser ejecutado.
- LIFO (Last In, First Out): El último proceso en llegar es el primero en ser ejecutado.
- SJF (Shortest Job First): El proceso con el menor tiempo de ejecución estimado es el primero en ser ejecutado.
- Round Robin: Cada proceso recibe una cantidad fija de tiempo para ejecutarse antes de ser interrumpido y pasar al siguiente proceso en la cola de preparados.
- Prioridad: Los procesos con mayor prioridad son ejecutados antes que los procesos con menor prioridad.
Creación de procesos en Java
En Java, los procesos se pueden crear utilizando la clase ProcessBuilder o el método Runtime.exec(). Estos métodos permiten ejecutar comandos del sistema operativo y crear nuevos procesos desde una aplicación Java.
Ejemplo con ProcessBuilder:
import java.io.IOException;
public class EjemploProcessBuilder {
public static void main(String[] args) {
ProcessBuilder processBuilder = new ProcessBuilder("comando", "arg1", "arg2");
processBuilder.redirectErrorStream(true);
try {
Process process = processBuilder.start();
// Manejar la entrada/salida del proceso
} catch (IOException e) {
e.printStackTrace();
}
}
}
Clase ProcessBuilder
La clase ProcessBuilder permite configurar y lanzar procesos del sistema operativo desde una aplicación Java. Algunas características importantes de la clase ProcessBuilder son:
- Cada instancia gestiona una serie de atributos del proceso.
- El método start() crea una nueva instancia de tipo Process con dichos atributos.
- El proceso puede ser invocado varias veces para crear nuevos subprocesos.
- El proceso creado redirige su terminal a la del proceso padre.
- Métodos de la clase Process : enlace
- Métodos de la clase ProcessBuilder : enlace
En Windows, un comando común para ejecutar es notepad.exe. El siguiente ejemplo muestra cómo crear un proceso que abre el Bloc de notas:
import java.io.IOException;
public class AbrirBlocDeNotas {
public static void main(String[] args) {
ProcessBuilder processBuilder = new ProcessBuilder("notepad.exe");
try {
Process process = processBuilder.start();
// El Bloc de notas se abre en una nueva ventana
} catch (IOException e) {
e.printStackTrace();
}
}
}
Nota
En linux se puede usar el comando gedit para abrir el editor de texto. Y en macOS se puede usar el comando open -a TextEdit.
Ahora, supongamos que queremos crear un programa en Java que aprovecha la capadidad de multiprocesamiento de un sistema con varios núcleos para realizar tareas intensivas. Como ejemplo, podemos crear un programa que realice la suma de todos los números que hay entre dos números pasados por argumento (ambos incluidos).
Empecemos creando la clase Sumador.java que realizará la suma de dos números:
// Sumador.java
public class Sumador {
public int suma(int a, int b) {
int resultado = 0;
for (int i = a; i <= b; i++) {
resultado += i;
}
return resultado;
}
public static void main(String[] args) {
int a = Integer.parseInt(args[0]);
int b = Integer.parseInt(args[1]);
Sumador s = new Sumador();
int resultado = s.suma(a, b);
System.out.println(resultado);
}
}
Ejecutar con argumentos en VS Code
- Abre la terminal en VS Code (Ctrl+`).
- Compila:
javac Sumador.java - Ejecuta pasando los argumentos:
java Sumador 1 100 S = 1 + 2 + ... + 100 = 5050
Ahora, vamos a crear la clase Launcher.java que se encargará de dividir el trabajo entre varios procesos:
// Launcher.java
public class Launcher {
public void launch(int a, int b) {
ProcessBuilder pb;
try {
pb = new ProcessBuilder("java", "Sumador", String.valueOf(a), String.valueOf(b));
pb.start();
} catch (Exception e) {
e.printStackTrace();
}
}
public static void main(String[] args){
Launcher l = new Launcher();
l.launch(1, 50);
l.launch(51, 100);
System.out.println("Procesos lanzados");
}
}
¿Y el resultado?
En este ejemplo, hemos lanzado dos procesos que suman los números entre 1 y 50, y entre 51 y 100, respectivamente. Sin embargo, no hemos gestionado la comunicación entre los procesos para obtener el resultado final.
En este ejemplo nos encontramos con la necesidad de comunicar los procesos para obtener el resultado final.
Comunicación entre procesos
La comunicación entre procesos (IPC, por sus siglas en inglés) es un mecanismo que permite a los procesos intercambiar datos y mensajes entre sí. Existen varios métodos para lograr la comunicación entre procesos, como:
- Pipes: Permiten la comunicación unidireccional entre procesos relacionados (padre-hijo).
- Sockets: Permiten la comunicación bidireccional entre procesos, incluso si se encuentran en diferentes máquinas.
- Memoria compartida: Permite que varios procesos accedan a una región de memoria común para intercambiar datos.
- Colas de mensajes: Permiten que los procesos envíen y reciban mensajes a través de una cola gestionada por el sistema operativo.
- Ficheros: Los procesos pueden leer y escribir en archivos para intercambiar datos.
- Señales: Permiten que los procesos envíen notificaciones a otros procesos para indicar que ha ocurrido un evento.
Ficheros temporales
En el ejemplo anterior, una forma sencilla de comunicar los procesos sería hacer que cada proceso escriba su resultado en un fichero temporal, y luego un proceso padre lea esos ficheros para obtener el resultado final.
Podemos modificar la clase Launcher.java para redirigir la salida de cada proceso a un fichero temporal:
// Launcher.java
import java.io.*;
public class Launcher {
public static void launch(int a, int b, String ficheroTemporal) {
ProcessBuilder pb;
try {
pb = new ProcessBuilder("java", "Sumador", String.valueOf(a), String.valueOf(b));
pb.redirectOutput(new File(ficheroTemporal)); // Redirigir la salida a un fichero temporal
pb.start();
} catch (Exception e) {
e.printStackTrace();
}
}
public static int leerResultado(String ficheroTemporal) {
try (BufferedReader br = new BufferedReader(new FileReader(ficheroTemporal))) {
String line = br.readLine(); // Solo la primera línea
if (line != null) {
return Integer.parseInt(line);
}
} catch (IOException e) {
e.printStackTrace();
}
return 0; // Error
}
public static void main(String[] args){
Launcher.launch(1, 50, "temp1.txt");
Launcher.launch(51, 100 , "temp2.txt");
System.out.println("Procesos lanzados");
try {
// Pequeña pausa para dar tiempo a que los hijos escriban los ficheros
// (en un diseño real deberías usar Process.waitFor)
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// Leemos resultados desde los ficheros
int r1 = Launcher.leerResultado("temp1.txt");
int r2 = Launcher.leerResultado("temp2.txt");
int total = r1 + r2;
System.out.println("Resultado final = " + total);
}
}
Version mejorada que espera a que los procesos terminen:
// Launcher.java
import java.io.*;
public class Launcher {
public static Process launch(int a, int b, String ficheroTemporal) {
try {
ProcessBuilder pb = new ProcessBuilder("java", "Sumador", String.valueOf(a), String.valueOf(b));
pb.redirectOutput(new File(ficheroTemporal));
return pb.start(); // Devolvemos el proceso para poder esperar
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
public static int leerResultado(String ficheroTemporal) {
try (BufferedReader br = new BufferedReader(new FileReader(ficheroTemporal))) {
String line = br.readLine(); // Solo la primera línea
if (line != null) {
return Integer.parseInt(line);
}
} catch (IOException e) {
e.printStackTrace();
}
return 0; // Error o fichero vacío
}
public static void main(String[] args){
// Lanzamos los procesos y guardamos sus referencias
Process p1 = Launcher.launch(1, 50, "temp1.txt");
Process p2 = Launcher.launch(51, 100, "temp2.txt");
System.out.println("Procesos lanzados, esperando a que terminen...");
// Esperamos a que ambos procesos terminen
try {
if (p1 != null) p1.waitFor(); // Espera al proceso p1
if (p2 != null) p2.waitFor(); // Espera al proceso p2
} catch (InterruptedException e) {
e.printStackTrace();
}
// Leemos resultados desde los ficheros
int r1 = Launcher.leerResultado("temp1.txt");
int r2 = Launcher.leerResultado("temp2.txt");
int total = r1 + r2;
System.out.println("Resultado final = " + total);
}
}
Streams (Pipes)
Otra forma de comunicar los procesos es a través de los streams de entrada y salida estándar.
Un stream es una secuencia de datos que se puede leer o escribir de manera continua. En Java, los procesos tienen tres streams estándar:
- Entrada estándar (stdin): Permite que un proceso reciba datos de entrada.
- Salida estándar (stdout): Permite que un proceso envíe datos de salida.
- Error estándar (stderr): Permite que un proceso envíe mensajes de error.
Para lograr esta comunicación, se emplean los pipes (tuberías), que permiten conectar la salida estándar de un proceso con la entrada estándar de otro proceso.
Tip
Pipe = el canal físico (mecanismo de comunicación)
Stream = la interfaz de programación que te permite leer/escribir datos en ese canal.
En java, podemos utilizar los métodos getInputStream() y getErrorStream() de la clase Process para leer la salida estándar y la salida de error de un proceso, respectivamente. Estos métodos streams se conectan mediante pipes creando una comunicación entre los procesos.
// LauncherStream.java
import java.io.*;
public class LauncherStream {
public static Process launch(int a, int b) {
try {
ProcessBuilder pb = new ProcessBuilder("java", "Sumador", String.valueOf(a), String.valueOf(b));
return pb.start();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
public static int leerResultado(Process proceso) {
try (BufferedReader br = new BufferedReader(new InputStreamReader(proceso.getInputStream()))) {
String line = br.readLine();
proceso.waitFor(); // Esperamos al proceso
if (line != null) {
return Integer.parseInt(line.trim());
}
} catch (IOException | InterruptedException e) { // Capturamos excepciones de E/S e interrupciones
e.printStackTrace();
}
return 0;
}
public static void main(String[] args) {
System.out.println("Lanzando procesos en paralelo...");
// Lanzamos ambos procesos simultáneamente
Process p1 = launch(1, 50);
Process p2 = launch(51, 100);
// Esperamos y recogemos resultados
int r1 = leerResultado(p1);
int r2 = leerResultado(p2);
int total = r1 + r2;
System.out.println("Resultado final = " + total);
}
}
Procesos dinámicos
En el ejemplo anterior, hemos dividido el trabajo en dos procesos fijos. Sin embargo, en situaciones reales, el número de procesos puede variar en función de la carga de trabajo y los recursos disponibles. Para manejar esto, podemos crear un programa que divida el trabajo en función del número de núcleos disponibles en el sistema.
Podemos utilizar el método Runtime.getRuntime().availableProcessors() para obtener el número de núcleos disponibles y dividir el trabajo en consecuencia.
Un ejemplo sencillo para contar el número de núclues disponibles seria:
// ContarNucleos.java
public class ContarNucleos {
public static void main(String[] args) {
int nucleos = Runtime.getRuntime().availableProcessors();
System.out.println("Número de núcleos disponibles: " + nucleos);
}
}
Para seguir con el ejemplo de la suma, podemos modificar la clase Launcher.java para dividir el trabajo en función del número de núcleos disponibles:
// LauncherDinamico.java
import java.io.*;
import java.util.*;
public class LauncherDinamico {
public static Process launch(int a, int b, String ficheroTemporal) {
try {
ProcessBuilder pb = new ProcessBuilder("java", "Sumador", String.valueOf(a), String.valueOf(b));
pb.redirectOutput(new File(ficheroTemporal));
return pb.start();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
public static int leerResultado(String ficheroTemporal) {
try (BufferedReader br = new BufferedReader(new FileReader(ficheroTemporal))) {
String line = br.readLine(); // Solo la primera línea
if (line != null) {
return Integer.parseInt(line);
}
} catch (IOException e) {
e.printStackTrace();
}
return 0; // Error o fichero vacío
}
public static void main(String[] args) {
// Calculamos el número de procesadores disponibles
int maxProcesos = Runtime.getRuntime().availableProcessors();
System.out.println("Número de procesadores disponibles: " + maxProcesos);
// Rango total a sumar
int a = 1;
int b = 100;
// Dividimos el rango según el número de procesadores
int rango = (b - a + 1) / maxProcesos;
// Guardamos procesos y nombres de archivos en arrays dinámicos
List<Process> procesos = new ArrayList<>();
List<String> archivos = new ArrayList<>();
// Lanzamos procesos
for (int i = 0; i < maxProcesos; i++) {
int inicio = a + i * rango;
int fin = (i == maxProcesos - 1) ? b : inicio + rango - 1; // El último llega hasta el final
String fichero = "temp" + i + ".txt";
Process p = launch(inicio, fin, fichero);
if (p != null) {
procesos.add(p);
archivos.add(fichero);
}
System.out.println("Proceso " + (i + 1) + " lanzado: suma de " + inicio + " a " + fin + " => " + fichero);
}
System.out.println("Procesos lanzados, esperando a que terminen...");
// Esperamos a que todos los procesos terminen
for (Process p : procesos) {
try {
p.waitFor();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// Leemos resultados desde los ficheros
int total = 0;
for (String fichero : archivos) {
total += leerResultado(fichero);
}
System.out.println("Resultado final = " + total);
}
}
Sincronización entre procesos
Sincronizar procesos es crucial cuando varios procesos necesitan acceder a recursos compartidos para evitar condiciones de carrera y asegurar la integridad de los datos. Algunos mecanismos comunes de sincronización incluyen:
- Semáforos: Variables que controlan el acceso a recursos compartidos mediante señales.
- Mutex (Mutual Exclusion): Objetos que permiten que solo un proceso acceda a un recurso compartido a la vez.
- Monitores: Estructuras que encapsulan variables compartidas y los métodos que las manipulan, asegurando la exclusión mutua.
- Barreras: Permiten que un grupo de procesos se sincronice en un punto específico antes de continuar.
- Bloqueos de archivos: Permiten que los procesos bloqueen archivos para evitar accesos concurrentes.
Bloqueo de archivos en Java
En Java, podemos utilizar la clase FileChannel junto con FileLock para implementar bloqueos de archivos y sincronizar el acceso a recursos compartidos entre procesos.
El siguiente programa demuestra el uso de bloqueos de archivos para sincronizar el acceso a un recurso compartido:
// SincronizarProcesos.java
import java.io.IOException;
public class SincronizarProcesos {
public static void main (String[] args){
try {
ProcessBuilder pb1 = new ProcessBuilder("java", "Worker", "Procesos1");
ProcessBuilder pb2 = new ProcessBuilder("java", "Worker", "Procesos2");
Process p1 = pb1.start();
Process p2 = pb2.start();
p1.waitFor();
p2.waitFor();
} catch (IOException | InterruptedException e){
e.printStackTrace();
}
System.out.println("Procesos finalizados.");
}
}
// Worker.java
import java.io.*;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
public class Worker {
public static void main(String[] args) {
if (args.length < 1) {
System.out.println("Se requiere un nombre de proceso como argumento.");
return;
}
String nombreProceso = args[0];
try (FileOutputStream archivo = new FileOutputStream("datos.txt", true); // Modo append
FileChannel channel = archivo.getChannel()) {
// Intentamos bloquear el archivo
FileLock lock = channel.lock(); // Bloqueamos el archivo
System.out.println(nombreProceso + " tiene el bloqueo. Escribiendo...");
for (int i = 0; i < 5; i++) {
String linea = nombreProceso + " escribe línea " + i + "\n";
archivo.write(linea.getBytes());
// Simulamos tiempo de escritura
Thread.sleep(200);
}
lock.release(); // Liberamos el bloqueo
System.out.println(nombreProceso + " liberó el bloqueo.");
} catch (IOException | InterruptedException e) {
e.printStackTrace();
}
}
}
Este programa crea dos procesos que intentan escribir en el mismo archivo datos.txt. Gracias al uso de bloqueos de archivos, solo un proceso puede escribir en el archivo a la vez, evitando condiciones de carrera y asegurando la integridad de los datos. Si un proceso intenta acceder al archivo mientras otro lo tiene bloqueado, esperará en el método lock() hasta que el bloqueo sea liberado.
El archivo datos.txt contendrá las líneas escritas por ambos procesos, pero estarán correctamente sincronizadas. Primero escribirá un proceso y luego el otro, sin mezclarse.
Para comprobar el funcionamiento sin una sincronizacion, se deben comentar las lineas:
FileLock lock = channel.lock();
...
lock.release();
Nota
Asegúrate de compilar el programa Worker.java de forma manual después de cualquier cambio:
javac Worker.java
Actividades
A1.1 Planificación de procesos FIFO
Supongamos que cinco procesos P1, P2, P3, P4 y P5 se están ejecutando concurrentemente en un sistema operativo con planificación FIFO.
En un instante dado, la situación es la siguiente:
- El proceso P3 está en ejecución.
- El proceso P5 y el proceso P4 se encuentran en la cola de preparados (en ese orden).
- El proceso P1 está en la cola de E/S del disco.
- El proceso P2 está en la cola de E/S de red.
En ese momento, el proceso P3 realiza una petición de E/S al disco, teniendo que esperar porque el dispositivo ya está ocupado.
Pregunta:
- Representa el estado de las colas después de este evento aplicando la política FIFO en la cola de preparados.
A1.2 Planificación de procesos SJF
Supongamos que tres procesos P1, P2 y P3 se están ejecutando concurrentemente en un sistema operativo con planificación SJF.
En un instante dado, la situación es la siguiente:
- No hay ningún proceso en ejecución.
- En el instante 1 el proceso P1 llega a la cola de preparado con un tiempo estimado de ejecución de 3 unidades de tiempo.
- En el instante 2 el proceso P2 llega a la cola de preparado con un tiempo estimado de ejecución de 4 unidades de tiempo.
- En el instante 3 el proceso P3 llega a la cola de preparado con un tiempo estimado de ejecución de 2 unidades de tiempo.
Pregunta:
- Representa la linea de tiempo que muestra la planificación de los procesos desde el instante 1 hasta que todos los procesos han finalizado.
- ¿Cual es el tiempo empleado por la CPU para ejecutar los tres procesos?
- ¿Cuál es el tiempo de espera medio de los procesos?
- ¿Cual es el tiempo de retorno medio de los procesos?
A1.3 Suma de números pares
Realiza un programa en Java que sume todos los números pares entre dos números pasados por parámetros (ambos incluidos). El programa debe dividir el trabajo en 4 procesos, de forma que cada proceso sume una parte del rango de números. Se debe pedir al usuario que introduzca los dos números por consola.
A1.4 Suma de números pares - Dinámico y streams
Realiza un programa en Java que sume todos los números pares entre dos números pasados por parámetros (ambos incluidos).
- El programa debe dividir el trabajo en varios procesos, de forma que cada proceso sume una parte del rango de números. El número de procesos debe ser igual al número de núcleos disponibles en el sistema y la comunicación entre procesos debe realizarse mediante streams.
- Se debe pedir al usuario que introduzca los dos números por consola.
- Los procesos deben ser lanzados de forma simultánea y el proceso padre debe esperar a que todos los procesos hijos terminen para mostrar el resultado final.