When to use mutex or atomic

My problem:

  1. I’ve got a field of a struct and I need to access the data across multiple io.async and io.concurrent.
  2. The data is small (< 8 bits)
  3. I need to read / write from thread 1 and read / write from thread 2.
  4. I do not need to protect it from reads/writes for extended periods, I only need to prevent it being read or written as corrupted.

Questions:

  1. When should I protect the data with a mutex vs std.atomic.Value()?
  2. Are there code portability concerns between using mutex vs atomic? Which is more portable?
  3. If something was different about my problem, what would encourage using mutex?
  4. If something was different about my problem, what would encourage using atomic?
  5. Why is this there no std.atomic.Value() equivalent in std.Io?
2 Likes
  1. Use mutex when you need to coordinate multiple values, or the values are larger than the maximum atomic size, or the critical sections are more complex than a simple atomic swap/incr/whatever.
  2. Both are portable, this is not an issue.
  3. If your value is larger than atomic size on your platform, you obviously need a mutex. There are also special problems, where you have read-heavy workload, and then some more complex write operation and you have concurrent writers, it might actually make sense to use both mutex and atomic. Atomic load on the read path, mutex + atomic write on the write path. Generally, under really heavy contention, mutexes do better. It’s counter-intuitive, because the promise of lock free is that it always advances, but CPUs still need to lock cachelines, etc.
  4. You already should use atomic. :slight_smile:
  5. Atomic is not an I/O concern. Mutex is in std.Io because it has to park the current task while it waits.
14 Likes

Just adding onto @lalinsky’s great response, look into a concept called “false sharing”.
Since you are dealing with really small shared values, it may be that you have the thread-1 and thread-2 specific data next to each other in memory. I know you didn’t really ask for performance differences, but if you are interested this could be much more important to speed of your application than Mutex vs Atomic choice.

Robert :blush:

2 Likes

I agree that atomics are the correct choice contextually. @lalinsky answer sums it up pretty well but I’d also add onto it that even when an atomic’s size would be enough there’s still a situation where you would want to use a Mutex.

Atomics by themselves don’t guarantee “mutual exclusivity” in the same way mutexes do. They guarantee order of operations.

So, as you say, if you need to protect this value within a block and you want to make sure no changes happen while you are doing some operations, you should use a mutex.

5 Likes

Yes, that’s the obvious case for mutexes that i completely forgot :slight_smile:

The canonical example for mutexes:

try mutex.lock(io);
defer mutex.unlock(io);

if (need_to_do_something) {
    doSomething();
    need_to_do_something = false; 
}

With naive atomic load/store instead of plain access, you have a race situation, you need mutual exclusivity.

However, in a simple case like that, there is also a lock-free version with atomics:

const do_it = need_to_do_something.swap(false, .acq_rel);
if (do_it) {
    doSomething();
}
2 Likes

A thing to be aware of is that the std library actually has two different types of Mutex:

  • std.Io.Mutex
  • std.atomic.Mutex

They both have the same function; prevent two threads from simultaneously entering a ‘critical section’ of code. std.atomic.Mutex is much simpler though, and doesn’t require an Io parameter.

Why then do we use std.Io.Mutex?

The problem is that your program runs under an operating system, and the OS can - at any moment - pause a thread and use the CPU to do something else for a while. If that happens in the middle of a critical section, the mutex will remain locked until the OS decides its time to run that thread again, which could take a very long time.

What do the other threads do in the meantime? If you’re using std.Atomic.Mutex, they will continue making futile attempts to lock the mutex, completely wasting CPU time until the OS pauses them as well. To make matters worse, there is no guarantee that the thread holding the mutex will start running before the threads that are waiting for it!

std.Io.Mutex solves this problem by taking an Io parameter. It uses it to tell the OS that it can’t proceed, and more importantly, why it can’t proceed, by passing pointer to the mutex it’s waiting for. The OS immediately stops running the thread, and doesn’t bother running it again until the thread that holds the mutex unlocks it.

There are situations in which std.atomic.Mutex is the better choice, but these are rare. If in doubt, use std.Io.Mutex.

4 Likes

(post deleted by author)

4 Likes

I honestly don’t know if I should really send the following and do the “uhm well akshually :nerd_face:”… but here I am. I just find that answer so … I can’t really describe it. This is by no means meant to be mean. I also don’t know if it’s just a language thing.


Atomic comes from indivisible. And this at least to me is much better at explaining it.

Everything on a computer is.

Atomics aren’t “single”. They are, at least for all the microarchitectures I’ve used, implemented in multiple uops(as are most other instructions). See here for amd64.

The second point about the “mid point”/“seem” is more correct. Another cpu can’t read a torn state. So if one cpu changes a value from 0x0000000 to 0xffffffff another one can’t read 0x0000ffff. This is the meaning of indivisible. It is, to me, somewhat similar to a database transaction but just on a single word.

mutex means mutual exclusion. This is also mutex:

const SpinLock = @This();
is_locked: atomic.Value(bool),
pub const init: SpinLock = .{ .is_locked = .init(false) };
pub fn lock(self: *SpinLock) void {
    while (self.is_locked.load(.unordered) or
        self.is_locked.cmpxchgWeak(false, true, .acq_rel, .monotonic) != null)
    {
        @branchHint(.unlikely);
        atomic.spinLoopHint();
    }
}

pub fn unlock(self: *SpinLock) void {
    self.is_locked.store(false, .release);
}

Operating system integration for mutexes is mostly to avoid busy wait loops and maybe don’t schedule lock holder off the cpu to keep the critical section short.

You don’t have to. Though generally using a mutex is a good starting point. There are various techniques that can be much faster and efficient than a mutex depending on how and when the data is changed; the read vs. write ratio; the length of the critical section…


Sorry for this post

4 Likes

Just as a fun fact, swap(true, .acl_rql) is the faster option for a spinlock. Was recently watching this video on atomics, really interesting stuff:

(Disclaimer: this is super advanced, don’t use spinlocks unless you really know what you are doing. If you are not sure, just use std.Io.Mutex, your code will be safe and fast enough.)

2 Likes

Yeah that talk is quite good.

I was writing the spinlock from memory and couldn’t quite remember if it was xchg or cmpxchg. Thank for the reminder :grinning_face_with_smiling_eyes: