Este artículo explora las diferencias fundamentales entre el mecanismo de sincronización basado en colas de Java y los canales de comunicación de Go, dos enfoques distintos para resolver problemas de concurrencia.
Distinción conceptual esencial
En Java, el AbstractQueuedSynchronizer (AQS) es una infraestructura que permite construir primitivas de sincronización como mutex, semáforos y barreras. Funciona manteniendo un contador de estado y una cola interna de hilos en espera. Los hilos compiten por modificar el estado; si no lo logran, se encolan y se suspenden hasta ser despertados por otro hilo que libere el recurso.
En Go, un channel es un conducto tipado por donde las goroutines envían y reciben datos. Inspirado en el modelo CSP (Communicating Sequential Processes), promueve la idea de que la coordinación entre concurrentes debe hacerse mediante el intercambio de mensajes, no mediante el acceso compartido a memoria.
Funcionamiento interno del AQS
El AQS opera como un sistema de turnos en una ventanilla de atención: existe un contador que indica la disponibilidad del recurso y una lista enlazada donde se ordenan los hilos que deben esperar. Cuando un hilo solicita acceso, decrementa el contador si es posible; de lo contrario, se agrega a la cola y queda suspendido. Al liberarse el recurso, se despierta al siguiente hilo en la cola.
class SincronizadorBasico {
private volatile int recursosDisponibles;
private final LinkedList<Thread> colaEspera = new LinkedList<>();
SincronizadorBasico(int cantidadRecursos) {
this.recursosDisponibles = cantidadRecursos;
}
void adquirir() {
synchronized (this) {
if (recursosDisponibles > 0) {
recursosDisponibles--;
return;
}
colaEspera.addLast(Thread.currentThread());
}
LockSupport.park();
}
void liberar() {
synchronized (this) {
recursosDisponibles++;
if (!colaEspera.isEmpty()) {
Thread siguiente = colaEspera.removeFirst();
LockSupport.unpark(siguiente);
}
}
}
}
Funcionamiento interno de un canal en Go
Un canal en Go se asemeja a una cinta transportadora con capacidad limitdaa. Las goroutines productoras depositan elementos en un extremo; si la cinta está llena, la goroutine se bloquea automáticamente hasta que haya espacio. Las goroutines consumidoras retiran elementos del otro extremo; si la cinta está vacía, esperan hasta que llegue algo nuevo. Todo este mecanismo de bloqueo y despertar es transparente para el programador.
func EjemploCintaTransportadora() {
cinta := make(chan string, 5)
// Goroutine que coloca productos
go func() {
productos := []string{"manzana", "plátano", "naranja", "uva", "mango", "pera"}
for _, p := range productos {
cinta <- p
fmt.Printf("Colocado en cinta: %s\n", p)
}
close(cinta)
}()
// Goroutine que retira productos
go func() {
for producto := range cinta {
fmt.Printf("Retirado de cinta: %s\n", producto)
time.Sleep(150 * time.Millisecond)
}
}()
time.Sleep(2 * time.Second)
}
Diferencias en el modelo de programación
| Aspecto | Java AQS | Go Channel |
|---|---|---|
| Paradigma | Sincronización sobre memoria compartida | Comunicación entre entidades concurrentes |
| Bloqueo | Explícito: el programador invoca park/unpark | Implícito: el runtime bloquea en operaciones de lectura/escritura |
| Estructura de cola | Buffer circular o sin buffer | |
| Orden de despacho | FIFO (modo justo) o con posibilidad de adelantamiento | Siempre FIFO |
| Datos transmitidos | Generalmente un entero que representa estado | Cualquier tipo de dato de Go |
| Multiplexación | Requiere implementación adicional (ej. Condition) | Soporte nativo mediante select |
| Manejo de errores | Excepciones como InterruptedException | Verificación del estado del canal (abierto/cerrado) |
Ejemplo práctico: Pool de conexiones con AQS
El patrón típico de uso del AQS involucra control explícito de la adquisciión y liberación de recursos. En el siguiente ejemplo se implementa un pool limitado usando un semáforo construido sobre AQS:
class PoolConexiones {
private final java.util.concurrent.Semaphore semaforo;
private final ConcurrentLinkedQueue<String> conexionesLibres;
PoolConexiones(int capacidadMaxima) {
this.semaforo = new java.util.concurrent.Semaphore(capacidadMaxima, true);
this.conexionesLibres = new ConcurrentLinkedQueue<>();
for (int idx = 0; idx < capacidadMaxima; idx++) {
conexionesLibres.offer("conexion_" + idx);
}
}
String obtenerConexion() throws InterruptedException {
semaforo.acquire();
return conexionesLibres.poll();
}
void devolverConexion(String conexion) {
conexionesLibres.offer(conexion);
semaforo.release();
}
}
Ejemplo prácol: Pool de trabajadores con Channel
En Go, el mismo patrón de pool de trabajadores se resuelve de forma más declarativa mediante canales. La cola de tareas y la recolección de resultados se modelan como canales independientes, y el bloqueo ocurre automáticamente cuando no hay trabajo disponible o cuando el buffer de resultados está lleno:
type Tarea struct {
Identificador int
Payload string
}
type Resultado struct {
TareaID int
ValorSalida string
}
func EjecutarPoolTrabajadores() {
canalTareas := make(chan Tarea, 50)
canalResultados := make(chan Resultado, 50)
// Lanzar 4 trabajadores
for id := 1; id <= 4; id++ {
go ejecutarTrabajador(id, canalTareas, canalResultados)
}
// Enviar 12 tareas
for i := 1; i <= 12; i++ {
canalTareas <- Tarea{Identificador: i, Payload: fmt.Sprintf("datos_%d", i)}
}
close(canalTareas)
// Recoger resultados
for cont := 0; cont < 12; cont++ {
res := <-canalResultados
fmt.Printf("Resultado tarea %d: %s\n", res.TareaID, res.ValorSalida)
}
}
func ejecutarTrabajador(id int, tareas <-chan Tarea, resultados chan<- Resultado) {
for tarea := range tareas {
time.Sleep(100 * time.Millisecond)
resultados <- Resultado{
TareaID: tarea.Identificador,
ValorSalida: fmt.Sprintf("procesado_%s_por_w%d", tarea.Payload, id),
}
}
}
Patrón productor-consumidor comparado
Implementación en Java con Lock y Condition
class BuzonMensajes {
private final ReentrantLock cerrojo = new ReentrantLock();
private final Condition hayEspacio = cerrojo.newCondition();
private final Condition hayDatos = cerrojo.newCondition();
private final ArrayDeque<String> mensajes = new ArrayDeque<>();
private final int tope;
BuzonMensajes(int capacidad) {
this.tope = capacidad;
}
void enviar(String msg) throws InterruptedException {
cerrojo.lock();
try {
while (mensajes.size() == tope) {
hayEspacio.await();
}
mensajes.addLast(msg);
hayDatos.signal();
} finally {
cerrojo.unlock();
}
}
String recibir() throws InterruptedException {
cerrojo.lock();
try {
while (mensajes.isEmpty()) {
hayDatos.await();
}
String msg = mensajes.removeFirst();
hayEspacio.signal();
return msg;
} finally {
cerrojo.unlock();
}
}
}
Implementación equivalente en Go con Channel
func BuzonConCanal() {
buzón := make(chan string, 8)
// Emisor
go func() {
mensajes := []string{"hola", "mundo", "concurrencia", "go", "java", "aqs", "canal", "fin"}
for _, m := range mensajes {
buzón <- m
fmt.Printf("[Emisor] Enviado: %s\n", m)
}
close(buzón)
}()
// Receptor
go func() {
for msg := range buzón {
fmt.Printf("[Receptor] Recibido: %s\n", msg)
time.Sleep(80 * time.Millisecond)
}
}()
time.Sleep(2 * time.Second)
}
Sincronización de puntos de encuentro
Ambos mecanismos permiten implementar barreras de sincronización donde múltiples entidades concurrentes deben llegar a un punto antes de continuar. En Java esto se logra con CountDownLatch o CyclicBarrier (construidos sobre AQS); en Go, se simula con WaitGroup o canales:
// Barrera usando CountDownLatch de Java
void ejecutarConBarrera() throws InterruptedException {
int participantes = 4;
CountDownLatch barrera = new CountDownLatch(participantes);
for (int i = 0; i < participantes; i++) {
final int id = i;
new Thread(() -> {
realizarTarea(id);
barrera.countDown();
}).start();
}
barrera.await(); // Bloquea hasta que todos terminen
System.out.println("Todos los participantes finalizaron.");
}
// Barrera usando WaitGroup de Go
func ejecutarConBarreraGo() {
var wg sync.WaitGroup
participantes := 4
for i := 0; i < participantes; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
realizarTarea(id)
}(i)
}
wg.Wait()
fmt.Println("Todos los participantes finalizaron.")
}
Comparación de escenarios de uso recomendados
El framework AQS de Java resulta más adecuado cuando:
- Se necesita control preciso sobre la adquisición y liberación de locks
- Se requieren primitivas complejas como ReadWriteLock, StampedLock o barreras cíclicas
- Es necesario gestionar pools de recursos con restricciones de capacidad
- Se demanda control de equidad (fairness) en el acceso a recursos compartidos
Los canales de Go son preferibles cuando:
- El diseño se centra en el flujo de datos entre etapas de procesamiento
- Se implementan pipelines donde la salida de una fase alimenta a la siguiente
- Se coordina la finalización o el inicio de múltiples goroutines
- Se busca un código más declarativo con menos gestión manual de bloqueos
Reflexión final sobre filosofías de diseño
La diferencia más profunda entre ambos enfoques radica en su concepción de la concurrencia. El AQS de Java parte del supuesto de que los hilos deben coordinarse para acceder a recursos compartidos de forma ordenada, actuando como un regulador de tráfico que decide quién pasa y quién espera. Los canales de Go, en cambio, adoptan la premisa de que la coordinación natural entre procesos concurrentes ocurre al intercambiar datos, como un servicio de mensajería que conecta emisores y receptores sin que estos necesiten conocer los detalles internos del otro.