7 ms·
Totally! Pro tip: if you really do know that contention is unlikely, and uncontended acquisition is super important, then it's theoretically impossible to do b
by pizlonator 2y ago
Totally!
Pro tip: if you really do know that contention is unlikely, and uncontended acquisition is super important, then it's theoretically impossible to do better than a spinlock.
Reason: locks that have the ability to put the thread to sleep on a queue must do compare-and-swap (or at least an atomic RMW) on `unlock`. But spinlocks can get away with just doing a store-release (or just a store with a compiler fence on X86) to `unlock`.
Spinlocks also have excellent throughput under most contention scenarios, though at the cost of power and being unkind to other apps on the system. If you want your spinlock to be hella fast on contention just make sure you `sched_yield` before each retry (or `SwitchToThread` on Windows, and on Darwin you can do a bit better with `thread_switch(MACH_PORT_NULL, SWITCH_OPTION_DEPRESS, 1)`).
- PaulDavisThe1st 2y agoWe use spinlocks where appropriate. In the 90s I recall that the general rule of thumb was if the lock is held for <10x the context switch time, spinlocks are generally a better choice. Not sure if that's still true of contemporary architectures. The more common pattern in rt/audio code is "try to take the lock, but have an alternate code path if that fails". It's not that is never going to be contention, but it will be extremely rare, and when it occurs, it probably matters. RWLocks are also a common pattern, with the RT thread(s) being read-only (and still being required to fall back on an alternate code path if the read lock cannot be taken).
- pizlonator 2y agoThese days, fast lock implementations use the following rough idiom, or some idiom that is demonstrably not any slower even for short critical sections. if (LIKELY(CAS(&lock, UNLOCKED, LOCKED))) return; for (unsigned i = 0; i < 40; ++i) { /* spin for a bounded a mount of time */ if (LIKELY(CAS(&lock, UNLOCKED, LOCKED))) return; sched_yield(); } ... /* actual full lock algo goes here */ So, the reason to use spinlocks isn't that they are faster for short critical sections, but that they don't have to CAS on unlock - and so they are faster especially in the uncontended case (and in all other cases too). In other words, the modern rule of thumb is something like: if you're going to grab the lock so frequently that the uncontended lock/unlock time shows up as a significant percentage of your execution time, then use a spinlock. That probably implies that you are probably holding the lock for <100x or even <1000x context switch time, or something around there.
- adastra22 2y agoHuh, interesting. TIL. Thanks for that!
- xxs 2y agoLots of code is 'lock free' and uses the same pattern (more or less). Attempt the change optimistically, if it fails spin it, if it keeps failing (Massive Contention), back off.
- adastra22 2y agoA properly lock-free architecture wouldn’t have spin-locks, no?
- xxs 2y agoof course, no lock. I have written quite the fair share of lock free code (back then when it was new), and generally enjoy writing it (to this day). Yet, for most folks it's just too hard to reason/understand/maintain it. Just a note, lock free is a lesser requirement than wait-free.
- pjdesno 2y agoA properly lock-free architecture is a spin-lock :-)
- xxs 2y agospin locks prevent forward grantees, e.g. when the thread is scheduled out (or serves an interrupt), no other thread can make progress. Lock free allows progress - there is no exclusivity. Of course if working on the kernel and controlling the scheduler, etc. the restriction does not apply immediately. Realistically though, lots of lock free employs copy-on-write or rcu.
- deleted 2y ago[deleted]
- spacechild1 2y agoKeep in mind that while try_lock() is realtime-safe, the following unlock() may not, as it may need to wake threads that have been blocked in the meantime! So I would only use this pattern for situations where the very fact that a NRT thread tries to acquire the lock already means that RT safety is not a concern anymore (e.g. a device has been disconnected)
- PaulDavisThe1st 2y agoExcellent reminder. Yes, this is both a design defect of try/unlock that can't really be solved, and an implementation defect on several platforms that makes it worse than it actually needs to be (Windows, I'm looking at you)
- spacechild1 2y agoOne thing I've been using is a spinlock where the RT thread only uses try_lock() + fallback path and the NRT thread(s) call try_lock() in a loop with pause instructions and occasional thread yielding (or even sleeping). This might waste lots of CPU cycles when a NRT thread tries to acquire a lock that is already held by the RT thread, but assuming this only happens occasionally/rarely, it's a reasonable trade-off.
- xmodem 2y agoWhat type of scenarios come up in RT/audio code where an alternative path is available if you can't get a lock? In most of the concurrent code I've written the options available if I can't get a lock is some subset of try again, try again with backoff, or fail.
- Archit3ch 2y agoLet's say you are doing audio processing in the real-time thread. You try to lock to read new parameters, and if you fail to get the lock you do the processing with the old parameters.
- lilyball 2y agoOn Darwin, it's possible for a pure spinlock to produce a priority inversion deadlock, because Darwin has a quality of service implementation in the kernel that differs from how everyone else handles thread priority. In other kernels, a low-priority thread will still eventually be guaranteed a cpu slice, so if it's holding a spinlock, it will eventually make progress and unlock. On Darwin with Quality of Service, it's possible for higher-QoS threads to preempt lower-QoS threads indefinitely. For this reason, on Darwin you want to avoid spinlocks unless you know that all threads touching the spinlock are always running in the same QoS. Instead of spinlocks, your go-to for low-overhead locks there is os_unfair_lock, which is a spinlock variant that donates priority of the blocked threads to the running thread.
- pizlonator 2y agoI’ve shipped code on Darwin that spinlocks and gets away with it without any noticeable cases of this happening. I know it can happen in theory. But theory and practice ain’t the same. I worked for Apple when I shipped this too lmao
- ninkendo 2y agoAlso worked for Apple for 13 years: there is a lot (and I mean a LOT) of priority inversions and abuse of locks in the codebase. Breaking out the internal profiler and actually having a look at whether you were running at the priority you thought can be a very illuminating thing. One particular app had a particularly nasty habit of losing priority because most of its work was done in a set of daemons and not in the UI process itself. The foreground app is allowed to use Performance cores and receive USER_INTERACTIVE priority for operations if it wants, but daemons cannot unless they have a mach IPC voucher stating that the UI is blocked on their work. But if you do the wrong thing with a semaphore or a spinlock, you get booted out of the thread group and now you’re running on Efficiency cores at USER_INITIATED priority and suddenly your latency numbers spike 10x on systems with asymmetric cores. I remember solving a particularly bad one which was basically doubling our request latency on phones with E cores. The company had a pretty widely spread internal article trying to whack people over the head to stop abusing lock primitives in this way, but we just kept shipping code with this issue. (Most of the inversion issues came from the use of semaphores though. The problem got 1000x worse in swift concurrency because using semaphores blocks an entire SC OS thread and you only get as many of those as you have cores. Most Apple Watches and HomePod only have 2 of them so you can easily deadlock. Which most people fixed by just… adding a timeout to their locks. It was horrifying.)
- ot 2y ago> Reason: locks that have the ability to put the thread to sleep on a queue must do compare-and-swap (or at least an atomic RMW) on `unlock`. But spinlocks can get away with just doing a store-release (or just a store with a compiler fence on X86) to `unlock`. This is something I've thinking about a lot over time, that the CAS is only there to atomically determine if there are any sleeping waiters on unlock and you have to do a futex_wake. I would really want some way to get away with non-fenced operations (at least on x86), but I don't know if it's just that nobody has figured out why, or there is a fundamental reason why that's not possible.
- pizlonator 2y agoYou do need a fence in the unlock path though (at least a release fence). I think the issue is that if you ask the CPU to just store something (like in a spin lock), whether or not there’s a fence, it’s an operation with limited data flow dependencies so it’s easy for the CPU to execute. Even the fence can be handled using wacky speculation tricks. But if you want to do something like, “store this value but only if the old value satisfies some predicate”, then there’s a load and the whole thing is dependent on the load. So you’re asking the CPU to load, then run a predicate, then store, and for that to be fenced, and atomic. Strictly more work. I don’t think there’s any trick to make it faster than just the store release.
- ot 2y ago> You do need a fence in the unlock path though (at least a release fence). Well yes but on x86 that comes for free. The overhead of the full fence brought in by lock cmpxchg or lock xchg is in the order of ~10ns, which for an uncontended lock means that a mutex is almost 2x as slow as a spinlock. A load acquire + store release would be a couple of ns (assuming everything in L1 etc...)
- gpderetta 2y agoAs far as I know it is a fundamental limitation. You need to release the mutex, then check that there were no waiters in this order not to miss wakeups. As the mutex release must be globally visible before the load, release ordering on the mutex is not sufficient as the load could be reordered before the unlock; hence you need a StoreLoad fence which is always the most expensive barrier. Consider the implementation of Dekker's algorithm for example.
- Animats 2y ago> just make sure you `sched_yield` before each retry Assuming `sched_yield` does something. There's a futex congestion problem inside Wine's memory allocator. There are several levels of locks. If you're growing a buffer, in the sense of C's "realloc", and no buffer is available, memory allocation is locked during the allocation of a bigger buffer, copying of the contents, and release of the old buffer. "Push" type operations can force this. Two orders of magnitude performance drops ensue when multi-threaded programs are contending for that lock.[1] Inside one of the lock loops is a call to "YieldProcessor". static void spin_lock( LONG *lock ) { while (InterlockedCompareExchange( lock, -1, 0 )) YieldProcessor(); } But the actual code for YieldProcessor is a NOP on x86:[2] static FORCEINLINE void YieldProcessor(void) { #ifdef __GNUC__ #if defined(__i386__) || defined(__x86_64__) __asm__ __volatile__( "rep; nop" : : : "memory" ); #elif defined(__arm__) || defined(__aarch64__) __asm__ __volatile__( "dmb ishst\n\tyield" : : : "memory" ); #else __asm__ __volatile__( "" : : : "memory" ); #endif #endif } } Wine devs are aware of this, but the mess is bad enough that no one has tackled it. This is down in the core of what "malloc" calls, so changes there could have unexpected effects on many programs. Needs attention from someone really into mutexes. [1] https://forum.winehq.org/viewtopic.php?t=37688 https://forum.winehq.org/viewtopic.php?t=37688 [2] https://gitlab.winehq.org/wine/wine/-/blob/HEAD/include/winnt.h https://gitlab.winehq.org/wine/wine/-/blob/HEAD/include/winn...
- exikyut 2y agoI know NQP here isn't Not Quite Perl, but I'm not sure what it *is*. Seeking enlightenment!
- pizlonator 2y agosched_yield isn’t a nop
- secondcoming 2y ago`rep; nop;` is actually the `pause` instruction. On older CPUs it’s a standard nop, but on newer CPUs it’s a more efficient nop. Spinning on the CMPXCHG is also a bad idea. You should spin on the read and only then attempt the CMPXCHG.
- wbl 2y agoAtomic RMW is all you need. Imagine 3 state futex lock of taken, sleeping, and unlocked. Waking thread just needs to see old value but writes unlocked unconditionally. I don't think this is more expensive than release certainly not because of the line where the write goes in most cache coherency protocols. Granted this lock does wake up too many people but you did say usually uncontended.
- pizlonator 2y agoAnd what, unconditionally futex wakes everyone? That'll be hella slow
- wbl 2y agoYou did say uncontended!
- pizlonator 2y agoSo you want uncontended unlocks to make a syscall to wake the threads that aren’t there? That syscall will be worse than a CAS just because it’s a syscall. Not to mention it’ll have to lock some kernel data structure just to figure out that there aren’t any threads to wake.
- wbl 2y agoI think I'm not doing a great job of explaining it. There are three states of the mutex: locked=1, unlocked=0, and sleeping=2. A thread comes along to a locked mutex, and atomically moves it to sleeping and sleeps via futex. The freeing thread unconditionally writes 0 and atomically sees the old value: if 2 wake all, if 1 no need to wake anybody. Maybe that is equally expensive to a CAS, but I don't think it ultimately is.
- pizlonator 2y ago“Atomically sees the old value” means you’re doing an atomic RMW, which is more expensive than just a store with a release fence. Atomic RMW is almost as expensive as CAS. Exactly as expensive as CAS on some cpus.
- spacechild1 2y ago> If you want your spinlock to be hella fast on contention just make sure you `sched_yield` before each retry Shouldn't you rather use pause instructions (_mm_pause, __yield, ISB, etc.) instead of thread switching? That's what I have seen in most spin lock implementations. Maybe it depends on the use case?
- xxs 2y agostart with few attempts w/o pause, just busy CAS, then CAS + pause, then yield (and backoff/sleep with random duration)
- senderista 2y agoNo, you shouldn’t be calling CAS in a tight loop, but rather a relaxed load to check if a CAS might be successful, with PAUSE equivalent after each load. After spinning for ~context switch latency you should back off with sched_yield before retrying the spin loop (and eventually start sleeping on backoff with exponential jitter etc).
- xxs 2y agoof course, there is very rarely any a naked CAS
- harrison_clarke 2y agobiased locking can be faster than spinlocking, in the uncontended case unbiasing/re-biasing a lock can be quite heavy, though. JVM did it by pausing threads (or waiting for GC to do so)