View Source Contracts in a Concurrent World
The Bond
moduledocs touched on the pitfalls of defining contracts for
stateful concurrent processes. This guide will provide a more in-depth
discussion of the problem and offer a solution for devising contracts in such
a context that does not compromise the strength of the assertions in the
contracts.
Let's revisit the example of a stateful Counter
module in the form it was
originally presented, this time with all of the Agent
details provided:
defmodule Counter do
use Agent
use Bond
def start_link(initial_count) do
Agent.start_link(fn -> initial_count end)
end
def get_count(agent) do
Agent.get(agent, & &1)
end
@post count_incremented_by_1: get_count(agent) == old(get_count(agent)) + 1
def increment_count(agent) do
Agent.update(agent, &(&1 + 1))
end
end
As we saw, this implementation of the Counter
agent suffers from race
conditions that invalidate the correctness of the postcondition for
increment_count/1
: if a concurrent call to increment_count/1
is interleaved
anywhere between the two calls to get_count/1
(in the postcondition) and the
call to Agent.update/3
, then the postcondition will fail because the count
will have increased by more than one. (Keep in mind that old
expressions are
resolved prior to function execution, and therefore the call to get_count/1
in old(get_count(agent))
will be done before the call to Agent.update/3
.)
Our solution to this problem was to weaken the assertion in the postcondition so that it guaranteed only that the count increased by some amount, not necessarily by exactly one:
@post count_increased: get_count(agent) > old(get_count(agent))
def increment_count(agent) do
Agent.update(agent, &(&1 + 1))
end
As noted, this is the best we can do with the given implementation of the
Counter
that uses an Agent
to store the counter state. Since agents are
stateful, concurrent processes that do not offer a locking mechanism or
isolated transactions, concurrent state updates between evaluation of
preconditions, postconditions, and the function body are always a possibility.
Given this possibility, contracts can only make weak guarantees about the
observable effects of concurrent state updates, such as count_increased
above.
However, we can do better if we refactor the Counter
module to separate the
purely functional parts from the stateful and concurrent parts of the code.
This oft-given advice is useful not only in the context of contract
programming, but also for improving the testability and design of the code in
general.
Let's see how we can do this for our Counter
module, and how it strengthens
the assertions that we can express:
defmodule Counter do
use Agent
use Bond
defmodule State do
use Bond
defstruct [:count]
@post count_incremented_by_1: result.count == current_count + 1
def increment_count(%__MODULE__{count: current_count} = state) do
%{state | count: current_count + 1}
end
end
def start_link(initial_count) when is_integer(initial_count) do
Agent.start_link(fn -> %State{count: initial_count} end)
end
def get_count(counter) do
Agent.get(counter, & &1.count)
end
@post count_increased: get_count(counter) > old(get_count(counter))
def increment_count(counter) do
Agent.update(counter, &State.increment_count/1)
end
end
We've added a nested State
module to our existing Counter
module. It
defines a struct with a single :count
field, and contains one pure function,
namely increment_count/1
. (This is for demonstration purposes only.
In a more realistic scenario, the Counter.State
module would exist
independently of the Counter
module and be given a name appropriate to its
role in the problem domain, and would likely have more than just one field
in the struct.)
The Counter
module has been updated to use an instance of this struct as the
agent state, and Counter.increment_count/1
uses
Counter.State.increment_count/1
to update the state in a purely functional
way.
Although the count_increased
assertion is still the strongest we can provide
for Counter.increment_count/1
, the stronger count_incremented_by_1
assertion is now valid for Counter.State.increment_count/1
, because it is a
pure function! Also notice that we didn't even need to use an old
expression
in count_incremented_by_1
since that assertion is comparing the "old" value
of the counter from the function argument to the "new" or "current" value of
the counter in the function result
.