Race Condition e Data Race: Criando Classes Threadsafe em Swift

Laura Pinheiro Marson
7 min readJul 26, 2023

--

Aprenda a acessar recursos compartilhados sem causar data races, mantendo a concorrência do seu app.

Photo by Ralfs Blumbergs on Unsplash

Conforme explicado no meu artigo anterior, a Concorrência é essencial para criar aplicativos performáticos e eficientes. Mas seu uso descuidado pode te trazer sérios problemas.

Race Condition e Data Race são uns dos problemas mais comuns que você pode enfrentar ao criar uma aplicação com várias threads, e apesar de serem parecidos, não são a mesma coisa. Além disso, ambos os problemas podem acontecer juntos ou de forma separada.

Race Condition

Acontece quando várias operações assíncronas são executadas na ordem incorreta, causando resultados inesperados. Por exemplo, atualizar a UI antes de receber os dados do servidor, ou tentar acessar um dado na memória que já não existe mais, e por aí vai. Veja o exemplo a seguir:

No exemplo acima, eu tento remover o último item do meu array de forma assíncrona, fora da Main Thread. Entretanto, dentro da Main Thread, eu removo todos os itens do array, de modo que dependendo de qual task for executada primeiro, é possível que eu tente remover o último elemento de uma coleção vazia, causando um fatal error, por tentar acessar algo que já não existe mais.

Data Race

Acontece quando mais de uma thread tenta acessar simultaneamente o mesmo recurso do sistema, e pelo menos um desses eventos consiste em alterar o valor desse recurso. Também conhecido como um problema de leitura/escrita (read/write), pois enquanto uma thread lê um valor, outra thread está sobrescrevendo aquele mesmo valor.

Nesse exemplo, a tarefa de escrita consiste em aumentar o valor do contador para 100. Já a tarefa de leitura vai acessar o contador enquanto ele está sendo atualizado por outra thread, o que vai gerar resultados diferentes e inesperados. Você pode acessar o código completo aqui e testar no Playground do Xcode.

Agora, você pode estar se questionando se esse último exemplo também não seria um caso de Race Condition, e você está certo! É por isso que Data Race e Race Condition são comumente tratados como sinônimos, visto que, muitas vezes, eles acontecem juntos. De qualquer forma, não nos aprofundaremos nessa discussão e partiremos logo para a resolução desses problemas.

O Problema dos Caixas Eletrônicos

Vamos começar com um exemplo bastante comum de caixas eletrônicos.

Imagine que dois caixas eletrônicos vão acessar a mesma conta bancária e realizar um saque.

  • O saldo atual da conta é de R$ 500.
  1. Thread 1: O Caixa 1 tenta realizar um saque de R$ 400 e verifica que esse valor é menor que R$ 500.
  2. Thread 2: O Caixa 2 tenta realizar um saque de R$ 300, e verifica que esse valor é menor que R$ 500.
  3. O Saque 1 é efetivado e o Saque 2 também, porque ambos os valores são menores que o saldo de R$ 500.

O problema é que os dois saques juntos ultrapassam o saldo da conta, deixando um valor negativado de R$ 200. Portanto, como as duas tasks estavam sendo realizadas simultaneamente, o sistema falhou em identificar que não haveria saldo para as duas transações.

Isso acontece no código abaixo:

struct ATM {
func withdraw(account: BankAccount, amount: Double) {
randomDelay(maxDuration: 0.6)
if account.balance >= amount { // verifica se há saldo suficiente
randomDelay(maxDuration: 0.6)
account.balance -= amount // realiza o saque
print("Sacou \(amount) reais da Conta do(a) \(account.name)")
} else {
print("Falha ao realizar saque de R$\(amount) da Conta do(a) \(account.name). Saldo insuficiente.")
}
}

func showBalance(account: BankAccount) {
randomDelay(maxDuration: 0.1)
print("Saldo atual da Conta do(a) \(account.name): R$\(account.balance)")
}
}

Perceba que o ATM (caixa eletrônico) verifica se a conta possui saldo suficiente para realizar o saque e, em caso positivo, prossegue com a transação. O problema é que transações reais podem demorar alguns milissegundos ou mais para serem concretizadas, representado aqui pelo 'randomDelay(maxDuration: 0.6)', o que pode causar data races, se essa função for executada de forma concorrente, como é visto abaixo:

let concurrentQueue = DispatchQueue.global()

let account1 = BankAccount(name: "Joao", balance: 500)

let atm1 = ATM()
let atm2 = ATM()

concurrentQueue.async {
atm1.withdraw(account: account1, amount: 400)
}

concurrentQueue.async {
atm2.withdraw(account: account1, amount: 300)
}

concurrentQueue.async {
atm1.showBalance(account: account1)
}

concurrentQueue.async {
sleep(2)
atm2.showBalance(account: account1)
}

Note que dois ATMs estão tentando sacar simultaneamente da mesma conta, e como a ordem de execução das tasks não é garantida, por se tratar de eventos concorrentes e assíncronos, é bem provável que o console do XCode vai exibir o seguinte resultado:

Para testar no seu Playground, você pode acessar o código completo aqui.

