Explorando el Mundo de los Paradigmas de Programación — Programación Concurrente

MasonCoding
23 min readMay 31, 2024

--

¡Hola a todos! Continuamos nuestra serie sobre paradigmas de programación, esta vez explorando el potente y complejo mundo de la Programación Concurrente. Este paradigma es fundamental para el desarrollo de software moderno que puede manejar múltiples tareas simultáneamente, mejorando así el rendimiento y la capacidad de respuesta de las aplicaciones.

Concepto de Programación Concurrente

La programación concurrente es un paradigma que permite la ejecución de múltiples procesos o hilos de manera simultánea. Este enfoque es crucial para maximizar el rendimiento en sistemas multiprocesador y mejorar la eficiencia de los recursos. En términos más técnicos, la concurrencia permite que diferentes partes de un programa se ejecuten en secuencia independiente, pero potencialmente simultánea, facilitando así la multitarea dentro de una aplicación.

Características Principales de la Programación Concurrente

Paralelismo:

El paralelismo es una técnica de programación concurrente que permite la ejecución simultánea de múltiples tareas o procesos en un sistema multicore. A diferencia de la multitarea tradicional, donde los hilos se alternan en la ejecución, el paralelismo ejecuta múltiples hilos al mismo tiempo en diferentes núcleos de CPU. Esto no solo mejora el rendimiento al reducir el tiempo total de ejecución de un programa, sino que también permite una mejor utilización de los recursos del sistema. En resumen, el paralelismo divide un problema grande en subproblemas más pequeños, los resuelve simultáneamente y luego combina los resultados.

Para ilustrar el paralelismo en los lenguajes Go, Python, Rust, Kotlin y Ruby, resolveremos el problema universal de calcular la suma de un gran array de números enteros utilizando múltiples hilos o gorutinas. Este problema es ideal para demostrar el paralelismo ya que se puede dividir en subtareas independientes, cada una de las cuales puede ejecutarse en paralelo.

Go:

package main

import (
"fmt"
"sync"
)

func sum(arr []int, c chan int, wg *sync.WaitGroup) {
total := 0
for _, v := range arr {
total += v
}
c <- total
wg.Done()
}

func main() {
arr := make([]int, 1000000)
for i := 0; i < 1000000; i++ {
arr[i] = i + 1
}

c := make(chan int)
var wg sync.WaitGroup
numGoroutines := 4
partSize := len(arr) / numGoroutines

for i := 0; i < numGoroutines; i++ {
start := i * partSize
end := start + partSize
wg.Add(1)
go sum(arr[start:end], c, &wg)
}

go func() {
wg.Wait()
close(c)
}()

totalSum := 0
for partialSum := range c {
totalSum += partialSum
}

fmt.Println("Total Sum:", totalSum)
}

En Go, utilizamos gorutinas y canales para dividir la tarea de sumar un gran array en múltiples partes. Cada gorutina calculará la suma de una porción del array y enviará el resultado a un canal. Finalmente, el resultado global se obtiene sumando los resultados parciales.

Python:

import concurrent.futures

def sum_part(arr):
return sum(arr)

if __name__ == "__main__":
arr = [i + 1 for i in range(1000000)]
num_threads = 4
part_size = len(arr) // num_threads

with concurrent.futures.ThreadPoolExecutor(max_workers=num_threads) as executor:
futures = [executor.submit(sum_part, arr[i*part_size:(i+1)*part_size]) for i in range(num_threads)]
total_sum = sum(f.result() for f in concurrent.futures.as_completed(futures))

print("Total Sum:", total_sum)

En Python, usamos el módulo concurrent.futures para ejecutar múltiples hilos en paralelo. Cada hilo se encargará de sumar una porción del array y el resultado final se obtendrá sumando los resultados de todos los hilos.

Rust:

use rayon::prelude::*;

fn main() {
let arr: Vec<i32> = (1..=1000000).collect();
let total_sum: i32 = arr.par_iter().sum();
println!("Total Sum: {}", total_sum);
}

En Rust, utilizamos el crate rayon para dividir la tarea de sumar el array en partes y ejecutarlas en paralelo. rayon facilita el trabajo con paralelismo mediante su API de iteradores paralelos.

Kotlin:

import kotlinx.coroutines.*
import kotlin.system.measureTimeMillis

suspend fun sumPart(arr: List<Int>): Int {
return arr.sum()
}

fun main() = runBlocking {
val arr = List(1000000) { it + 1 }
val numCoroutines = 4
val partSize = arr.size / numCoroutines

val time = measureTimeMillis {
val deferred = (0 until numCoroutines).map {
async(Dispatchers.Default) {
sumPart(arr.subList(it * partSize, (it + 1) * partSize))
}
}

val totalSum = deferred.sumOf { it.await() }
println("Total Sum: $totalSum")
}
println("Completed in $time ms")
}

En Kotlin, empleamos coroutines para paralelizar la suma del array. Usamos el contexto Dispatchers.Default para ejecutar las coroutines en un pool de hilos.

Ruby:

require 'concurrent'

def sum_part(arr)
arr.sum
end

arr = (1..1_000_000).to_a
num_threads = 4
part_size = arr.size / num_threads

futures = (0...num_threads).map do |i|
Concurrent::Future.execute { sum_part(arr[i * part_size, part_size]) }
end

total_sum = futures.map(&:value).sum
puts "Total Sum: #{total_sum}"

En Ruby, usamos el módulo Concurrent para crear futuros (futures) que se ejecutan en paralelo. Cada futuro calcula la suma de una porción del array y el resultado final se obtiene sumando los resultados de todos los futuros.

