O advento do método de pagamento instantâneo PIX, sem dúvida, revolucionou a maneira como lidamos com transações financeiras digitais. No entanto, por trás da simplicidade e velocidade dessas operações, há um desafio crucial a ser enfrentado: como garantir que os dados permaneçam coerentes em meio a uma enxurrada de transações em tempo real?
Exemplificando o problema
Vamos considerar uma situação comum em aplicações financeiras: a transferência de valores entre contas bancárias. Para ilustrar esse cenário, utilizaremos a linguagem de programação Go.
package main
import (
"fmt"
)
type Conta struct {
Numero int
Saldo float64
}
func transferir(origem, destino *Conta, valor float64) {
if origem.Saldo >= valor {
origem.Saldo -= valor
destino.Saldo += valor
fmt.Printf("Transferência de R$%.2f da conta %d para a conta %d realizada com sucesso.\n", valor, origem.Numero, destino.Numero)
} else {
fmt.Printf("Saldo insuficiente na conta %d para realizar a transferência de R$%.2f.\n", origem.Numero, valor)
}
}
func main() {
// Criando contas
conta1 := Conta{Numero: 1, Saldo: 1000}
conta2 := Conta{Numero: 2, Saldo: 500}
// Realizando transferência
transferir(&conta1, &conta2, 300)
// Exibindo saldos após transferência
fmt.Printf("Saldo da conta 1: R$%.2f\n", conta1.Saldo)
fmt.Printf("Saldo da conta 2: R$%.2f\n", conta2.Saldo)
}
Neste exemplo, criamos duas contas bancárias (conta1 e conta2) com saldos iniciais e realizamos uma transferência de R$300 da conta1 para a conta2, verificando se há saldo disponível antes da transação.
Agora, vamos levar esse exemplo ao extremo, introduzindo concorrência utilizando as goroutines do Go para simular várias transferências simultâneas entre as contas, como múltiplos clientes fazendo diversas requisições de transferência.
package main
import (
"fmt"
"sync"
)
type Conta struct {
Numero int
Saldo float64
}
func transferir(origem, destino *Conta, valor float64, wg *sync.WaitGroup) {
defer wg.Done()
if origem.Saldo >= valor {
origem.Saldo -= valor
destino.Saldo += valor
}
}
func main() {
// Criando contas
conta1 := Conta{Numero: 1, Saldo: 1000}
conta2 := Conta{Numero: 2, Saldo: 500}
// Definindo o número de transferências e a quantidade de cada transferência
numTransferencias := 100
valorTransferencia := 10
var wg sync.WaitGroup
// Iniciando as transferências concorrentes
for i := 0; i < numTransferencias; i++ {
// se i é divisível por 10 e espera as outras transações acabarem para continuar as outras 10
if i%10 ==0 {
wg.Wait()
}
wg.Add(10)
go transferir(&conta1, &conta2, float64(valorTransferencia), &wg)
}
// Aguardando a conclusão de todas as goroutines
wg.Wait()
// Exibindo saldos após transferências
fmt.Printf("Saldo da conta 1: R$%.2f\n", conta1.Saldo)
fmt.Printf("Saldo da conta 2: R$%.2f\n", conta2.Saldo)
}
Se executarmos esse código acima teremos um cenário parecido com o que teríamos em aplicações de transferências bancarias, mas claro, bem longe da realidade. Executando esse código com concorrência temos nosso primeiro erro de acesso simultâneo na memória o temido DeadLock
Entendendo Deadlock
Para compreendermos o que é um Deadlock, podemos recorrer às nossas aulas de Sistemas Operacionais. Um Deadlock ocorre quando dois ou mais processos ficam bloqueados e incapazes de prosseguir com suas execuções. No contexto da concorrência em Go, podemos inadvertidamente criar um Deadlock ao não gerenciar adequadamente as dependências entre as goroutines.
Imagine que, em nosso exemplo de transferências bancárias, decidimos limitar o número de tarefas em execução simultânea a 1, aguardando cada uma ser concluída antes de iniciar a próxima. Isso, no entanto, contraria a natureza concorrente das operações bancárias, onde várias transferências podem ocorrer simultaneamente.
Atomicidade e gerenciamento de concorrência
Quando falamos de Sistemas de banco de dados, uma coisa muito citada é a atomicidade das operações dentro dele, como no nosso caso não iremos usar um sistema de gerenciamento de banco de dados vamos garantir que nossas operações sejam atômica em memória. Vamos primeiro para definição de uma operação atômica.
Operação Atômica: : A atomicidade é a propriedade que garante que uma operação ocorra como uma única unidade indivisível, sem ser interrompida por outras operações.
Dito isso como garantimos uma atomicidade no nosso código? Antes de irmos para a solução, voltamos mais uma vez em uma das causas do DeadLock a Exclusão Mútua que é a existência de recursos que precisam ser acessados de forma exclusiva, que em nosso exemplo seria os valores das contas que são alterados ali durante as transferências. Visto a essa necessidade, a estrutura de dados Mutex foi criada
Mutex ou Mutual Exclusion
Mutex é uma estrutura de dados essencial em programação concorrente, garantindo exclusão mútua, o que significa que apenas uma tarefa (ou goroutine) pode acessar um recurso compartilhado por vez.
Exclusão Mútua implica que apenas uma solicitação de transação (ou tarefa) pode acessar um recurso compartilhado em determinado momento. No contexto de operações bancárias, cada solicitação de transação é tratada individualmente e com segurança.
O objetivo do mutex é garantir que cada solicitação de transação tenha acesso exclusivo aos recursos compartilhados, como os saldos das contas, evitando conflitos e inconsistências nos dados ao processar múltiplas transações simultaneamente.
Controle de concorrência com channels e mutex
Os Channels são uma estrutura de dados comum em linguagens de programação concorrentes, permitindo a comunicação e sincronização entre processos ou threads. Eles são usados para transferir dados entre diferentes partes do programa de forma segura e eficiente, facilitando a coordenação da execução concorrente.
Os Channels possuem uma estrutura de fila, onde os dados são armazenados temporariamente enquanto aguardam ser lidos por outra parte do programa. Eles garantem que a escrita e a leitura de dados ocorram de maneira assíncrona e segura, evitando problemas como race conditions e deadlocks.
Por exemplo, em um programa que possui duas threads, uma responsável por gerar dados e outra por processá-los, podemos utilizar um Channel para enviar os dados da thread de geração para a thread de processamento. Isso permite que as threads trabalhem de forma independente, sem interferir uma na outra, e ainda assim coordenem suas atividades através da troca de dados pelo Channel.
Contornando o DeadLock
Nesse código abaixo podemos ver o uso do mutex para garantir que o saldo entre as contas que operam de transferências de maneira concorrente tenham exclusão mútua, em seguia são utilizado os channels como um mecanismo de coordenação entre nossas tarefas concorrentes, ou melhor nossas goroutines, nesse exemplo utilizamos um semáforo que controla a quantidade de go routine que podem acessar nosso recurso compartilhado, dessa meneira, evitamos o nosso temido DeadLock.
Outra alteração importante foi a passagem das 10 goroutines sendo adicionadas ao wait group para uma adição individual, enquanto o channel faz a gestão de 10 goroutines em concorrência, o primeiro exemplo estava forçando as 10 no wait group era para forçar nosso DeadLock
package main
import (
"fmt"
"sync"
)
type Conta struct {
Numero int
Saldo float64
mu sync.Mutex // Mutex para proteger o acesso às contas
}
func transferir(origem, destino *Conta, valor float64, wg *sync.WaitGroup, sema chan struct{}) {
defer wg.Done()
sema <- struct{}{} // Adquire um token do semáforo
defer func() { <-sema }() // Libera o token do semáforo ao finalizar
origem.mu.Lock()
defer origem.mu.Unlock()
destino.mu.Lock()
defer destino.mu.Unlock()
if origem.Saldo >= valor {
origem.Saldo -= valor
destino.Saldo += valor
}
}
func main() {
// Criando contas
conta1 := Conta{Numero: 1, Saldo: 1000}
conta2 := Conta{Numero: 2, Saldo: 500}
// Definindo o número de transferências e o valor de cada transferência
numTransferencias := 100
valorTransferencia := 10
var wg sync.WaitGroup
sema := make(chan struct{}, 10) // Semáforo com capacidade para 10 tokens
// Iniciando as transferências concorrentes
for i := 0; i < numTransferencias; i++ {
wg.Add(1)
go transferir(&conta1, &conta2, float64(valorTransferencia), &wg, sema)
}
// Aguardando a conclusão de todas as goroutines
wg.Wait()
// Exibindo saldos após as transferências
fmt.Printf("Saldo da conta 1: R$%.2f\n", conta1.Saldo)
fmt.Printf("Saldo da conta 2: R$%.2f\n", conta2.Saldo)
}
Considerações
É importante destacar que os códigos e exemplos fornecidos têm propósitos puramente educacionais. Enquanto o uso de canais (channels) por si só em condições simples já evita deadlock, a combinação coordenada de mutexes e canais é uma estratégia valiosa, especialmente ao lidar com ambientes mais complexos que compartilham mais do que uma única propriedade de memória ou outras estruturas.
Essa abordagem permite um controle mais refinado sobre o acesso concorrente aos recursos compartilhados, garantindo a consistência e integridade dos dados em ambientes concorrentes.
Top comments (2)
Muito bom, migo 🚀
Obrigado, miga ❤️