HEADER_lecciones_de_software

Programación concurrente: Parte 1

por Nicolás Archila Gómez, el 23 de junio de 2022


IMAGEN LECCIONES FONDO

En esta ocasión vamos a hablar un poco de los conceptos y métodos base para entender qué es la concurrencia y cómo trabajan los hilos. 

Mostraremos de manera progresiva métodos que nos proveen mayor control, mejores prácticas y menos esfuerzo a la hora de implementar concurrencia; así como también dejaremos algunas referencias que nos permitirán profundizar en este tema. Para continuar aprendiendo sobre concurrencia, visita la parte 2.

Concurrencia

Para hablar de concurrencia, primero vamos a hablar de monotarea y multitarea.

Normalmente una aplicación tiene un hilo que permite ejecutar una monotarea y, si solo se trabaja con este hilo, los demás procesos de la aplicación quedan bloqueados hasta que este hilo termine de ejecutarse.

Multitarea, por su parte, consiste en manejar varios hilos; es decir, mientras un hilo está ejecutando un proceso, otro hilo puede ejecutar al mismo tiempo otro proceso de manera independiente. 

Una vez teniendo claros estos dos términos, vamos a hablar de la concurrencia que se asocia a la multitarea. Esto se da cuando dos o más tareas se desarrollan en el mismo intervalo de tiempo. 

Es importante aclarar que, aunque se desarrollen en el mismo intervalo de tiempo, no necesariamente se ejecutan en el mismo instante. A este otro concepto se le denomina paralelismo.

En el párrafo anterior, estábamos hablando de que cada hilo ejecuta una tarea. Vamos a profundizar un poco en este aspecto. 

Un hilo es un objeto con capacidad de ejecutar una tarea o, dicho de otra manera, ejecutar concurrentemente el método run() de java. 

Los hilos pueden tener diferentes estados entre los cuales estan nuevo, ejecutable, bloqueado, en espera y terminado (este último se puede lograr con el método stop(), que destruye el hilo de manera brusca, lo que no es una buena práctica porque puede causar inconsistencias en el sistema y es un método depreciado).

Los hilos son herramientas muy poderosas para manejar aplicaciones multitarea, lo que ayuda a mejorar o mantener el rendimiento de un sistema que gestiona peticiones concurrentes.

Al mismo tiempo, al trabajar con hilos debe tenerse mucho cuidado porque se pueden generar starvation, deadlock o inconsistencias en las operaciones del sistema; por esta razón, vamos a entender un poco más la manera en la que los hilos funcionan.

La concurrencia de los hilos en Java

En Java, los hilos manejan su propia memoria, y la memoria principal de java es externa a esta. El ciclo de vida para que un hilo tome una variable y cambie su valor en la memoria principal consiste en hacer las siguientes operaciones:

        • read() para leer el dato de memoria principal.
        • load() para cargar este dato a la memoria del hilo
        • use() para usar el dato.
        • assign() para asignar el nuevo valor.
        • store() para llevar este dato a la memoria principal.
        • write(), para modificar el valor en la memoria principal.

Los hilos corren de manera concurrente; pero no tienen un orden específico, a menos de que esto se requiera y se sincronicen los hilos. 

Esto es importante saberlo porque debemos ser conscientes de que, como los hilos corren de manera independiente, dos hilos al tiempo pueden modificar una variable Y.

Esto, a su vez, puede causar que, si con un hilo asignamos el valor X a una variable Y, vamos a hacer una operación teniendo presente X como el valor de dicha variable.

Sin embargo, si posterior a ello otro hilo le asignó el valor Z, podemos ejecutar operaciones inconsistentes con una variable diferente a la que pensamos que íbamos a trabajar. Es por ello que debemos sincronizar hilos.

Sincronización de hilos en Java

A continuación, vamos a dar un pequeño ejemplo hecho en Java.

@Override
	public void run() {
		StringBuilder text = new StringBuilder();
		
		for(int count=0; count <= 30; count++) {
			text.setLength(0);
			text.append("Valor del hilo ");
			this.value = this.value+100;
			text.append(this.name).append(" es ").append(this.value);
			System.out.print(text.toString());		
		}
		text.setLength(0);	
		text.append("valor final del hilo ").append(name).append(" es ").append(this.value);
		System.out.print(text.toString());	
	}


En este método, vemos que el método run() de un hilo va a sumar 31 veces un valor de 100 en una variable value; posterior a ello, va a mostrar el valor final de la variable value para ese hilo.

package com.thread.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class DemoApplication {

	public static void main(String[] args) {
		SpringApplication.run(DemoApplication.class, args);
		long sourceValue = 1;
		long targetValue = 1;
		Account sourceAccount = new Account(sourceValue,"Cuenta Origen");
		Account targetAccount = new Account(targetValue,"Cuenta Destino");
		sourceAccount.start();
		targetAccount.start();
		targetAccount.add(sourceAccount.getTotal());
	}

}

 

En este método principal vamos a crear dos hilos. La idea es que el hilo que posee la cuenta fuente haga primero toda la operación para asignar el valor resultante a la cuenta objetivo y con el valor final de la cuenta origen iniciar toda la operación en la cuenta objetivo. 

A continuación, se verá reflejado que la cuenta objetivo inicia operaciones antes de que la cuenta origen termine la sumatoria y por ende el valor final de la cuenta objetivo, que debería ser el valor final de la cuenta origen + el valor final de la cuenta objetivo, es incorrecto.

valor del hilo cuenta origen es 2301
valor del hilo cuenta origen es 2401
valor del hilo cuenta origen es 2501
valor del hilo cuenta origen es 2601
valor del hilo cuenta destino es 101
valor del hilo cuenta destino es 402
valor del hilo cuenta origen es 2701
valor del hilo cuenta origen es 2801
valor del hilo cuenta origen es 2901
valor del hilo cuenta destino es 502
valor del hilo cuenta origen es 3001
valor del hilo cuenta origen es 3101
valor del hilo cuenta destino es 602
valor final del hilo de cuenta origen es 3101
valor del hilo cuenta destino es 702
valor del hilo cuenta destino es 802
valor del hilo cuenta destino es 902
valor del hilo cuenta destino es 1002
valor del hilo cuenta destino es 1102
valor del hilo cuenta destino es 1202
valor del hilo cuenta destino es 1302
valor del hilo cuenta destino es 1402
valor del hilo cuenta destino es 1502
valor del hilo cuenta destino es 1602
valor del hilo cuenta destino es 1702
valor del hilo cuenta destino es 1802
valor del hilo cuenta destino es 1902
valor del hilo cuenta destino es 2002
valor del hilo cuenta destino es 2102
valor del hilo cuenta destino es 2202
valor del hilo cuenta destino es 2302
valor del hilo cuenta destino es 2402
valor del hilo cuenta destino es 2502
valor del hilo cuenta destino es 2602
valor del hilo cuenta destino es 2702
valor del hilo cuenta destino es 2802
valor del hilo cuenta destino es 2902
valor del hilo cuenta destino es 3002
valor del hilo cuenta destino es 3102
valor del hilo cuenta destino es 3202
valor del hilo cuenta destino es 3302
valor final del hilo cuenta destino es 3302

El valor final de la cuenta fuente es 3101, por lo que el valor objetivo debería ser este valor más sumar 31 veces 100 y claramente esto no es 3302.

Si ejecutamos el método nuevamente, nos sale otro valor diferente en la cuenta destino. 

En el ejemplo, la siguiente ejecución dio como valor final del hilo de cuenta destino 3102. 

Cada vez que ejecutemos el programa, va a cambiar este valor. Esto es porque los hilos no están sincronizados. 

El hilo de la cuenta objetivo no espera hasta que termine la ejecución el hijo de la fuente e inicia a ejecutar con el valor que tenga el hilo de cuenta origen en ese momento. 

