From C/C++, Java to Go: The Trade-offs Behind Concurrency Semantics

Have you ever encountered a scenario where two threads work together, one responsible for “preparing data” and the other for “starting work as soon as it’s ready”? Everything works fine in unit tests, but once deployed, it occasionally reads an updated configuration halfway through and crashes inexplicably. Intuitively, you might think: since these bugs are related to details like out-of-order execution and caching, why don’t CPUs and compilers simply mandate that all threads’ memory accesses must strictly follow the code order? Wouldn’t that make things easier?

The problem lies here: if everything is done in order, programmers would indeed be more comfortable, but the machine would suffer greatly. Modern CPUs strive to run faster by aggressively utilizing pipelining, caching, and out-of-order execution; compilers also boldly reorder instructions to squeeze out performance. If a language were to categorically say “prohibit all reordering,” it would effectively disable all these optimizations, leading to a significant drop in performance. Thus, language designers have to balance between the “cognitive burden on programmers” and the “performance potential of machines,” and this compromise is known as the memory model.

Next, we will clarify several core concepts, then look at how early C/C++ was “hands-off,” and finally examine where mainstream languages like Java, Go, and C++ stand on this spectrum.

Core Concept: What Does “Relaxed” Mean?

First, let’s discuss what the term “relaxed” actually refers to.

In many people’s imagined “ideal world,” all threads’ reads and writes to memory can be arranged along a timeline: each thread executes in the order of the source code, and multithreading merely interleaves these operations. This model, where “everyone queues up on the same timeline,” is known as Sequential Consistency (SC).

However, in real machines, things are far from simple. To reduce memory waits and cache flushes, compilers may quietly reorder instructions without changing single-threaded results; CPUs also utilize write buffers and out-of-order execution to allow subsequent instructions to run first. This leads to:

What appears as “A before B” in the source code may very well appear as “B before A” in another thread’s view.

As long as single-threaded semantics remain unchanged, this behavior of “reordering” is considered a legal optimization by hardware and compilers. If a language chooses to accept such optimizations, it is said to have a relatively “relaxed” memory model; if a language prohibits such behavior and forces all threads’ accesses to queue up on a single timeline, it has a relatively “strong” model.

Thus, it can be roughly remembered as: Relaxed = accepting more “strange” execution orders in exchange for performance; Strong model = prohibiting these strange orders in exchange for easier-to-understand semantics.

Option 1: Early C/C++ – Completely Hands-off, Left to Hardware

For a long time, C and C++ were essentially “lying flat”: the standards hardly discussed concurrency, let alone memory models. Multithreading was entirely a library-level concern, whether you used pthreads or the Windows thread API, the language specification itself remained silent.

This seemed to give implementers a lot of freedom: each platform could decide how to implement threads, locks, and atomic operations based on its hardware characteristics. The problem is that programmers completely lost a unified “reference coordinate system”.

Imagine you wrote a multithreaded C++ code that runs perfectly on an x86 server, but when ported to an ARM phone, it occasionally reads old values; it runs fine with a certain version of GCC with the <span>-O3</span> option, but crashes intermittently with a newer version. You check the standard and find it doesn’t tell you at all: what behaviors are allowed and what are not in the presence of data races.

In other words, the program’s semantics were “outsourced” to specific combinations: compiler version + optimization options + hardware architecture. If you want to write truly reliable concurrent programs, you have to consult the manuals for every CPU and repeatedly experiment with each compiler’s optimization strategies. This is what Hans Boehm complained about in his famous paper “Threads Cannot be Implemented as a Library”:

It is impossible to clarify multithreading solely through libraries; the language itself must provide a definition.

Option 2: Strong Sequential Model – Everything Queues Up, Everyone is at Ease

At the other end of the spectrum is the “ideal but expensive” strong model. Remember the earlier hypothetical that “all threads’ reads and writes interleave on a timeline”? If the language directly stipulates:

All threads’ memory accesses must execute strictly according to a global order, just like on this timeline; the order seen by each thread must also be completely consistent with the source code.

Then we achieve an almost perfect world:

  • You can think of multithreaded programs as “several segments of single-threaded code executing in a certain order,” making reasoning very intuitive;
  • Many bizarre execution results are inherently impossible under this model, so there’s no need to worry.

