Implementasi lock-free queue pada background worker sering lolos pengujian pada build debug (-O0), namun memicu silent data race dan korupsi memori ketika dikompilasi dengan optimasi -O2 atau -O3. Masalah ini bukan disebabkan oleh kegagalan logika algoritma, melainkan akibat compiler reordering dan eksekusi memori yang longgar (weakly ordered memory model) pada CPU modern.

Anatomi Masalah: Relaxed Ordering dan Reordering

Secara default, pengembang sering mengasumsikan bahwa instruksi CPU dieksekusi persis sesuai urutan penulisan kode sumber. Asumsi ini tidak berlaku dalam lingkungan multithreading performa tinggi. Ketika producer menulis data payload ke dalam buffer kemudian memperbarui indeks atomik menggunakan std::memory_order_relaxed, compiler dan hardware berhak mengubah urutan operasi tersebut.

  • Compiler Reordering: Pada level optimasi -O2/-O3, optimizer menganalisis bahwa penulisan struct payload dan atomic store tidak memiliki dependensi data langsung dalam satu thread. Akibatnya, compiler dapat memindahkan instruksi store index sebelum penulisan payload selesai demi efisiensi pipeline register.
  • CPU Memory Reordering: Pada arsitektur dengan model memori lemah seperti ARM64, CPU dapat mem-flush store buffer ke cache di luar urutan instruksi program jika tidak ada instruksi memory barrier.

Dampaknya, consumer worker melihat indeks atomik berubah lebih dulu, membaca slot antrean, dan mengambil data payload yang belum selesai ditulis atau masih berisi data lama (stale data).

Kode Bermasalah: Penggunaan memory_order_relaxed

Berikut adalah contoh minimal single-producer single-consumer (SPSC) ring buffer yang mengandung bug data race tersembunyi:

#include <atomic>
#include <cstdint>

struct Task {
    uint64_t id;
    void (*handler)(void*);
    void* payload;
};

template <size_t Capacity>
class BrokenQueue {
    Task buffer[Capacity];
    std::atomic<size_t> tail{0};
    std::atomic<size_t> head{0};

public:
    bool push(const Task& task) {
        size_t t = tail.load(std::memory_order_relaxed);
        size_t h = head.load(std::memory_order_relaxed);

        if ((t + 1) % Capacity == h) {
            return false; // Queue penuh
        }

        // Bug: Compiler dapat menukar urutan operasi berikut
        buffer[t] = task;
        tail.store((t + 1) % Capacity, std::memory_order_relaxed);
        return true;
    }

    bool pop(Task& task) {
        size_t h = head.load(std::memory_order_relaxed);
        size_t t = tail.load(std::memory_order_relaxed);

        if (h == t) {
            return false; // Queue kosong
        }

        // Bug: Consumer membaca payload sebelum sinkronisasi valid
        task = buffer[h];
        head.store((h + 1) % Capacity, std::memory_order_relaxed);
        return true;
    }
};

Solusi: Semantic Memory Ordering (Acquire-Release)

Untuk memastikan penulisan payload selesai sebelum indeks bertambah, gunakan relasi synchronizes-with menggunakan Acquire-Release semantics. Anda tidak memerlukan lock atau barrier penuh (std::memory_order_seq_cst) yang membebani performa bus CPU.

  • Release (Producer): Menjamin semua operasi penulisan memori (baik atomik maupun non-atomik) sebelum instruksi store(..., std::memory_order_release) selesai dikomit dan terlihat oleh thread lain sebelum nilai flag/indeks diubah.
  • Acquire (Consumer): Menjamin bahwa pembacaan memori setelah instruksi load(..., std::memory_order_acquire) tidak akan mendahului operasi pembacaan flag itu sendiri.

Implementasi Fix

#include <atomic>
#include <cstdint>

struct Task {
    uint64_t id;
    void (*handler)(void*);
    void* payload;
};