Estos ejemplos muestran cómo diferentes lenguajes de programación implementan el paralelismo para resolver el mismo problema de manera eficiente. Cada lenguaje tiene sus propias herramientas y bibliotecas que facilitan la concurrencia y el paralelismo, permitiendo a los desarrolladores aprovechar al máximo los recursos del hardware moderno.

Sincronización

La sincronización en la programación concurrente es un mecanismo que asegura que varios hilos o procesos pueden operar sobre datos compartidos de manera segura y coherente. Sin la sincronización adecuada, puede haber condiciones de carrera, donde dos o más hilos intentan modificar el mismo recurso simultáneamente, resultando en comportamientos impredecibles y errores. Los mecanismos de sincronización más comunes incluyen bloqueos (locks), semáforos, y variables de condición (condition variables).

Para ilustrar la sincronización en los lenguajes Go, Python, Rust, Kotlin y Ruby, resolveremos el problema universal de incrementar un contador compartido desde múltiples hilos. Este problema es ideal para demostrar la sincronización ya que múltiples hilos deben acceder y modificar una variable compartida de manera segura.

Go:

package main

import (
"fmt"
"sync"
)

func increment(wg *sync.WaitGroup, mu *sync.Mutex, counter *int) {
defer wg.Done()
for i := 0; i < 1000; i++ {
mu.Lock()
*counter++
mu.Unlock()
}
}

func main() {
var counter int
var wg sync.WaitGroup
var mu sync.Mutex

for i := 0; i < 10; i++ {
wg.Add(1)
go increment(&wg, &mu, &counter)
}

wg.Wait()
fmt.Println("Final Counter:", counter)
}

En Go, utilizamos un mutex para asegurar que solo un gorutina puede acceder y modificar el contador a la vez.

Python:

import threading

counter = 0
lock = threading.Lock()

def increment():
global counter
for _ in range(1000):
with lock:
counter += 1

threads = []
for _ in range(10):
thread = threading.Thread(target=increment)
threads.append(thread)
thread.start()

for thread in threads:
thread.join()

print("Final Counter:", counter)

En Python, usamos el módulo threading y un Lock para sincronizar el acceso al contador compartido.

Rust:

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];

for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
for _ in 0..1000 {
let mut num = counter.lock().unwrap();
*num += 1;
}
});
handles.push(handle);
}

for handle in handles {
handle.join().unwrap();
}

println!("Final Counter: {}", *counter.lock().unwrap());
}

En Rust, utilizamos Mutex de la biblioteca estándar para sincronizar el acceso a un contador compartido.

Kotlin:

import java.util.concurrent.locks.ReentrantLock
import kotlinx.coroutines.*

var counter = 0
val lock = ReentrantLock()

fun increment() {
repeat(1000) {
lock.lock()
try {
counter++
} finally {
lock.unlock()
}
}
}

fun main() = runBlocking {
val jobs = List(10) {
GlobalScope.launch(Dispatchers.Default) {
increment()
}
}
jobs.forEach { it.join() }
println("Final Counter: $counter")
}

En Kotlin, empleamos ReentrantLock para sincronizar el acceso a un contador compartido.

Ruby:

require 'thread'

counter = 0
mutex = Mutex.new

threads = 10.times.map do
Thread.new do
1000.times do
mutex.synchronize do
counter += 1
end
end
end
end

threads.each(&:join)
puts "Final Counter: #{counter}"

En Ruby, utilizamos el módulo Mutex para sincronizar el acceso a un contador compartido.

Estos ejemplos demuestran cómo diferentes lenguajes de programación implementan mecanismos de sincronización para asegurar el acceso seguro a recursos compartidos. La sincronización es crucial para evitar condiciones de carrera y asegurar la coherencia de los datos en aplicaciones concurrentes, permitiendo un funcionamiento correcto y predecible del software.

Control de concurrencia

El control de concurrencia es el conjunto de técnicas y mecanismos que permiten la correcta ejecución de múltiples hilos o procesos en un entorno concurrente. Estos mecanismos aseguran que los hilos no interfieran entre sí de manera perjudicial, previniendo condiciones de carrera y otros problemas asociados con la concurrencia. Las técnicas de control de concurrencia incluyen la utilización de bloqueos (locks), semáforos, monitores, y otros métodos para coordinar la ejecución de hilos y procesos.

Para ilustrar el control de concurrencia en los lenguajes Go, Python, Rust, Kotlin y Ruby, resolveremos el problema universal de gestionar el acceso concurrente a una cola compartida. Este problema es ideal para demostrar el control de concurrencia ya que múltiples hilos deben acceder y modificar una estructura de datos compartida de manera segura y ordenada.

Go:

package main

import (
"container/list"
"fmt"
"sync"
"time"
)

type Queue struct {
items *list.List
mu sync.Mutex
cond *sync.Cond
}

func NewQueue() *Queue {
q := &Queue{
items: list.New(),
}
q.cond = sync.NewCond(&q.mu)
return q
}

func (q *Queue) Enqueue(item int) {
q.mu.Lock()
q.items.PushBack(item)
q.cond.Signal()
q.mu.Unlock()
}

func (q *Queue) Dequeue() int {
q.mu.Lock()
for q.items.Len() == 0 {
q.cond.Wait()
}
e := q.items.Front()
q.items.Remove(e)
q.mu.Unlock()
return e.Value.(int)
}

