CHƯƠNG 06 · CONCURRENCY · ~140 phút

Synchronization
Mutex, Semaphore, Monitor

Khi nhiều thread cùng đọc/ghi 1 biến → race condition. Đây là chương cực kỳ kinh điển trong phỏng vấn: Mutex vs Semaphore, Producer-Consumer, Reader-Writer, Dining Philosophers, atomic operations. Hiểu chương này, bạn không bao giờ viết bug đồng bộ trong code production nữa.

6.1 Race Condition — bug khó tìm nhất

Race condition = kết quả của chương trình phụ thuộc vào thứ tự thực thi của các thread — mà thứ tự đó không xác định được trước.

Ví dụ counter increment

int counter = 0;

void* increment(void* arg) {
    for (int i = 0; i < 1000000; i++) {
        counter++;  // có vẻ atomic, nhưng KHÔNG!
    }
    return NULL;
}

// Tạo 2 thread cùng gọi increment()
// Kỳ vọng: counter = 2,000,000
// Thực tế: counter ~= 1,200,000 (giá trị random!)

Tại sao? counter++ trong CPU thực ra là 3 instruction:

1. LOAD  counter → register   (đọc)
2. ADD   register, 1          (cộng 1)
3. STORE register → counter   (ghi)

Nếu thread A và B đan xen:

Time Thread A Thread B counter ─── ────────── ────────── ───────── 1 LOAD counter (=5) 5 2 LOAD counter (=5) 5 3 ADD reg, 1 (=6) 5 4 ADD reg, 1 (=6) 5 5 STORE 6 6 6 STORE 6 6 ← LOST!

Cả 2 thread đều "tăng" counter, nhưng kết quả chỉ tăng 1 thay vì 2. Lost update.

6.2 Critical Section — đoạn cần bảo vệ

Critical Section (CS) = đoạn code truy cập tài nguyên chia sẻ, cần đảm bảo chỉ 1 thread vào tại 1 thời điểm.

3 yêu cầu của giải pháp đồng bộ

  1. Mutual Exclusion (loại trừ lẫn nhau): chỉ 1 thread trong CS
  2. Progress: nếu không có thread nào trong CS, thread muốn vào không phải đợi vô lý
  3. Bounded Waiting: thread đợi không đợi vô hạn (no starvation)

6.3 Mutex — cơ chế phổ biến nhất

Mutex (mutual exclusion) = lock cho phép chính xác 1 thread vào CS. 2 operation chính:

  • lock(): nếu mutex đang free → chiếm; nếu đang busy → block đến khi free
  • unlock(): trả mutex; OS đánh thức 1 thread đang đợi

Sửa counter bằng mutex

#include <pthread.h>

int counter = 0;
pthread_mutex_t lock;

void* increment(void* arg) {
    for (int i = 0; i < 1000000; i++) {
        pthread_mutex_lock(&lock);
        counter++;
        pthread_mutex_unlock(&lock);
    }
    return NULL;
}