template <size_t Capacity>
class LockFreeQueue {
    Task buffer[Capacity];
    std::atomic<size_t> tail{0};
    std::atomic<size_t> head{0};

public:
    bool push(const Task& task) {
        size_t t = tail.load(std::memory_order_relaxed);
        size_t h = head.load(std::memory_order_acquire); // Sinkronisasi head consumer

        if ((t + 1) % Capacity == h) {
            return false;
        }

        buffer[t] = task; // Payload ditulis sebelum tail dipublikasi

        // Menjamin payload tuntas ditulis sebelum tail diperbarui
        tail.store((t + 1) % Capacity, std::memory_order_release);
        return true;
    }

    bool pop(Task& task) {
        size_t h = head.load(std::memory_order_relaxed);
        size_t t = tail.load(std::memory_order_acquire); // Sinkronisasi tail producer

        if (h == t) {
            return false;
        }

        task = buffer[h]; // Aman: Payload dijamin terlihat utuh

        // Publikasikan ke producer bahwa slot telah dibebaskan
        head.store((h + 1) % Capacity, std::memory_order_release);
        return true;
    }
};

Verifikasi Assembly: Inspeksi Instruksi CPU

Perbedaan antara Relaxed dan Acquire-Release tampak jelas pada instruksi assembly yang dihasilkan, terutama pada arsitektur ARM64.

Kompilasi kode menggunakan g++ atau clang++ lalu periksa instruksi via objdump:

clang++ -O3 -std=c++17 -c queue.cpp -o queue.o
objdump -d -M intel queue.o

Pada arsitektur x86_64, model hardware adalah Total Store Order (TSO), sehingga instruksi mov standar sudah memiliki semantic acquire/release pada level hardware. Namun, semantic Acquire-Release di C++ tetap mutlak diperlukan untuk mencegah compiler menggeser instruksi mov pada saat kompilasi.

Pada arsitektur AArch64 (ARM64), perbedaan instruksi hardware terlihat langsung:

  • Relaxed Store: Menghasilkan instruksi str biasa.
  • Release Store: Menghasilkan instruksi stlr (Store-Release Register) yang menahan instruksi store melewati barrier hardware.
  • Acquire Load: Menghasilkan instruksi ldar (Load-Acquire Register) alih-alih ldr standar.

Deteksi Runtime dengan ThreadSanitizer (TSan)

Data race akibat kesalahan memory ordering sulit direproduksi secara konsisten di unit test biasa. Gunakan ThreadSanitizer yang terintegrasi pada Clang/GCC untuk mendeteksi pelanggaran relasi happens-before saat runtime.

Jalankan kompilasi dengan flag sanitizer:

clang++ -fsanitize=thread -O2 -g -pthread main.cpp -o worker_runner
./worker_runner

Jika menggunakan std::memory_order_relaxed pada skenario multithreaded produsen-konsumen, ThreadSanitizer akan langsung menghentikan program dan mengeluarkan laporan:

WARNING: ThreadSanitizer: data race (pid=45211)
  Read of size 8 at 0x7fff519280a0 by thread T2:
    #0 LockFreeQueue<1024ul>::pop(Task&) queue.h:43
    #1 worker_consumer() main.cpp:18

  Previous write of size 8 at 0x7fff519280a0 by thread T1:
    #0 LockFreeQueue<1024ul>::push(Task const&) queue.h:28
    #1 worker_producer() main.cpp:12

  Location is heap block of size 24576 at 0x7fff51928000 allocated by main thread.

Setelah mengganti operasi store/load dengan pasangan memory_order_release dan memory_order_acquire, ThreadSanitizer tidak akan memicu peringatan data race karena relasi sinkronisasi formal antar-thread telah terpenuhi.

Rangkuman Keputusan Desain

Gunakan memory_order_relaxed hanya untuk penghitung metrik independen di mana urutan baca/tulis data lain tidak relevan. Untuk semua operasi penyerahan status (handover state) antar-thread pada antrean lock-free, gunakan selalu pasangan memory_order_release saat mempublikasi dan memory_order_acquire saat mengonsumsi data.