func main() {
q := NewQueue()

// Producer
go func() {
for i := 0; i < 10; i++ {
q.Enqueue(i)
fmt.Println("Produced:", i)
time.Sleep(time.Millisecond * 100)
}
}()

// Consumer
go func() {
for i := 0; i < 10; i++ {
item := q.Dequeue()
fmt.Println("Consumed:", item)
}
}()

time.Sleep(time.Second * 2)
}

En Go, utilizamos un mutex y un condicional para controlar el acceso a una cola compartida entre múltiples productores y consumidores.

Python:

import threading
import queue
import time

q = queue.Queue()

def producer():
for i in range(10):
q.put(i)
print("Produced:", i)
time.sleep(0.1)

def consumer():
for i in range(10):
item = q.get()
print("Consumed:", item)
q.task_done()

producer_thread = threading.Thread(target=producer)
consumer_thread = threading.Thread(target=consumer)

producer_thread.start()
consumer_thread.start()

producer_thread.join()
consumer_thread.join()

En Python, usamos el módulo queue y el módulo threading para controlar el acceso a una cola compartida entre múltiples productores y consumidores.

Rust:

use crossbeam::channel;
use std::thread;
use std::time::Duration;

fn main() {
let (sender, receiver) = channel::unbounded();

// Producer
let producer = thread::spawn(move || {
for i in 0..10 {
sender.send(i).unwrap();
println!("Produced: {}", i);
thread::sleep(Duration::from_millis(100));
}
});

// Consumer
let consumer = thread::spawn(move || {
for _ in 0..10 {
let item = receiver.recv().unwrap();
println!("Consumed: {}", item);
}
});

producer.join().unwrap();
consumer.join().unwrap();
}

En Rust, utilizamos el crate crossbeam para controlar el acceso a una cola compartida entre múltiples productores y consumidores.

Kotlin:

import kotlinx.coroutines.*
import java.util.concurrent.LinkedBlockingQueue

val queue = LinkedBlockingQueue<Int>()

fun main() = runBlocking {
val producer = launch(Dispatchers.Default) {
for (i in 0..9) {
queue.put(i)
println("Produced: $i")
delay(100)
}
}

val consumer = launch(Dispatchers.Default) {
repeat(10) {
val item = queue.take()
println("Consumed: $item")
}
}

producer.join()
consumer.join()
}

En Kotlin, empleamos BlockingQueue y coroutines para controlar el acceso a una cola compartida entre múltiples productores y consumidores.

Ruby:

require 'thread'

queue = Queue.new

producer = Thread.new do
10.times do |i|
queue << i
puts "Produced: #{i}"
sleep 0.1
end
end

consumer = Thread.new do
10.times do
item = queue.pop
puts "Consumed: #{item}"
end
end

producer.join
consumer.join

En Ruby, utilizamos Queue y Thread para controlar el acceso a una cola compartida entre múltiples productores y consumidores.

Estos ejemplos demuestran cómo diferentes lenguajes de programación implementan mecanismos de control de concurrencia para gestionar el acceso a recursos compartidos. El control de concurrencia es crucial para asegurar que las aplicaciones concurrentes funcionen de manera correcta y eficiente, permitiendo una colaboración segura entre múltiples hilos y procesos.

Comunicación entre procesos

La comunicación entre procesos es un aspecto crucial de la programación concurrente que permite que diferentes procesos intercambien información y coordinen su trabajo. Los procesos pueden comunicarse a través de varios mecanismos, como pipes, sockets, memoria compartida, y colas de mensajes. La comunicación entre procesos es fundamental para construir sistemas distribuidos, aplicaciones multihilo y sistemas operativos modernos.

Para ilustrar la comunicación entre procesos en los lenguajes Go, Python, Rust, Kotlin y Ruby, resolveremos el problema universal de coordinar el trabajo entre dos procesos utilizando un mecanismo específico de comunicación.

Go:

package main

import (
"fmt"
)

func sender(ch chan<- int) {
for i := 0; i < 5; i++ {
ch <- i
}
close(ch)
}

func receiver(ch <-chan int) {
for {
if val, ok := <-ch; ok {
fmt.Println("Received:", val)
} else {
break
}
}
}

func main() {
ch := make(chan int)
go sender(ch)
receiver(ch)
}

En Go, utilizamos canales para la comunicación entre dos gorutinas, donde una gorutina envía datos y la otra los recibe.

Python:

from multiprocessing import Process, Pipe

def sender(conn):
for i in range(5):
conn.send(i)
conn.close()

def receiver(conn):
while True:
try:
val = conn.recv()
print("Received:", val)
except EOFError:
break

if __name__ == "__main__":
parent_conn, child_conn = Pipe()
sender_proc = Process(target=sender, args=(child_conn,))
receiver_proc = Process(target=receiver, args=(parent_conn,))
sender_proc.start()
receiver_proc.start()
sender_proc.join()
receiver_proc.join()

En Python, utilizamos el módulo multiprocessing y pipes para la comunicación entre dos procesos.

Rust:

use std::thread;
use crossbeam::channel;

fn sender(tx: channel::Sender<i32>) {
for i in 0..5 {
tx.send(i).unwrap();
}
}

fn receiver(rx: channel::Receiver<i32>) {
for val in rx {
println!("Received: {}", val);
}
}

fn main() {
let (tx, rx) = channel::unbounded();
let sender_handle = thread::spawn(move || sender(tx));
let receiver_handle = thread::spawn(move || receiver(rx));
sender_handle.join().unwrap();
receiver_handle.join().unwrap();
}

En Rust, utilizamos canales (channels) del crate crossbeam para la comunicación entre dos threads.

Kotlin

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*

fun sender(channel: Channel<Int>) {
repeat(5) {
channel.send(it)
}
channel.close()
}