int main() {
    pthread_mutex_init(&lock, NULL);
    pthread_t t1, t2;
    pthread_create(&t1, NULL, increment, NULL);
    pthread_create(&t2, NULL, increment, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    printf("counter = %d\n", counter);  // chính xác 2,000,000
}

Đặc điểm Mutex

  • Ownership: mutex có "owner" — thread lock phải là thread unlock (recursive mutex thì có thể lock nhiều lần cùng thread)
  • Block: thread đợi mutex bị OS đưa vào trạng thái BLOCKED, không tiêu CPU
  • Wakeup: khi unlock, OS đánh thức 1 thread đang đợi (FIFO hoặc tuỳ implementation)
⚠️ Bug phổ biến với mutex
  • Quên unlock: thread khác đợi vô hạn → deadlock
  • Lock 2 lần cùng thread: deadlock với chính mình (trừ recursive mutex)
  • Lock orderingmismatch: 2 thread lock theo thứ tự khác nhau → deadlock (chương 7)

6.4 Semaphore — đếm cờ hiệu

Semaphore = biến nguyên N + 2 atomic operation:

  • P() hoặc wait(): nếu N > 0 thì N--; nếu N = 0 thì block
  • V() hoặc signal(): N++; nếu có thread đang đợi thì wakeup 1

Counting Semaphore

N có thể > 1 — cho phép N thread vào CS đồng thời. Vd: pool có 5 connection, muốn cấp tối đa 5 thread truy cập DB cùng lúc.

sem_t db_pool;
sem_init(&db_pool, 0, 5);  // 5 slots

void* worker(void* arg) {
    sem_wait(&db_pool);    // chiếm 1 slot
    // ... truy cập DB ...
    sem_post(&db_pool);    // trả slot
}

Binary Semaphore

N = 0 hoặc 1. Tương đương Mutex về mặt chức năng nhưng không có ownership.

Mutex vs Semaphore — phân biệt

Mutex

  • Lock primitive — chỉ 1 thread giữ
  • ownership: thread lock phải unlock
  • Dùng cho: bảo vệ critical section

Semaphore

  • Counter — nhiều thread có thể qua nếu N>0
  • Không có ownership: thread nào cũng V() được
  • Dùng cho: signaling, resource pool, producer-consumer

Quy tắc: "Mutex là cái bạn cầm để vào phòng. Semaphore là số chỗ trống trong bãi đỗ xe."

6.5 Monitor — abstraction cấp cao hơn

Monitor đóng gói data + procedure + lock vào 1 đơn vị. Chỉ 1 thread chạy procedure tại 1 thời điểm. Java dùng monitor qua synchronized keyword.

// Java — synchronized = monitor
class Counter {
    private int count = 0;

    public synchronized void increment() {  // 1 thread tại 1 lúc
        count++;
    }

    public synchronized int get() {
        return count;
    }
}

Monitor có condition variable để thread đợi điều kiện:

class BoundedBuffer {
    private final Queue<Integer> q = new LinkedList<>();
    private final int capacity = 10;

    public synchronized void put(int x) throws InterruptedException {
        while (q.size() == capacity) wait();  // đợi có chỗ
        q.add(x);
        notifyAll();  // báo cho consumer
    }

    public synchronized int take() throws InterruptedException {
        while (q.isEmpty()) wait();
        int x = q.poll();
        notifyAll();
        return x;
    }
}

JS không có monitor built-in, nhưng async/await + Promise tương tự về tinh thần.

6.6 Spinlock — busy waiting

Spinlock: thay vì block (đợi OS đánh thức), thread vào loop liên tục check lock có free chưa. "Spin" = quay tròn.

// Pseudo-code spinlock đơn giản
typedef volatile int spinlock_t;

void lock(spinlock_t *l) {
    while (__sync_lock_test_and_set(l, 1)) {
        // busy wait
    }
}

void unlock(spinlock_t *l) {
    __sync_lock_release(l);
}

Khi nào dùng spinlock?

  • Đoạn CS rất ngắn (vài instruction): cost của block + wakeup > cost của spin vài chục ns
  • Trong kernel: nơi không thể block (vd interrupt handler)
  • Multi-core: chỉ có ý nghĩa khi nhiều CPU — single-core spinlock = lãng phí

Đừng dùng spinlock trong user code thường — block (mutex) tốt hơn 99% trường hợp.

6.7 Read/Write Lock — nhiều reader, một writer

Khi data thường được đọc (vd cache), nhiều reader đọc đồng thời không vấn đề. Chỉ writer mới phải exclusive.

pthread_rwlock_t lock;
pthread_rwlock_init(&lock, NULL);

// Reader: nhiều reader có thể đồng thời
void* reader(void* arg) {
    pthread_rwlock_rdlock(&lock);
    // ... đọc data ...
    pthread_rwlock_unlock(&lock);
}

// Writer: exclusive
void* writer(void* arg) {
    pthread_rwlock_wrlock(&lock);
    // ... ghi data ...
    pthread_rwlock_unlock(&lock);
}

Tốt cho: cache, in-memory database, config object đọc nhiều ghi ít.

6.8 Bài kinh điển 1: Producer-Consumer (Bounded Buffer)

Bài toán: 1 producer sinh data đẩy vào buffer kích thước N. 1 consumer lấy data từ buffer ra xử lý.

  • Buffer đầy → producer phải đợi
  • Buffer rỗng → consumer phải đợi
  • Đảm bảo mutual exclusion khi truy cập buffer
#define N 10
int buffer[N];
int in = 0, out = 0;

sem_t empty;   // số slot trống — init = N
sem_t full;    // số slot đầy — init = 0
pthread_mutex_t mutex;

void* producer(void* arg) {
    while (1) {
        int item = produce();
        sem_wait(&empty);          // đợi có slot trống
        pthread_mutex_lock(&mutex);
        buffer[in] = item;
        in = (in + 1) % N;
        pthread_mutex_unlock(&mutex);
        sem_post(&full);           // báo có slot đầy
    }
}

void* consumer(void* arg) {
    while (1) {
        sem_wait(&full);           // đợi có data
        pthread_mutex_lock(&mutex);
        int item = buffer[out];
        out = (out + 1) % N;
        pthread_mutex_unlock(&mutex);
        sem_post(&empty);          // báo có slot trống
        consume(item);
    }
}

Ý tưởng: 2 semaphore empty/full đảm bảo waiting condition; mutex bảo vệ thao tác trên buffer.

Đây là pattern phổ biến: thread pool, message queue, stream processing — đều có producer-consumer.

6.9 Bài kinh điển 2: Reader-Writer Problem

Nhiều reader đọc shared data. Writer thỉnh thoảng update. Yêu cầu:

  • Nhiều reader có thể đọc đồng thời
  • Khi writer ghi, không ai khác (reader/writer) được vào

Có 2 biến thể: reader-preferred (writer có thể starve), writer-preferred (reader có thể starve). RWLock thường implement writer-preferred để fair.

// Reader-preferred (đơn giản, có thể starve writer)
int readers = 0;
pthread_mutex_t mutex;     // bảo vệ readers
pthread_mutex_t writelock; // exclusive cho writer

void* reader(void* arg) {
    pthread_mutex_lock(&mutex);
    readers++;
    if (readers == 1) pthread_mutex_lock(&writelock);  // chặn writer
    pthread_mutex_unlock(&mutex);

    // đọc data ...

    pthread_mutex_lock(&mutex);
    readers--;
    if (readers == 0) pthread_mutex_unlock(&writelock);
    pthread_mutex_unlock(&mutex);
}

void* writer(void* arg) {
    pthread_mutex_lock(&writelock);
    // ghi data ...
    pthread_mutex_unlock(&writelock);
}

6.10 Bài kinh điển 3: Dining Philosophers

5 triết gia ngồi quanh bàn tròn. Giữa mỗi 2 triết gia có 1 đôi đũa (5 đũa total). Triết gia chỉ ăn khi cầm cả 2 đũa bên trái và bên phải.

P0 🥢 🥢 / \ P4 P1 \ / 🥢 🥢 P3 — P2 🥢

Naïve solution dễ dẫn đến deadlock:

void philosopher(int i) {
    while (1) {
        think();
        pick_up(left[i]);   // BUG: nếu cả 5 cùng pick left → deadlock!
        pick_up(right[i]);
        eat();
        put_down(right[i]);
        put_down(left[i]);
    }
}

Giải pháp 1: chỉ cho phép tối đa 4 trong bàn (semaphore)

sem_t seats; sem_init(&seats, 0, 4);  // 4 ghế trên 5 chỗ

void philosopher(int i) {
    while (1) {
        think();
        sem_wait(&seats);      // đợi có ghế
        pick_up(left[i]);
        pick_up(right[i]);
        eat();
        put_down(right[i]);
        put_down(left[i]);
        sem_post(&seats);
    }
}

Giải pháp 2: lock ordering

Triết gia chẵn pick left trước, lẻ pick right trước → không thể tất cả "cầm left chờ right".

Giải pháp 3: chỉ pick khi cả 2 đũa đều free (atomic check)

Cần 1 mutex global hoặc transaction. Phổ biến trong DB (commit only if both rows lockable).

Bài toán này demo: locking 2+ resource dễ gây deadlock; cần kỹ thuật phòng tránh (chương 7).

6.11 Atomic Operations — không cần lock

CPU hiện đại có instruction atomic: Test-And-Set (TAS), Compare-And-Swap (CAS), Fetch-And-Add. Đây là cơ sở của mutex/semaphore.

Compare-And-Swap (CAS)

// Pseudo: atomic, kernel guarantees no other thread interrupts
bool CAS(int *addr, int expected, int new_val) {
    if (*addr == expected) {
        *addr = new_val;
        return true;
    }
    return false;
}

CAS là vũ khí của lock-free programming. Vd: lock-free counter:

void atomic_inc(int *counter) {
    int old, new_val;
    do {
        old = *counter;
        new_val = old + 1;
    } while (!CAS(counter, old, new_val));
}

C11 / C++11 Atomic

#include <stdatomic.h>
atomic_int counter = 0;
atomic_fetch_add(&counter, 1);     // counter++ atomic
atomic_compare_exchange(&counter, &expected, new_val);

JS Atomics (cho SharedArrayBuffer)

const sab = new SharedArrayBuffer(4);
const view = new Int32Array(sab);
Atomics.add(view, 0, 1);          // atomic counter++
Atomics.compareExchange(view, 0, expected, newVal);
📐 Lock-free vs Lock-based
Lock-free dùng atomic op, không có lock → không deadlock. Nhưng phức tạp gấp 10× lock-based. Production thực tế: chỉ những thư viện cốt lõi (queue, hash map của DB high-perf) mới làm lock-free. App thông thường vẫn dùng mutex.

Bài tập

Bài 1 — Demo race condition

Viết C/Java/Go program với 2 thread cùng counter++ 1 triệu lần. Quan sát kết quả không phải 2M. Sau đó sửa bằng mutex.

Bài 2 — Mutex vs Semaphore

Khi nào nên Mutex, khi nào Semaphore? Cho 3 ví dụ thực tế cho mỗi cái.

Bài 3 — Producer-Consumer trong Node.js

Cài đặt Producer-Consumer dùng async/await và 1 array làm buffer. Producer push 100 item, consumer xử lý từng item với delay 100ms. Limit buffer = 10.

Bài 4 — Reader-Writer với pthread_rwlock

Cài đặt 1 cache đơn giản (hash map int → int) thread-safe dùng pthread_rwlock. Test với 10 reader thread + 2 writer thread.

Bài 5 — Dining Philosophers

Cài đặt 1 trong 3 giải pháp (semaphore seat, ordering, hoặc atomic). Test 10 phút không deadlock.

Bài 6 — Atomic counter trong JS

Dùng SharedArrayBuffer + Atomics + Worker Threads cài atomic counter shared giữa main và 4 worker. Đảm bảo +1 mỗi worker 1M lần → final = 4M.

🧪 Quiz cuối chương

Câu 1. Tại sao counter++ trong multithread không an toàn?

  • Vì C bị bug
  • Vì compiler không tối ưu
  • counter++ là 3 instruction (load/add/store), không atomic — 2 thread đan xen → lost update
  • Vì memory không đủ

Đáp án: 3 instruction. Race condition kinh điển. Cần mutex hoặc atomic operation.

Câu 2. Mutex và Semaphore khác nhau ở đâu?

  • Mutex có ownership (thread lock phải unlock); Semaphore không có ownership, chỉ là counter
  • Mutex chậm hơn
  • Semaphore chỉ trên Linux
  • Cả hai giống nhau

Đáp án: Mutex có ownership. Mutex = cái khoá; Semaphore = counter cho phép N thread.

Câu 3. Spinlock nên dùng khi?

  • Critical section dài
  • Mọi tình huống
  • CS rất ngắn (vài instruction) trên multi-core
  • Single-core CPU

Đáp án: CS ngắn + multi-core. Spin tốn CPU; chỉ đáng khi cost block + wakeup > cost spin vài ns.

Câu 4. Trong bài Producer-Consumer, vai trò của 2 semaphore emptyfull?

  • empty đếm slot trống cho producer; full đếm slot có data cho consumer — tự đồng bộ buffer đầy/rỗng
  • Cả hai đếm số process
  • Chỉ để đếm thread
  • Không cần thiết

Đáp án: empty đếm slot trống, full đếm data. Đảm bảo producer block khi đầy, consumer block khi rỗng.

Câu 5. Read/Write lock cho phép gì?

  • Chỉ 1 reader và 1 writer cùng lúc
  • Nhiều reader đồng thời, hoặc 1 writer độc quyền
  • Mọi thread đọc/ghi cùng lúc
  • Không cho ai cả

Đáp án: nhiều reader hoặc 1 writer. Tốt cho data đọc nhiều ghi ít — cache, config.

Câu 6. CAS (Compare-And-Swap) là?

  • Một thuật toán sort
  • Một loại lock
  • Atomic instruction CPU: nếu giá trị = expected thì set new; cơ sở của lock-free programming
  • Một loại memory

Đáp án: atomic instruction. CAS dùng để implement mutex, atomic counter, lock-free queue.

Câu 7. Trong Dining Philosophers, deadlock xảy ra khi?

  • Có quá nhiều triết gia
  • Đũa quá ít
  • Tất cả triết gia đồng thời cầm đũa trái rồi cùng đợi đũa phải — không ai có 2 đũa
  • Triết gia ngủ quá lâu

Đáp án: tất cả pick left, đợi right. Cả 5 đợi nhau → vòng tròn chờ đợi → deadlock kinh điển.

Câu 8. Java synchronized tương đương với?

  • Monitor
  • Spinlock
  • Atomic operation
  • Read/Write lock

Đáp án: Monitor. Java implement monitor qua keyword synchronized + wait/notify.

Tổng kết chương 6

  • ✅ Race condition: 2+ thread cùng đọc/ghi shared data → kết quả phụ thuộc thứ tự
  • ✅ Critical Section cần Mutual Exclusion + Progress + Bounded Waiting
  • Mutex: lock có ownership; Semaphore: counter; Monitor: high-level (Java synchronized)
  • Spinlock: busy wait, dùng cho CS ngắn trên multi-core
  • Read/Write lock: nhiều reader hoặc 1 writer
  • Producer-Consumer: 2 semaphore + mutex; pattern phổ biến nhất
  • Reader-Writer: cho cache, config
  • Dining Philosophers: bài học về deadlock với multi-resource lock
  • Atomic operation (CAS): cơ sở của lock-free programming
← Chương trước Chương 05: IPC Chương kế tiếp Chương 07: Deadlock →