Diseño de estructuras Thread-Safe
Parte de la sección Programación Orientada a Objetos del Journey de GO de Coddy — lección 65 de 107.
Ahora que entiendes los mutexes y los WaitGroups, combinémoslos para diseñar structs que sean seguros de usar desde múltiples goroutines simultáneamente. Un struct seguro para hilos (thread-safe) encapsula la sincronización dentro de sus métodos, de modo que quienes los llaman no tengan que preocuparse por el bloqueo (locking).
El patrón es sencillo: incrusta un mutex en tu estructura y bloquéalo en cada método que acceda al estado compartido:
type SafeCounter struct {
mu sync.Mutex
count int
}
func (c *SafeCounter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
func (c *SafeCounter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}Ten en cuenta que incluso el método de solo lectura Value() bloquea el mutex. Sin esto, una goroutine podría leer mientras otra escribe, provocando una condición de carrera (data race). Si las lecturas son mucho más comunes que las escrituras, utiliza sync.RWMutex en su lugar y llama a RLock() para las lecturas.
Un principio de diseño clave: mantener el mutex privado. Al usar un nombre de campo en minúsculas (mu), evitas que el código externo acceda a él directamente. Toda la sincronización ocurre a través de tus métodos, lo que te otorga un control total sobre la seguridad de hilos (thread safety).
Para estructuras con múltiples campos, proteja todos los campos relacionados con el mismo mutex para garantizar un estado consistente:
type Account struct {
mu sync.Mutex
balance int
history []string
}
func (a *Account) Deposit(amount int) {
a.mu.Lock()
defer a.mu.Unlock()
a.balance += amount
a.history = append(a.history, fmt.Sprintf("+%d", amount))
}Tanto balance como history se actualizan de forma atómica—ninguna goroutine puede observar un estado inconsistente donde uno haya cambiado pero el otro no.
Desafío
FácilVamos a construir un sistema de cuenta bancaria seguro para hilos (thread-safe) que demuestre una encapsulación adecuada de la sincronización dentro de los métodos de una estructura. Tu cuenta manejará depósitos, retiros y consultas de saldo concurrentes de forma segura sin exponer ningún detalle de bloqueo a quienes la utilicen.
Organizarás tu código en dos archivos:
account.go: Define tu cuenta bancaria segura para hilos.Crea una estructura
BankAccountcon unsync.Mutexembebido, un campobalance(int) y un slicetransactionsque registre todas las operaciones exitosas como cadenas de texto.Implementa estos métodos:
NewBankAccount(initial int) *BankAccount- Crea una nueva cuenta con el saldo inicial dado y un slice de transacciones vacío.Deposit(amount int)- Suma el monto al saldo y registra la transacción como+[amount].Withdraw(amount int) bool- Si existen fondos suficientes, resta el monto, registra-[amount]y devuelvetrue. De lo contrario, devuelvefalsesin modificar nada.Balance() int- Devuelve el saldo actual.History() []string- Devuelve una copia del slice de transacciones.
Cada método que acceda a los campos de la estructura debe bloquear el mutex para garantizar la seguridad de los hilos. Usa
deferpara desbloquear. Mantén el mutex y todos los campos sin exportar (en minúsculas) para que el código externo deba usar tus métodos.main.go: Procesa las operaciones bancarias y demuestra tu cuenta segura para hilos.Lee el saldo inicial y luego el número de operaciones. Para cada operación, lee el tipo (
deposit,withdrawobalance) y, para deposit/withdraw, lee el monto.Imprime los resultados de cada operación:
deposit: ImprimeDeposited [amount], Balance: [new balance]withdraw: ImprimeWithdrew [amount], Balance: [new balance]si tiene éxito, oWithdrawal failed: insufficient fundssi no.balance: ImprimeCurrent balance: [balance]
Después de todas las operaciones, imprime el historial de transacciones con cada entrada en una línea nueva, precedida por
History:solo para la primera entrada.
Se proporcionarán las siguientes entradas:
- Línea 1: Saldo inicial (entero)
- Línea 2: Número de operaciones (entero)
- Líneas siguientes: Para cada operación, el tipo (
deposit,withdrawobalance), y para deposit/withdraw, el monto en la línea siguiente.
Por ejemplo, dado:
100
5
deposit
50
balance
withdraw
30
withdraw
200
balanceTu salida debería ser:
Deposited 50, Balance: 150
Current balance: 150
Withdrew 30, Balance: 120
Withdrawal failed: insufficient funds
Current balance: 120
History: +50
-30El principio clave aquí es que toda la sincronización está oculta dentro de tus métodos de BankAccount. Quienes llaman simplemente usan Deposit(), Withdraw() y Balance() sin pensar nunca en bloqueos; tu estructura maneja la seguridad de hilos internamente.
Hoja de referencia
Una struct segura para hilos (thread-safe) encapsula la sincronización dentro de sus métodos incrustando un mutex y bloqueándolo en cada método que accede al estado compartido:
type SafeCounter struct {
mu sync.Mutex
count int
}
func (c *SafeCounter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
func (c *SafeCounter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}Principios clave:
- Bloquear el mutex en todos los métodos que acceden al estado compartido, incluyendo los métodos de solo lectura, para prevenir condiciones de carrera (data races)
- Usar
deferpara asegurar que el mutex se desbloquee incluso si la función retorna antes de tiempo - Mantener el mutex privado (nombre de campo en minúscula) para que el código externo no pueda acceder a él directamente
- Para cargas de trabajo con muchas lecturas, usar
sync.RWMutexy llamar aRLock()para las lecturas
Para structs con múltiples campos, proteja todos los campos relacionados con el mismo mutex para asegurar un estado consistente:
type Account struct {
mu sync.Mutex
balance int
history []string
}
func (a *Account) Deposit(amount int) {
a.mu.Lock()
defer a.mu.Unlock()
a.balance += amount
a.history = append(a.history, fmt.Sprintf("+%d", amount))
}Esto asegura que tanto balance como history se actualicen de forma atómica; ninguna goroutine puede observar un estado inconsistente.
Pruébalo tú mismo
package main
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
)
func main() {
reader := bufio.NewReader(os.Stdin)
// Leer el saldo inicial
initialStr, _ := reader.ReadString('\n')
initial, _ := strconv.Atoi(strings.TrimSpace(initialStr))
// Leer el número de operaciones
numOpsStr, _ := reader.ReadString('\n')
numOps, _ := strconv.Atoi(strings.TrimSpace(numOpsStr))
// Crear la cuenta bancaria
account := NewBankAccount(initial)
// Procesar cada operación
for i := 0; i < numOps; i++ {
opType, _ := reader.ReadString('\n')
opType = strings.TrimSpace(opType)
switch opType {
case "deposit":
amountStr, _ := reader.ReadString('\n')
amount, _ := strconv.Atoi(strings.TrimSpace(amountStr))
// TODO: Llamar a Deposit e imprimir el resultado
// Formato: "Deposited [amount], Balance: [new balance]"
case "withdraw":
amountStr, _ := reader.ReadString('\n')
amount, _ := strconv.Atoi(strings.TrimSpace(amountStr))
// TODO: Llamar a Withdraw e imprimir el resultado apropiado
// Si tiene éxito: "Withdrew [amount], Balance: [new balance]"
// Si falla: "Withdrawal failed: insufficient funds"
_ = amount // Elimina esta línea cuando lo implementes
case "balance":
// TODO: Llamar a Balance e imprimir el resultado
// Formato: "Current balance: [balance]"
}
}
// TODO: Imprimir el historial de transacciones
// La primera entrada debe tener el prefijo "History: "
// Las entradas posteriores deben estar en líneas nuevas sin prefijo
}
Esta lección incluye un breve cuestionario. Empieza la lección para responderlo y registrar tu progreso.
Todas las lecciones de Programación Orientada a Objetos
1Fundamentos de POO en Go
Archivos externosEspacio de trabajo y módulos de GoPaquetes e importacionesNombres exportados vs. no exportadosIntroducción a la POO en GoStructs como clasesDefinición de métodos en structsReceptores de puntero vs. de valorInicialización de structsFunciones constructorasResumen - Calculadora simple4Interfaces
Introducción a las interfacesImplementación implícitaLa interfaz como contratoInterfaz vacía (any)Type AssertionType SwitchComposición de interfacesInterfaces Stringer y ErrorResumen: Calculadora de figuras7Encapsulamiento
Campos exportados vs. no exportadosEncapsulamiento a nivel de paqueteMétodos Getter y SetterOcultamiento de información en GoResumen - Registros de estudiantes10Genéricos (Go 1.18+)
Introducción a los GenéricosParámetros de tipoRestricciones de tipoStructs genéricosSolución alternativa para métodos genéricosResumen - Colección genérica13Patrones de diseño - Parte 1
Introducción a los patrones de diseñoPatrón SingletonPatrón FactoryPatrón Abstract FactoryPatrón ObserverPatrón Strategy2Inmersión profunda en Tipos y Structs
Tipos básicos y compuestosDefiniciones de tipos personalizadosEtiquetas de StructsStructs anónimosStructs anidadosValores cero y por defectoRepaso - Libreta de contactos5Composición sobre herencia
Por qué Go no tiene herenciaConceptos básicos de incrustación de structsPromoción de métodosIncrustación de múltiples structsIncrustación vs AgregaciónShadowing de métodos incrustadosResumen - Jerarquía de empleados8Manejo de Errores y OOP
La interfaz errorTipos de errores personalizadosEnvoltura de errores (fmt.Errorf)Errores centinelaerrors.Is() y errors.As()Panic, Defer y RecoverResumen - Analizador de archivos3Punteros y Memoria
Fundamentos de punteros en GoPunteros a StructsPaso por valor vs referenciaLa función new()Garbage Collection en GoResumen - Constructor de Linked List6Polimorfismo en Go
Polimorfismo mediante interfacesDuck Typing en GoReglas de satisfacción de interfacesColecciones polimórficasInyección de dependenciasResumen - Procesador de pagos9Concurrencia y POO
Fundamentos de GoroutinesChannels y comunicaciónChannels con buffer vs. sin bufferSentencia Selectsync.Mutex y sync.RWMutexsync.WaitGroupDiseño de estructuras Thread-SafeResumen - Worker Pool