This is the charm of Sequential Consistency (SC). Many people envision this world when they first encounter concurrency.

However, for compilers and CPUs, this world is too “idealized.” To create the illusion that “everyone is queuing up,” they must:

  • Be more cautious about instruction reordering, and many previously safe optimizations cannot be used;
  • Frequently synchronize cache states across multiple cores, inserting additional barrier instructions, sacrificing some degree of parallelism.

This is akin to turning a highway that could allow free overtaking into one where everyone must queue up slowly. Accidents decrease, but traffic flow also drops.

Option 3: The Compromise Route of Mainstream Languages

In the real world, languages ultimately did not choose either of these extremes: they cannot be completely hands-off like early C/C++, nor can they enforce “everything must be strongly sequential” on modern hardware. Thus, different languages have found their own compromise routes.

Some languages (like Java) will write a long list of rules that specify under what conditions a thread’s modification to memory must be visible to another thread; these rules collectively form the so-called Happens-Before relationship;

Other languages (like Go) simply do not encourage you to manipulate shared memory directly, but instead encourage you to write concurrency as “messages flowing through pipes,” allowing the language runtime to handle the details internally;

Then there is C++, a language that provides you with a complete set of fine-tuned controls: it offers high-level abstractions like locks and condition variables, while also exposing low-level switches like atomic operations and memory orders.

To better understand these differences, let’s start with Java, which is the “most detailed in its rules.”

1. Java: The Pioneer’s Attempt (Java 5+)

Java recognized early on: “If the language itself does not clarify concurrency semantics, library authors and compiler authors will guess among themselves, and in the end, no one will be able to clarify whose fault a bug is.” Thus, in the JSR-133 specification of Java 5, it formally introduced its own memory model (Java Memory Model, JMM).

JMM does several key things:

First, it guarantees basic type safety—such as preventing scenarios where “reading a partially written object reference” occurs. Second, it gives <span>volatile</span> a relatively strong semantic meaning: writing to a <span>volatile</span> variable not only means “do not merge, do not cache,” but also establishes a clear “before and after” order in other threads. Furthermore, it defines the so-called Happens-Before relationship through a series of seemingly abstract rules.

A common “configuration loading” story can help understand Happens-Before:

Thread A loads the configuration at startup, placing the result in a <span>config</span> object, and then sets a flag <span>initialized</span> to <span>true</span>; during its execution, Thread B checks this flag first, and if it finds it is already <span>true</span>, it confidently reads <span>config</span>. In a naive imagination, as long as B sees <span>initialized == true</span>, it should definitely see the latest configuration. However, in a relaxed world, if the language does not manage this, hardware and compilers could completely reorder A’s two assignments, making the “write flag” step visible first, while the “write configuration” step is seen later by other cores. The result is that B sees <span>initialized</span> as true but reads an old configuration.

The role of Happens-Before is to help us “draw a line” in such scenarios: if the specification states “operation A happens-before operation B,” it means all modifications to memory by A must be completed from B’s perspective. Java stipulates that writing to a <span>volatile</span> variable happens-before any subsequent reads of the same variable by other threads. Therefore, as long as <span>initialized</span> is declared as <span>volatile</span>, once Thread B sees it change to <span>true</span>, it can be assured that it is in the world where Thread A has already written <span>config</span>.

From a programmer’s perspective, with these rules in place, you do not need to understand every CPU’s out-of-order details; you can design the “who comes first, who comes later” relationships around Happens-Before, allowing you to write predictable concurrent code in most cases.

2. Go: Simplicity at Its Best

Having discussed Java, let’s look at a nearly opposite example: Go. From the very beginning, Go has championed “simplicity and engineering”; its basic attitude towards concurrency can be summarized in one sentence: do not push ordinary engineers to wrestle with the details of the memory model. You may have heard Go’s official slogan:

“Do not communicate by sharing memory; instead, share memory by communicating.”

This statement is somewhat abstract, so let’s start with a specific small scenario. Suppose you have two goroutines, where the producer generates tasks and the consumer processes them. If you follow the traditional C/C++ habit, you might write a global <span>Task task</span> and a <span>bool ready</span>. The producer fills in the <span>task</span> once it is ready and sets <span>ready</span> to <span>true</span>; the consumer then loops, watching <span>ready</span>, and once it sees it change to <span>true</span>, it reads <span>task</span> and starts processing.

