Measuring mutexes, spinlocks and how bad the Linux scheduler is
probablydance.com
probablydance.com
There’s some potentially misleading information here. Background: I’ve spent the last 20+ years writing low-latency realtime audio applications, technically cross-platform but focused on Linux.
If you care about low latency on any general purpose OS, you need to use a realtime scheduling policy. The default scheduling on these OS’s is intended to maximise some combination of bandwidth and/or fairness. Low latency requires ditching both of those in favor of limiting the maximum scheduling delay of a thread that is otherwise ready to run.
Measuring how long synchronization primitives take without SCHED_FIFO is illustrative, but only of why, if you care about scheduling latency, you need SCHED_FIFO. There are several alternative schedulers for Linux – none of them remove the need for SCHED_FIFO if latency is important.
It is absolutely not the case that using SCHED_FIFO automatically starves non-SCHED_FIFO threads. Scheduling policy is set per-thread, and SCHED_FIFO will only cause issues if the threads that use it really do “burn the CPU” (e.g. by using spinlocks). If you combine SCHED_FIFO with spinlocks you need to be absolutely certain that the locks have low contention and/or are held for extremely short periods (preferably just a few instructions). If you use mutexes (which ultimately devolve to futexes at the kernel level), the kernel will take care of you a little better, unless your SCHED_FIFO thread doesn’t block – if it doesn’t do that, that’s entirely on you. Blocking means making some sort of system call that will cause the scheduler to put the thread to sleep – could be a wait on a futex, waiting for data, or an explicit sleep.
In particular, this: “This was known for a while simply because audio can stutter on Linux when all cores are busy (which doesn’t happen on Windows)” is NOT true. Linux actually has better audio performance than Windows or macOS, but only if the app developer understands a few basic principles. One of them is using SCHED_FIFO appropriately.
Pro-audio/music creation scheduling is MUCH more demanding than video, and a bit more demanding than games. Linux handles this stuff just fine – it just gives you enough rope to shoot yourself in the foot if you don’t fully understand what you’re doing.
> SCHEDULING
> high-priority= Renice the daemon after startup to become a high-priority process. This a good idea if you experience drop-outs during playback. However, this is a certain security issue, since it works when called SUID root only, or RLIMIT_NICE is used. root is dropped immediately after gaining the nice level on startup, thus it is presumably safe. See pulseaudio(1) for more information.
> Takes a boolean argument, defaults to yes. The --high-priority command line option takes precedence.
> realtime-scheduling= Try to acquire SCHED_FIFO scheduling for the IO threads. The same security concerns as mentioned above apply. However, if PA enters an endless loop, realtime scheduling causes a system lockup. Thus, realtime scheduling should only be enabled on trusted machines for now. Please note that only the IO threads of PulseAudio are made real-time. The controlling thread is left a normally scheduled thread. Thus enabling the high-priority option is orthogonal. See pulseaudio(1) for more information.
> Takes a boolean argument, defaults to yes. The --realtime command line option takes precedence.
RLIMIT_NICE is of zero use with realtime audio.
This documentation is based on (1) the state of Linux circa 2008 or so (2) a complete misunderstanding of the role of nice values for scheduling (hint; it's only useful for altering the behavior of non-RT threads, which means threads that do not have hard or semi-hard timing deadlines).
It is a notable problem that there is so much out of date information about the use of nice with audio applications still circulating online. It was never true, and it's even less true today.
> a complete misunderstanding of the role of nice values for scheduling (hint; it's only useful for altering the behavior of non-RT threads, which means threads that do not have hard or semi-hard timing deadlines).
I suspect you're misreading the quoted documentation, I believe the quoted documentation is fine. To recite, "only the IO threads of PulseAudio are made real-time. The controlling thread is left a normally scheduled thread. Thus enabling the high-priority option is orthogonal (to SCHED_FIFO)".
Nice is applied to the controller responsible for other tasks, SCHED_FIFO is applied to the I/O threads, independently.
> On modern Linux systems, it is impossible for SCHED_FIFO to cause system lockups. [...] so even a SCHED_FIFO thread that runs "while (true){}" will not be allowed to use 100% of a core's cycles.
Really? The rtkit documentation mentioned,
> If processes that have real-time scheduling privileges enter a busy loop they can freeze the entire the system. To make sure such run-away processes cannot do this RLIMIT_RTTIME has been introduced. Being a per-process limit it is however easily cirumvented by combining a fork bomb with a busy loop.
> RealtimeKit hands out RT scheduling to specific threads that ask for it -- but only to those and due to SCHED_RESET_ON_FORK it can be sure that this won't 'leak'.
> In contrast to RLIMIT_RTPRIO, the RealtimeKit logic makes sure that only a certain number of threads can be made realtime, per user, per process and per time interval.
So "while (true){}" won't lock your system up, but "while (true) { fork(); }" certainly will, so the rational of only using SCHED_FIFO on trusted processes is reasonable.
I think it's still true. Is it?
The use of nice to try to adjust scheduling of audio apps (the entire application or specific threads) is completely off-base. If you use SCHED_OTHER, you're making a declaration that timing is not important. You can bump the nice value of a SCHED_OTHER thread, and that will change its behavior among the pool of the other SCHED_OTHER threads, but that's almost never useful in an audio app.
Now, ionice is another (and more complex) story ...
The point of rtkit is creating a convenient framework to enforce SCHED_RESET_ON_FORK and rate-throttling in a single place. So if I'm interpreting your comment correctly, running a "while (true) { fork(); }", even without restrictions of SCHED_RESET_ON_FORK, will not lock the system up in a modern system? Is it correct?
> The use of nice to try to adjust scheduling of audio apps (the entire application or specific threads) is completely off-base.
I think the renicing mechanism of "high-priority=" was historical, introduced to PulseAudio long before the system resource limit like RLIMIT_RTPRIO, or the rtkit framework has been put into place. As another document mentioned, g
> To minimize the risk of drop-outs during playback it is recommended to run PulseAudio with real-time schedulin [...] Alternatively, if the risk of locking up the machine is considered too big to enable real-time scheduling, high-priority scheduling can be enabled [...]
So while renicing is still here, PulseAudio prefers SCHED_FIFO nowadays, and the renice is only a fallback.
So, can you point out where exactly is wrong in the first documentation I quoted in the first comment? Certainly, the paragraph, "high-priority= Renice the daemon after startup to become a high-priority process. This a good idea if you experience drop-outs during playback" is outdated, but if one removes the word "Renice", and moves this paragraph from "high-priority=" to "realtime-scheduling=", it's still correct.
Basically, read: https://access.redhat.com/documentation/en-us/red_hat_enterp...
(This is from RH, but the kernel params are generic)
Again, RTKit is a convenience wrapper, nothing more or less. It runs with sufficient priviledge to ask for SCHED_FIFO, which by default is not permitted. The more conventional approach is to create a user group and use PAM or equivalent to set the rt priority limit for that group, thus allowing any user in the group to run any app that can then ask for SCHED_FIFO with prio up to the limit. RTKit was created by Lennart to avoid this type of sysconfig (in favor of a different type of sysconfig).
You're mistaken about the historical ordering of things too. SCHED_FIFO was available long before PulseAudio or RTKit was created.
Yes, you could shuffle the text around as you suggest, but that's far from a "trivial" edit. It's basically equivalent to saying "yeah, the old doc was basically totally wrong, here's a correct version" :)
Pro-Tip: Hacker News has a "new comment cool-off timer", it gives a minute or two before allowing a comment to be answered. It gives a chance for the poster to edit the comment. Just relax when you cannot see a reply button.
> Basically, read: https://access.redhat.com/documentation/en-us/red_hat_enterp.... (This is from RH, but the kernel params are generic)
I see. "The default values for the Real-time throttling mechanism define that 95% of the CPU time can be used by real-time tasks. The remaining 5% will be devoted to non-realtime tasks (tasks running under SCHED_OTHER and similar scheduling policies). It is important to note that if a single real-time task occupies that 95% CPU time slot, the remaining real-time tasks on that CPU will not run. The remaining 5% of CPU time is used only by non-realtime tasks".
So a fork bomb is as safe is an empty loop (at least from a scheduling prospectively), as long as a strict limit is set.
I have noted it down.
> Yes, you could shuffle the text around as you suggest, but that's far from a "trivial" edit. It's basically equivalent to saying "yeah, the old doc was basically totally wrong, here's a correct version" :)
Okay, the main documentation for "man pulseaudio" was a bit better,
> To minimize the risk of drop-outs during playback it is recommended to run PulseAudio with real-time scheduling if the underlying platform supports it. This decouples the scheduling latency of the PulseAudio daemon from the system load a
You see, here it mentions real-time scheduling, not high-priority scheduling in "man pulse-daemon.conf". So I guess the explanation of " high-priority=" was more or less untouched since 2008, but the code kept moving on.
> You're mistaken about the historical ordering of things too. SCHED_FIFO was available long before PulseAudio or RTKit was created.
I meant the renicing mechanism was in PulseAudio before RLIMIT_RTPRIO, or the rtkit framework. I didn't say a word about SCHED_FIFO, which was indeed POSIX, but less useful without later improvement on scheduling policies.
Thanks for your patience and answers, all my questions have been explained.
See here for more details: https://access.redhat.com/documentation/en-us/red_hat_enterp...
That seems unlikely to me and doesn't match any experience I've ever had or known anyone to have. Can you explain what you mean by that?
Regular users of not so tuned apps probably experience this differently because Windows tries to do the tuning automatically and Linux doesn't.
The Windows scheduler has other issues, like random prio boosting to escape from prio inversion.
(Granted, the out of box behavior and the achievable latency are two separate issues)
I have never seen a mac box achieving reliable lower latency than Linux or Windows on the same hardware. Macs are nice for the plug'n'play experience - even though more often than not you still want to install your soundcard's driver.
But when it comes to doing actual work... https://www.dawbench.com/win7-v-osx-6.htm
If we're talking pure latency then UA, Focusrite, Apogee and Avid have lower RTL for the same buffer size in macOS. RME is identical on both platforms and I'm unsure about antelope.
Regarding loads before under-runs that's a very tough thing to test, as it varies between products. GENERALLY: Comparing Digital Performer between platforms heavily favours macOS. Comparing Pro Tools favours macOS (currently). Comparing Reaper favours windows slightly. Cubase favours Windows... etc...
There's also load type that factors in, as with macs you get a specific build with minimal leeway. Windows builds can be planned for a given build (highly serial, highly parallel, RAM hungry, Latency performant etc...). It's not too hard to build a test suite that favours macOS everywhere on the same machine, likewise for Windows.
But to the original topic, latency, macOS is generally superior in this regard when looking at RTL specs for the same device.
Speaking of which, do you know of a tool that would allow to ensure that e.g. your RT threads won't do bad things - short of putting a breakpoint on every syscall-like thing ? I wonder if at that point the audio community should not look into developing some clang-based static analyzer to enforce that.
Clang can be used for this but AFAIK you need to annotate functions/methods to indicate whether or not they are intended to be RT-safe. This gets complex if you have code that can be used in an RT context and in a non-RT context. The annotation part would be relatively simple in, say, a small audio player. In a DAW, it would be a substantial and complex task.
It's usually sufficient to do this in testing, and then have as is, in production.
Plus highly contended critical sections should really really be avoided. So I'm not sure the author's benchmark even make any sense: if all of your workload is serialized, use a single thread.
First of all, a program that calls sched_yield() for synchronization is simply broken, since doing so can cause 100% CPU usage (if the other thread is stopped at an unfortunate place), so the only correct method among the ones benchmarked is std::mutex.
When using std::mutex (the only correct approach), Linux and Windows both seem to have similar average performance, but Windows has 10x latency, so Linux is better.
Spinlocks assume the critical section is fast and non-blocking, so when the spinning lasts longer than expected, throwing in a yield can help by handing some CPU time to something else.
The lock-holder might be getting scheduled on the same CPU and waiting for this thread to yield, or there might just be excessive threads runnable right now and burning cycles pointlessly spinning isn't helping get the lock-holder to run and release the lock any sooner.
In a situation where you have exactly as many threads as there are CPUs, and they've been assigned their own respective CPUs, and there's nothing else running on the system, I wouldn't expect the yield to help. But that ideal isn't how things generally look in practice.
This is what pthread_mutex (and pretty much every decent OS provided mutex does) on glibc by default.
[1] how much is 'few' of course is often application dependent.
Instead, one should optionally spin for up to a constant number of iterations and then call sys_futex.
Which I agree with, most of the time userspace spinlocks don't fit well.
But TFA is clearly comparing the two, and observing a variety of spinlock implementations with sched_yield() demonstrating an interesting positive effect on the spinlocks as tested.
What I'm saying is that if your use case is such that you expect enough contention to consider using TATAS (which is actually a pessimization in the uncontented case) and look into optimizing sched_yield, probably a spinlock is not appropriate on the first place.
Edit: hence a spinlock shouldn't bother with yield and just do a tight xchg spin (I haven't measured it inna while, but heard rumors that pause can severely harm acquire latency on very recent CPUs as it will quickly put them in a deeper power saving mode than in the past)
So this is measuring a mess of a few things:
For mutexes (assuming all the implementations actually make syscalls for unlocking), this has something to do with how long it takes for the scheduler to activate the waiter and run it, plus how long the scheduler waits because the system is doing something else or it wants to try to avoid bouncing a task between CPUs.
For anything that does not involve the kernel in unlocking, this is really just a measurement of the pauses in execution of long-running threads that continuously hog the CPU. If the thread is also calling sched_yield(), the results are erratic, since sched_yield() more or less means “I don’t want to run now, but I’m not actually waiting for anything visible to the scheduler and, um, run me again soon maybe?” This is almost always the wrong solution. Other than that, this test has essentially nothing to do with which spinlock is in use, unless the spinlock itself wastes time failing to figure out that it’s ready.
From old memory, the Windows scheduler is (was?) mostly based on per-thread priorities, highest priority wins, where the priorities are occasionally automatically adjusted. So, if you set a high-ish priority, you get to hog the CPU. In contrast, Linux tries quite hard not to let normal (non-RT) threads hog the CPU, so a thread that effectively runs continuously measuring pauses will detect significant pauses in which Linux decides that the rest of the system deserves to run too.
I think your characterization of sched_yield() is a bit off. The man page is clear:
"sched_yield() causes the calling thread to relinquish the CPU. The thread is moved to the end of the queue for its static priority and a new thread gets to run."
and
"If the calling thread is the only thread in the highest priority list at that time, it will continue to run after a call to sched_yield()."
In particular, it does not say "I’m not actually waiting for anything visible to the scheduler and, um, run me again soon maybe?".
So assuming that the threads are running at some relatively high priority, I'm struggling to see how delays on the order of 60ms could arise.
EDIT: reading further:
"sched_yield() is intended for use with real-time scheduling policies (i.e., SCHED_FIFO or SCHED_RR). Use of sched_yield() with nondeterministic scheduling policies such as SCHED_OTHER is unspecified and very likely means your application design is broken."
So maybe that's it.
So it is no surprise that latencies are high, because a yielded thread has to wait for absolutely every other thread in the system before it gets a chance to run.
There was a half-baked design for locking that kind of worked 20 years ago: try to acquire the spinlock and, if it’s contended, yield. On a single CPU, that probably means that the lock holder gets to run, and if they drop the lock quickly, then the yielding thread will (on an otherwise mostly idle system) get to run soon.
This was a dubious design 20 years ago, although, admittedly, OS lockout primitives were worse back then. But now, with multiple CPUs on a system, the whole scheme falls apart — the lock holder might be on a different CPU and yield() has no effect on how quickly the lock is released.
So, in most cases, yield() really does mean “I don’t know what I’m doing, but I don’t want to run right now.” Anything the kernel does in response is, at best, a wild guess.
Very interesting post though. I’ve never considered the idle latency before.
Even if you use _mm_pause() to let CPU know it's doing spinlock, for the eye of the OS, it still looks like a busy thread, it can't be distinguished from non-spinlock computation heavy thread.
It surprised me to not see even a mention of CPU affinity.
A mutex can also refer to a particular group of “mutual exclusion” methods where in the current process/thread is suspended until the condition is true.
Hope this helps.
Typically mutex / spinlock is implemented to reduce and prevent race conditions
It's still there. It's just a compile-time "race checker", not runtime.
Since in Rust it is illegal for a value to be referenced for writing in one place and referenced for anything else in any other place, regardless of concurrency/parallelism, Rust code is data-race free by design.
I still think that GP's comment was a non-sequitur.
Go's race detection says (with a high probability) you do or do not have any data races in the paths actually taken during some execution of your program. It is a (probabilistic) guarantee about how you ran your program.
Do you have some links about the known soundness issues? I was under the impression that although it hadn't yet been proven it was still a medium-term goal, and any soundness bugs were considered fairly major.
(And yeah, "mathematical proof" is strong regardless since e.g. LLVM is also in there somewhere. But it captures the idea more closely than any comparison of "compile vs. runtime" or "static vs. dynamic" does.)
The difference is actually in the implementation of the mutual exclusion. A mutex is generally a system call which often starts as a spin lock before the system call is invoked (which is an assembly routine that changes the process privilege level and cedes control to the host operating system). A spinlock literally "spins" in a loop until an atomic flag or semaphore indicates that it can enter the protected region. When it does, it atomically sets the flag so that it has exclusive access. Because a spinlock does not interact directly with the OS scheduler, it needs a pause instruction or yield syscall at some point in its loop (or every few interations). Failure to do so introduces pathological scheduling problems as outlined in the article.
Spinlocks are sometimes used because it is believed that the odds of a collision are very low, and the cost of a syscall is perceived as being high to the developer. However, it's something that should only be used if you know what you're doing, and its behavior is subject to not only the OS, but also the hardware you run on.
I know what both a mutex and a spin lock are because I’m a developer myself but very much enjoy having my mind blown by someone on here who may have actually written the scheduler for Windows or Linux or be an expert of some sort.
When the article assumes reader knowledge (especially very specific stuff) tho... there are quite a few very good responses here, it'd be a shame to reduce them. There are some extremely knowledgeable people lurking around here, and they're sometimes doing so for this kind of reason - to spread the knowledge. Plus context-specific Q&A can be far more effective than random googling / other articles / etc, as a learning tool.
Problem
1. We have a process with multiple threads of execution (e.g. A and B).
2. A and B will at some point act on the same data or object state.
3. Both A and B can't operate on the same data at the same time.
Mutexes function more or less as locks. They lock up your data to prevent unwanted race conditions. In multithreaded environments, a thread of execution has to acquire a mutex prior to accessing the data. Once it's done operating on the data, it releases the mutex so that another thread of execution can acquire it.
Mutexes are again, simply locks. But not all locks are the same. Locks come in different shapes and sizes and work in different ways. Same with mutexes, they can have different semantics.
Spinlocks are a kind of mutex that cause threads trying to acquire them to spin or loop around while waiting for the resource to be available.
Spinlock is a mutex algorithm based on spinning until the mutex is available.
Mutex is a MUTual EXclusion abstraction. A spinlock is one implementation. But spinlocks are problematic (for lots of reasons) so most of the time when someone says they’ve got a mutex it’s actually an adaptive lock:
- It has a fast path to acquire the lock.
- It may or may not be fair.
- It has a slow path that may enqueue the thread and deschedule it, but that slow path may have some adaptive spinning for a finite amount of time.
The spinlocking child will react to his arrival faster, but the sleeping (mutex) child makes a more efficient use of your attention.
- Uncontended case + mutex: no syscall, fast path is basically the cost of the memory barrier.
- Uncontended case + spinlock: no syscall, fast path is basically the cost of the memory barrier. Essentially same cost than a mutex, sometimes a tiny bit cheaper but _if your lock isn't contented in the first place it will be very hard to measure, since not being a bottleneck_.
- Contended case + mutex: thread is waiting in the mutex list without taking CPU. Hyper-threading may also give hand immediately to another thread, perhaps lucky enough to run. The OS mutex may have a bit of capped spinning before waiting in the scheduler.
- Contended case + spinlock: Anything goes. CPU gets consumed just for spinning. Wrong thread might get priority. If you are lucky you did put a HLT instruction to mitigate the complete disaster that spinlocks are, perhaps it even does something. Your consolation: no syscall or thread pausing... because they are spinning.
tl;dr: spinlock are worse in the contended case and essentially the same performance in the uncontended case against your typical OS mutex. SPINLOCKS WON'T YIELD THE EXPECTED BENEFITS, USE REGULAR OS MUTEXES.
https://github.com/mirror/x264/blob/d4099dd4c722f52c4f3c1457...
In my own attempts I've been unable to measure the impact of the spinlock count, and took spincount = 40 like in https://trac.webkit.org/browser/webkit/trunk/Source/WTF/wtf/... ...who also took measurement from elsewhere. The default is 4000 on Windows!
I don't argue spinning isn't valuable ; what I say is that it's necessarily never the bottleneck (else your task size really is too small).
After all, if you spend all your time in synchronization, that means you probably need to have bigger tasks, not more clever synchronization primitives.
Of course you can roll your own proper mutex if you want to get your hands dirty with futexes. IIRC you only need two bits (one waiter bit and one locked bit).
Edit: I think I see what you're getting at, but the point from the article about cache invalidation for the naive implementation still is valid.
That obviously requires you to read the current state upfront, whereas a plan compare exchange from 0 to 1 on 32/64Bit avoids that.
0: CAS_bit_n(void* adr, int n)
1: while(true):
2: old <- read unit(adr)
3: new <- compute-new-value-with-changed-bit-n(old, n)
4: if CAS(adr, old, new):
5: break
6: else
7: continue
Indeed it seems it is correct if you retry. auto old = addr.load(std::memory_order_relaxed);
while(not addr.compare_exchange_weak(old, old|1))
;
In fact you can set old to some random value and it will still convergence to the correct value, but is of course suboptimal.I've always wondered how the wait list was implemented.
Note: I wrote the above from the top of my head, I haven't actually had the need to implement a concurrent hashtable yet.
My guess is that if the thread gets descheduled inside the lock() spin loop (either by an explicit call to yield() or otherwise) then the "Four longest idle times" reflect the fact that all of the worker threads have remained descheduled for an extended period. But assuming that the threads have elevated priority (why would you use spinlocks on non-high-priority threads?) this seems implausible on an 8 core machine.
I would be curious to know what the results are if isolcpus was used and the workers were explicitly assigned to those CPUs.
What "longest wait" measures is (b_i - a_i) in:
Time a_0
lock
Time b_0
unlock
Time a_1
lock
Time b_1
unlock
...
What "idle time" measures is (b_i - a_i) in: lock
Time a_0
unlock
lock
Time b_0
Time a_1
unlock
lock
Time b_1
...
At least for the spinlock variants, "unlock" should be an extremely cheap operation (just a store for most variants), which means that those two sequences ought to be essentially identical in terms of timing: it ought to be possible to move the "Time a_i"s across the "unlock" operations with a timing impact less than a microsecond, i.e. the two sequences ought to have essentially the same timings.The fact that the timings are supposedly different on a millisecond scale is therefore quite suspicious.
Perhaps the chosen clock ends up calling a syscall, which can end up in the scheduler on syscall return occasionally and throw off the measurements that way? Or just the fact that there's one extra syscall in the critical section sometimes causes other threads to make different yielding decisions which throws off the scheduling?
More generally, it would have been great to see a deeper investigation into scheduling behavior, which is possible on Linux using the tracing infrastructure and visualizing with different tools like Hotspot.
Getting a worst-case delay of ~1.5ms with a naive ticket_spinlock that can yield is really not surprising, to be honest. All it takes is for patterns like:
1. 3 threads pull their tickets in order. 2. The middle thread yields and something else is chosen to run by the scheduler, or perhaps the scheduler decides not to run anything for a short while on the off-chance that something else becomes runnable, which is a fair choice because after all, you yielded. 3. The last thread is then blocked by the middle thread.
It just boils down to the fact that "yield" is a bad primitive. You should fall back to futex-based synchronization instead so that the kernel has a better idea of what you're trying to do. The article comes to the same conclusion anyway, in the form of "just use std::mutex".
That said, his conclusion is in fact just as you stated, if you read in between the lines. (This paper is really about the scheduler, so it's not exactly that conclusion.)
Sigh. From the article itself:
> I hope to have convinced you to avoid spinlocks, even for very short sections. Spinlocks are only a benefit if you really really care about the fast path and are certain that the lock will essentially always be free to take. On my computer the fast path on a spinlock is roughly 7ns and on a mutex it’s roughly 14ns, so both of those numbers are very fast. But when spinlocks go bad, the scheduler can be really confused, at least on Linux.
For most of my use cases, I prefer to keep processes/threads parallel and communicate via IPC chanels/sockets. This makes it easier to write code that can scale without limit.
For most use cases, I don't see much point of writing scalable code that you know only scales up to a certain point and then needs to be rewritten.
Avoid using shared memory if you have an embarassingly parallel problem.
If you have a simple REST web server, single-thread is simply not a realistic option. The next simplest thing is to do shared memory parallelism, which works perfectly for a few dozens to low hundreds of threads. That already covers a lot of scale for many realistic problems. Moving from that to a distributed system design is a significant jump in implementation complexity, and will likely increase per-request latency with most straight-forward implementations.
It's a lot more complicated that than. It depends heavily on how many threads are writing to the shared memory and how often. If the workload is write-heavy from many threads then then it can really add up. Also, if there are multiple shared memory locations which all use mutexes, it adds up as well. Shared memory offers diminishing returns as the logic becomes more complex.
Often, it's better to simply design the system in such a way that it removes or at least minimizes interactions and data sharing between different threads or processes. With such designs, often the overheads of IPC communication become very low.
Some engines like Node.js are focused around IPC as the main mechanism for sharing data between different processes. It produces relatively elegant code which is easy to follow and this design makes it relatively easy to identify the bottlenecks.
I agree that the approach I was suggesting is probably not ideal for low-concurrency situations (like games as I mentioned in my last comment) but I still think that shared memory access mechanisms are not elegant. That said, I find that programming solutions which try to squeeze every last drop of performance out of the hardware are rarely elegant architecturally.