fun receiver(channel: ReceiveChannel<Int>) {
for (value in channel) {
println("Received: $value")
}
}

fun main() = runBlocking {
val channel = Channel<Int>()
val senderJob = launch { sender(channel) }
val receiverJob = launch { receiver(channel) }
senderJob.join()
receiverJob.join()
}

En Kotlin, utilizamos canales (channels) de coroutines para la comunicación entre dos coroutines.

Ruby:

reader, writer = IO.pipe

sender = fork do
writer.close
5.times do |i|
reader.puts(i)
end
end

receiver = fork do
reader.close
while (line = writer.gets)
puts "Received: #{line.chomp}"
end
end

Process.wait(sender)
Process.wait(receiver)

En Ruby, utilizamos IO.pipe para la comunicación entre dos procesos a través de un pipe.

La comunicación entre procesos es esencial para la construcción de sistemas concurrentes y distribuidos. Los diferentes lenguajes de programación proporcionan mecanismos específicos para facilitar esta comunicación, como canales, pipes, y sockets. Al comprender y utilizar adecuadamente estos mecanismos, los desarrolladores pueden construir sistemas robustos y escalables que pueden coordinar el trabajo entre múltiples procesos de manera eficiente.

Interacción entre hilos

La interacción entre hilos es un aspecto fundamental de la programación concurrente que permite la coordinación y comunicación entre diferentes hilos en un proceso. Los hilos pueden interactuar entre sí a través de diversas técnicas, como el uso de variables compartidas, semáforos, mutex, y otras estructuras de datos sincronizadas. La interacción entre hilos es esencial para construir aplicaciones multihilo que puedan ejecutar tareas de manera concurrente de manera segura y eficiente.

Para ilustrar la interacción entre hilos en los lenguajes Go, Python, Rust, Kotlin y Ruby, resolveremos el problema universal de coordinar el trabajo entre múltiples hilos utilizando estructuras de datos compartidas y mecanismos de sincronización.

Go:

package main

import (
"fmt"
"sync"
)

var counter int
var mu sync.Mutex

func increment(wg *sync.WaitGroup) {
defer wg.Done()
mu.Lock()
counter++
mu.Unlock()
}

func main() {
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go increment(&wg)
}
wg.Wait()
fmt.Println("Final Counter:", counter)
}

En Go, utilizamos un mutex para controlar el acceso a una variable compartida entre múltiples gorutinas.

Python:

import threading

counter = 0
lock = threading.Lock()

def increment():
global counter
with lock:
counter += 1

threads = []
for _ in range(10):
thread = threading.Thread(target=increment)
threads.append(thread)
thread.start()

for thread in threads:
thread.join()

print("Final Counter:", counter)

En Python, utilizamos un Lock para controlar el acceso a una variable compartida entre múltiples hilos.

Rust:

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];

for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}

for handle in handles {
handle.join().unwrap();
}

println!("Final Counter: {}", *counter.lock().unwrap());
}

En Rust, utilizamos un Mutex para controlar el acceso a una variable compartida entre múltiples threads.

Kotlin:

import kotlinx.coroutines.*
import java.util.concurrent.atomic.AtomicInteger

val counter = AtomicInteger(0)
val mutex = Mutex()

suspend fun increment() {
mutex.withLock {
counter.incrementAndGet()
}
}

fun main() = runBlocking {
val jobs = List(10) {
GlobalScope.launch {
increment()
}
}
jobs.forEach { it.join() }
println("Final Counter: ${counter.get()}")
}

En Kotlin, utilizamos un Mutex para controlar el acceso a una variable compartida entre múltiples coroutines.

Ruby:

require 'thread'

counter = 0
mutex = Mutex.new

threads = 10.times.map do
Thread.new do
mutex.synchronize do
counter += 1
end
end
end

threads.each(&:join)
puts "Final Counter: #{counter}"

En Ruby, utilizamos Mutex para controlar el acceso a una variable compartida entre múltiples threads.

La interacción entre hilos es esencial para construir aplicaciones multihilo que puedan ejecutar tareas de manera concurrente de manera segura y eficiente. Los diferentes lenguajes de programación proporcionan mecanismos específicos para facilitar esta interacción, como Mutex, Lock, y otras estructuras de datos sincronizadas. Al comprender y utilizar adecuadamente estos mecanismos, los desarrolladores pueden construir sistemas concurrentes robustos y escalables que puedan coordinar el trabajo entre múltiples hilos de manera efectiva.

Características Secundarias de la Programación Concurrente

Desempeño mejorado en sistemas multiprocesador

La programación concurrente ofrece un desempeño mejorado en sistemas multiprocesador al permitir que múltiples tareas se ejecuten simultáneamente en diferentes núcleos de CPU. Esto aprovecha al máximo los recursos disponibles y reduce el tiempo total de ejecución de un programa al distribuir la carga de trabajo de manera más eficiente.

Para ilustrar la mejora en el desempeño en sistemas multiprocesador utilizando programación concurrente, presentamos ejemplos en los lenguajes Go, Python, Rust, Kotlin y Ruby que demuestran cómo distribuir tareas entre múltiples hilos o procesos puede acelerar la ejecución de un programa.

Go:

package main

import (
"fmt"
"time"
)

func calcular(id int) {
result := 0
for i := 0; i < 1000000000; i++ {
result += i
}
fmt.Printf("Gorutina %d: Resultado = %d\n", id, result)
}

func main() {
for i := 0; i < 4; i++ {
go calcular(i)
}
time.Sleep(time.Second)
}