At first glance, this seems fine, but upon deeper reflection, many hidden dangers emerge. You need to ensure the order of “write task then write ready” appears the same on other CPU cores; you must worry about whether the consumer might see <span>ready == true</span> before the task is fully written; and you have to consider whether to lock these two variables, how granular the lock should be, and whether it might cause performance bottlenecks. All these concerns are essentially competing with the “relaxed memory model”.

In Go, the recommended approach is to change your thinking: directly create a <span>chan Task</span>. The producer simply executes <span>ch <- task</span> after preparing the task; the consumer only needs to write <span>t := <-ch</span> in its goroutine. On the surface, we have merely replaced “writing a global variable + changing a flag” with “sending a message through a Channel,” but the underlying story is entirely different. Before sending, the producer first fills in the <span>task</span> in its stack or heap; during sending, the Go runtime copies the task’s content into the queue maintained internally by the Channel; upon receiving, it copies this data from the queue to the consumer. The entire “send → receive” process inherently provides a strong guarantee: as long as the consumer successfully retrieves a <span>Task</span> from the Channel, it is guaranteed to see the complete version that the sender had already fully written before sending, not some intermediate state.

What if you insist on using shared variables? Go’s stance is very direct: if two goroutines access the same variable simultaneously, and at least one is a write operation, you must use <span>sync.Mutex</span> or other synchronization primitives to protect it; if you do not, it is considered a data race, the behavior is undefined, and running it is a matter of luck, with debugging tools treating it as a complete bug. Unlike Java, which discusses “the minimum guarantees I can give you without synchronization,” Go chooses to outright ban these dangerous practices—either take the safe route through Channels or lock up properly, without expecting to play tricks on shared memory.

Looking back at Channels themselves, there is no “magical physics” involved. At the implementation level, they are still shared memory with locks and condition waits, just encapsulated into a clean-looking pipeline. You can think of it as a locked circular buffer: when sending, it locks and writes data into the circular queue; when receiving, it locks again to retrieve the next piece of data from the queue. During this process, the Go runtime will insert memory barriers at appropriate places to establish a clear “write data first, then let others see” relationship. For you, the only thing to remember is: you either get a complete piece of data from the Channel or nothing at all; there will be no bizarre situation where “half is old data, half is new data.”

From an engineering practice perspective, Go combines three things: making correct concurrent writing (Channel / Mutex) as straightforward as possible; categorically deeming those “seemingly runnable but actually dangerous” practices (bare shared variables + luck) as errors; and burying most of the complexity of the memory model in the runtime and tools (like <span>go vet</span> and <span>-race</span> detection), rather than placing it on every business engineer. This is the true meaning of “simplicity at its best”—not that its underlying implementation is simple, but that it strives not to confront you with those complex details.

3. C/C++: Ultimate Control (C++11+)

Now, shifting back to C/C++, the temperament here is clearly different: it was designed to write operating systems, databases, and browser kernels, targeting users who want to confront hardware directly.

A core motto of C++ is zero-cost abstractionabstraction can be provided, but once it is not used, it must not cost you an extra instruction. This also means: it cannot help you too much at runtime like Go; instead, it tends to place all control knobs in front of you, allowing you to decide whether to turn them.

Before C++11, the standard did not formally define a “memory model” concept, and threads were merely a library-level concern. Hans Boehm pointed out in his famous 2005 paper “Threads Cannot be Implemented as a Library”: If the language does not define concurrency semantics, it is impossible to clarify what multithreaded programs are doing solely through libraries. This paper essentially pressured the C/C++ standards committee to include the memory model in the language specification.