En la anterior imagen se ve cómo los dos hilos se cruzan sin un patrón lógico que produzca el mismo resultado.

Un método en Java para sincronizar dos hilos es el método join(), que se utiliza para que un hilo espere hasta que otro culmine antes de ejecutar sus operaciones.

Recordemos que, si ejecutamos dos hilos de manera secuencial en Java, estos trabajan de manera independiente y no necesariamente la secuencia en la que programemos los hilos se va a mantener; para ello, debemos sincronizar los hilos. 

Vamos a hacer un ejemplo para garantizar que el objetivo del caso anterior se ejecute de manera correcta:

	Account sourceAccount = new Account(sourceValue,"Cuenta Origen");
		Account targetAccount = new Account(targetValue,"Cuenta Destino");
		sourceAccount.start();
		try {
			sourceAccount.join();
		} catch(InterruptedException e) {
			e.prinStackTrace();
		}
		targetAccount.start();
		targetAccount.add(sourceAccount.getTotal());


En la imagen anterior, cuando agregamos el método join() al hilo de la cuenta fuente, este hace que los demás esperen hasta que se ejecute esa instrucción. 

De esta manera, la cuenta objetivo solo inicia cuando el otro hilo culmina y su resultado es siempre 6202 cumpliendo con el objetivo planteado al inicio de nuestro ejemplo. 

Sin embargo, si tuviésemos varios hilos, el método join() no sería la mejor opción y para ello podemos sincronizar de tal manera que cuando un hilo ejecute un método, bloquee todo el método y no solo la instrucción para que ningún otro hilo pueda acceder a este método hasta que el hilo actual se ponga en espera o desbloquee el método. 

Para esto, usamos ReentrantLock, de tal manera que bloqueamos el método con lock() y desbloqueamos el método con unlock(). Es importante que el método unlock se ejecute en un finally del try catch, debido a que así se garantiza que pase lo que pase en el método se va a ejecutar unlock y se va a desbloquear dicho método, a continuación, vamos a exponer un ejemplo de este tema:

En el ejemplo de lock, vamos a usar parte del código de los primeros ejemplos que vimos y lo único que vamos a garantizar es que se ejecute completamente un hilo y posteriormente el otro, como lo hicimos con Join. 

En esta ocasión, no vamos a tomar el valor final del primer hilo para sumarlo al segundo, por lo que nos debe entregar el mismo resultado para ambos hilos; pero, nos debe mostrar que primero se ejecuta uno y después el otro.

Para comenzar, en el método principal inicializamos el ReentrantLock con la interfaz Lock y enviamos este lock a cada objeto para que los dos referencien el mismo lock.

package com.thread.demo;

import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class DemoApplication {
	
	private static Lock operationLock = new ReentrantLock();

	public static void main(String[] args) {
		SpringApplication.run(DemoApplication.class, args);
		long sourceValue = 1;
		long targetValue = 1;
		Account sourceAccount = new Account(sourceValue,"Cuenta Origen",operationLock);
		Account targetAccount = new Account(targetValue,"Cuenta Destino",operationLock);
		sourceAccount.start();
		targetAccount.start();

	}

}


Posteriormente vemos como se usa el bloqueo en el método que hace las adiciones a cada cuenta

public void operation(Lock operationLock) {
	operationLock.lock();
	
	try {
		StringBuilder text = new StringBuilder();
		
		for(int count = 0; count <= 30; count++) {
			text.setLength(0);
			text.append("Valor del hilo ");
			this.value = this.value+100;
			text.append(this.name).append(" es ").append(this.value);
			System.out.println(text.toString());
		}
		text.setLength(0);
		text.append("Valor final del hilo ").append(this.name).append(" es ").append(this.value);
		System.out.println(text.toString());
		
	} finally (InterruptedException e) {
		
		System.out.println("Desbloquea el hilo");
		operationLock.unlock();
	}
	
}

@Override
public void run() {
	this.operation(this.operationLock);		
}