En Go, podemos aprovechar la concurrencia para realizar cálculos intensivos en paralelo, distribuyendo la carga entre múltiples gorutinas para utilizar eficientemente los núcleos de CPU disponibles.

Python:

import threading

def calcular():
result = 0
for i in range(1000000000):
result += i
print("Hilo:", threading.current_thread().name, "Resultado:", result)

threads = []
for i in range(4):
thread = threading.Thread(target=calcular)
threads.append(thread)
thread.start()

for thread in threads:
thread.join()

En Python, podemos utilizar múltiples hilos para realizar cálculos intensivos en paralelo y aprovechar los recursos de la CPU de manera más efectiva.

Rust:

use std::thread;

fn calcular(id: usize) {
let mut result = 0;
for i in 0..1000000000 {
result += i;
}
println!("Thread {}: Resultado = {}", id, result);
}

fn main() {
let mut handles = vec![];
for i in 0..4 {
let handle = thread::spawn(move || {
calcular(i);
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
}

En Rust, podemos utilizar threads para realizar cálculos intensivos en paralelo, aprovechando así los múltiples núcleos de la CPU.

Kotlin:

import kotlinx.coroutines.*

suspend fun calcular(id: Int) {
var result = 0L
for (i in 0 until 1000000000) {
result += i
}
println("Corutina $id: Resultado = $result")
}

fun main() = runBlocking {
val jobs = List(4) { id ->
GlobalScope.launch {
calcular(id)
}
}
jobs.forEach { it.join() }
}

En Kotlin, podemos utilizar coroutines para realizar cálculos intensivos en paralelo y aprovechar así los múltiples núcleos de la CPU de manera más efectiva.

Ruby:

threads = []
4.times do |i|
threads << Thread.new do
result = 0
1000000000.times do |j|
result += j
end
puts "Hilo #{i}: Resultado = #{result}"
end
end

threads.each(&:join)

En Ruby, podemos utilizar múltiples threads para realizar cálculos intensivos en paralelo y aprovechar así los múltiples núcleos de la CPU.

Estos ejemplos demuestran cómo la programación concurrente puede mejorar el desempeño en sistemas multiprocesador al distribuir tareas entre múltiples hilos o procesos. Al aprovechar al máximo los recursos disponibles, los programas concurrentes pueden ejecutarse de manera más eficiente y reducir significativamente el tiempo total de ejecución. La capacidad de ejecutar tareas en paralelo es una ventaja clave de la programación concurrente en entornos multiprocesador, lo que la hace invaluable en aplicaciones que requieren un alto rendimiento y una utilización eficiente de los recursos de la CPU.

Utilización eficiente de recursos

La programación concurrente permite una utilización más eficiente de los recursos del sistema, como la CPU, la memoria y los dispositivos de entrada/salida. Al permitir que múltiples tareas se ejecuten simultáneamente y aprovechar al máximo los tiempos de espera, la programación concurrente puede mejorar significativamente el rendimiento y la eficiencia de las aplicaciones.

Para ilustrar la utilización eficiente de recursos, presentaremos ejemplos en los lenguajes Go, Python, Rust, Kotlin y Ruby que demuestran cómo distribuir tareas de E/S y computacionales entre múltiples hilos o procesos puede mejorar la eficiencia de un programa.

Go:

package main

import (
"fmt"
"net/http"
"time"
)

func fetchURL(url string, ch chan<- string) {
start := time.Now()
resp, err := http.Get(url)
if err != nil {
ch <- fmt.Sprintf("Error fetching %s: %v", url, err)
return
}
ch <- fmt.Sprintf("Fetched %s in %v", url, time.Since(start))
resp.Body.Close()
}

func main() {
urls := []string{
"https://www.google.com",
"https://www.facebook.com",
"https://www.twitter.com",
}
ch := make(chan string)
for _, url := range urls {
go fetchURL(url, ch)
}
for range urls {
fmt.Println(<-ch)
}
}

En Go, utilizamos gorutinas para realizar múltiples operaciones de E/S y cómputo en paralelo, mejorando la utilización de los recursos del sistema.

Python:

import concurrent.futures
import requests
import time

def fetch_url(url):
start = time.time()
try:
response = requests.get(url)
return f"Fetched {url} in {time.time() - start} seconds"
except requests.RequestException as e:
return f"Error fetching {url}: {e}"

urls = [
"https://www.google.com",
"https://www.facebook.com",
"https://www.twitter.com",
]

with concurrent.futures.ThreadPoolExecutor() as executor:
results = executor.map(fetch_url, urls)

for result in results:
print(result)

En Python, utilizamos el módulo concurrent.futures para realizar múltiples operaciones de E/S y cómputo en paralelo, mejorando la utilización de los recursos del sistema.

Rust:

use tokio::time::{sleep, Duration};
use reqwest;
use std::time::Instant;

async fn fetch_url(url: &str) -> Result<String, reqwest::Error> {
let start = Instant::now();
let response = reqwest::get(url).await?;
let duration = start.elapsed();
Ok(format!("Fetched {} in {:?}", url, duration))
}

#[tokio::main]
async fn main() {
let urls = vec![
"https://www.google.com",
"https://www.facebook.com",
"https://www.twitter.com",
];
let mut handles = vec![];

for url in urls {
let handle = tokio::spawn(async move {
match fetch_url(&url).await {
Ok(result) => println!("{}", result),
Err(e) => eprintln!("Error fetching {}: {}", url, e),
}
});
handles.push(handle);
}

for handle in handles {
handle.await.unwrap();
}
}

En Rust, utilizamos async y await junto con tokio para realizar múltiples operaciones de E/S en paralelo, mejorando la utilización de los recursos del sistema.

Kotlin:

import kotlinx.coroutines.*
import java.net.URL
import kotlin.system.measureTimeMillis

suspend fun fetchUrl(url: String): String {
return withContext(Dispatchers.IO) {
val time = measureTimeMillis {
URL(url).readText()
}
"Fetched $url in $time ms"
}
}

fun main() = runBlocking {
val urls = listOf(
"https://www.google.com",
"https://www.facebook.com",
"https://www.twitter.com"
)

val results = urls.map { url ->
async { fetchUrl(url) }
}.awaitAll()

results.forEach { println(it) }
}

En Kotlin, utilizamos coroutines para realizar múltiples operaciones de E/S y cómputo en paralelo, mejorando la utilización de los recursos del sistema.

Ruby:

require 'net/http'
require 'uri'

urls = [
"https://www.google.com",
"https://www.facebook.com",
"https://www.twitter.com"
]

threads = urls.map do |url|
Thread.new do
start = Time.now
uri = URI(url)
response = Net::HTTP.get(uri)
puts "Fetched #{url} in #{Time.now - start} seconds"
rescue => e
puts "Error fetching #{url}: #{e}"
end
end

threads.each(&:join)

En Ruby, utilizamos múltiples threads para realizar múltiples operaciones de E/S y cómputo en paralelo, mejorando la utilización de los recursos del sistema.

Estos ejemplos demuestran cómo la programación concurrente permite una utilización más eficiente de los recursos del sistema al realizar múltiples operaciones de E/S y cómputo en paralelo. Al aprovechar al máximo los tiempos de espera y distribuir la carga de trabajo entre múltiples hilos o procesos, las aplicaciones concurrentes pueden mejorar significativamente su rendimiento y eficiencia. La capacidad de ejecutar tareas simultáneamente es una ventaja clave de la programación concurrente, lo que la hace invaluable en aplicaciones que requieren una utilización eficiente de los recursos del sistema.

Escalabilidad

La escalabilidad es la capacidad de un sistema para manejar una cantidad creciente de trabajo o su potencial para acomodar el crecimiento. En términos de programación concurrente, la escalabilidad se refiere a cómo un sistema puede ampliar su capacidad de procesamiento al distribuir tareas entre múltiples hilos, procesos, o nodos en una red. Esto permite a las aplicaciones gestionar mayores volúmenes de datos y un mayor número de usuarios sin degradar el rendimiento.

Para ilustrar la escalabilidad utilizando programación concurrente, presentaremos ejemplos en los lenguajes Go, Python, Rust, Kotlin y Ruby que demuestran cómo distribuir tareas entre múltiples hilos o procesos puede permitir que un sistema maneje cargas de trabajo crecientes de manera eficiente.

Go:

package main

import (
"fmt"
"net/http"
)

func handler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hola, concurrente mundo!")
}

func main() {
http.HandleFunc("/", handler)
fmt.Println("Escuchando en :8080")
http.ListenAndServe(":8080", nil)
}

En Go, podemos escalar un servidor web básico que maneja múltiples solicitudes simultáneamente utilizando gorutinas.

Python:

import asyncio
from aiohttp import web

async def handle(request):
return web.Response(text="Hola, concurrente mundo!")

app = web.Application()
app.router.add_get('/', handle)

web.run_app(app, port=8080)

En Python, utilizamos asyncio para manejar múltiples solicitudes simultáneamente, mejorando la escalabilidad del sistema.

Rust

use tokio::net::TcpListener;
use tokio::prelude::*;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let listener = TcpListener::bind("127.0.0.1:8080").await?;
println!("Servidor escuchando en: 127.0.0.1:8080");

loop {
let (mut socket, _) = listener.accept().await?;

tokio::spawn(async move {
let response = b"HTTP/1.1 200 OK\r\nContent-Length: 22\r\n\r\nHola, concurrente mundo!";
socket.write_all(response).await.unwrap();
socket.flush().await.unwrap();
});
}
}

En Rust, utilizamos tokio para manejar múltiples solicitudes simultáneamente, mejorando la escalabilidad del sistema.

Kotlin:

import io.ktor.application.*
import io.ktor.http.*
import io.ktor.response.*
import io.ktor.routing.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*

fun main() {
embeddedServer(Netty, port = 8080) {
routing {
get("/") {
call.respondText("Hola, concurrente mundo!", ContentType.Text.Plain)
}
}
}.start(wait = true)
}

En Kotlin, utilizamos Ktor para manejar múltiples solicitudes simultáneamente, mejorando la escalabilidad del sistema.

Ruby

require 'sinatra'

get '/' do
"Hola, concurrente mundo!"
end

Sinatra::Application.run! port: 8080

En Ruby, utilizamos el framework Sinatra para manejar múltiples solicitudes simultáneamente, mejorando la escalabilidad del sistema.

Modelos de concurrencia (memoria compartida, paso de mensajes, actores)

La programación concurrente ofrece varios modelos de concurrencia que permiten a los desarrolladores elegir la mejor estrategia para sus aplicaciones. Estos modelos incluyen:

  • Memoria Compartida: Los hilos comparten un espacio de memoria común y se comunican a través de variables compartidas. Este modelo es eficiente pero puede ser propenso a condiciones de carrera y problemas de sincronización.

En este modelo, los hilos comparten variables y sincronizan el acceso a ellas mediante mecanismos como mutexes para evitar condiciones de carrera.

Go:

package main

import (
"fmt"
"sync"
)

var counter int
var mutex sync.Mutex

func increment(wg *sync.WaitGroup) {
defer wg.Done()
mutex.Lock()
counter++
mutex.Unlock()
}

func main() {
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go increment(&wg)
}
wg.Wait()
fmt.Println("Counter:", counter)
}

