Having access to multiple parallel CPU cores isn’t a new thing by any
means, people have been programming in parallel for half a century now,
but recent years we’ve found ourselves at an inflection point. Moore’s
law is dying, beefy single cores are no longer keeping up. Modern
computers come with multiple CPU cores, so exploiting parallel compute
is more important than ever. Given how long it’s been an area of
research we can naturally expect that effective tools have taken root
and that synchronizing threads is trivial now right…?
Unfortunately this has not been my experience, and I’m willing to bet
it hasn’t been yours either. Managing shared state across threads is
hard, and the most commonly used tools: mutexes and semaphores, simply
haven’t evolved much since their inception.
The words that follow will dig into the problems inherent to mutexes
and synchronizing shared mutable state. Afterwards we’ll look into other
avenues which should prove more helpful.
The Problem with Shared
State
Let’s begin by crafting a simple software system which needs
synchronization in the first place.
I’ll present a commonly used example: the task of managing bank
account balances correctly in spite of parallel transfer requests.
Of course real banks don’t store all their account balances in RAM,
so I’ll hope that the reader can apply the concepts from this
pedagogical example to a their own domain as necessary, it serves as a
stand-in for any sufficiently complex system which requires ad-hoc
synchronization of arbitrary data between multiple threads.
Here’s some golang’ish pseudo-code (please don’t try to actually
compile it) for a simple bank account and the operations upon it. I’m
focused on the synchronization problems here, so forgive me for skipping
the double-entry accounting, input validation, and other real-world
complexities.
struct Account {
balance int,
}
// Deposit money into an account
func (a *Account) deposit(amount int) {
a.balance += amount
}
// Withdraw money from an account, or return false if there are insufficient funds
func (a *Account) withdraw(amount int) bool {
if (a.balance <= amount) {
return false
} else {
balance -= amount
return true
}
}Great! This defines our Account type and some methods for withdrawing
and depositing money into such an account. Now let’s add a function to
transfer money between accounts:
func transfer(from *Account, to *Account, amount int) bool {
if (from.withdraw(amount)) {
to.deposit(amount)
return true
} else {
return false
}
}Looks good, but now what happens when we start handling multiple
requests concurrently?
struct TransferRequest {
from *Account,
to *Account,
amount int,
}
func main() {
// loop forever, accepting transfer requests and processing them in goroutines
for {
req := acceptTransferRequest()
go transfer(req.from, req.to, req.amount)
}
}Things may work well in your tests if you’re (un)lucky, and might
even work well in production for a while, but sooner or later you’re
going to lose track of money and have some confused and angry
customers.
Do you see why? This brings us to our first synchronization problem
to solve, Data Races.
Data races
Most programming languages are imperative with
mutable data structures [citation needed], so passing
pointers to multiple threads leads to shared mutable data, and
shared mutable data necessarily causes data races.
A data race occurs any time two threads access the same memory
location concurrently and non-deterministically when at least one of the
accesses is a write. When a data race is present two runs of the same
code with the same state may non-deterministically have a different
result.
We’re passing accounts by reference here, so multiple threads have
access to modify the same account. With multiple transfer go-routines
running on the same account, each could be paused by the scheduler at
nearly any point during its execution. This means that even within this
simple example we’ve already introduced a data race. Take another look
at the withdraw function, I’ll point it out:
// Withdraw money from an account, or return false if there are insufficient funds
func (a *Account) withdraw(amount int) bool {
hasFunds := a.balance >= amount
// HERE! The scheduler could pause execution here and switch to another thread
if (hasFunds) {
balance -= amount
return true
} else {
return false
}
}If two threads are withdrawing $100 from Alice’s account, which only
has $150 in it, it’s possible that thread 1 checks the balance, sees
there’s enough money, then gets paused by the scheduler. Thread 2 runs,
checks the balance, also sees there’s enough money, then withdraws $100.
When thread 1 later resumes execution after the check it
withdraws its $100 too, Alice’s account ends up with a negative balance
of -$50, which is invalid even though we had validation!
This sort of concurrency error is particularly insidious because the
original withdraw method is perfectly reasonable,
idiomatic, and correct in a single-threaded program; however when we
decide to add concurrency at a completely different
place in the system we’ve introduced a bug deep within existing
previously correct code. The idea that a perfectly normal evolution from
a single-threaded to a multi-threaded program can introduce
critical system-breaking bugs in completely unrelated
code without so much as a warning is quite frankly completely
unacceptable. As a craftsman I expect better from my tools.
Okay, but now that we’ve lost thousands if not millions of dollars,
how do we fix this?
Traditional knowledge points us towards Mutexes.
Mutexes
Okay, we’ve encountered a problem with our shared mutable state, the
traditional approach to solving these problems is to enforce
exclusive access to the shared data in so-called “critical
sections”. Mutexes are so-named because they provide
mutual exclusion, meaning only a
single thread may access a given virtual resource at a time.
Here’s how we can edit our program to fix the data race problems
using a mutex:
struct Account {
mutex Mutex,
balance int,
}
func (a *Account) deposit(amount int) {
a.mutex.lock()
defer a.mutex.unlock()
a.balance += amount
}
func (a *Account) withdraw(amount int) bool {
a.mutex.lock()
defer a.mutex.unlock()
hasFunds := a.balance >= amount
if (hasFunds) {
balance -= amount
return true
} else {
return false
}
}Now every Account has a mutex on it, which acts as an
exclusive lock.
It’s much like a bathroom key in a busy restaurant. When you want to
use the bathroom, you take the key, there’s only one key available for
each bathroom, so while you’ve got hold of it nobody else can use that
bathroom. Now you’re free to do your business, then you return the key
to the hook on the wall for the next person.
Unlike a bathroom key however, mutexes are only conceptual
locks, not real locks, and as such they operate on the honor
system.
If the programmer forgets to lock the mutex the system won’t stop
them from accessing the data anyways, and even then there’s no actual
link between the data being locked and the lock itself, we need to trust
the programmers to both understand and respect the
agreement. A risky prospect on both counts.
In this case, we’ve addressed the data-race within
withdraw and deposit by using mutexes, but
we’ve still got a problem within the transfer function.
What happens if a thread is pre-empted between the calls to
withdraw and deposit while running the
transfer function? It’s possible that money will been
withdrawn from an account, but won’t have yet been deposited in the
other. This is an inconsistent state of the system, the money has
temporarily disappeared, existing only in the operating memory of a
thread, but not visible in any externally observable state. This can
(and will) result in very strange behaviour.
As a concrete way to observe the strangeness let’s write a
report function which prints out all account balances:
func report() {
for _, account := range accounts {
account.mutex.lock()
fmt.Println(account.balance)
account.mutex.unlock()
}
}If we run a report while transfers are ongoing we’ll
likely see that the count of the total amount of money that exists
within the system is incorrect, and changes from report to report, which
should be impossible in a closed system like this! This inconsistency
occurs even if we obtain the locks for each individual account before
checking the balance.
In larger systems this sort of inconsistency problem can cause flaws
in even simple logic, since choices may be made against inconsistent
system states. The root of this issue is that the transfer
function requires holding multiple independent locks, but they’re not
grouped in any way into an atomic operation.
Composing Critical Sections
We need some way to make the entire transfer operation atomic, at
least from the perspective of other threads who are respecting our
mutexes.
Okay, well no problem, we can just lock both accounts, right?
func transfer(from *Account, to *Account, amount int) bool {
from.mutex.lock()
to.mutex.lock()
defer from.mutex.unlock()
defer to.mutex.unlock()
if (from.withdraw(amount)) {
to.deposit(amount)
return true
} else {
return false
}
}I’m sure some readers have already seen a problem here, but have you
seen two problems here?
The first is obvious when you point it out, remember that
withdraw and deposit also lock the
mutex on the account, so we’re trying to acquire the same lock twice in
the same thread.
transfer won’t even begin to run in this state, it will
block forever inside withdraw when it tries to lock the
from.mutex for the second time.
Some systems, like re-entrant locks and Java’s
synchronized keyword do some additional book-keeping which
allow a single thread to lock the same mutex multiple times, so using a
re-entrant lock here would solve this particular problem. However other
systems, like golang, avoid providing re-entrant locks on
a matter of principle.
So what can we do? I suppose we’ll need to pull the locks out
of withdraw and deposit so we can lock
them in transfer instead.
func (a *Account) deposit(amount int) {
a.balance += amount
}
func (a *Account) withdraw(amount int) bool {
hasFunds := a.balance >= amount
if (hasFunds) {
balance -= amount
return true
} else {
return false
}
}
func transfer(from *Account, to *Account, amount int) bool {
from.mutex.lock()
to.mutex.lock()
defer from.mutex.unlock()
defer to.mutex.unlock()
if (from.withdraw(amount)) {
to.deposit(amount)
return true
} else {
return false
}
}Ugh, a correct transfer function should conceptually
just be the composition of our well encapsulated
withdraw and a deposit functions, but defining
it correctly has forced us to remove the locking from both
withdraw and deposit, making both of them
less safe to use. It has placed the burden of locking on the
caller (without any system-maintained guarantees), and even
worse, we now need to remember to go and add locking around
every existing withdraw and deposit call in
the entire codebase. Even if we try to encapsulate everything within the
module and only export “safe” operations we’ve caused duplication since
we now need synchronized and unsynchronized versions of our
withdraw and deposit operations. And we’d
still need to expose the mutexes if we want to allow callers to
synchronize operations with other non-Account data.
What I’m getting at is that mutexes don’t compose! They
don’t allow us to chain multiple critical sections into a single atomic
unit, they force us to break encapsulation and thrust the implementation
details of mutexes and locking onto the caller who shouldn’t need to
know the details about which invariants must be maintained deep within
the implementation. Adding or removing access to synchronized variables
within an operation will also necessitate adding or removing locking to
every call site, and those call sites may be in a completely
different application or library. This is an absolute mess.
All that sounds pretty bad, but would you believe those aren’t the
only problems here? It’s not just composition that’s broken here though,
in fixing transfer to make it an atomic operation we’ve
managed to introduce a new, extra-well-hidden deadlock bug.
Deadlocks/Livelocks
Recall that in our main loop we’re accepting arbitrary transfer
requests and spawning them off in goroutines. What happens in our system
if we have two transfer requests, Alice is trying to Venmo Bob $25 for
the beanbag chair she just bought off him, meanwhile Bob remembers he
needs to Venmo Alice the $130 he owes her for Weird Al concert
tickets.
If by sheer coincidence they both submit their requests at the same
time, we have two transfer calls:
transfer(aliceAccount, bobAccount, 25)transfer(bobAccount, aliceAccount, 130)
Each of these calls will attempt to lock their from
account and then their to account. If Alice and
Bob get very unlucky, the system will start the first
transfer and lock Alice’s account, then get paused by the
scheduler. When the second transfer call comes in, it first
locks Bob’s account, then tries to lock Alice’s account, but can’t
because it’s already locked by the first transfer call.
This is a classic deadlock situation. Both threads will be stuck
forever, and worse, both Alice and Bob’s accounts will be locked until
the system restarts.
This is a pretty disastrous consequence for a problem which is
relatively hard to spot even in this trivially simple example. In a real
system with dozens or hundreds of methods being parallelized in a
combinatorial explosion of ways it’s very difficult to
reason about this, and can be a lot of work to ensure locks are obtained
in a safe and consistent order.
Golang gets some credit here in that it does provide some
runtime tools for detecting both dead-locks and data-races, which is
great, but these detections only help if your tests encounter the
problem; they don’t prevent the problem from happening in the first
place. Most languages aren’t so helpful, these issues can be very
difficult to track down in production systems.
Assessing the damage
What a dumpster fire we’ve gotten ourselves into…
While it may be no accident that the example I’ve engineered happens
to hit all of the worst bugs at once, in my experience, given enough
time and complexity these sorts of problems will crop up any system
eventually. Solving them with mutexes is especially dangerous because it
will seem to be an effective solution at first. Mutexes work
fine in small localized use-cases, thus tempting us to use them, but as
the system grows organically we stretch them too far and they fail
catastrophically as the complexity of the system scales up, causing all
sorts of hacky workarounds. I’m of the opinion that crossing your
fingers and hoping for the best is not an adequate software-engineering
strategy.
So, we’ve seen that architecting a correct software system using
mutexes is possible, but very difficult. Every
attempt we’ve made to fix one problem has spawned a couple more.
Here’s a summary of the problems we’ve encountered:
- Data races causing non-determinism and logic bugs
- Lack of atomicity causing inconsistent system states
- Lack of composition causing
- Broken encapsulation
- Code duplication
- Cognitive overload on callers
- Deadlocks/livelocks causing system-wide freezes
- New features may require changes to every call-site
In my opinion, we’ve tried to stretch mutexes beyond their limits,
both in this blog post and in the industry as a whole. Mutexes work
great in small, well-defined scopes where you’re locking a
single resource which is only ever accessed in a handful of
functions in the same module, but they’re too hard to wrangle in larger
complex systems with many interacting components maintained by dozens or
hundreds of developers. We need to evolve our tools and come up with
more reliable solutions.
Cleaning up the Chaos
Thankfully, despite an over-reliance on mutexes, we as an industry
have still learned a thing or two since the 1960s. Particularly I think
that enforcing immutability by default goes a long way
here. For many programmers this is a paradigm shift from what they’re
used to, which usually causes some uneasiness. Seatbelts, too, were
often scorned in their early years for their restrictive nature, but
over time it has become the prevailing opinion that the mild
inconvenience is more than worth the provided safety.
More and more languages (Haskell, Clojure, Erlang, Gleam, Elixir,
Roc, Elm, Unison, …) are realizing this and are adopting this as core
design principle. Obviously not every programmer can switch to an
immutable-first language over night, but I think it would behoove most
programmers to strongly consider an immutable language if parallelism is
a large part of their project’s workload.
Using immutable data structures immediately prevents data-races,
full-stop. So stick with immutable data everywhere you can, but in a
world of immutability we’ll still need some way to synchronize parallel
processes and for that most of these languages do still provide some
form of mutable reference. It’s never the default, and there’s typically
some additional ceremony or tracking in the type system which acts as an
immediate sign-post that shared-mutable state is involved; here there be
dragons.
Even better than mutable references, decades of research and
industrial research have provided us with a swath battle-tested
high-level concurrency patterns which are built on top of lower-level
synchronization primitives like mutexes or mutable references, typically
exposing much safer interfaces to the programmer.
Concurrency Patterns
Actor systems and Communicating Sequential Processes (CSP) are some
of the most common concurrency orchestration patterns. Each of these
operate by defining independent sub-programs which have their own
isolated states which only they can access. Each actor or process
receives messages from other units and can respond to them in turn. Each
of these deserves a talk or blog post of their own so I won’t dive too
deeply into them here, but please look into them deeper if this is the
first you’re hearing of them.
These approaches work great for task parallelism, where
there are independent processes to run, and where your parallelism needs
are bounded by the number of tasks you’d like to run. As an example, I
used an actor-based system when building Unison’s code-syncing protocol.
There was one actor responsible for loading and sending requests for
code, one for receiving and unpacking code, and one for validating the
hashes of received code. This system required exactly 3
workers to co-operate regardless of how many things I was
syncing. Actor and CSP systems are great choices when the number of
workers/tasks we need to co-ordinate is statically known, i.e. a fixed
number of workers, or a pre-defined map-reduce pipeline. These patterns
can scale well to many cores since each actor or process can run
independently on its own core without worrying about synchronizing
access to shared mutable state, and as a result can often scale to
multiple machines as well.
However, there are also problems where the parallelism is
dynamic or ad-hoc, meaning there could be any number of
runtime-spawned concurrent actors that must co-ordinate well with each
other. In those cases these systems tend to break down. I’ve seen
consultants describe complex patterns for dynamically introducing
actors, one-actor-per-resource systems, tree-based actor resource
hierarchies and other complex ideas but in my opinion these systems
quickly outgrow the ability of any one developer to understand and
debug.
So how then do we model a system like the bank account example? Even
if we were to limit the system to a fixed number of transfer-workers
they’d still be concurrently accessing the same data (the bank accounts)
and need some way to express atomic transfers between
them, which isn’t easily accomplished with actors or CSP.
What’s a guy to do?
A new (old) synchronization
primitive
In the vast majority of cases using a streaming system, actors or CSP
is going to be most effective and understandable. However in cases where
we must synchronize individual chunks of data across many workers, and
require operations to affect multiple chunks of data atomically, there’s
only one name in town that gets the job done right.
Software Transactional Memory (STM) is a criminally under-utilized
synchronization tool which solves all of the problems we’ve encountered
so far while providing more safety, better compositionality, and cleaner
abstractions. Did I mention they prevent most deadlocks and livelocks
too?
To understand how STM works, think of database transactions; in a
database transaction isolation provides you with a consistent view of
data in spite of concurrent access. Each transaction sees an isolated
view of the data, untampered by other reads and writes. After making all
your reads and writes you commit the transaction. Upon commit,
the transaction either succeeds completely and applies ALL the
changes you made to the data snapshot, or it may result in a
conflict. In cases of a conflict the transaction fails
and rolls back all your changes as though nothing happened,
then it can retry on the new data snapshot.
STM works in much the same way, but instead of the rows and columns
in a database, transactions operate on normal in-memory data structures
and variables.
To explore this technique let’s convert our bank account example into
Haskell so we can use STM instead of mutexes.
data Account = Account {
-- Data that needs synchronization is stored in a
-- Transactional Variable, a.k.a. TVar
balanceVar :: TVar Int
}
-- Deposit money into an account.
deposit :: Account -> Int -> STM ()
deposit Account{balanceVar} amount = do
-- We interact with the data using TVar operations which
-- build up an STM transaction.
modifyTVar balanceVar (\existing -> existing + amount)
-- Withdraw money from an account
-- Everything within the `do` block
-- is part of the same transaction.
-- This guarantees a consistent view of the TVars we
-- access and mutate.
withdraw :: Account -> Int -> STM Bool
withdraw Account{balanceVar} amount = do
existing <- readTVar balanceVar
if existing <= amount
then (return False)
else do
writeTVar balanceVar (existing - amount)
return True
-- Transfer money between two accounts atomically
transfer :: Account -> Account -> Int -> STM Bool
transfer from to amount = do
-- These two individual transactions seamlessly
-- compose into one larger transaction, guaranteeing
-- consistency without any need to change the individual
-- operations.
withdrawalSuccessful <- withdraw from amount
if successful
then do
deposit to amount
return True
else
return FalseLet’s do another lap over all the problems we had with mutexes to see
how this new approach fares.
Data Races
Data races are a problem which I believe are best solved at the
language level itself. As mentioned earlier, using immutable data by
default simply prevents data races from existing in the first place.
Since data in Haskell is all immutable by default, pre-emption can occur
at any point in normal code and we know we won’t get a
data race.
When we need mutable data, it’s made explicit by wrapping
that data in TVars. The language further protects us by
only allowing us to mutate these variables within transactions, which we
compose into operations which are guaranteed a consistent uncorrupted
view of the data.
Let’s convert withdraw to use STM and our
balaceVar TVar.
-- Withdraw money from an account
withdraw :: Account -> Int -> STM Bool
withdraw Account{balanceVar} amount = do
existing <- readTVar balanceVar
if existing <= amount
then (return False)
else do
-- No data races here!
writeTVar balanceVar (existing - amount)
return TrueWe can see that the code we wrote looks very much like the original
unsynchronized golang version, but while using STM it’s perfectly safe
from data races! Even if it the thread is pre-empted in the middle of
the operation, the transaction-state is invisible to other threads until
the transaction commits.
Deadlock/Livelock
STM is an optimistic concurrency system. This means
that threads never block waiting for locks. Instead,
each concurrent operation proceeds, possibly in parallel, on their own
independent transaction log. Each transaction tracks which pieces of
data it has accessed or mutated and if at commit time it is detected
that some other transaction has been committed and altered data which
this transaction also accessed, then the latter transaction is rolled
back and is simply retried.
This arrangement is fundamentally different from a lock-based
exclusive access system. In STM, you don’t deal with locks at all, you
simply read and write data within a transaction as necessary. Our
transfer function reads and writes two different
TVars, but since we’re not obtaining exclusive
locks to these vars, we don’t need to worry about deadlock
at all. If two threads happen to be running a
transfer on the same TVars at the same time,
whichever commits first will atomically apply its updates to both
accounts and the other transaction will detect this update at
commit-time and will retry against the new balances.
This can cause some contention and possibly even starvation
of any single transaction if many threads are trying to update the same
data at the same time, but since a conflict can only occur if some other
transaction has been committed, it does still have the guarantee that
the system will make progress on at least some work. In Haskell, STM
transactions must be pure code, and can’t do IO, so most
transactions are relatively short-running and should proceed eventually.
This seems like a downside, but in practice it only surfaces as a rare
annoyance and can usually be worked around without too much trouble.
Composition
It may not be immediately obvious from the types if you’re not used
to Haskell code, but all three of withdraw,
deposit, and transfer are all functions which
return their results wrapped in the STM monad, which is
essentially a sequence of operations which we can ask to execute in a
transaction using the atomically function.
We can call out to any arbitrary methods which return something
wrapped in STM and it will automatically be joined in as
part of the current transaction.
Unlike our mutex setup, callers don’t need to manually handle locks
when callingwithdraw and deposit, nor do we
need to expose special synchronized versions of these
methods for things to be safe. We can define them exactly once and use
that one definition either on its own or within a more complex operation
like transfer without any additional work. The abstraction
is leak-proof, the caller doesn’t need to know which synchronized data
is accessed or lock or unlock any mutexes. It simply runs the
transaction and the STM system happily handles the rest for you.
Here’s what it looks like to actually run our STM transactions, which
we do using the atomically function:
main :: IO ()
main = do
forever $ do
req <- acceptTransferRequest
-- Run each transfer on its own green-thread, in an atomic transaction.
forkIO (atomically (transfer req.from req.to req.amount)If we’d like to compile a report of all account balances as we did
previously, we can do that too. This time however we won’t get a
potentially inconsistent snapshot of the system by accident, instead the
type-system forces us to make an explicit choice of which behaviour we’d
like.
We can either:
- Access and print each account balance individually as separate
transaction which means accounts may be edited in-between
transactions, leading to an inconsistent report like we saw
earlier. - Or, we can wrap the entire report into a single
transaction, reading all account balances in a single
transaction. This will provide a consistent snapshot of the
system, but due to the optimistic transaction system, the entire
transaction will be retried if any individual transfers commit
and edit accounts while we’re collecting the report. It’s possible that
if transfers are happening very frequently, the report
may be retried many times before it can complete.
This is a legitimate tradeoff that the developer of the system should
be forced to consider.
Here’s what those two different implementations look like:
-- Inconsistent report, may see money disappear/appear
reportInconsistent :: [Account] -> IO ()
reportInconsistent accounts = do
for_ accounts $ \Account{balanceVar} -> do
balance <- atomically (readTVar balanceVar)
print balance
-- Consistent report, may be retried indefinitely
-- if transfers are happening too frequently
reportConsistent :: [Account] -> IO ()
reportConsistent accounts = do
balances <- atomically do
for accounts $ \Account{balanceVar} -> do
readTVar balanceVar
-- Now that we've got a snapshot we can print it out
for_ balances printSmart Retries
One last benefit of STM which we haven’t yet discussed is that it
supports intelligent transaction retries based on conditions of
the synchronized data itself. For instance, if we have a task to
withdraw $100 from Alice’s account but it only has $50 in it, the
mutex-based system has no choice to but fail the withdrawal entirely and
return the failure up the stack. We can wrap that call with code to try
again later, but how will we know when it’s reasonable to try again?
This would once again require the caller to understand the
implementation details, and which locks the method is
accessing.
STM, instead, supports failure and retrying as a first-class concept.
At any point in an STM transaction you can simply call
retry, this will record every TVar that the
transaction has accessed up until that point, then will abort the
current transaction and will sleep until any of those TVars
has been modified by some other successful transaction. This avoids
busy-waiting, and allows writing some very simple and elegant code.
For example, here’s a new version of our withdraw
function which instead of returning a failure will simply block the
current thread until sufficient funds are available, retrying only when
the balance of that account is changed by some other transaction’s
success.
-- Withdraw money from an account, blocking until sufficient funds are available
withdraw :: Account -> Int -> STM ()
withdraw Account{balanceVar} amount = do
existing <- readTVar balanceVar
if existing <= amount
then retry
else do
writeTVar balanceVar (existing - amount)You typically wouldn’t use this to wait for an event which may take
days or weeks to occur like in this example; but it’s a very elegant and
efficient solution for waiting on a channel, waiting for a future to
produce a result, or waiting on any other short-term condition to be
met.
Here’s an example utility for zipping together two STM queues. The
transaction will only succeed and produce a result when a value is
available on both queues, and if that’s not the case, it will only
bother retrying when one of the queues is modified since
readTQueue calls retry internally if the queue
is empty.
zipQueues :: TQueue a -> TQueue b -> STM (a, b)
zipQueues q1 q2 = do
val1 <- readTQueue q1
val2 <- readTQueue q2
return (val1, val2)Nifty!
We’ve covered a lot in this post, if there’s only one thing
you can take away from it, I hope that you’ve taken the time to consider
whether mutexes with shared mutable state are providing you with utility
which outweighs their inherent costs and complexities. Unless you need
peak performance, you may want to think twice about using such dangerous
tools. Instead, consider using a concurrency pattern like actors, CSP,
streaming, or map-reduce if it matches your use-case.
If you need something which provides greater flexibility or
lower-level control, Software Transactional Memory (STM) is a fantastic
choice if it’s available in your language of choice, though note that
not all languages support it, or if they do, may not be able to provide
sufficient safety guarantees due to mutable variables and data
structures.
If you’re starting a new project for which concurrency or parallelism
is a first-class concern, consider trying out a language that supports
STM properly, I can recommend Unison or Haskell as great starting
points.
Hopefully you learned something 🤞! If you did, please consider
joining my Patreon
to keep up with my projects, or
check out my book: It teaches the principles of using optics in
Haskell and other functional programming languages and takes you all
the way from an beginner to wizard in all types of optics! You can get
it here. Every
sale helps me justify more time writing blog posts like this one and
helps me to continue writing educational functional programming
content. Cheers!