Race condition terjadi ketika dua atau lebih thread mengakses memori yang sama, setidaknya satu di antaranya melakukan penulisan, dan urutan eksekusi tidak terkoordinasi. Hasil akhir program menjadi tidak terduga—bisa benar di satu eksekusi, salah di eksekusi berikutnya. Bug jenis ini sulit direproduksi dan dapat lolos pengujian bertahun-tahun. Pada chapter ini kita menulis program sederhana: tiga thread meng-increment counter yang sama sebanyak satu juta kali. Ekspektasi kita jelas (tiga juta), tetapi output aktual jauh di bawah angka tersebut—dan berubah-ubah setiap kali program dijalankan.

Counter Shared Tanpa Proteksi #

Program berikut membuat tiga thread yang masing-masing meng-increment variabel global counter sebanyak satu juta kali. Tanpa sinkronisasi, kita berharap hasil akhirnya bukan tiga juta.

Visualisasi Konsep #

Diagram berikut memperlihatkan bagaimana dua thread yang sama-sama melakukan counter++ (operasi read-modify-write tiga langkah) dapat saling menimpa dan menyebabkan satu increment hilang:

Pada diagram di atas, kedua thread menjalankan operasi counter++ yang seolah-olah tunggal di kode C, tetapi sebenarnya terdiri dari tiga instruksi mesin: (1) read nilai counter dari memori ke register, (2) increment nilai di register, (3) store nilai register kembali ke memori. Karena tidak ada kunci (mutex), scheduler sistem operasi boleh menghentikan sebuah thread di antara langkah-langkah tersebut. Pada contoh di atas, Thread 1 selesai read dan increment tetapi dihentikan sebelum store; Thread 2 kemudian membaca nilai counter yang lama (5), menambahnya, dan menulis kembali (6); saat Thread 1 melanjutkan store, ia juga menulis 6—padahal seharusnya hasil akhir 7. Satu increment hilang karena kedua thread menulis tanpa koordinasi. Inilah inti race condition: hasil program bergantung pada urutan scheduling yang tidak terprediksi.

c
/* File: race_counter.c - race condition pada counter shared */
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <pthread.h>

#define N_ITER 1000000
static long counter = 0;             /* shared mutable state */

static void *naikkan(void *arg) {
    (void) arg;
    for (long i = 0; i < N_ITER; i++) {
        counter++;                   /* race: read-modify-write */
    }
    return NULL;
}

int main(void) {
    pthread_t t1, t2, t3;
    pthread_create(&t1, NULL, naikkan, NULL);
    pthread_create(&t2, NULL, naikkan, NULL);
    pthread_create(&t3, NULL, naikkan, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);

    printf("Ekspektasi: %d\n", 3 * N_ITER);
    printf("Aktual    : %ld\n", counter);
    return 0;
}

Compile dan jalankan beberapa kali:

bash
gcc -std=c11 -Wall -Wextra -pthread -o race_counter race_counter.c
./race_counter && ./race_counter && ./race_counter

Output yang Dihasilkan (nilai aktual berubah-ubah antar eksekusi):

text
Ekspektasi: 3000000
Aktual    : 1937945
Ekspektasi: 3000000
Aktual    : 1999535
Ekspektasi: 3000000
Aktual    : 1975339

Mari kita telaah baris-baris kode di atas:

  1. Baris 7 (static long counter = 0;): Variabel global. Semua thread melihat dan memodifikasi counter yang sama. Inilah sumber masalah—counter adalah shared mutable state.
  2. Baris 10 ((void) arg;): Menghindari warning -Wunused-parameter. Thread tidak butuh argumen.
  3. Baris 11 (for (long i = 0; i < N_ITER; i++)): Setiap thread melakukan satu juta iterasi. Bila satu thread saja yang berjalan, hasilnya pasti N_ITER.
  4. Baris 12 (counter++;): Operasi ini terlihat satu langkah di kode C, tetapi di tingkat mesin terdiri dari tiga instruksi: (a) load counter dari memori ke register, (b) increment register, (c) store register kembali ke memori. Karena tidak ada kunci, scheduler bisa menghentikan thread di antara langkah-langkah tersebut—akibatnya dua thread bisa membaca nilai counter yang sama, keduanya menambah satu, dan menulis kembali nilai yang sama. Salah satu increment hilang.
  5. Baris 17-19 (pthread_create x3): Tiga thread dibuat. Kernel scheduler dapat menjalankan ketiganya secara paralel pada mesin multicore, memperbesar kemungkinan race condition.
  6. Baris 20-22 (pthread_join x3): main menunggu semua thread selesai. Setelah baris ini, kita membaca counter—yang sudah tidak konsisten karena banyak update hilang.