Python:

import threading

counter = 0
lock = threading.Lock()

def increment():
global counter
with lock:
counter += 1

threads = []
for _ in range(1000):
thread = threading.Thread(target=increment)
threads.append(thread)
thread.start()

for thread in threads:
thread.join()

print("Counter:", counter)

Rust:

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];

for _ in 0..1000 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}

for handle in handles {
handle.join().unwrap();
}

println!("Counter: {}", *counter.lock().unwrap());
}

Kotlin:

import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.thread

var counter = 0
val lock = ReentrantLock()

fun increment() {
lock.lock()
try {
counter++
} finally {
lock.unlock()
}
}

fun main() {
val threads = List(1000) {
thread { increment() }
}
threads.forEach { it.join() }
println("Counter: $counter")
}

Ruby:

require 'thread'

counter = 0
mutex = Mutex.new

threads = 1000.times.map do
Thread.new do
mutex.synchronize do
counter += 1
end
end
end

threads.each(&:join)
puts "Counter: #{counter}"
  • Paso de Mensajes: Los procesos se comunican intercambiando mensajes a través de canales de comunicación. Este modelo es más seguro y puede evitar problemas de sincronización, pero puede ser menos eficiente en términos de rendimiento debido a la sobrecarga de la comunicación.

En este modelo, los procesos o hilos se comunican enviando mensajes a través de canales, evitando así el uso de memoria compartida.