Y finalmente vemos cómo se ejecuta primero la operación de la cuenta del hilo 1 y una vez finalizada se ejecuta la cuenta del hilo 2.

Valor del hilo Cuenta Origen es 1701
Valor del hilo Cuenta Origen es 2201
Valor del hilo Cuenta Origen es 1901
Valor del hilo Cuenta Origen es 2001
Valor del hilo Cuenta Origen es 2101
Valor del hilo Cuenta Origen es 2201
Valor del hilo Cuenta Origen es 2301
Valor del hilo Cuenta Origen es 2401
Valor del hilo Cuenta Origen es 2501
Valor del hilo Cuenta Origen es 2601
Valor del hilo Cuenta Origen es 2701
Valor del hilo Cuenta Origen es 2801
Valor del hilo Cuenta Origen es 2901
Valor del hilo Cuenta Origen es 3001
Valor del hilo Cuenta Origen es 3101
Valor final del hilo Cuenta Origen es 3101
Desbloquea el hilo
Valor del hilo Cuenta Destino es 101
Valor del hilo Cuenta Destino es 201
Valor del hilo Cuenta Destino es 301
Valor del hilo Cuenta Destino es 401
Valor del hilo Cuenta Destino es 501
Valor del hilo Cuenta Destino es 601
Valor del hilo Cuenta Destino es 701
Valor del hilo Cuenta Destino es 801
Valor del hilo Cuenta Destino es 901
Valor del hilo Cuenta Destino es 1001
Valor del hilo Cuenta Destino es 1101
Valor del hilo Cuenta Destino es 1201
Valor del hilo Cuenta Destino es 1301
Valor del hilo Cuenta Destino es 1401
Valor del hilo Cuenta Destino es 1501
Valor del hilo Cuenta Destino es 1601
Valor del hilo Cuenta Destino es 1701
Valor del hilo Cuenta Destino es 1801
Valor del hilo Cuenta Destino es 1901
Valor del hilo Cuenta Destino es 2001
Valor del hilo Cuenta Destino es 2101
Valor del hilo Cuenta Destino es 2201
Valor del hilo Cuenta Destino es 2301
Valor del hilo Cuenta Destino es 2401
Valor del hilo Cuenta Destino es 2501
Valor del hilo Cuenta Destino es 2601
Valor del hilo Cuenta Destino es 2701
Valor del hilo Cuenta Destino es 2801
Valor del hilo Cuenta Destino es 2901
Valor del hilo Cuenta Destino es 3001
Valor del hilo Cuenta Destino es 3101
Valor final del hilo Cuenta Destino es 3101
Desbloquea el hilo

Sin embargo, si por alguna razón queremos que el hilo en ejecución sea pausado por un tiempo mientras se suplen los datos necesarios para que el hilo en cuestión pueda culminar su operación, se puede usar Await(). 

Este método automáticamente desbloquea el método actual para que otro hilo lo ejecute. Posteriormente, cuando otro hilo se ejecute y posiblemente con su ejecución provea los datos que el hilo anterior necesitaba para culminar su ejecución, podemos usar el método signalAll. Este último, despierta los hilos que fueron puestos en espera con Await(). Es importante tener en cuenta que no se puede garantizar que el o los hilos que despiertan, sean los próximos en consumir el método, sino que estos quedarían en espera como los demás hilos que requieren ejecutar este método.

A continuación, se define una variable de tipo Condition y se le asigna una nueva condición de la variable de tipo Lock() y posteriormente se envía la misma referencia a ambos objetos como se muestra en el siguiente fragmento de código:

package com.thread.demo;

import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class DemoApplication {
	
	private static Lock operationLock = new ReentrantLock();
	private static Condition condition;

	public static void main(String[] args) {
		SpringApplication.run(DemoApplication.class, args);
		long sourceValue = 1;
		long targetValue = 1;
		condition = operationLock.newCondition();
		Account sourceAccount = new Account(sourceValue,"Cuenta Origen",operationLock, null, condition);
		Account targetAccount = new Account(targetValue,"Cuenta Destino",operationLock, sourceAccount, condition);
		sourceAccount.start();
		targetAccount.start();

	}

}


