Menu
Coddy logo textTech

Design de Structs Thread-Safe

Parte da seção Programação Orientada a Objetos do Journey de GO da Coddy — lição 65 de 107.

Agora que você entende mutexes e WaitGroups, vamos combiná-los para projetar structs que sejam seguras para uso em múltiplas goroutines simultaneamente. Uma struct thread-safe encapsula a sincronização dentro de seus métodos, para que os chamadores não precisem se preocupar com o bloqueio.

O padrão é simples: incorpore um mutex em sua struct e bloqueie-o em cada método que acessa o estado compartilhado:

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
}

Observe que até mesmo o método Value() de apenas leitura bloqueia o mutex. Sem isso, uma goroutine pode ler enquanto outra escreve, causando uma condição de corrida (data race). Se as leituras forem muito mais comuns que as escritas, use sync.RWMutex em vez disso e chame RLock() para leituras.

Um princípio de design fundamental: mantenha o mutex privado. Ao usar um nome de campo em letras minúsculas (mu), você impede que o código externo o acesse diretamente. Toda a sincronização acontece por meio de seus métodos, oferecendo a você controle total sobre a segurança de threads (thread safety).

Para structs com múltiplos campos, proteja todos os campos relacionados com o mesmo mutex para garantir um 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 quanto history são atualizados atomicamente—nenhuma goroutine pode observar um estado inconsistente onde um mudou, mas o outro não.

challenge icon

Desafio

Fácil

Vamos construir um sistema de conta bancária thread-safe que demonstra o encapsulamento adequado da sincronização dentro dos métodos da struct. Sua conta lidará com depósitos, saques e verificações de saldo concorrentes de forma segura, sem expor nenhum detalhe de travamento (locking) aos chamadores.

Você organizará seu código em dois arquivos:

  • account.go: Defina sua conta bancária thread-safe.

    Crie uma struct BankAccount com um sync.Mutex embutido, um campo balance (int) e um slice transactions que registra todas as operações bem-sucedidas como strings.

    Implemente estes métodos:

    • NewBankAccount(initial int) *BankAccount - Cria uma nova conta com o saldo inicial fornecido e um slice de transações vazio
    • Deposit(amount int) - Adiciona o valor ao saldo e registra a transação como +[amount]
    • Withdraw(amount int) bool - Se houver fundos suficientes, subtrai o valor, registra -[amount] e retorna true. Caso contrário, retorna false sem modificar nada
    • Balance() int - Retorna o saldo atual
    • History() []string - Retorna uma cópia do slice de transações

    Todo método que acessa os campos da struct deve travar o mutex para garantir a segurança de thread. Use defer para destravar. Mantenha o mutex e todos os campos não exportados (letra minúscula) para que o código externo precise usar seus métodos.

  • main.go: Processe as operações bancárias e demonstre sua conta thread-safe.

    Leia o saldo inicial e, em seguida, o número de operações. Para cada operação, leia o tipo (deposit, withdraw ou balance) e, para deposit/withdraw, leia o valor.

    Imprima os resultados para cada operação:

    • deposit: Imprima Deposited [amount], Balance: [new balance]
    • withdraw: Imprima Withdrew [amount], Balance: [new balance] se for bem-sucedido, ou Withdrawal failed: insufficient funds caso contrário
    • balance: Imprima Current balance: [balance]

    Após todas as operações, imprima o histórico de transações com cada entrada em uma nova linha, prefixada por History: apenas para a primeira entrada.

As seguintes entradas serão fornecidas:

  • Linha 1: Saldo inicial (inteiro)
  • Linha 2: Número de operações (inteiro)
  • Linhas seguintes: Para cada operação, o tipo (deposit, withdraw ou balance) e, para deposit/withdraw, o valor na linha seguinte

Por exemplo, dado:

100
5
deposit
50
balance
withdraw
30
withdraw
200
balance

Sua saída deve ser:

Deposited 50, Balance: 150
Current balance: 150
Withdrew 30, Balance: 120
Withdrawal failed: insufficient funds
Current balance: 120
History: +50
-30

O princípio fundamental aqui é que toda a sincronização está oculta dentro dos métodos da sua BankAccount. Os chamadores simplesmente usam Deposit(), Withdraw() e Balance() sem nunca pensar em travas — sua struct lida com a segurança de thread internamente.

Folha de consulta

Uma struct thread-safe encapsula a sincronização dentro de seus métodos ao incorporar um mutex e bloqueá-lo em cada método que acessa o estado compartilhado:

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
}

Princípios fundamentais:

  • Bloqueie o mutex em todos os métodos que acessam o estado compartilhado, incluindo métodos de apenas leitura, para evitar condições de corrida (data races)
  • Use defer para garantir que o mutex seja desbloqueado mesmo se a função retornar antecipadamente
  • Mantenha o mutex privado (nome de campo em letra minúscula) para que o código externo não possa acessá-lo diretamente
  • Para cargas de trabalho com muitas leituras, use sync.RWMutex e chame RLock() para leituras

Para structs com múltiplos campos, proteja todos os campos relacionados com o mesmo mutex para garantir um 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))
}

Isso garante que tanto o balance quanto o history sejam atualizados atomicamente — nenhuma goroutine poderá observar um estado inconsistente.

Experimente você mesmo

package main

import (
	"bufio"
	"fmt"
	"os"
	"strconv"
	"strings"
)

func main() {
	reader := bufio.NewReader(os.Stdin)

	// Ler o saldo inicial
	initialStr, _ := reader.ReadString('\n')
	initial, _ := strconv.Atoi(strings.TrimSpace(initialStr))

	// Ler o número de operações
	numOpsStr, _ := reader.ReadString('\n')
	numOps, _ := strconv.Atoi(strings.TrimSpace(numOpsStr))

	// Criar a conta bancária
	account := NewBankAccount(initial)

	// Processar cada operação
	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: Chamar Deposit e imprimir o resultado
			// Formato: "Deposited [amount], Balance: [new balance]"

		case "withdraw":
			amountStr, _ := reader.ReadString('\n')
			amount, _ := strconv.Atoi(strings.TrimSpace(amountStr))
			// TODO: Chamar Withdraw e imprimir o resultado apropriado
			// Se bem-sucedido: "Withdrew [amount], Balance: [new balance]"
			// Se falhar: "Withdrawal failed: insufficient funds"
			_ = amount // Remova esta linha quando implementar

		case "balance":
			// TODO: Chamar Balance e imprimir o resultado
			// Formato: "Current balance: [balance]"
		}
	}

	// TODO: Imprimir o histórico de transações
	// A primeira entrada deve ter o prefixo "History: "
	// As entradas subsequentes devem estar em novas linhas sem prefixo
}
quiz iconTeste seus conhecimentos

Esta lição inclui um quiz rápido. Comece a lição para respondê-lo e acompanhar seu progresso.

Todas as lições de Programação Orientada a Objetos