Go:

package main

import (
"fmt"
)

func worker(id int, messages chan int) {
messages <- id
}

func main() {
messages := make(chan int)
for i := 0; i < 5; i++ {
go worker(i, messages)
}

for i := 0; i < 5; i++ {
fmt.Println("Received:", <-messages)
}
}

Python

import threading
import queue

def worker(id, q):
q.put(id)

q = queue.Queue()
threads = []

for i in range(5):
thread = threading.Thread(target=worker, args=(i, q))
threads.append(thread)
thread.start()

for thread in threads:
thread.join()

while not q.empty():
print("Received:", q.get())

Rust

use std::sync::mpsc;
use std::thread;

fn main() {
let (tx, rx) = mpsc::channel();

for id in 0..5 {
let tx = tx.clone();
thread::spawn(move || {
tx.send(id).unwrap();
});
}

for _ in 0..5 {
println!("Received: {}", rx.recv().unwrap());
}
}

Kotlin

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel

fun main() = runBlocking {
val channel = Channel<Int>()

repeat(5) { id ->
launch {
channel.send(id)
}
}

repeat(5) {
println("Received: ${channel.receive()}")
}

channel.close()
}

Ruby

require 'thread'

queue = Queue.new

threads = 5.times.map do |i|
Thread.new do
queue << i
end
end

threads.each(&:join)

until queue.empty?
puts "Received: #{queue.pop}"
end
  • Actores: Los actores son entidades independientes que encapsulan estado y comportamiento, y se comunican enviándose mensajes entre sí. Este modelo promueve la modularidad y la escalabilidad al evitar el uso directo de la memoria compartida.

En este modelo, los actores son entidades independientes que encapsulan estado y comportamiento, y se comunican enviándose mensajes entre sí.

Go

package main

import (
"fmt"
)

type Actor struct {
id int
inbox chan int
}

func (a *Actor) act() {
for {
msg := <-a.inbox
fmt.Printf("Actor %d received: %d\n", a.id, msg)
}
}

func main() {
actors := make([]*Actor, 5)
for i := range actors {
actors[i] = &Actor{id: i, inbox: make(chan int)}
go actors[i].act()
}

for i := range actors {
actors[i].inbox <- i
}
}

Python

import pykka

class ActorExample(pykka.ThreadingActor):
def __init__(self, id):
super().__init__()
self.id = id

def on_receive(self, message):
print(f"Actor {self.id} received: {message}")

actors = [ActorExample.start(i) for i in range(5)]

for i, actor in enumerate(actors):
actor.tell(i)

pykka.ActorRegistry.stop_all()

Rust

use actix::prelude::*;

struct MyActor {
id: usize,
}

impl Actor for MyActor {
type Context = Context<Self>;
}

struct MyMessage(usize);

impl Message for MyMessage {
type Result = ();
}

impl Handler<MyMessage> for MyActor {
type Result = ();

fn handle(&mut self, msg: MyMessage, _: &mut Context<Self>) {
println!("Actor {} received: {}", self.id, msg.0);
}
}

fn main() {
let system = System::new();

for i in 0..5 {
let addr = MyActor { id: i }.start();
addr.do_send(MyMessage(i));
}

system.run().unwrap();
}

Kotlin

import akka.actor.*

class MyActor(private val id: Int) : AbstractActor() {
override fun createReceive(): Receive {
return receiveBuilder()
.match(Int::class.java) { msg ->
println("Actor $id received: $msg")
}
.build()
}
}

fun main() {
val system = ActorSystem.create("ActorSystem")
val actors = (0 until 5).map { id ->
system.actorOf(Props.create(MyActor::class.java, id))
}

actors.forEachIndexed { id, actor ->
actor.tell(id, ActorRef.noSender())
}

system.terminate()
}

Ruby

require 'celluloid'

class ActorExample
include Celluloid

def initialize(id)
@id = id
end