Classe Threadsafe usando Dispatch Barrier

Para evitar que esse problema ocorra, nós devemos transformar o ATM em uma classe thread safe, e é possível fazer isso sem perder o benefício de usar queues concorrentes.

Para isso, podemos manter as tasks que sejam apenas de leitura como concorrentes e rodando ao mesmo tempo. Já uma task de escrita entrará na queue como uma barrier task, a partir daí, a queue concorrente se comportará como uma serial queue temporariamente.

Funciona da seguinte forma: ao mandar executar uma barrier task, as tasks que já estavam sendo executadas continuam rodando de forma concorrente, quando todas acabarem, a barrier task iniciará sua execução, e todas as tasks novas que chegarem deverão esperar sua finalização. Veja:

A barrier task funciona como uma barreira, apenas ela é executada naquele momento

Dessa forma, enquanto houver alguma thread sobrescrevendo determinado valor, nenhuma outra poderá acessá-lo. No nosso exemplo, isso evita que dois ATMs realizem saques ao mesmo tempo, resolvendo o problema de data race.

Além disso, para evitar um problema de race condition, como atualizar a UI antes de receber um valor, a task de leitura deve ser executada de forma síncrona (.sync).

Passos para a criação da classe threadsafe:

  1. Criar uma queue concorrente privada, responsável unicamente por executar todas as tasks de leitura/escrita relacionadas com a BankAccount:
let isolationQueue = DispatchQueue(label: "com.myproject.atm.isolation",
attributes: .concurrent)

2. Executar as tasks de escrita de forma assíncrona e com uma barrier (isso é feito nas funções withdraw, deposit e transfer):

struct ATM {
func withdraw(account: BankAccount, amount: Double) {
isolationQueue.async(flags: .barrier) {
randomDelay(maxDuration: 0.6)
if account.balance >= amount {
randomDelay(maxDuration: 0.6)
account.balance -= amount
print("Sacou \(amount) reais da Conta do(a) \(account.name)")
} else {
print("Falha ao realizar saque de R$\(amount) da Conta do(a) \(account.name). Saldo insuficiente.")
}
}
}
[...]
}

3. Executar as tasks de leitura de forma síncrona:

struct ATM {
[...]
func showBalance(account: BankAccount) {
isolationQueue.sync {
randomDelay(maxDuration: 0.1)
print("Saldo atual da Conta do(a) \(account.name): R$\(account.balance)")
}
}
}

4. Opcional: no nosso exemplo, o saldo da conta bancária, que é o recurso compartilhado e que deve ser protegido de data races, está em uma classe separada, a BankAccount, a qual não é threadsafe, apenas ATM que é. Por isso, para evitar que outras classes não threadsafe interfiram no saldo da conta, eu adicionei um 'fileprivate', para que apenas o ATM possa acessar o saldo bancário:

class BankAccount {
let name: String
fileprivate var balance: Double

fileprivate init(name: String, balance: Double) {
self.name = name
self.balance = balance
}
}

Por fim, nosso sistema bancário threadsafe ficará da seguinte forma:

Agora, vamos brincar com nosso ATM e realizar várias operações concorrentes: saques, transferência entre contas e visualização do saldo:

let concurrentQueue = DispatchQueue(label: "com.myproject.atm.isolation",
attributes: .concurrent)

let bank = Bank()
let account1 = bank.createAccount(name: "Joao", balance: 100)
let account2 = bank.createAccount(name: "Maria", balance: 200)
let atm1 = ATM()
let atm2 = ATM()
let atm3 = ATM()

concurrentQueue.async {
atm1.withdraw(account: account1, amount: 50)
}

concurrentQueue.async {
atm2.withdraw(account: account1, amount: 60)
}

concurrentQueue.async {
atm1.showBalance(account: account1)
}

concurrentQueue.async {
atm3.transfer(from: account2, to: account1, amount: 100)
}

concurrentQueue.async {
atm2.withdraw(account: account2, amount: 150)
}

concurrentQueue.async {
atm3.showBalance(account: account2)
}

concurrentQueue.async {
sleep(2)
atm2.showBalance(account: account1)
}

Provavelmente, nosso log ficará da seguinte forma:

Para testar no seu Playground, acesse o código completo aqui.

Mas em outro momento, o log foi este:

No primeiro log, a transferência da conta da Maria foi bem sucedida, já o saque falhou por saldo insuficiente. No segundo log, ao contrário, o saque da conta da Maria ocorreu, mas a transferência não. De qualquer forma, os resultados foram consistentes.

Portanto, mesmo que haja uma imprevisibilidade em qual task será executada primeiro, a nossa classe estará protegida para que não haja um saque ou uma transferência com saldo insuficiente. Portanto, estamos garantindo uma consistência de dados.

A criação de queues aumenta a complexidade do seu app e pode trazer problemas de concorrência, por isso, tais medidas devem ser tomadas com sabedoria. São ferramentas poderosas, mas que devem ser usadas com cuidado.

--

--

Laura Pinheiro Marson
Laura Pinheiro Marson

Written by Laura Pinheiro Marson

iOS Developer na Zup Innovation, em busca de aprender e compartilhar conhecimento

Responses (1)