Entendendo memória em transações financeiras

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$%.2fn, conta1.Saldo)
fmt.Printf(“Saldo da conta 2: R$%.2fn, 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$%.2fn, conta1.Saldo)
fmt.Printf(“Saldo da conta 2: R$%.2fn, 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 chanels 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

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$%.2fn, conta1.Saldo)
fmt.Printf(“Saldo da conta 2: R$%.2fn, conta2.Saldo)
}

Observações

O código e os exemplos são meramentes com fins pedagógicos, levavamos ao código ao cenário que precisamos para usar a conciliação entre channels e Mutex, apenas a utlização de channels nas tais condições simples já evitaria o DeadLock, mas o uso coordenado é uma ótima estratégia a se ter na mão, quando lidamos com ambientes mais complexos que compartilham mais que uma propriedade de memória ou outras estruturas.

Leave a Reply

Your email address will not be published. Required fields are marked *