Part 01 · Low Level Design

Concurrency in LLD: Locks and Thread Safety

Race conditionsLock placementImmutabilityProducer-consumerThread pool

দুইটা 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 এ দুইটা bookingCheck আর লেখা আলাদা ধাপে, দুটো ঘিরে কোনো lock নেই
একটা counter যেটা যত হওয়ার কথা তার চেয়ে কমcount++ আসলে পড়া, যোগ, লেখা, আর thread গুলো মাঝখানে ঢুকে পড়ে
একটা map এ এলোমেলো crashThread-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)।

bounded queue: 5 slotsproducerorders 1..20worker 1worker 2worker 3worker 4#1#2#3#4#5#6#7#8#9#5queue full: the producer waits instead of piling up workworker 1 took #5, so the producer can add more
Play চাপো। Order গুলো একজন producer থেকে ৫ slot এর একটা queue হয়ে চারজন worker এর কাছে যায়। সব worker ব্যস্ত আর queue ভরা হলে producer কাজ জমাতে না থেকে অপেক্ষা করে; worker 1 #5 নেওয়ার সাথে সাথেই producer আবার যোগ করতে পারে।

তবে একটা জায়গায় মেলে না। বাথরুমের বাইরে অপেক্ষা করা মানুষ টের পায় ভেতরে কেউ অজ্ঞান হয়ে গেছে কিনা। Lock এর জন্য অপেক্ষা করা thread চিরকাল অপেক্ষা করবে যদি lock ধরে রাখা জন কখনো না ছাড়ে (Lock আর Unlock এর মাঝে crash, বা deadlock)। এজন্যই আসল code lock ছাড়ে defer, with, finally বা RAII দিয়ে।

আগে অনুমান করো

Book(seat) করে: lock; seat খালি হলে { দখল মার্ক করো }; unlock। আটটা thread প্রত্যেকে seat ১ থেকে ১০ চেষ্টা করে। কয়টা booking সফল হবে?

Race, lock আর নিরাপদে হাতবদল

  1. Race condition হয় যখন ফলাফল thread গুলোর timing এর উপর নির্ভর করে। চেনা রূপ হলো check-then-act ("খালি হলে নাও") বা read-modify-write (count++), যেখানে মাঝখানে আরেকটা thread চলে আসতে পারে।
  2. Critical section হলো সেই code যেটা একা চলতে হবে। Lock কোথায় বসবে মানে এর সীমানা আঁকা: check আর কাজ দুটোই একই critical section এর ভেতরে থাকতে হবে। শুধু লেখার অংশে lock, বা check আর লেখার জন্য আলাদা দুইটা lock দিলে race থেকেই যায়।
  3. Critical section ছোট রাখো, আর lock ধরে রেখে কখনো ধীর I/O (network call, disk) কোরো না। ধীর কাজটা বাইরে করো, তারপর অল্প সময়ের জন্য lock নিয়ে shared state update করো।
  4. Immutability তে lock লাগে না। যে object বানানোর পরে আর কখনো বদলায় না (একটা config এর snapshot, একটা দামের list, একটা Money value), সেটা যত thread খুশি নিরাপদে পড়তে পারে। "বদলাতে" হলে নতুন একটা বানাও আর একটা reference বদলে দাও।
  5. Producer-consumer: object share না করে একটা thread-safe queue দিয়ে পাঠাও। Producer কাজ ঢোকায়, consumer বের করে। Bounded queue backpressure দেয়: consumer পিছিয়ে পড়লে producer ধীর হয়, memory সারাক্ষণ বাড়তে থাকে না।
  6. Thread pool: fixed কয়টা worker thread, যারা একটা queue থেকে কাজ নেয়। একসাথে কয়টা চলবে সেটা তুমি ঠিক করো (৪টা worker, ৪,০০০ thread না), আর প্রতিটা কাজের জন্য নতুন thread না বানিয়ে thread গুলো আবার ব্যবহার হয়।
  7. Deadlock: thread A ধরে আছে lock 1 আর অপেক্ষা করছে lock 2 এর জন্য; thread B ধরে আছে lock 2 আর অপেক্ষা করছে lock 1 এর জন্য। এড়াতে সবসময় একই নির্দিষ্ট ক্রমে lock নাও, বা একবারে একটাই lock ধরো।

দুইটা thread, একটা seat

ধাপে ধাপে দেখো

Counter A আর counter B দুজনেই seat 7 book করছে

Counter AHall lockSeat mapCounter B

ধাপ 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 আছে?

প্রতিটা case চালিয়ে দেখো

এটা কি thread-safe?

lock; if !taken[s] { taken[s] = me }; unlock
কাজ করে: নিরাপদ। পুরো check-then-act একটা critical section।

Code এ 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)
}

ছয়টাই হুবহু এটা 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, আর Arc thread গুলোর মধ্যে মালিকানা share করে। Go bounded queue হিসেবে ৫ capacity র একটা channel ব্যবহার করে, আর channel বন্ধ করলে worker রা থামে। Python এর queue.Queue(maxsize=5) ভরা থাকলে put এ আটকে থাকে; GIL count += 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 করে

প্রতিটা case চালিয়ে দেখো

Concurrency র bug টা বাছো

A: lock(acc1); lock(acc2)   B: lock(acc2); lock(acc1)
ভাঙে: Deadlock: প্রত্যেকে একটা lock ধরে আরেকটার জন্য চিরকাল অপেক্ষা করে। সবসময় একটা নির্দিষ্ট ক্রমে lock নাও (যেমন account ID ধরে)।

একটু থেমে ভাবো

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 পাও →