def receive_message(msg)
puts "Actor #{@id} received: #{msg}"
end
end

actors = 5.times.map { |i| ActorExample.new(i) }

actors.each_with_index do |actor, i|
actor.async.receive_message(i)
end

Estos ejemplos demuestran cómo la programación concurrente ofrece varios modelos de concurrencia (memoria compartida, paso de mensajes y actores) que permiten a los desarrolladores elegir la mejor estrategia para sus aplicaciones. Cada modelo tiene sus propias ventajas y desventajas, y la elección del modelo adecuado depende de los requisitos específicos de la aplicación y del entorno en el que se va a ejecutar. La capacidad de utilizar diferentes modelos de concurrencia es una ventaja clave de la programación concurrente, permitiendo a los desarrolladores construir aplicaciones robustas y eficientes.

Tolerancia a fallos

La tolerancia a fallos es la capacidad de un sistema para seguir funcionando correctamente incluso en presencia de fallos en algunos de sus componentes. En programación concurrente, se pueden utilizar varias técnicas para lograr tolerancia a fallos, tales como el reinicio de tareas fallidas, el aislamiento de fallos mediante procesos separados, y la recuperación automática de errores. Estos enfoques aseguran que un sistema puede manejar errores sin detenerse por completo, mejorando su robustez y disponibilidad.

Para ilustrar la tolerancia a fallos utilizando programación concurrente, presentaremos ejemplos en los lenguajes Go, Python, Rust, Kotlin y Ruby que demuestran cómo manejar errores de manera eficaz y mantener la estabilidad del sistema.

Go

package main

import (
"fmt"
"time"
)

func worker(id int) {
defer func() {
if r := recover(); r != nil {
fmt.Printf("Worker %d recovered from panic: %v\n", id, r)
}
}()
if id%2 == 0 {
panic(fmt.Sprintf("Worker %d failed", id))
}
fmt.Printf("Worker %d completed successfully\n", id)
}

func main() {
for i := 0; i < 5; i++ {
go worker(i)
}
time.Sleep(time.Second) // Wait for all goroutines to finish
}

En Go, utilizamos defer y recover para manejar pánicos y asegurar que el programa continúe ejecutándose incluso si ocurre un fallo.

Python

import threading

def worker(id):
try:
if id % 2 == 0:
raise Exception(f"Worker {id} failed")
print(f"Worker {id} completed successfully")
except Exception as e:
print(f"Worker {id} recovered from error: {e}")

threads = []
for i in range(5):
thread = threading.Thread(target=worker, args=(i,))
threads.append(thread)
thread.start()

for thread in threads:
thread.join()

En Python, utilizamos try y except dentro de hilos para capturar y manejar excepciones, asegurando que otros hilos continúen ejecutándose.

Rust

use std::thread;

fn worker(id: usize) -> Result<(), String> {
if id % 2 == 0 {
Err(format!("Worker {} failed", id))
} else {
println!("Worker {} completed successfully", id);
Ok(())
}
}

fn main() {
let mut handles = vec![];

for i in 0..5 {
let handle = thread::spawn(move || {
match worker(i) {
Ok(_) => (),
Err(e) => println!("Worker {} recovered from error: {}", i, e),
}
});
handles.push(handle);
}

for handle in handles {
handle.join().unwrap();
}
}

En Rust, utilizamos el resultado del thread::spawn para manejar errores y asegurar que el programa continúe ejecutándose incluso si un hilo falla.

Kotlin

import kotlinx.coroutines.*

fun worker(id: Int) {
try {
if (id % 2 == 0) {
throw Exception("Worker $id failed")
}
println("Worker $id completed successfully")
} catch (e: Exception) {
println("Worker $id recovered from error: ${e.message}")
}
}

fun main() = runBlocking {
val jobs = List(5) { id ->
launch {
worker(id)
}
}
jobs.forEach { it.join() }
}

En Kotlin, utilizamos coroutines y bloques try para capturar y manejar excepciones, asegurando que otras coroutines continúen ejecutándose.

Ruby

threads = 5.times.map do |i|
Thread.new do
begin
if i % 2 == 0
raise "Worker #{i} failed"
end
puts "Worker #{i} completed successfully"
rescue => e
puts "Worker #{i} recovered from error: #{e.message}"
end
end
end

threads.each(&:join)

En Ruby, utilizamos begin, rescue dentro de hilos para capturar y manejar excepciones, asegurando que otros hilos continúen ejecutándose.

Conclusión

En este artículo, hemos explorado de manera exhaustiva el paradigma de programación concurrente, analizando sus características principales y secundarias, y proporcionando ejemplos prácticos en diversos lenguajes de programación: Go, Python, Rust, Kotlin y Ruby. La programación concurrente es un paradigma poderoso y esencial en la era moderna del desarrollo de software. Permite la creación de aplicaciones robustas, eficientes y escalables, capaces de manejar múltiples tareas simultáneamente y recuperarse de fallos. A través de este artículo, hemos proporcionado una visión profunda y técnica de los aspectos clave de la programación concurrente, apoyada con ejemplos prácticos en varios lenguajes de programación. La elección del modelo de concurrencia adecuado y la implementación efectiva de técnicas de sincronización y control de concurrencia son cruciales para aprovechar al máximo las ventajas de la programación concurrente en el desarrollo de software.

Para seguir explorando este fascinante mundo funcional, te invito a hacer clic en los enlaces proporcionados:

--

--

MasonCoding

Remote Developer FullStack | Ruby | Python | Javascript | RPA | Django | Ruby on Rails | MongoDB | PostgreSQL | AWS S3 | Docker