Mengapa counter++ Tidak Atomic #

Untuk memahami penyebab race condition, kita melihat kode mesin yang dihasilkan compiler. Berikut program kecil yang hanya meng-increment counter global satu kali, dengan qualifier volatile agar compiler tidak mengoptimalkannya menjadi konstanta:

c
/* File: counter_increment.c - satu increment untuk dianalisis assembly */
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>

static volatile long counter = 0;

int main(void) {
    counter++;
    printf("counter = %ld\n", counter);
    return 0;
}

Generate assembly dengan flag -S:

bash
gcc -std=c11 -Wall -Wextra -O2 -S -o counter_increment.s counter_increment.c
grep -A 2 -B 1 "addq\|incq" counter_increment.s | head -20

Output yang Dihasilkan (kode mesin x86_64):

text
        movq    counter(%rip), %rax        ; (a) LOAD: baca counter ke register rax
        leaq    .LC0(%rip), %rdi
        addq    $1, %rax                  ; (b) ADD: rax = rax + 1
        movq    %rax, counter(%rip)        ; (c) STORE: tulis rax kembali ke counter
        xorl    %eax, %eax

Tiga instruksi mesin ini menjelaskan semuanya:

  1. movq counter(%rip), %rax — Load. CPU membaca nilai counter dari RAM ke register rax.
  2. addq $1, %rax — Add. CPU menambah 1 ke register rax. Pada titik ini, memori counter belum berubah.
  3. movq %rax, counter(%rip) — Store. CPU menulis nilai baru dari rax kembali ke memori counter.

Skenario race yang khas: Thread A melakukan Load (membaca counter = 5), lalu scheduler memindahkan CPU ke Thread B sebelum A melakukan Store. Thread B juga melakukan Load (masih counter = 5), Add, Store (counter = 6). Thread A dijadwalkan kembali, melakukan Add pada rax yang berisi 6 (salah—seharusnya 5 lalu jadi 6, lalu 6 lagi dari B jadi 7), dan Store (counter = 6). Hasil akhir: dua increment yang seharusnya menghasilkan 7, hanya menghasilkan 6. Satu increment hilang.

Race pada Operasi Multi-Langkah Lain #

Race condition tidak hanya terjadi pada counter++. Operasi yang melibatkan beberapa variabel juga rentan, meskipun setiap variabel individual ditulis secara atomic. Contoh klasik: transfer bank antara dua akun.

c
/* File: race_transfer.c - race condition pada struct multi-field */
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <pthread.h>

typedef struct {
    long saldo_a;
    long saldo_b;
} Akun;

static Akun akun = { 100000, 100000 };

/* Transfer dari A ke B tanpa proteksi */
static void transfer(long jumlah) {
    akun.saldo_a -= jumlah;
    /* window rentan: scheduler bisa berpindah thread di sini */
    akun.saldo_b += jumlah;
}

static void *pelanggan(void *arg) {
    (void) arg;
    for (long i = 0; i < 10000; i++) {
        transfer(1);
    }
    return NULL;
}

int main(void) {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, pelanggan, NULL);
    pthread_create(&t2, NULL, pelanggan, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);

    long total = akun.saldo_a + akun.saldo_b;
    printf("Saldo A    : %ld\n", akun.saldo_a);
    printf("Saldo B    : %ld\n", akun.saldo_b);
    printf("Total (harus 200000): %ld\n", total);
    return 0;
}

Compile dan jalankan beberapa kali:

bash
gcc -std=c11 -Wall -Wextra -pthread -o race_transfer race_transfer.c
./race_transfer && ./race_transfer && ./race_transfer

Output yang Dihasilkan (nilai variabel antar eksekusi):

text
Saldo A    : 86863
Saldo B    : 113123
Total (harus 200000): 199986
Saldo A    : 90000
Saldo B    : 110000
Total (harus 200000): 200000
Saldo A    : 89519
Saldo B    : 110481
Total (harus 200000): 200000

Pada Run 1, total uang yang tersisa 199986—uang sebesar 14 hilang dari sistem. Penyebabnya: thread A mengurangi saldo_a tetapi sebelum menambah saldo_b, scheduler berpindah ke thread B. Thread B membaca saldo_a yang sudah dikurangi, lalu menambahkan ke saldo_b. Saat thread A melanjutkan, ia menambah ke saldo_b dengan nilai yang berbeda dari ekspektasi. Inilah masalah invariant pecah: total saldo seharusnya selalu konstan, tetapi race condition melanggar invariant tersebut.