Part 01 · Low Level Design
Concurrency in LLD: Locks and Thread Safety
দুইটা thread, একটা object: lock কোথায় বসবে, যে object এর lock লাগেই না, queue দিয়ে producer-consumer, আর thread pool। এখানে শুধু এক machine এর concurrency, distributed টা পরে আসবে।
⏱ 2-3 ঘণ্টা · Station 12 / 80
A-7 seat এর দুইটা ticket
ঢাকার একটা cinema একই সময়ে counter আর online দুই জায়গাতেই ticket বিক্রি করে। দুইটাই একই code চালায়: "A-7 seat খালি থাকলে দখল মার্ক করো আর ticket print করো"। এক শুক্রবার সন্ধ্যায় দুইজনের হাতে A-7 এর ticket।
Rule এ কোনো ভুল নেই। দুইটা thread প্রায় একই মুহূর্তে check করেছে "খালি আছে?", দুজনেই দেখেছে হ্যাঁ, আর দুজনেই লিখেছে দখল। Bug টা থাকে check আর লেখার মাঝের ফাঁকে।
এই chapter একটা program এর ভেতরের concurrency নিয়ে: কয়েকটা thread (বা goroutine, বা async task) object share করছে। Race কীভাবে হয়, lock কোথায় বসে, কীভাবে lock ছাড়াই চলা যায়, আর কীভাবে একদল worker এর হাতে নিরাপদে কাজ তুলে দেওয়া যায়। অনেক machine জুড়ে concurrency (distributed lock, consensus) আলাদা একটা সমস্যা, সেটা HLD part এ।
সমস্যা: shared state আর একসাথে চলা কাজ
| যা দেখা যায় | কারণ |
|---|---|
| এক seat এ দুইটা booking | Check আর লেখা আলাদা ধাপে, দুটো ঘিরে কোনো lock নেই |
| একটা counter যেটা যত হওয়ার কথা তার চেয়ে কম | count++ আসলে পড়া, যোগ, লেখা, আর thread গুলো মাঝখানে ঢুকে পড়ে |
| একটা map এ এলোমেলো crash | Thread-safe না এমন একটা map এ একসাথে দুইটা thread লিখছে |
| App চিরকালের জন্য আটকে যায় | দুইটা lock দুই রকম ক্রমে নেওয়া (deadlock) |
| Process মরে যাওয়া পর্যন্ত memory বাড়ে | কাজ তৈরি হচ্ছে খরচের চেয়ে দ্রুত, একটা সীমাহীন queue তে |
এই chapter এর code এর shared counter টা আমরা lock ছাড়া চালিয়েছি: ৪টা goroutine × ১০,০০০ বার যোগ, আশা ৪০,০০০। তিনবারে print হলো ৪০,০০০, ২৬,২২৪ আর ২৮,১৮৫। একবার ঠিক, ভাগ্যের জোরে। Race এর বিপদ এটাই: test প্রায়ই pass করে যায়।
অফিসের একটাই বাথরুমের চাবির কথা ভাবো
একটা অফিসে একটাই বাথরুম, আর একটা চাবি হুকে ঝোলানো। যে চাবি নেয় সে ঢোকে; বাকিরা অপেক্ষা করে। চাবিটা "দরজা দেখো, ঢোকো, তালা দাও" কে একটা ধাপ বানিয়ে দেয়, তাই দুজন একসাথে ভাবতে পারে না যে রুম খালি। এটা mutex (mutual exclusion)।
আবার একটা restaurant এর রান্নাঘরের কথা ভাবো: waiter রা একটা রেলে order এর slip গাঁথে, যেখানে পাঁচটা slip এর জায়গা, আর চারজন রাঁধুনি ফাঁকা হলেই পরের slip টা নেয়। রেল ভরা থাকলে waiter জায়গা না হওয়া পর্যন্ত slip হাতে রাখে। এটা worker pool সহ একটা bounded queue (producer-consumer)।
তবে একটা জায়গায় মেলে না। বাথরুমের বাইরে অপেক্ষা করা মানুষ টের পায় ভেতরে কেউ অজ্ঞান হয়ে গেছে কিনা। Lock এর জন্য অপেক্ষা করা thread চিরকাল অপেক্ষা করবে যদি lock ধরে রাখা জন কখনো না ছাড়ে (Lock আর Unlock এর মাঝে crash, বা deadlock)। এজন্যই আসল code lock ছাড়ে defer, with, finally বা RAII দিয়ে।
Book(seat) করে: lock; seat খালি হলে { দখল মার্ক করো }; unlock। আটটা thread প্রত্যেকে seat ১ থেকে ১০ চেষ্টা করে। কয়টা booking সফল হবে?
Race, lock আর নিরাপদে হাতবদল
- Race condition হয় যখন ফলাফল thread গুলোর timing এর উপর নির্ভর করে। চেনা রূপ হলো check-then-act ("খালি হলে নাও") বা read-modify-write (
count++), যেখানে মাঝখানে আরেকটা thread চলে আসতে পারে। - Critical section হলো সেই code যেটা একা চলতে হবে। Lock কোথায় বসবে মানে এর সীমানা আঁকা: check আর কাজ দুটোই একই critical section এর ভেতরে থাকতে হবে। শুধু লেখার অংশে lock, বা check আর লেখার জন্য আলাদা দুইটা lock দিলে race থেকেই যায়।
- Critical section ছোট রাখো, আর lock ধরে রেখে কখনো ধীর I/O (network call, disk) কোরো না। ধীর কাজটা বাইরে করো, তারপর অল্প সময়ের জন্য lock নিয়ে shared state update করো।
- Immutability তে lock লাগে না। যে object বানানোর পরে আর কখনো বদলায় না (একটা config এর snapshot, একটা দামের list, একটা
Moneyvalue), সেটা যত thread খুশি নিরাপদে পড়তে পারে। "বদলাতে" হলে নতুন একটা বানাও আর একটা reference বদলে দাও। - Producer-consumer: object share না করে একটা thread-safe queue দিয়ে পাঠাও। Producer কাজ ঢোকায়, consumer বের করে। Bounded queue backpressure দেয়: consumer পিছিয়ে পড়লে producer ধীর হয়, memory সারাক্ষণ বাড়তে থাকে না।
- Thread pool: fixed কয়টা worker thread, যারা একটা queue থেকে কাজ নেয়। একসাথে কয়টা চলবে সেটা তুমি ঠিক করো (৪টা worker, ৪,০০০ thread না), আর প্রতিটা কাজের জন্য নতুন thread না বানিয়ে thread গুলো আবার ব্যবহার হয়।
- Deadlock: thread A ধরে আছে lock 1 আর অপেক্ষা করছে lock 2 এর জন্য; thread B ধরে আছে lock 2 আর অপেক্ষা করছে lock 1 এর জন্য। এড়াতে সবসময় একই নির্দিষ্ট ক্রমে lock নাও, বা একবারে একটাই lock ধরো।
দুইটা thread, একটা seat
Counter A আর counter B দুজনেই seat 7 book করছে
ধাপ 1/5 A আগে আসে আর lock নেয়
Counter A Book(7) ডাকে। Lock পায়। এক microsecond পরে counter B ও Book(7) ডাকে, আর তাকে অপেক্ষা করতে হয়।
- lock held by
- A
- B
- waiting
Shared data র জন্য তিনটা প্রশ্ন
একাধিক thread যে data তে পৌঁছাতে পারে, তার প্রতিটার জন্য জিজ্ঞেস করো:
- এটা কি immutable বানানো যায়? তাহলে কোনো lock লাগে না।
- Share করা কি বন্ধ করা যায়? প্রতিটা thread কে নিজের copy দাও, বা queue দিয়ে পাঠাও যাতে একবারে একটাই thread এর মালিক থাকে।
- Share করতেই হলে আর বদলাতেই হলে: কোন lock এটাকে রক্ষা করে, আর সেই lock কি পুরো check-then-act টা ঢেকে রাখে?
নিজে বলো: নিরাপদ না race আছে?
এটা কি thread-safe?
lock; if !taken[s] { taken[s] = me }; unlockCode এ seat, worker pool আর counter
আটটা thread একটা lock দিয়ে দশটা seat book করছে, একজন producer ৫ এর একটা bounded queue দিয়ে ৪ জন worker কে কাজ দিচ্ছে, আর একটা shared counter একটা lock দিয়ে রক্ষা করা। প্রতিবার output একই আসে, কারণ প্রতিটা shared লেখা synchronised, আর মোট হিসাব ক্রমের উপর নির্ভর করে না।
package main
import (
"fmt"
"sync"
)
// ---- Lock placement: the check ("is it free?") and the write ("mark it taken") must be
// inside the SAME critical section, or two threads can both see "free".
type Hall struct {
mu sync.Mutex
seats map[int]string // seat -> who booked it
}
func (h *Hall) Book(seat int, who string) bool {
h.mu.Lock()
defer h.mu.Unlock()
if _, taken := h.seats[seat]; taken {
return false
}
h.seats[seat] = who
return true
}
func main() {
// 1. Eight booking counters race for the same ten seats.
hall := &Hall{seats: map[int]string{}}
var wg sync.WaitGroup
var countMu sync.Mutex
booked, refused := 0, 0
for w := 1; w <= 8; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for seat := 1; seat <= 10; seat++ {
ok := hall.Book(seat, fmt.Sprintf("counter-%d", w))
countMu.Lock()
if ok {
booked++
} else {
refused++
}
countMu.Unlock()
}
}()
}
wg.Wait()
fmt.Printf("seats: %d booked, %d requests refused, %d seats with an owner\n", booked, refused, len(hall.seats))
// 2. Producer-consumer: one producer, a bounded queue of 5, a pool of 4 workers.
jobs := make(chan int, 5) // bounded: the producer waits when 5 orders are queued
results := make(chan int)
var workers sync.WaitGroup
for range 4 {
workers.Add(1)
go func() {
defer workers.Done()
for order := range jobs {
results <- order * 10 // "price" each order
}
}()
}
go func() {
for order := 1; order <= 20; order++ {
jobs <- order
}
close(jobs) // no more work: workers finish their loops
}()
go func() { workers.Wait(); close(results) }()
count, total := 0, 0
for r := range results {
count++
total += r
}
fmt.Printf("orders: %d processed by 4 workers through a queue of 5, total %d tk\n", count, total)
// 3. A shared counter: 4 threads x 10,000 increments, protected by a lock.
var mu sync.Mutex
counter := 0
var cw sync.WaitGroup
for range 4 {
cw.Add(1)
go func() {
defer cw.Done()
for range 10000 {
mu.Lock()
counter++
mu.Unlock()
}
}()
}
cw.Wait()
fmt.Printf("counter with a lock: %d (expected %d)\n", counter, 4*10000)
}import queue
import threading
class Hall:
"""The check ("is it free?") and the write ("mark it taken") share one lock."""
def __init__(self) -> None:
self._lock = threading.Lock()
self.seats: dict[int, str] = {}
def book(self, seat: int, who: str) -> bool:
with self._lock:
if seat in self.seats:
return False
self.seats[seat] = who
return True
# 1. Eight booking counters race for the same ten seats.
hall = Hall()
count_lock = threading.Lock()
counts = {"booked": 0, "refused": 0}
def counter_desk(w: int) -> None:
for seat in range(1, 11):
ok = hall.book(seat, f"counter-{w}")
with count_lock:
counts["booked" if ok else "refused"] += 1
desks = [threading.Thread(target=counter_desk, args=(w,)) for w in range(1, 9)]
for t in desks:
t.start()
for t in desks:
t.join()
print(f"seats: {counts['booked']} booked, {counts['refused']} requests refused, {len(hall.seats)} seats with an owner")
# 2. Producer-consumer: one producer, a bounded queue of 5, a pool of 4 workers.
jobs: "queue.Queue[int | None]" = queue.Queue(maxsize=5) # put() waits when 5 are queued
results: "queue.Queue[int]" = queue.Queue()
def worker() -> None:
while (order := jobs.get()) is not None:
results.put(order * 10) # "price" each order
pool = [threading.Thread(target=worker) for _ in range(4)]
for t in pool:
t.start()
for order in range(1, 21):
jobs.put(order)
for _ in pool:
jobs.put(None) # one "stop" per worker
for t in pool:
t.join()
done = [results.get() for _ in range(results.qsize())]
print(f"orders: {len(done)} processed by 4 workers through a queue of 5, total {sum(done)} tk")
# 3. A shared counter: 4 threads x 10,000 increments, protected by a lock.
lock = threading.Lock()
counter = 0
def bump() -> None:
global counter
for _ in range(10000):
with lock:
counter += 1
threads = [threading.Thread(target=bump) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
print(f"counter with a lock: {counter} (expected {4 * 10000})")// JavaScript runs this code on one thread. Concurrency comes from async tasks that take
// turns at every `await`, so a race can only happen across an await, never inside sync code.
class Hall {
readonly seats = new Map<number, string>();
// No await between the check and the write, so no other task can run in between.
book(seat: number, who: string): boolean {
if (this.seats.has(seat)) return false;
this.seats.set(seat, who);
return true;
}
}
// A bounded async queue: put() waits while it's full, take() waits while it's empty.
class BoundedQueue<T> {
readonly #items: T[] = [];
readonly #capacity: number;
#waitingTakers: ((v: T) => void)[] = [];
#waitingPutters: (() => void)[] = [];
constructor(capacity: number) {
this.#capacity = capacity;
}
async put(v: T): Promise<void> {
while (this.#items.length >= this.#capacity) await new Promise<void>((r) => this.#waitingPutters.push(r));
const taker = this.#waitingTakers.shift();
if (taker) taker(v);
else this.#items.push(v);
}
async take(): Promise<T> {
const v = this.#items.shift();
if (v !== undefined) {
this.#waitingPutters.shift()?.();
return v;
}
return new Promise<T>((r) => this.#waitingTakers.push(r));
}
}
const tick = () => new Promise<void>((r) => setTimeout(r, 0));
async function main(): Promise<void> {
// 1. Eight booking counters race for the same ten seats.
const hall = new Hall();
let booked = 0;
let refused = 0;
await Promise.all(
Array.from({ length: 8 }, async (_, i) => {
for (let seat = 1; seat <= 10; seat++) {
await tick(); // let other counters run between requests
if (hall.book(seat, `counter-${i + 1}`)) booked++;
else refused++;
}
}),
);
console.log(`seats: ${booked} booked, ${refused} requests refused, ${hall.seats.size} seats with an owner`);
// 2. Producer-consumer: one producer, a bounded queue of 5, a pool of 4 workers.
const jobs = new BoundedQueue<number | null>(5);
const results: number[] = [];
const workers = Array.from({ length: 4 }, async () => {
for (let order = await jobs.take(); order !== null; order = await jobs.take()) {
results.push(order * 10); // "price" each order
}
});
for (let order = 1; order <= 20; order++) await jobs.put(order);
for (let i = 0; i < 4; i++) await jobs.put(null); // one "stop" per worker
await Promise.all(workers);
const total = results.reduce((a, b) => a + b, 0);
console.log(`orders: ${results.length} processed by 4 workers through a queue of 5, total ${total} tk`);
// 3. A shared counter: 4 tasks x 10,000 increments. counter++ has no await inside it, so the
// single thread itself acts as the lock (the label below matches the other languages).
let counter = 0;
await Promise.all(
Array.from({ length: 4 }, async () => {
for (let i = 0; i < 10000; i++) {
counter++;
if (i % 1000 === 0) await tick();
}
}),
);
console.log(`counter with a lock: ${counter} (expected ${4 * 10000})`);
}
void main();use std::collections::HashMap;
use std::sync::mpsc;
use std::sync::{Arc, Mutex};
use std::thread;
// The check ("is it free?") and the write ("mark it taken") happen under one lock.
// In Rust the map lives INSIDE the Mutex, so it can't be touched without locking.
struct Hall { seats: Mutex<HashMap<u32, String>> }
impl Hall {
fn book(&self, seat: u32, who: &str) -> bool {
let mut seats = self.seats.lock().unwrap();
if seats.contains_key(&seat) { return false; }
seats.insert(seat, who.to_string());
true
}
}
fn main() {
// 1. Eight booking counters race for the same ten seats.
let hall = Arc::new(Hall { seats: Mutex::new(HashMap::new()) });
let counts = Arc::new(Mutex::new((0u32, 0u32))); // (booked, refused)
let desks: Vec<_> = (1..=8).map(|w| {
let (hall, counts) = (Arc::clone(&hall), Arc::clone(&counts));
thread::spawn(move || {
for seat in 1..=10 {
let ok = hall.book(seat, &format!("counter-{w}"));
let mut c = counts.lock().unwrap();
if ok { c.0 += 1 } else { c.1 += 1 }
}
})
}).collect();
for d in desks { d.join().unwrap(); }
let (booked, refused) = *counts.lock().unwrap();
println!("seats: {booked} booked, {refused} requests refused, {} seats with an owner", hall.seats.lock().unwrap().len());
// 2. Producer-consumer: one producer, a bounded queue of 5, a pool of 4 workers.
let (job_tx, job_rx) = mpsc::sync_channel::<u32>(5); // send() blocks when 5 are queued
let job_rx = Arc::new(Mutex::new(job_rx)); // workers share one receiver
let (res_tx, res_rx) = mpsc::channel::<u32>();
let pool: Vec<_> = (0..4).map(|_| {
let (rx, tx) = (Arc::clone(&job_rx), res_tx.clone());
thread::spawn(move || loop {
let next = rx.lock().unwrap().recv();
match next {
Ok(order) => tx.send(order * 10).unwrap(), // "price" each order
Err(_) => break, // channel closed: no more work
}
})
}).collect();
drop(res_tx);
for order in 1..=20 { job_tx.send(order).unwrap(); }
drop(job_tx); // close the queue
for p in pool { p.join().unwrap(); }
let done: Vec<u32> = res_rx.iter().collect();
println!("orders: {} processed by 4 workers through a queue of 5, total {} tk", done.len(), done.iter().sum::<u32>());
// 3. A shared counter: 4 threads x 10,000 increments, protected by a lock.
let counter = Arc::new(Mutex::new(0u32));
let threads: Vec<_> = (0..4).map(|_| {
let counter = Arc::clone(&counter);
thread::spawn(move || for _ in 0..10000 { *counter.lock().unwrap() += 1; })
}).collect();
for t in threads { t.join().unwrap(); }
println!("counter with a lock: {} (expected {})", *counter.lock().unwrap(), 4 * 10000);
}import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
public class Main {
// The check ("is it free?") and the write ("mark it taken") share one lock.
static final class Hall {
private final Map<Integer, String> seats = new HashMap<>();
synchronized boolean book(int seat, String who) {
if (seats.containsKey(seat)) return false;
seats.put(seat, who);
return true;
}
synchronized int owned() { return seats.size(); }
}
private static int counter = 0;
private static final Object LOCK = new Object();
public static void main(String[] args) throws InterruptedException {
// 1. Eight booking counters race for the same ten seats.
Hall hall = new Hall();
AtomicInteger booked = new AtomicInteger(), refused = new AtomicInteger();
ExecutorService desks = Executors.newFixedThreadPool(8);
for (int w = 1; w <= 8; w++) {
final int id = w;
desks.submit(() -> {
for (int seat = 1; seat <= 10; seat++) {
if (hall.book(seat, "counter-" + id)) booked.incrementAndGet(); else refused.incrementAndGet();
}
});
}
desks.shutdown();
desks.awaitTermination(10, TimeUnit.SECONDS);
System.out.printf("seats: %d booked, %d requests refused, %d seats with an owner%n", booked.get(), refused.get(), hall.owned());
// 2. Producer-consumer: one producer, a bounded queue of 5, a pool of 4 workers.
BlockingQueue<Integer> jobs = new ArrayBlockingQueue<>(5); // put() waits when 5 are queued
AtomicInteger count = new AtomicInteger(), total = new AtomicInteger();
final int stop = -1; // one "stop" per worker
ExecutorService pool = Executors.newFixedThreadPool(4);
for (int i = 0; i < 4; i++) {
pool.submit(() -> {
try {
for (int order = jobs.take(); order != stop; order = jobs.take()) {
count.incrementAndGet();
total.addAndGet(order * 10); // "price" each order
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
for (int order = 1; order <= 20; order++) jobs.put(order);
for (int i = 0; i < 4; i++) jobs.put(stop);
pool.shutdown();
pool.awaitTermination(10, TimeUnit.SECONDS);
System.out.printf("orders: %d processed by 4 workers through a queue of 5, total %d tk%n", count.get(), total.get());
// 3. A shared counter: 4 threads x 10,000 increments, protected by a lock.
Thread[] threads = new Thread[4];
for (int i = 0; i < 4; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 10000; j++) synchronized (LOCK) { counter++; }
});
threads[i].start();
}
for (Thread t : threads) t.join();
System.out.printf("counter with a lock: %d (expected %d)%n", counter, 4 * 10000);
}
}#include <condition_variable>
#include <cstdio>
#include <map>
#include <mutex>
#include <optional>
#include <queue>
#include <string>
#include <thread>
#include <vector>
// The check ("is it free?") and the write ("mark it taken") share one lock.
class Hall {
public:
bool book(int seat, const std::string& who) {
std::lock_guard lock(mu_);
if (seats_.count(seat)) return false;
seats_[seat] = who;
return true;
}
size_t owned() {
std::lock_guard lock(mu_);
return seats_.size();
}
private:
std::mutex mu_;
std::map<int, std::string> seats_;
};
// A bounded queue: put() waits while full, take() waits while empty.
template <class T>
class BoundedQueue {
public:
explicit BoundedQueue(size_t cap) : cap_(cap) {}
void put(T v) {
std::unique_lock lock(mu_);
notFull_.wait(lock, [&] { return q_.size() < cap_; });
q_.push(std::move(v));
notEmpty_.notify_one();
}
T take() {
std::unique_lock lock(mu_);
notEmpty_.wait(lock, [&] { return !q_.empty(); });
T v = std::move(q_.front());
q_.pop();
notFull_.notify_one();
return v;
}
private:
size_t cap_;
std::mutex mu_;
std::condition_variable notFull_, notEmpty_;
std::queue<T> q_;
};
int main() {
// 1. Eight booking counters race for the same ten seats.
Hall hall;
std::mutex countMu;
int booked = 0, refused = 0;
std::vector<std::thread> desks;
for (int w = 1; w <= 8; w++) {
desks.emplace_back([&, w] {
for (int seat = 1; seat <= 10; seat++) {
bool ok = hall.book(seat, "counter-" + std::to_string(w));
std::lock_guard lock(countMu);
(ok ? booked : refused)++;
}
});
}
for (auto& d : desks) d.join();
std::printf("seats: %d booked, %d requests refused, %zu seats with an owner\n", booked, refused, hall.owned());
// 2. Producer-consumer: one producer, a bounded queue of 5, a pool of 4 workers.
BoundedQueue<std::optional<int>> jobs(5);
std::mutex resMu;
int count = 0, total = 0;
std::vector<std::thread> pool;
for (int i = 0; i < 4; i++) {
pool.emplace_back([&] {
while (auto order = jobs.take()) { // std::nullopt means "stop"
std::lock_guard lock(resMu);
count++;
total += *order * 10; // "price" each order
}
});
}
for (int order = 1; order <= 20; order++) jobs.put(order);
for (int i = 0; i < 4; i++) jobs.put(std::nullopt); // one "stop" per worker
for (auto& p : pool) p.join();
std::printf("orders: %d processed by 4 workers through a queue of 5, total %d tk\n", count, total);
// 3. A shared counter: 4 threads x 10,000 increments, protected by a lock.
std::mutex mu;
int counter = 0;
std::vector<std::thread> threads;
for (int i = 0; i < 4; i++)
threads.emplace_back([&] {
for (int j = 0; j < 10000; j++) {
std::lock_guard lock(mu);
counter++;
}
});
for (auto& t : threads) t.join();
std::printf("counter with a lock: %d (expected %d)\n", counter, 4 * 10000);
}ছয়টাই হুবহু এটা print করে:
seats: 10 booked, 70 requests refused, 10 seats with an owner
orders: 20 processed by 4 workers through a queue of 5, total 2100 tk
counter with a lock: 40000 (expected 40000)
Output টা পড়ি:
- seats: ৮টা counter × ১০টা seat = ৮০টা request। ঠিক ১০টা সফল, প্রতিটা seat এ একটা, আর ৭০টা ফিরিয়ে দেওয়া। কোন counter কোন seat জেতে, সেটা প্রতিবার বদলায়; মোট সংখ্যা বদলায় না।
- orders: ২০টা order এরই দাম হিসাব হয়েছে (১ + ২ + … + ২০ = ২১০, × ১০ = ২,১০০)। Queue তে কখনো ৫টার বেশি ছিল না, তাই memory না বেড়ে producer ধীর হয়েছে।
- counter: lock সহ প্রতিবার ৪০,০০০। Lock ছাড়া আমরা দেখেছি ২৬,২২৪ আর ২৮,১৮৫।
- Go version টা
go run -raceদিয়েও চালানো হয়েছে, মানে Go র race detector দিয়ে, আর কিছুই ধরা পড়েনি। - Language ভেদে কী আলাদা: Rust data টাকে
Mutexএর ভেতরে রাখে (Mutex<HashMap<…>>), তাই lock ছাড়া seat ছুঁতে গেলে compile error, আরArcthread গুলোর মধ্যে মালিকানা share করে। Go bounded queue হিসেবে ৫ capacity র একটা channel ব্যবহার করে, আর channel বন্ধ করলে worker রা থামে। Python এরqueue.Queue(maxsize=5)ভরা থাকলেputএ আটকে থাকে; GILcount += 1কে নিরাপদ বানায় না, তাই lock তবুও লাগে। Java ব্যবহার করেsynchronized,ArrayBlockingQueueআরExecutorService। C++ bounded queue বানায় একটাmutexআর দুইটাcondition_variableদিয়ে। TypeScript আলাদা: এটা চলে এক thread এ, তাইawaitছাড়া code মাঝখানে থামানো যায় না, আর "lock" মানে শুধুbookএর ভেতরে await না করা।
Production code এ concurrency
- Share না করাই ভালো। Data queue বা channel দিয়ে পাঠাও, বা প্রতিটা worker কে নিজের state দাও আর শেষে ফল মিলিয়ে নাও। Shared mutable state হলো শেষ উপায়।
- Standard tool ব্যবহার করো।
sync.Mutex,RWMutex(অনেক পাঠক, একজন লেখক), সাধারণ counter এর জন্য atomic, language এ ভালো থাকলে concurrent map। নিজের locking বানিও না। - Lock নিজে থেকে ছাড়ার ব্যবস্থা রাখো।
defer mu.Unlock(),with lock:,try/finally, RAII guard। Error এর রাস্তায় ভুলে যাওয়া একটা unlock মানে একটা hang অপেক্ষা করছে। - সবকিছুর সীমা দাও। Queue, worker এর সংখ্যা, connection pool। সীমাহীন কাজ মানে সীমাহীন memory।
- CI তে race detector চালাও। Go র
-race, C/C++ আর Rust এর জন্য ThreadSanitizer, আর এমন stress test যেটা concurrent অংশ টা বারবার চালায়। - Local lock কোথায় থামে জেনে রাখো। একটা
sync.Mutexএকটা process কে রক্ষা করে। দুইটা server একই seat book করলে লাগবে database (একটা unique constraint,SELECT … FOR UPDATE) বা একটা distributed lock: সেটা HLD এর এলাকা।
যে design গুলো পরে problem create করে
Concurrency র bug টা বাছো
A: lock(acc1); lock(acc2) B: lock(acc2); lock(acc1)
একটু থেমে ভাবো
Booking এর code এখন একটা load balancer এর পেছনে তিনটা server এ চলে, প্রত্যেকের নিজের Hall আর নিজের mutex। A-7 seat কি এখনো নিরাপদ?
উত্তর দেখো
না। প্রতিটা mutex শুধু নিজের process এর memory রক্ষা করে, আর প্রতিটা server এর seat map এর নিজের copy আছে। দুইটা server দুজনেই A-7 কে খালি দেখতে পারে। সত্যটা থাকতে হবে একটা shared জায়গায়: database এ। (show_id, seat) এর উপর একটা unique constraint দাও, যাতে দ্বিতীয় insert fail করে, বা UPDATE seats SET owner = ? WHERE id = ? AND owner IS NULL চালিয়ে দেখো একটা row বদলেছে কিনা। প্রতিটা server এর ভেতরে local lock তবুও কাজের, কিন্তু server গুলো জুড়ে ঠিক থাকা আসে shared store থেকে। এখান থেকেই HLD শুরু।
ছোট্ট quiz
১. Check আর লেখা কেন একই critical section এ থাকতে হবে?
উত্তর
না থাকলে মাঝখানে আরেকটা thread চলে আসতে পারে, একই "খালি" অবস্থা দেখে সেও কাজ করে ফেলে: দুজনেই seat নিয়ে নেয়।
২. Immutable object এর lock লাগে না কেন?
উত্তর
Race এর জন্য অন্তত একজন লেখক লাগে। Object যদি কখনো কেউ না বদলায়, প্রতিটা পাঠক সবসময় একই, পুরো value দেখে।
৩. সীমাহীন queue যা দেয় না, bounded queue তা কী দেয়?
উত্তর
Backpressure: consumer পিছিয়ে পড়লে producer memory না ভরিয়ে অপেক্ষা করে। একসাথে কতটুকু কাজ চলছে, তার একটা সীমাও থাকে।
৪. একটা transfer এ দুইটা account এর lock লাগলে deadlock কীভাবে এড়াবে?
উত্তর
সবসময় একই ক্রমে lock নাও (যেমন ছোট account ID আগে), নয়তো design বদলাও যাতে একবারে একটাই lock লাগে।
তোমার challenge: VIP সারি
Hall এ BookBest(who) (int, bool) যোগ করো: এটা সবচেয়ে ছোট নম্বরের খালি seat book করে সেটা ফেরত দেয়, আর hall ভরা থাকলে false। তারপর আগের race গুলোর পরে, ১০ seat এর একটা নতুন hall এ ১২টা thread চালাও, প্রত্যেকে একবার করে BookBest ডাকবে।
- সবচেয়ে ছোট খালি seat খোঁজা আর লেখা একটাই critical section এ থাকতে হবে।
চালানোর আগে আন্দাজ করো নতুন লাইনটা কী হবে।
হয়েছে বুঝবে যখন নতুন একটা লাইন print হবে best seats: 10 booked, 2 refused, seats 1-10 each have one owner, race detector (বা ThreadSanitizer) চুপ থাকবে, আর আগের তিনটা লাইন একই থাকবে।
এক কথায়
Race থাকে check আর কাজের মাঝের ফাঁকে। পুরো check-then-act একটা lock এর ভেতরে রাখো আর ছোট রাখো, share করার চেয়ে immutable data আর bounded queue দিয়ে কাজ পাঠানো বেশি পছন্দ করো, worker এর একটা নির্দিষ্ট pool ব্যবহার করো, আর মনে রেখো, process এর ভেতরের lock শুধু একটা process কেই রক্ষা করে।
Station stamp
Chapter শেষ করো
পড়া শেষ হলে Complete চাপো, progress save হবে। XP শুধু Pro তে — Pro থাকলে এই chapter এ +10 XP পেতে।
Pro নিয়ে XP পাও →