DEV Community

Cover image for Entendendo memória em transações financeiras
pedrovian4
pedrovian4

Posted on

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$%.2f\n", conta1.Saldo)
    fmt.Printf("Saldo da conta 2: R$%.2f\n", conta2.Saldo)
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
}

Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
lumamontes profile image
Luma Montes

Muito bom, migo 🚀

Collapse
 
pedrovian4 profile image
pedrovian4

Obrigado, miga ❤️