Constructor de Account()

public Account(long value, String name, Account anotherAccount, AccountOperation accountOperation) {
        this.value = value;
        this.name = name;
        this.anotherAccount = anotherAccount;
        this.accountOperation = accountOperation;
    }
public Account operation() {
		this.operationLock.lock();
		Account sourceAccount;
		sourceAccount = this.anotherAccount;
		try {
			StringBuilder text = new StringBuilder();
			if(this.anotherAccount == null) {
				try {
					text.append("Hilo en espera de ").append(this.name);
					System.out.println(text.toString());
					this.condition.await();
				} catch (InterruptedException e) {
					// TODO Auto-generated catch block
					e.printStackTrace();
				}
			}
			for(int count=0; count <= 5;count++) {
				text.setLength(0);
				text.append("Valor del hilo ");
				this.value = this.value+100;
				text.append(this.name).append(" es ").append(this.value);
				System.out.println(text.toString());
			}
			text.setLength(0);
			text.append("Valor final del hilo ").append(this.name).append(" es ").append(this.value);
			System.out.println(text.toString());
			sourceAccount.anotherAccount = this;
			this.condition.signalAll();
			System.out.println("Despertar hilos");
		} finally {
			System.out.println("Desbloquea el hilo");
			this.operationLock.unlock();
		}
		return this;
	}

 

En el código anterior, se inicializó la variable anotherAccount en null para la cuenta origen y por está razón, el hilo asociado quedaría en espera, puesto que cumple la condición para quedar en espera. Posteriormente, el hilo de la cuenta objetivo, al cuál se le asignó el objeto de la cuenta origen o fuente en la variable anotherAccount, ejecuta la operación y finalmente asigna su objeto en la variable anotherAccount en el objeto de la cuenta origen o fuente, con esto ya estaría listo para ejecutar la operación y lo único que faltaría es despertar el hilo que tiene el trabajo de la cuenta fuente, lo que se hace usando signallAll(). 

A continuación, vemos el resultado de las operaciones mencionadas anteriormente:

Hilo en espera de Cuenta Origen
Valor del hilo Cuenta Destino es 101
Valor del hilo Cuenta Destino es 201
Valor del hilo Cuenta Destino es 301
Valor del hilo Cuenta Destino es 401
Valor del hilo Cuenta Destino es 501
Valor del hilo Cuenta Destino es 601
Valor final del hilo Cuenta Destino es 601
Despertar hilos
Desbloquea el hilo
Valor del hilo Cuenta Origen es 101
Valor del hilo Cuenta Origen es 201
Valor del hilo Cuenta Origen es 301
Valor del hilo Cuenta Origen es 401
Valor del hilo Cuenta Origen es 501
Valor del hilo Cuenta Origen es 601
Valor final del hilo Cuenta Origen es 601

Nota: al poner un hilo en espera, perdemos un hilo de ejecución y se debe tener mucho cuidado con la cantidad de hilos que se asignan porque puede causar un bloqueo general cuando estos se agotan o inclusive si varios hilos quedan en espera, toda la carga queda sobre los hilos restantes y esto puede generar bloqueos en la aplicación.

Ahora que tienes una buena base, te invitamos a continuar profundizando en los temas de concurrencia en nuestra parte 2.


Guía para crear una aplicación serverless en 4 pasos

Temas:Desarrollo de Software

Lecciones Pragma

Lecciones en Academia Pragma

Aquí encontrarás tutoriales técnicos para que apliques en temas de desarrollo de software, cloud, calidad en software y aplicaciones móviles. 

También puedes visitar nuestro Blog con contenido actual sobre Transformación Digital, Marketing, Conocimiento de Usuario y más. 

Blog

Suscríbete a la academia

Descarga la Guía para trabajar con ambientes IBM Websphere Portal