Skip to main content 0xc's blog

Optimistic and Pessimistic Locks in Elixir

When should I use each of them? I will smash your question with the hammer of knowledge.

Introduction

If you don’t know anything about concurrency, don’t worry, I’ll give a brief explanation about it and probably post something more in-depth about it in the future, but for now, this little introduction will do the work.

Concurrency

Concurrency is the concept of allowing multiple processes or threads to access and manipulate shared resources at the same time. It is common for multiple operations to attempt to access the same data simultaneously, whether for reading or writing, and this can lead to problems such as race conditions and data inconsistencies.

Race conditions

They happen when multiple operations try to access or modify the same data simultaneously, leading to inconsistent or unexpected behavior.

Example using Elixir IEx:

elixir code snippet start

# Agent to store state, starting with 0
{:ok, counter} = Agent.start_link(fn -> 0 end)

increment = fn ->
    # Await for a random time (max 1 sec) and get our state
    :timer.sleep(:rand.uniform(1000))
    current = Agent.get(counter, & &1)

    # Await for a random time (max 1 sec) and update our state
    :timer.sleep(:rand.uniform(1000))
    Agent.update(counter, fn _ -> current + 1 end)
end

# Execute multiple increments at the same time, sharing state
for _ <- 1..10, do: Task.async(fn -> increment.() end)

elixir code snippet end

We are using :time.sleep to simulate network instability, so you will need to wait 1 second and run:

elixir code snippet start

Agent.get(counter, & &1)

elixir code snippet end

You will probably get different results each time. The expected result without a race condition should be 10.


with these concepts, you’ll probably understand the rest of the post :)


Pessimistic

If you don’t know what to expect, expect the worst

That’s what people say, and in most cases, they’re right. If you don’t know what to do (the best thing to do is to start knowing what to do), expecting the worst is a good choice, and this applies to locks.

Pessimistic Locking is a strategy that assumes conflicts will happen frequently. It locks a resource as soon as it is accessed, ensuring that no other process can modify it until the lock is released.

If you’re uncertain about how often your resources will be used, opting for a pessimistic lock is likely a better choice. In MY OPINION, consistency is more important than performance most of the time. (use common sense).

Use Cases:

  • When there is a high likelihood of conflicts, such as when many users are accessing the same resource.
  • In critical systems, like financial systems, maintaining data consistency is a top priority.

Example using Elixir and Ecto:

elixir code snippet start

defmodule MyApp.Resource.GetWithLock do
  alias MyApp.Repo

  def get_resource_with_pessimistic_lock(id) do
    Repo.transaction(fn ->
      Repo.one(
        from r in Resource,
        where: r.id == ^id,
        lock: "FOR UPDATE"
      )
    end)
  end
end

elixir code snippet end

Or use Elixir default syntax

elixir code snippet start

defmodule MyApp.Resource.GetWithLock do
  alias MyApp.Repo

  def get_resource_with_pessimistic_lock(id) do
    Repo.transaction(fn ->
      Repo.get!(MyApp.Resource, id, lock: "FOR UPDATE")
    end)
  end
end

elixir code snippet end


Optimistic

I never used this sh**

Optimistic Locking is based on the assumption that conflicts are rare. It enables multiple transactions to access a resource simultaneously and detects any conflicts when changes are being saved. To facilitate this, it employs an additional field, typically referred to as a “version,” which is incremented with each modification.

Use Cases:

  • When conflicts are infrequent and performance is prioritized over immediate consistency.
  • In systems characterized by high read and low write activity, such as control panels or applications that generate numerous reports.

Example using Elixir and Ecto:

Add a lock_version row to your database table.

elixir code snippet start

def change do
  alter table(:resources) do
    add :lock_version, :integer, default: 0, null: false
  end
end

elixir code snippet end

You can use other types to lock_version, but I won’t cover that here

Define your schema and changeset as usual, but include the lock_version field and utilize the optimistic_lock function provided by Ecto.changeset

elixir code snippet start

defmodule MyApp.Resource do
  use Ecto.Schema
  import Ecto.Changeset

  schema "resources" do
    field :name, :string
    field :lock_version, :integer, default: 0
  end

  def changeset(resource, attrs) do
    resource
    |> cast(attrs, [:name])
    |> optimistic_lock(:lock_version)
  end
end

elixir code snippet end

Use it as a regular update, Ecto will do the magic for you.

elixir code snippet start

defmodule MyApp.Resource.UpdateWithLock do
  def update_resource_with_optimistic_lock(resource, params) do
    alias MyApp.Repo

    changeset = MyApp.Resource.changeset(resource, params)

    case Repo.update(changeset) do
      {:ok, updated_resource} ->
        {:ok, updated_resource}

      {:error, %Ecto.StaleEntryError{}} ->
        {:error, :conflict}
    end
  end
end

elixir code snippet end


Conclusion

That’s it! Simple as a quantum computer running another computer inside Terraria underwater in the center of an erupting volcano. this is a joke ok?

  • Consistency and conflict do occur often -> Pessimistic Lock
  • Performance and conflict do not occur often -> Optimistic Lock

I hope you enjoyed it! Follow my blog or my socials for more content like this :)

LinkedIn Twitter