<spanthus, <span>std::atomic</span> and <code><span>std::memory_order</span>. You can understand their design philosophy as follows:

  • If you do nothing and simply use the default <span>std::atomic<int></span> without specifying memory order, the compiler will treat you according to the safest, most intuitive approach (<span>memory_order_seq_cst</span><span>), as close as possible to the "strong sequential worldview" we discussed earlier. Most business code only needs to cooperate with mutexes and condition variables, and this is sufficient.</span>
  • When you start caring about performance and find the “default a bit heavy,” you can first learn about <span>memory_order_acquire</span> / <span>memory_order_release</span>. They act like a rope between two threads: one end is <span>release</span> writing, and the other is <span>acquire</span> reading; as long as this rope is established, patterns like “write data first, then set flag” can be safely expressed without incurring the full cost of sequential consistency.
  • Further down, there is <span>memory_order_relaxed</span>, the “expert mode.” Here, C++ essentially reveals all its cards: aside from ensuring that single operations are atomic, it does not care about other orders, trading off performance that is almost the same as ordinary reads and writes. This is typically used in counters or performance monitoring metrics that only need rough statistics; if misused, it can lead to very subtle bugs.

You can view this design as: C++ does not make decisions for you but provides a whole ladder from “completely worry-free” to “completely manual”. With the same concurrent logic, you can first write a correct version using default atomics and locks, and then, knowing exactly what you are doing, gradually switch to acquire/release, and even use relaxed in specific places to explore performance space.

Historically, although C++11’s memory model has been criticized as “too difficult to understand,” it has since become a template for system-level languages: Rust, Swift, and others have looked to it to varying degrees when designing low-level atomic operations. In a sense, it has helped the entire industry clarify the terminology and rules surrounding “hardware-level concurrency”.

C++’s “Regulation”: DRF-SC

Having discussed so many details, C++ does not expect everyone to draw happens-before diagrams or study the out-of-order rules of every CPU. It has prepared a “safety rope” that can be firmly grasped, which is often referred to as the DRF-SC (Data Race Free implies Sequential Consistency) principle. Although the name sounds intimidating, it translates to a simple statement:

As long as your program has no data races, it appears to execute as if in a strong sequential ideal world.

Here, “no data races” can be thought of as a discipline you set for yourself: either a shared variable is always read and written by a single thread; or once multiple threads need to access it simultaneously, all accesses must be placed within locks or protected by default sequentially consistent atomic operations (<span>seq_cst</span><code><span>). As long as you adhere to this discipline, the C++ standard stands by you, ensuring that you can think about the program as if it executes in a "single-threaded, sequential" manner without being intimidated by compiler reordering and CPU out-of-order execution.</span>

From this perspective, DRF-SC is like a gentleman’s agreement between you and the implementation: you promise not to write code with data races, and the language and compiler promise to help you shield most low-level details, making the program’s behavior as consistent as possible with the strong sequential model. This greatly reduces the cognitive burden on ordinary C++ developers—most of the time, you only need to pay attention to where to add locks and where to use atomics to write predictable concurrent code.

Of course, C++ also honestly tells you: if one day you seek ultimate performance and deliberately use <span>memory_order_relaxed</span> or other more relaxed memory orders, intentionally bypassing this protection, you step out of the comfort zone of DRF-SC. From that moment on, the language no longer guarantees that you will see strong sequential effects, and many “outrageous” execution results become legal. You gain more freedom but must also understand the details of the underlying memory model and take full responsibility for the subtle bugs that may arise.

Conclusion

Looking back, the paths of Java, Go, and C++ regarding memory models outline a spectrum from “strong protection” to “strong control”:

  • Java prefers to be the careful guardian: helping you think through most dangerous situations, using Happens-Before and other rules to prohibit behaviors that should be banned.
  • Go chooses to be an engineering manager: establishing simple and direct usage norms (Channel / Mutex), hiding complexity in the runtime and tools, and having zero tolerance for dangerous practices.
  • C++ resembles handing you the keys to the laboratory: from safe default atomics to fine-grained memory orders, to completely relaxed modes, all tools are laid out on the table for you to decide how to use them.

Understanding these trade-offs behind the scenes, when you look at C++’s <span>std::atomic</span><span> and various </span><code><span>memory_order</span><span>, you will no longer see them as "syntax monsters designed to make things difficult" but rather as a set of finely tuned control knobs—</span>

  • If you only need to “write correct code,” just hold onto the safety rope of DRF-SC, using locks and default atomics;
  • If you really need to explore extreme performance, then consider those more relaxed options and bear the corresponding cognitive costs.

Leave a Comment