multithreading
optimization
performance issues
debugging
compiler options

Multithreading program stuck in optimized mode but runs normally in -O0

Data Structures & Algorithms practice on Codemia

Step through 300 algorithm problems with animated visualisers that show the data structure changing as the code runs.

Practice algorithms

Introduction

When a multithreaded program works at -O0 but hangs or misbehaves with optimization enabled, the bug is usually already present in the code. The optimizer is not "breaking" a correct program. It is exposing a race condition, missing synchronization, or undefined behavior that -O0 happened to mask. The right fix is almost never "compile without optimization forever." It is to make the shared-state rules explicit.

Why -O0 Can Hide Bugs

At -O0, compilers preserve a lot of the original structure of the code. Reads and writes often happen in a way that feels close to the source. Once optimization is enabled, the compiler is free to reorder, cache, inline, and eliminate operations as long as the single-threaded abstract machine rules are preserved.

If your code has a data race, those transformations can make the bug visible.

Example of broken shared state:

cpp
1#include <thread>
2#include <iostream>
3
4bool ready = false;
5
6void worker() {
7    while (!ready) {
8    }
9    std::cout << "started\n";
10}
11
12int main() {
13    std::thread t(worker);
14    ready = true;
15    t.join();
16}

This code has a data race on ready. Under optimization, the worker may never observe the write in a defined way.

Use Atomics or Locks for Shared Data

The correct fix is to synchronize access to shared state. For a simple flag, std::atomic<bool> is often enough.

cpp
1#include <thread>
2#include <iostream>
3#include <atomic>
4
5std::atomic<bool> ready{false};
6
7void worker() {
8    while (!ready.load(std::memory_order_acquire)) {
9    }
10    std::cout << "started\n";
11}
12
13int main() {
14    std::thread t(worker);
15    ready.store(true, std::memory_order_release);
16    t.join();
17}

Now the communication between threads is defined. The behavior no longer depends on optimization level luck.

Busy-Wait Loops Need Special Care

Programs that appear "stuck" under optimization often contain spin loops, hand-rolled flags, or shared variables accessed without synchronization. The compiler may keep values in registers, and CPUs may reorder memory operations. Without atomics, there is no guarantee another thread’s write becomes visible the way you expect.

That is why volatile is not a substitute for synchronization in normal multithreaded C or C++ code. Volatile affects certain compiler behaviors for special memory, but it does not provide the cross-thread ordering guarantees that atomics and mutexes do.

Prefer Higher-Level Synchronization

Many waiting problems are better expressed with condition variables than with manual spin loops.

cpp
1#include <thread>
2#include <mutex>
3#include <condition_variable>
4#include <iostream>
5
6std::mutex mutex_;
7std::condition_variable cv;
8bool ready = false;
9
10void worker() {
11    std::unique_lock<std::mutex> lock(mutex_);
12    cv.wait(lock, [] { return ready; });
13    std::cout << "started\n";
14}
15
16int main() {
17    std::thread t(worker);
18
19    {
20        std::lock_guard<std::mutex> lock(mutex_);
21        ready = true;
22    }
23
24    cv.notify_one();
25    t.join();
26}

This is clearer and far more robust than a custom polling loop.

Look for Other Undefined Behavior Too

Optimization-sensitive multithreading bugs are not always just missing atomics. Other undefined behavior can become visible only in optimized builds, including:

  • accessing objects after lifetime ends
  • unsafely sharing non-thread-safe containers
  • reading partially initialized memory
  • relying on timing rather than synchronization

A program that "only breaks under -O2" is usually already broken. Optimization is just making the invalid assumptions harder to ignore.

Debug with Sanitizers and Reduced Test Cases

The most effective debugging step is usually to shrink the failing code path and run a thread sanitizer build if the toolchain supports it. Sanitizers are far more informative than staring at optimized assembly and hoping the bug explains itself.

Even without specialized tooling, turning a suspected pattern into a tiny reproducible example often reveals whether the problem is a missing atomic, a lock inversion, or a lifetime issue.

Common Pitfalls

  • Blaming the optimizer instead of the underlying race or undefined behavior.
  • Using plain shared booleans or integers for thread communication.
  • Treating volatile as a complete multithreading fix.
  • Building custom spin loops when a mutex or condition variable would express the intent better.
  • Assuming code is thread-safe because it "usually works" at -O0.

Summary

  • If optimized multithreaded code hangs while -O0 works, the code likely already contains a synchronization bug.
  • Compilers and CPUs are allowed to reorder unsynchronized operations in ways that expose data races.
  • Use std::atomic, mutexes, and condition variables for cross-thread communication.
  • Volatile is not a replacement for proper synchronization.
  • Fix the race rather than relying on low optimization to hide it.

Related reading
Course
Intermediate
27 lessons
15 hours
DSA Fundamentals

Master algorithmic patterns and data structures through hands-on LeetCode-style problems - from arrays and hashing to dynamic programming and advanced graphs.

View the course
Track what you have practised

A free account saves your progress, solutions and study plan across every problem on Codemia.

Data Structures & Algorithms practice on Codemia

Step through 300 algorithm problems with animated visualisers that show the data structure changing as the code runs.

Practice algorithms

All Rights Reserved.