কনকারেন্সি হাতে-কলমে: থ্রেড A, থ্রেড B আর একটা শেয়ার্ড কাউন্টার
একসাথে অনেক কাজ চালানো সহজ। কঠিন হলো সেগুলো যখন একই ডেটা ছোঁয়। এই গাইডে প্রতিটা কনসেপ্ট বাংলায়, বাস্তব উদাহরণ আর ছোট কোডসহ ব্যাখ্যা করা হয়েছে। শেষে আছে ৫টা প্রোগ্রাম, যেগুলো নিজের হাতে বানিয়ে ভাঙতে হবে। শুরুটা নিচের ডেমো দিয়ে করো।
দুটো থ্রেড, প্রত্যেকে ৫ বার counter += 1
প্রতিটা += 1 আসলে তিনটা ধাপ: মান পড়া (R), এক যোগ করা (+1), লেখা (W)। শিডিউলার যেকোনো ধাপের মাঝে থ্রেড বদলাতে পারে। ফলাফল ১০ হওয়ার কথা।
প্রত্যাশিত
10
আসলে পেলাম
–
হারানো আপডেট
–
R 3 = শেয়ার্ড মান 3 পড়ল+1 = নিজের লোকাল কপিতে যোগW 4 = শেয়ার্ড মানে 4 লিখললাল W = পুরনো মান থেকে লিখেছে, অন্য থ্রেডের কাজ মুছে গেছে
কনকারেন্সি আসলে কী, প্যারালেলিজম থেকে এটা কোথায় আলাদা, আর অপারেটিং সিস্টেম কাজগুলোকে কীভাবে চালায়।
০১
কনকারেন্সি Concurrency
একই সময়-জানালায় অনেকগুলো কাজ এগিয়ে চলে, যদিও ঠিক একই মুহূর্তে সবগুলো চলছে না।
একটা CPU কোর এক মুহূর্তে একটাই ইনস্ট্রাকশন চালায়। কিন্তু অপারেটিং সিস্টেম কয়েক মিলিসেকেন্ড পর পর কাজ বদলায় (context switch)। এত দ্রুত বদলায় যে মনে হয় সব একসাথে চলছে।
এর আসল লাভ আসে অপেক্ষার সময়ে। একটা ওয়েব সার্ভার যখন ডেটাবেসের উত্তরের জন্য ৫০ms বসে থাকে, সেই সময়ে CPU অন্য রিকোয়েস্ট সামলাতে পারে। কনকারেন্সি ছাড়া প্রতিটা ইউজারকে আগের জনের কাজ শেষ হওয়া পর্যন্ত অপেক্ষা করতে হতো।
বাস্তব উদাহরণ
একজন রাঁধুনি, তিনটা চুলা। ভাত বসিয়ে দিয়ে পেঁয়াজ কাটছেন, তারপর ডালে ফোড়ন দিচ্ছেন। মানুষ একজনই, কিন্তু তিনটা রান্নাই এগোচ্ছে। এটাই কনকারেন্সি।
মনে রাখো: কনকারেন্সি হলো প্রোগ্রামের গঠন, অর্থাৎ কাজগুলোকে আলাদা আলাদা স্বাধীন অংশে ভাগ করা। সেগুলো আসলে একসাথে চলবে কিনা, সেটা হার্ডওয়্যারের ব্যাপার।
০২
কনকারেন্সি বনাম প্যারালেলিজম Concurrency vs Parallelism
কনকারেন্সি মানে পালা করে চালানো (interleaving)। প্যারালেলিজম মানে একাধিক কোরে সত্যিই একই মুহূর্তে চালানো।
উপরে A আর B দুটোই এগোচ্ছে, কিন্তু কখনো একই মুহূর্তে নয়। নিচে দুটো আসলেই একসাথে চলছে।
Go ভাষার অন্যতম স্রষ্টা Rob Pike-এর কথায়: "Concurrency is about dealing with lots of things at once." প্যারালেলিজম হলো অনেক কাজ একসাথে করা। একটা ভালো কনকারেন্ট ডিজাইন বেশি কোর পেলে প্যারালেলও চলতে পারে।
কাজের ধরন
বাধা কোথায়
যা কাজে দেয়
I/O-bound (API কল, DB query, ফাইল)
অপেক্ষা
কনকারেন্সি: থ্রেড, async/await
CPU-bound (ইমেজ প্রসেসিং, হিসাব)
CPU সময়
প্যারালেলিজম: একাধিক কোর, একাধিক প্রসেস
মনে রাখো: Go-তে একই কোড দুটোই পায়। Go runtime সব goroutine-কে GOMAXPROCS সংখ্যক OS থ্রেডে চালায় (ডিফল্ট = CPU কোরের সংখ্যা)। ১টা কোরে goroutine-গুলো পালা করে চলে (কনকারেন্সি), ৮টা কোরে সত্যিই একসাথে চলে (প্যারালেলিজম)। কোড বদলাতে হয় না।
০৩
প্রসেস বনাম থ্রেড Processes vs Threads
প্রসেস আলাদা ও ভারী। থ্রেড হালকা, মেমোরি শেয়ার করে, আর সেজন্যই ঝুঁকিপূর্ণ।
প্রতিটা প্রসেস নিজের আলাদা মেমোরি পায়। একটা প্রসেস আরেকটার ভেরিয়েবল ছুঁতে পারে না। একটা প্রসেসের ভেতরে একাধিক থ্রেড থাকে। তারা heap মেমোরি, খোলা ফাইল, গ্লোবাল ভেরিয়েবল সব শেয়ার করে। প্রত্যেকের শুধু নিজের stack আর register আলাদা।
প্রসেস
থ্রেড
মেমোরি
আলাদা
শেয়ার্ড
তৈরির খরচ
বেশি
কম
একে অপরের সাথে কথা বলা
IPC লাগে: pipe, socket, queue
সরাসরি একই ভেরিয়েবল পড়ে
একটা crash করলে
বাকিগুলো বেঁচে থাকে
পুরো প্রসেস পড়ে যেতে পারে
বাগের ঝুঁকি
কম
রেস কন্ডিশন, ডেডলক
Go-র goroutine থ্রেডের চেয়েও হালকা। একটা goroutine শুরু হয় মাত্র কয়েক KB stack নিয়ে, দরকার হলে বাড়ে। Go runtime হাজার হাজার goroutine-কে অল্প কয়েকটা OS থ্রেডে ভাগ করে চালায় (M:N scheduling)। তবে মেমোরি শেয়ার করার ঝুঁকি থ্রেডের মতোই।
বাস্তব উদাহরণ
প্রসেস হলো আলাদা আলাদা ফ্ল্যাট, প্রত্যেকের নিজের রান্নাঘর। থ্রেড হলো একই ফ্ল্যাটের রুমমেট, যারা একটা ফ্রিজ শেয়ার করে। রুমমেটদের মধ্যে যোগাযোগ সহজ, কিন্তু কেউ না বলে দুধ শেষ করে ফেললে ঝামেলা। Chrome প্রতিটা ট্যাবকে আলাদা প্রসেসে চালায়, যাতে একটা ট্যাব crash করলে পুরো ব্রাউজার না পড়ে।
০৪
থ্রেড লাইফসাইকেল Thread Lifecycle
একটা থ্রেড জন্ম থেকে শেষ পর্যন্ত কয়েকটা অবস্থার (state) মধ্য দিয়ে যায়।
নীল অবস্থায় থ্রেড চলতে প্রস্তুত বা চলছে। কমলা অবস্থায় সে কারো জন্য অপেক্ষা করছে, তখন CPU খরচ করে না।
New: থ্রেড অবজেক্ট বানানো হয়েছে, start() ডাকা হয়নি।
Runnable: চলতে প্রস্তুত, শিডিউলারের লাইনে অপেক্ষা করছে।
Running: এই মুহূর্তে কোনো কোরে চলছে।
Blocked: একটা লক চাইছে যেটা অন্য কেউ ধরে আছে।
Waiting: কারো সংকেতের (signal, join(), I/O) অপেক্ষায় ঘুমিয়ে আছে। টাইমআউটসহ হলে Java একে বলে TIMED_WAITING।
Terminated: কাজ শেষ বা exception-এ মারা গেছে। আর চালু করা যায় না।
মনে রাখো: production-এ থ্রেড ডাম্প (Java-তে jstack, Go-তে pprof-এর goroutine প্রোফাইল বা চলন্ত প্রোগ্রামে kill -QUIT) নিলে অনেক থ্রেড BLOCKED দেখলে বুঝবে লক নিয়ে কাড়াকাড়ি চলছে। এটা পারফরম্যান্স সমস্যা খোঁজার প্রথম সূত্র।
অংশ ২
শেয়ার্ড স্টেট সামলানো
সমস্যার শুরু যখন একাধিক থ্রেড একই ডেটা বদলায়। এই অংশে সমস্যাটা আর তার সমাধানের হাতিয়ারগুলো।
০৫
রেস কন্ডিশন Race Condition
দুটো থ্রেড কোনো সমন্বয় ছাড়া শেয়ার্ড ডেটা বদলায়, আর ফলাফল নির্ভর করে কে আগে পৌঁছাল তার উপর।
balance += 100 দেখতে এক লাইন, কিন্তু CPU-র কাছে তিনটা ধাপ: মেমোরি থেকে পড়ো, যোগ করো, মেমোরিতে লেখো। দুটো থ্রেড একসাথে এটা করলে এমন হতে পারে:
সময়
থ্রেড A
থ্রেড B
balance
t1
পড়ল 500
500
t2
পড়ল 500
500
t3
লিখল 600
600
t4
লিখল 600
600 (হওয়ার কথা 700)
দুজনেই ১০০ টাকা জমা দিল, কিন্তু একজনের টাকা হারিয়ে গেল। একে বলে lost update। উপরের ডেমোর লাল W ঠিক এটাই দেখায়।
আরেকটা সাধারণ রূপ হলো check-then-act: "সিট খালি আছে? তাহলে বুক করো।" দুজন একসাথে চেক করে দুজনেই খালি দেখে, তারপর দুজনেই একই সিট বুক করে।
বাস্তব উদাহরণ
ঈদের টিকেট বিক্রির সময় একই সিট দুজনের কাছে বিক্রি হওয়া, বা ফ্ল্যাশ সেলে স্টকের চেয়ে বেশি অর্ডার নিয়ে নেওয়া। দুটোই রেস কন্ডিশন।
মনে রাখো: রেস কন্ডিশন সবসময় দেখা দেয় না। টেস্টে ১০০০ বার পাস করে প্রোডাকশনে লোড বাড়লে হঠাৎ ভুল হয়। সমাধান তিন রকম: লক দিয়ে পাহারা দাও, atomic অপারেশন ব্যবহার করো, অথবা ডেটা শেয়ারই কোরো না।
০৬
Mutex Mutual Exclusion
একটা থ্রেড critical section-এ ঢোকে, বাকিরা দরজায় অপেক্ষা করে।
Critical section হলো কোডের সেই অংশ যেটা শেয়ার্ড ডেটা ছোঁয়। Mutex একটা তালার মতো। যে থ্রেড তালা নেয় (lock/acquire), শুধু সে-ই ভেতরে যায়। কাজ শেষে তালা ছেড়ে দেয় (unlock/release), তখন অপেক্ষারত একজন ঢোকে।
var (
mu sync.Mutex
balance = 500
)
func deposit(amount int) {
mu.Lock()
defer mu.Unlock() // ফাংশন থেকে বের হলেই unlock, panic হলেও
current := balance
balance = current + amount
}
বাস্তব উদাহরণ
অফিসের একটাই ওয়াশরুম, দরজায় ছিটকিনি। একজন ভেতরে থাকলে বাকিরা বাইরে লাইনে।
মনে রাখো:Lock()-এর ঠিক পরের লাইনেই defer Unlock() লেখো। মাঝপথে exception হয়ে লক না ছাড়লে বাকি সব থ্রেড চিরকাল আটকে থাকবে। আর লকের ভেতরে যত কম কাজ করা যায় তত ভালো, বিশেষ করে নেটওয়ার্ক কল কখনোই না।
০৭
Semaphore Semaphore
ক্যাপাসিটিসহ mutex। একসাথে N সংখ্যক থ্রেডকে একটা রিসোর্স ব্যবহার করতে দেয়।
Semaphore ভেতরে একটা কাউন্টার রাখে। acquire() কাউন্টার এক কমায়; শূন্য হলে পরের জনকে অপেক্ষা করতে হয়। release() কাউন্টার এক বাড়ায় এবং অপেক্ষারত একজনকে জাগায়।
// buffered channel = semaphore; একসাথে সর্বোচ্চ ১০টা DB query
var dbSlots = make(chan struct{}, 10)
func runQuery(q string) (*sql.Rows, error) {
dbSlots <- struct{}{} // acquire: ১০টা ভর্তি থাকলে এখানে অপেক্ষা
defer func() { <-dbSlots }() // release
return db.Query(q)
}
বাস্তব উদাহরণ
১০টা গাড়ি রাখার পার্কিং। গেটে একটা বোর্ডে লেখা "খালি: ৩"। গাড়ি ঢুকলে সংখ্যা কমে, বের হলে বাড়ে। শূন্য হলে নতুন গাড়ি গেটে অপেক্ষা করে।
ডেটাবেস কানেকশন পুল, যাতে DB-তে অতিরিক্ত চাপ না পড়ে।
থার্ড-পার্টি API-তে একসাথে সর্বোচ্চ ৫টা রিকোয়েস্ট।
একসাথে কতগুলো ফাইল ডাউনলোড চলবে তার সীমা।
মনে রাখো: mutex-এর একজন মালিক থাকে, যে লক নিয়েছে শুধু সে-ই ছাড়তে পারে। Semaphore-এর কোনো মালিক নেই, যেকোনো থ্রেড release করতে পারে। Go-তে আলাদা semaphore টাইপ লাগে না, buffered channel-ই যথেষ্ট। ওজনসহ semaphore লাগলে (যেমন বড় কাজ ৩টা স্লট নেবে) golang.org/x/sync/semaphore প্যাকেজ আছে।
০৮
Condition Variable Condition Variable
একটা থ্রেড ঘুমিয়ে থাকে যতক্ষণ না আরেকটা থ্রেড জানায় যে কোনো শর্ত এখন সত্য হয়েছে।
ধরো consumer থ্রেড কিউ থেকে কাজ নেবে, কিন্তু কিউ খালি। বারবার for len(items) == 0 {} চালালে CPU পুড়বে (busy waiting)। Condition variable দিয়ে থ্রেড ঘুমিয়ে পড়ে, আর producer কাজ রেখে তাকে ডেকে তোলে।
var (
mu sync.Mutex
cond = sync.NewCond(&mu)
items []string
)
func consume() string {
mu.Lock()
defer mu.Unlock()
for len(items) == 0 { // if নয়, for লুপ!
cond.Wait() // লক ছেড়ে ঘুমায়, জাগলে আবার লক নেয়
}
x := items[0]
items = items[1:]
return x
}
func produce(x string) {
mu.Lock()
items = append(items, x)
mu.Unlock()
cond.Signal() // একজন অপেক্ষারতকে জাগাও
}
বাস্তব উদাহরণ
রেস্তোরাঁয় অর্ডার দিলে একটা বাজার (pager) দেয়। তুমি কাউন্টারে দাঁড়িয়ে বারবার জিজ্ঞেস করো না। বসে থাকো, খাবার তৈরি হলে বাজার বাজে।
মনে রাখো:Wait() সবসময় for লুপে রাখো। থ্রেড কখনো কারণ ছাড়াই জেগে উঠতে পারে (spurious wakeup), আবার কখনো জাগার আগেই অন্য কেউ আইটেমটা নিয়ে যায়। জেগে আবার শর্ত চেক করতেই হবে। বাস্তবে Go-তে sync.Cond কমই লাগে, কারণ channel থেকে পড়া (<-ch) নিজেই এই অপেক্ষা আর জাগানোর কাজ করে।
০৯
Coarse বনাম Fine-grained লকিং Coarse vs Fine-grained Locking
বড় একটা লক সহজ কিন্তু ধীর। অনেক ছোট লক ভালো স্কেল করে, কিন্তু সেগুলো নিয়ে চিন্তা করা কঠিন।
Coarse-grained: পুরো ক্যাশ বা পুরো HashMap-এর জন্য একটা লক। কোড সহজ, ভুলের সুযোগ কম। কিন্তু ১০০টা থ্রেড ১০০টা আলাদা key পড়তে চাইলেও লাইনে দাঁড়াতে হয়।
Fine-grained: প্রতিটা bucket বা প্রতিটা রেকর্ডের জন্য আলাদা লক। ভিন্ন key-তে কাজ করা থ্রেডরা একে অপরকে আটকায় না। Java-র পুরনো ConcurrentHashMap ম্যাপটাকে ১৬টা segment-এ ভাগ করে আলাদা লক রাখত (lock striping)।
বাস্তব উদাহরণ
পুরো লাইব্রেরির জন্য একটা চাবি বনাম প্রতিটা আলমারির আলাদা চাবি। ডেটাবেসেও একই জিনিস: পুরো টেবিল লক বনাম শুধু একটা row লক।
মনে রাখো: লক যত বেশি, ডেডলকের সম্ভাবনা তত বেশি, কারণ থ্রেডকে একসাথে একাধিক লক নিতে হতে পারে। শুরু করো coarse দিয়ে, প্রোফাইল করে দেখো সত্যিই লকে প্রতিযোগিতা (contention) হচ্ছে কিনা, তারপর ভাঙো।
১০
Reentrant Lock Reentrant Lock
একই থ্রেড একই লক একাধিকবার নিরাপদে নিতে পারে।
সাধারণ লক যে থ্রেড ধরে আছে, সে আবার সেটা চাইলে নিজেই নিজের জন্য অপেক্ষা করবে, অর্থাৎ নিজের সাথে ডেডলক। Reentrant লক মনে রাখে কোন থ্রেড মালিক এবং কতবার নিয়েছে (hold count)। যতবার নেবে ততবার ছাড়তে হবে, কাউন্ট শূন্য হলে তবেই লক মুক্ত হয়।
Go-র sync.Mutex ইচ্ছে করেই reentrant নয়। Go টিমের যুক্তি: reentrant লক লুকিয়ে ফেলে যে ডেটা কখন অর্ধেক-বদলানো অবস্থায় আছে। তাই Go-তে একই লক দুবার নিলে নিজের সাথেই deadlock হয়। সমাধান হলো কোডটা দুই ভাগ করা: বাইরের পাবলিক মেথড লক নেয়, ভেতরের ...Locked মেথড ধরে নেয় লক আগেই নেওয়া আছে।
type Bank struct{ mu sync.Mutex }
func (b *Bank) Transfer(from, to *Account, amt int) {
b.mu.Lock()
defer b.mu.Unlock()
b.withdrawLocked(from, amt) // b.Withdraw() ডাকলে আবার Lock() → নিজের সাথে deadlock
b.depositLocked(to, amt)
}
func (b *Bank) Withdraw(acc *Account, amt int) {
b.mu.Lock()
defer b.mu.Unlock()
b.withdrawLocked(acc, amt)
}
// নিয়ম: ...Locked মেথড ডাকার আগে caller লক নিয়ে রাখবে
func (b *Bank) withdrawLocked(acc *Account, amt int) { acc.Balance -= amt }
func (b *Bank) depositLocked(acc *Account, amt int) { acc.Balance += amt }
মনে রাখো: Java-র synchronized আর Python-এর RLock reentrant, Go-র sync.Mutex নয়। এক ভাষা থেকে আরেক ভাষায় গেলে এই পার্থক্য থেকে প্রায়ই অদ্ভুত deadlock হয়। ইন্টারভিউতে "Go-তে reentrant লক নেই কেন?" প্রশ্নটা আসে।
অংশ ৩
যখন সব আটকে যায়
লক ভুল ডেটা ঠেকায়, কিন্তু নতুন তিনটা বিপদ আনে: কেউ নড়ে না, সবাই নড়ে কিন্তু কাজ হয় না, অথবা কেউ কখনো সুযোগ পায় না।
১১
Deadlock Deadlock
থ্রেড A অপেক্ষা করছে B-এর জন্য, B অপেক্ষা করছে A-এর জন্য। কেউ নড়ে না।
সলিড তীর মানে লক ধরে আছে, ড্যাশ তীর মানে চাইছে। তীরগুলো একটা বৃত্ত বানিয়েছে, সেখান থেকে কেউ বের হতে পারে না।
ডেডলক হতে চারটা শর্ত (Coffman conditions) একসাথে সত্য হতে হয়:
Mutual exclusion: রিসোর্স একবারে একজনই ব্যবহার করতে পারে।
Hold and wait: একটা ধরে রেখে আরেকটার জন্য অপেক্ষা।
No preemption: জোর করে কারো কাছ থেকে লক কেড়ে নেওয়া যায় না।
Circular wait: অপেক্ষার একটা বৃত্ত তৈরি হয়।
যেকোনো একটা ভাঙলেই ডেডলক অসম্ভব। বাস্তবে সবচেয়ে সহজ হলো lock ordering: সব থ্রেড সবসময় একই ক্রমে লক নেবে (যেমন অ্যাকাউন্ট আইডি ছোট থেকে বড়)। তাহলে বৃত্ত তৈরি হতেই পারে না। আরেকটা উপায় হলো timeout দিয়ে লক চাওয়া (tryLock), না পেলে সব ছেড়ে দিয়ে আবার চেষ্টা।
বাস্তব উদাহরণ
সরু গলিতে দুদিক থেকে দুটো গাড়ি ঢুকেছে, কেউ পিছাতে রাজি নয়। ডেটাবেসে দুটো transaction উল্টো ক্রমে দুটো row আপডেট করলে একই ঘটনা ঘটে। PostgreSQL ও MySQL এটা ধরে ফেলে এবং একটা transaction বাতিল করে দেয়, তাই অ্যাপে retry লজিক রাখতে হয়।
১২
Livelock Livelock
থ্রেডগুলো একে অপরের প্রতি সাড়া দিতে থাকে, সক্রিয় থাকে, কিন্তু আসল কোনো কাজ শেষ হয় না।
ডেডলকে থ্রেডরা ঘুমিয়ে থাকে, CPU খরচ শূন্য। লাইভলকে তারা পুরো দমে চলছে, CPU ১০০%, কিন্তু অগ্রগতি শূন্য। এটা প্রায়ই হয় ডেডলক এড়ানোর "ভদ্র" কোড থেকে: "লক না পেলে আমার লক ছেড়ে দিয়ে আবার চেষ্টা করি।" দুই থ্রেড একই ছন্দে এটা করলে চিরকাল ছাড়ে আর ধরে।
বাস্তব উদাহরণ
করিডোরে দুজন মুখোমুখি। দুজনেই ভদ্রতা করে বাঁয়ে সরলেন, আবার মুখোমুখি। দুজনেই ডানে সরলেন, আবার মুখোমুখি। দুজনেই নড়ছেন, কেউ এগোচ্ছেন না।
মনে রাখো: সমাধান হলো র্যান্ডম অপেক্ষা (randomized backoff)। প্রত্যেকে আবার চেষ্টার আগে একটা র্যান্ডম সময় অপেক্ষা করে, ফলে ছন্দ ভেঙে যায়। Ethernet-এর collision সামলানো আর API retry-র exponential backoff with jitter এই ধারণা থেকেই এসেছে।
১৩
Starvation Starvation
বাকিরা এগিয়ে যাচ্ছে, কিন্তু একটা থ্রেড বারবার CPU বা লক পাওয়া থেকে বঞ্চিত হচ্ছে।
সিস্টেম সামগ্রিকভাবে চলছে, তাই এটা ধরা কঠিন। কারণগুলো সাধারণত:
Unfair লক: লক ছাড়ার সাথে সাথে যে প্রথম ধরতে পারে সে পায়। নতুন আসা থ্রেড বারবার জিতে যায়, পুরনোটা অপেক্ষাতেই থাকে।
Priority: উচ্চ priority-র কাজ অবিরাম আসতে থাকলে নিচের priority-র কাজ কখনো সুযোগ পায় না।
Reader-writer লক: পাঠক অবিরাম আসতে থাকলে লেখক কখনো লক পায় না (writer starvation)।
বাস্তব উদাহরণ
ব্যাংকে VIP গ্রাহকদের আলাদা অগ্রাধিকার। VIP-রা অবিরাম আসতে থাকলে সাধারণ লাইনের মানুষ সারাদিন দাঁড়িয়েই থাকবেন।
মনে রাখো: সমাধান হলো fairness: FIFO ক্রমে লক দেওয়া (Java-তে new ReentrantLock(true)), অথবা aging, অর্থাৎ যত বেশি অপেক্ষা তত priority বাড়ানো। Go-র sync.Mutex এটা নিজেই সামলায়: কোনো goroutine ১ms-এর বেশি অপেক্ষা করলে mutex "starvation mode"-এ চলে যায় আর লাইনের ক্রমে লক দেয়। Fair লক কিছুটা ধীর, তাই Go শুধু দরকারের সময় এই মোড চালু করে।
অংশ ৪
গতি ও প্রবাহ
লক ছাড়াই নিরাপদ থাকার উপায়, থ্রেড পুনর্ব্যবহার, আর কাজের প্রবাহ নিয়ন্ত্রণ: বড় সিস্টেম এগুলো দিয়েই টিকে থাকে।
১৪
Atomic অপারেশন Atomic Operations
ছোট অপারেশন যেটা পুরোটা হয় অথবা একেবারেই হয় না। মাঝপথে কেউ বাধা দিতে পারে না।
CPU-তে বিশেষ ইনস্ট্রাকশন আছে যেটা পড়া-বদলানো-লেখা এক ধাপে করে। সবচেয়ে গুরুত্বপূর্ণটা হলো CAS (Compare-And-Swap): "মান যদি এখনো 5 থাকে তবেই 6 বানাও, নইলে ব্যর্থ হও।" ব্যর্থ হলে থ্রেড নতুন মান পড়ে আবার চেষ্টা করে। কোনো লক লাগে না, কেউ ঘুমায় না।
var views atomic.Int64
views.Add(1) // থ্রেড-সেফ, লক ছাড়া
// CAS নিজে হাতে: মান এখনো old থাকলে তবেই old-1 বানাও
var stock atomic.Int64
func takeOne() bool {
for {
old := stock.Load()
if old == 0 {
return false // স্টক শেষ
}
if stock.CompareAndSwap(old, old-1) {
return true // সফল
}
// মাঝখানে অন্য কেউ বদলে ফেলেছে, আবার চেষ্টা
}
}
-- SQL: এক স্টেটমেন্টে চেক আর আপডেট, তাই রেস নেই
UPDATE products SET stock = stock - 1
WHERE id = 42 AND stock > 0;
-- Redis-এ একই কাজ: INCR page:home:views
বাস্তব উদাহরণ
ফ্ল্যাশ সেলের স্টক কমানো। কোডে আগে স্টক পড়ে তারপর কমালে রেস হবে। উপরের SQL-এর মতো এক স্টেটমেন্টে করলে ডেটাবেস নিজেই একে atomic রাখে। আপডেট হওয়া row-এর সংখ্যা ০ হলে বুঝবে স্টক শেষ।
মনে রাখো: দুটো atomic অপারেশন পাশাপাশি রাখলে পুরোটা atomic হয় না। if views.Load() < 10 { views.Add(1) }-এর মাঝখানে অন্য থ্রেড ঢুকতে পারে। একটা ভ্যারিয়েবলের জন্য atomic যথেষ্ট, একাধিক ভ্যারিয়েবল একসাথে বদলাতে লক লাগবে।
১৫
Thread Pool Thread Pool
প্রতিটা কাজের জন্য নতুন থ্রেড না বানিয়ে নির্দিষ্ট কয়েকটা worker থ্রেড বারবার ব্যবহার করা।
থ্রেড বানানো সস্তা নয়। প্রতিটা থ্রেডের নিজস্ব stack লাগে (Java-তে সাধারণত ১MB পর্যন্ত reserve হয়), আর OS-কে kernel কল করতে হয়। প্রতি রিকোয়েস্টে নতুন থ্রেড বানালে ১০,০০০ রিকোয়েস্টে সার্ভার মেমোরি শেষ হয়ে crash করবে। Thread pool সীমা টেনে দেয়: ধরো ২০টা worker, বাকি কাজ কিউতে অপেক্ষা করবে।
Go-তে goroutine সস্তা, তাই থ্রেড বানানোর খরচ নিয়ে চিন্তা কম। কিন্তু সীমা তবুও লাগে, কারণ ডেটাবেস কানেকশন, বাইরের API-র rate limit আর মেমোরি সীমিত। Go-তে একে বলে worker pool:
jobs := make(chan string)
var wg sync.WaitGroup
for w := 0; w < 20; w++ { // ২০টা worker goroutine
wg.Add(1)
go func() {
defer wg.Done()
for url := range jobs { // কাজ না থাকলে অপেক্ষা, close হলে বের হয়
fetch(url)
}
}()
}
for _, u := range urls {
jobs <- u
}
close(jobs) // আর কাজ আসবে না
wg.Wait() // সব worker শেষ হওয়া পর্যন্ত অপেক্ষা
কয়টা থ্রেড রাখবে?
CPU-bound: মোটামুটি কোরের সংখ্যার সমান। বেশি দিলে শুধু context switch বাড়ে।
I/O-bound: কোর × (১ + অপেক্ষার সময় ÷ কাজের সময়)। ধরো ৮ কোর, প্রতি কাজে ৯০ms অপেক্ষা আর ১০ms CPU, তাহলে ৮ × (১ + ৯) = ৮০টা থ্রেড।
মনে রাখো: Java-র Executors.newFixedThreadPool() ভেতরে unbounded কিউ ব্যবহার করে। কাজ যত দ্রুত আসে তত দ্রুত শেষ না হলে কিউ বাড়তেই থাকে, শেষে OutOfMemoryError। Go-তে একই ভুলের রূপ হলো for _, u := range urls { go fetch(u) }: ১০ লাখ URL মানে ১০ লাখ goroutine একসাথে। প্রোডাকশনে সবসময় সীমা দাও (প্রজেক্ট B5 দেখো)।
১৬
Producer-Consumer Producer-Consumer
এক পক্ষ কাজ তৈরি করে, আরেক পক্ষ সেটা প্রসেস করে। মাঝের কিউটাই আসল খেলোয়াড়।
কিউ দুই পক্ষকে আলাদা (decouple) করে দেয়। Producer জানে না কে প্রসেস করবে, consumer জানে না কাজ কোথা থেকে এল। দুই পক্ষ নিজের গতিতে চলে, আর আলাদাভাবে স্কেল করা যায়: চাপ বাড়লে শুধু consumer বাড়াও।
কিউ ডিজাইনের সময় তিনটা প্রশ্নের উত্তর দিতে হয়:
কিউ ভর্তি হলে কী হবে? producer অপেক্ষা করবে, নতুন কাজ ফেলে দেবে, নাকি error দেবে?
ক্রম কি জরুরি? একটা অর্ডারের "তৈরি" ইভেন্ট "বাতিল" ইভেন্টের আগে প্রসেস হতে হবে কি?
consumer মাঝপথে মারা গেলে? কাজটা হারাবে, নাকি আবার কিউতে ফিরবে (acknowledgement)?
বাস্তব উদাহরণ
ফুড ডেলিভারি অ্যাপে অর্ডার এলে সেটা কিউতে যায়। পেমেন্ট সার্ভিস, রেস্তোরাঁ নোটিফিকেশন, রাইডার খোঁজা, প্রত্যেকে নিজের গতিতে প্রসেস করে। Kafka, RabbitMQ, AWS SQS সবই বড় আকারের producer-consumer কিউ।
মনে রাখো: consumer থামানোর পরিষ্কার উপায় হলো poison pill: কিউতে একটা বিশেষ মান রাখো যেটা দেখে consumer বুঝবে কাজ শেষ। Go-তে এর জন্য আছে close(ch)। চ্যানেল বন্ধ হলে for x := range ch লুপ নিজে থেকেই শেষ হয়, যত consumer-ই থাকুক। শুধু মনে রাখো, চ্যানেল বন্ধ করবে producer, consumer নয়।
১৭
Blocking বনাম Non-blocking Blocking vs Non-blocking
Blocking মানে কাজ শেষ না হওয়া পর্যন্ত বসে থাকা। Non-blocking মানে সাথে সাথে ফিরে আসা, পরে আবার দেখা।
Blocking কলে (যেমন সাধারণ socket.recv()) ডেটা না আসা পর্যন্ত থ্রেড ঘুমিয়ে থাকে। কোড সহজ, কিন্তু ১০,০০০ কানেকশনের জন্য ১০,০০০ থ্রেড লাগে।
Non-blocking কল সাথে সাথে ফেরে: "ডেটা আছে, এই নাও" অথবা "এখনো নেই"। একটা event loop OS-কে জিজ্ঞেস করে (Linux-এ epoll) কোন কানেকশন প্রস্তুত, তারপর শুধু সেগুলো সামলায়। এভাবে একটা থ্রেড হাজার হাজার কানেকশন চালায়। Node.js, Nginx, Python-এর asyncio এভাবেই কাজ করে।
Go দুটোর সুবিধা একসাথে দেয়। তুমি সাধারণ blocking কোড লেখো (conn.Read()), কিন্তু ভেতরে Go runtime epoll দিয়ে non-blocking I/O করে। কোনো goroutine অপেক্ষায় থাকলে runtime সেই OS থ্রেডে অন্য goroutine চালায়। আর চ্যানেলে নিজে থেকে non-blocking কাজ করতে চাইলে আছে select:
// Blocking: চ্যানেলে জায়গা না থাকলে অপেক্ষা করে
results <- r
// Non-blocking: default থাকলে সাথে সাথে ফেরে
select {
case results <- r:
// পাঠানো গেছে
default:
log.Println("চ্যানেল ভর্তি, এটা বাদ দিলাম")
}
// মাঝামাঝি: টাইমআউট পর্যন্ত অপেক্ষা
select {
case resp := <-respCh:
handle(resp)
case <-time.After(2 * time.Second):
return errors.New("timeout")
}
বাস্তব উদাহরণ
ডাক্তারের চেম্বারে লাইনে দাঁড়িয়ে থাকা হলো blocking। টোকেন নিয়ে পাশের দোকানে চা খেতে যাওয়া, মাঝে মাঝে বোর্ডে নম্বর দেখে আসা হলো non-blocking।
মনে রাখো: Node.js বা Python asyncio-তে একটাও blocking কল পুরো event loop আটকে দেয়। Go-তে goroutine block হলে সমস্যা নেই, কিন্তু Go-র নিজের কমন বাগ হলো goroutine leak: একটা goroutine এমন চ্যানেলে চিরকাল আটকে আছে যেটা আর কেউ কখনো পড়বে না। প্রতিটা goroutine কীভাবে শেষ হবে তা আগে থেকে ঠিক করো, আর বাতিল করার জন্য context.Context ব্যবহার করো।
১৮
Backpressure Backpressure
consumer যখন producer-এর চেয়ে ধীর, তখন সিস্টেমের এমন একটা উপায় লাগে যাতে কাজ ঢোকার গতি কমানো যায়।
ধরো প্রতি সেকেন্ডে ১০০০টা ইভেন্ট আসছে, কিন্তু তুমি প্রসেস করতে পারো ৮০০টা। প্রতি সেকেন্ডে ২০০টা জমছে। এক ঘণ্টায় ৭ লাখের বেশি। unbounded কিউ থাকলে মেমোরি ভরে সার্ভার মারা যাবে, আর মরার আগে প্রতিটা রিকোয়েস্টের latency বাড়তেই থাকবে।
কৌশল
কী করে
কখন ভালো
Block
কিউ ভর্তি হলে producer অপেক্ষা করে
একই প্রসেসের ভেতরে, ডেটা হারানো চলবে না
Drop / Load shedding
নতুন বা পুরনো কাজ ফেলে দাও
মেট্রিক্স, লগ, লাইভ লোকেশন আপডেট
Reject
HTTP 429 / 503 ফেরত দাও
পাবলিক API; ক্লায়েন্ট পরে retry করবে
Signal upstream
consumer বলে দেয় কতটা নিতে পারবে
Reactive Streams-এর request(n), TCP flow control
বাস্তব উদাহরণ
মেট্রো স্টেশনে প্ল্যাটফর্মে ভিড় বেশি হলে গেটে লোক ঢোকা থামিয়ে দেওয়া হয়। প্ল্যাটফর্ম হলো কিউ, গেট হলো backpressure। গেট না থাকলে প্ল্যাটফর্মে বিপদ।
মনে রাখো: unbounded কিউ মানে সমস্যাটাকে পরের জন্য রেখে দেওয়া, সমাধান নয়। প্রতিটা কিউর একটা সীমা আর সীমা পার হলে কী হবে তার একটা স্পষ্ট নীতি থাকা চাই।
অংশ ৫
নিরাপদ ডিজাইন
সবচেয়ে ভালো লক হলো যেটা লাগেই না। আর সবচেয়ে ভালো retry হলো যেটা দুবার হলেও ক্ষতি নেই।
১৯
Immutability Immutability
যে ডেটা কেউ বদলাতে পারে না, সেটা সুরক্ষিত রাখা সবচেয়ে সহজ।
রেস কন্ডিশনের জন্য দুটো জিনিস লাগে: শেয়ার করা এবং বদলানো। একটা সরিয়ে দিলেই সমস্যা শেষ। Immutable অবজেক্ট তৈরির পর আর বদলায় না, তাই হাজারটা থ্রেড একসাথে পড়লেও কোনো লক লাগে না। "বদলাতে" চাইলে পুরনোটা রেখে নতুন একটা কপি বানাও।
type Config struct {
timeout time.Duration // ছোট হাতের নাম: প্যাকেজের বাইরে থেকে বদলানো যায় না
retries int
}
func NewConfig(t time.Duration, r int) Config { return Config{timeout: t, retries: r} }
func (c Config) Retries() int { return c.retries }
// value receiver: c হলো একটা কপি, তাই আসলটা অপরিবর্তিত থাকে
func (c Config) WithRetries(r int) Config {
c.retries = r
return c
}
cfg := NewConfig(5*time.Second, 3)
newCfg := cfg.WithRetries(5) // cfg.Retries() এখনো 3
Go-তে frozen বা final নেই, তাই immutability আসে নিয়ম মেনে: ফিল্ড unexported রাখো, শুধু পড়ার মেথড দাও, আর বদল মানে নতুন কপি ফেরত দেওয়া। সাবধান: struct কপি করলেও ভেতরের slice আর map আগের মেমোরিই দেখায়, সেগুলো আলাদা করে কপি করতে হয়।
বাস্তব উদাহরণ
ছাপানো বই হাজারজন একসাথে পড়লেও কোনো ঝামেলা নেই। কিন্তু একটা Google Doc সবাই একসাথে এডিট করলে সমন্বয়ের জটিল ব্যবস্থা লাগে। Java ও Python-এর String, Git-এর commit, ব্যাংকের লেজার (শুধু নতুন এন্ট্রি যোগ হয়, পুরনো কখনো মোছে না), সবই এই নীতিতে চলে।
মনে রাখো: কনফিগারেশন হট-রিলোড করার নিরাপদ উপায়: নতুন immutable config অবজেক্ট বানাও, তারপর একটা atomic রেফারেন্স একবারে নতুনটায় সরিয়ে দাও (Go-তে atomic.Pointer[Config]-এর Store(), আর পড়ার সময় Load())। কোনো থ্রেড কখনো অর্ধেক-আপডেট হওয়া config দেখবে না।
২০
কনকারেন্সিতে Idempotency Idempotency under concurrency
retry বা একই কাজ দুবার চললেও ফলাফল সঠিক থাকা চাই।
ডিস্ট্রিবিউটেড সিস্টেমে ডুপ্লিকেট এড়ানো যায় না। নেটওয়ার্ক টাইমআউট হলে ক্লায়েন্ট জানে না সার্ভারে কাজটা হয়েছে কিনা, তাই আবার পাঠায়। মেসেজ কিউ সাধারণত "at-least-once" গ্যারান্টি দেয়, মানে একই মেসেজ দুবার আসতে পারে। Idempotent অপারেশন একবার করা আর দশবার করার ফল একই।
SET status = 'paid' idempotent। balance = balance - 500 idempotent নয়, প্রতিবার আরো টাকা কাটবে।
সমাধান হলো idempotency key: ক্লায়েন্ট প্রতিটা অপারেশনের জন্য একটা ইউনিক আইডি পাঠায়। সার্ভার আগে দেখে এই আইডি দিয়ে কাজ হয়েছে কিনা।
কিন্তু এখানে লুকিয়ে আছে আরেকটা রেস কন্ডিশন। দুটো ডুপ্লিকেট রিকোয়েস্ট একই মিলিসেকেন্ডে এলে দুজনেই চেক করে "আগে হয়নি" দেখবে, দুজনেই টাকা কাটবে। তাই চেক আর লেখা atomic হতে হবে, আর সেটা করার কাজ ডেটাবেসের unique constraint-এর:
CREATE TABLE payments (
idempotency_key TEXT PRIMARY KEY,
amount INTEGER NOT NULL,
status TEXT NOT NULL
);
-- দুটো রিকোয়েস্ট একসাথে এলেও শুধু একটা insert সফল হবে
INSERT INTO payments (idempotency_key, amount, status)
VALUES ('ord-8812-try', 500, 'processing')
ON CONFLICT (idempotency_key) DO NOTHING;
-- ০টা row insert হলে: এটা ডুপ্লিকেট, আগের ফলাফল ফেরত দাও
বাস্তব উদাহরণ
bKash বা কার্ডে পেমেন্ট দেওয়ার সময় নেটওয়ার্ক চলে গেল, তুমি আবার "Pay" চাপলে। ভালো সিস্টেম idempotency key দেখে বুঝবে এটা একই পেমেন্ট, দুবার টাকা কাটবে না। Stripe-এর API ঠিক এই উদ্দেশ্যে Idempotency-Key হেডার নেয়।
মনে রাখো: এটা পুরো গাইডের সারকথা। রেস কন্ডিশন (০৫) সমাধানে atomic অপারেশন (১৪) লাগে, আর সেটাই idempotency (২০)-কে নিরাপদ করে। কনকারেন্সির কনসেপ্টগুলো আলাদা নয়, একটা আরেকটার উপর দাঁড়িয়ে।
হাতে-কলমে
৫টা প্রোগ্রাম: বানাও, চালাও, ভাঙো
সংজ্ঞা মুখস্থ করে কনকারেন্সি শেখা যায় না। নিচের প্রতিটা প্রোগ্রাম Go-তে লেখা (Go 1.22 বা নতুন), শুধু standard library লাগে। আলাদা ফোল্ডারে main.go নামে সেভ করে go run main.go দিয়ে চালাও, তারপর "এবার ভাঙো" অংশের পরীক্ষাগুলো করো। প্রতিটা প্রোগ্রাম go run -race main.go দিয়েও চালিয়ে দেখো, Go-র race detector কী ধরে।
B1 · কনসেপ্ট ০৫, ০৬
একটা রেস কন্ডিশন বানাও
৪টা থ্রেড প্রত্যেকে ১০,০০০ বার জমা দেবে। ফলাফল ৪০,০০০ হওয়ার কথা।
package main
import (
"fmt"
"runtime"
"sync"
)
var balance int
func deposit(times int, wg *sync.WaitGroup) {
defer wg.Done()
for i := 0; i < times; i++ {
current := balance // ১. পড়ো
runtime.Gosched() // অন্য goroutine-কে সুযোগ দাও, যাতে রেস চোখে পড়ে
balance = current + 1 // ২. লেখো
}
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 4; i++ {
wg.Add(1)
go deposit(10_000, &wg)
}
wg.Wait()
fmt.Printf("প্রত্যাশিত: 40000, পেলাম: %d\n", balance)
}
কী দেখবে
৪০,০০০-এর অনেক কম কোনো সংখ্যা (আমার মেশিনে ১০,৩৩৯), আর প্রতিবার চালালে আলাদা।
runtime.Gosched() শুধু goroutine বদলের সম্ভাবনা বাড়ায়। বাস্তব সিস্টেমে এই ফাঁক তৈরি হয় নেটওয়ার্ক কল বা ডেটাবেস query থেকে।
go run -race main.go চালালে Go সরাসরি WARNING: DATA RACE দেখাবে, কোন লাইনে পড়া আর কোন লাইনে লেখা হচ্ছে সহ।
এবার ভাঙো ও সারাও
var mu sync.Mutex বানাও, পড়া-লেখার অংশ mu.Lock() আর mu.Unlock()-এর মাঝে রাখো। ফল ঠিক ৪০,০০০ হবে।
লক শুধু লেখার লাইনে দাও, পড়ার লাইনে নয়। কেন এখনো ভুল আসছে?
runtime.Gosched() বাদ দিয়ে atomic.Int64-এর Add(1) দিয়ে লেখো। Mutex আর atomic-এর সময় মেপে তুলনা করো।
B2 · কনসেপ্ট ০৮, ১৬, ১৮
Producer-Consumer কিউ
দ্রুত producer, ধীর consumer, মাঝে ৫ ঘরের একটা কিউ।
package main
import (
"fmt"
"math/rand"
"time"
)
func producer(n int, orders chan<- string) {
for i := 0; i < n; i++ {
orders <- fmt.Sprintf("order-%d", i) // চ্যানেল ভর্তি থাকলে এখানে আটকে থাকবে
fmt.Printf("[P] বানালাম order-%d (চ্যানেলে আছে %d)\n", i, len(orders))
time.Sleep(time.Duration(10+rand.Intn(40)) * time.Millisecond)
}
close(orders) // poison pill-এর Go রূপ: "আর কিছু আসবে না"
}
func consumer(orders <-chan string, done chan<- struct{}) {
for o := range orders { // খালি থাকলে আটকে থাকবে, close হলে লুপ শেষ
time.Sleep(100 * time.Millisecond) // consumer ধীর
fmt.Printf(" [C] প্রসেস করলাম %s\n", o)
}
done <- struct{}{}
}
func main() {
orders := make(chan string, 5) // bounded: ৫টার বেশি জমবে না
done := make(chan struct{})
go producer(15, orders)
go consumer(orders, done)
<-done
fmt.Println("সব অর্ডার শেষ")
}
কী দেখবে
শুরুতে producer দ্রুত ৫টা বানায়, তারপর চ্যানেল ভর্তি হয়ে যায়।
এরপর producer-এর গতি consumer-এর গতিতে নেমে আসে। এটাই backpressure। চ্যানেলের ভেতরে Go runtime নিজেই লক আর অপেক্ষা-জাগানোর কাজ করছে।
এবার ভাঙো
বাফার ০ করো: make(chan string)। তখন প্রতিটা পাঠানো একটা হ্যান্ডশেক, producer আর consumer একসাথে মিলিত না হলে কেউ এগোয় না।
Go-তে unbounded চ্যানেল নেই। এটা ইচ্ছাকৃত ডিজাইন। কেন, ভাবো।
৩টা consumer চালাও। close() একবারই যথেষ্ট কেন? done-এর বদলে sync.WaitGroup ব্যবহার করো।
select আর default দিয়ে ভর্তি চ্যানেলে কাজ ফেলে দেওয়ার (drop) নীতি বানাও।
B3 · কনসেপ্ট ০৬, ০৭
Semaphore দিয়ে কানেকশন পুল
১০টা রিকোয়েস্ট, কিন্তু ডেটাবেস কানেকশন মাত্র ৩টা।
package main
import (
"errors"
"fmt"
"math/rand"
"sync"
"time"
)
type ConnectionPool struct {
slots chan struct{} // semaphore: কয়টা জায়গা নেওয়া হয়েছে তার হিসাব
mu sync.Mutex // free স্লাইসটা পাহারা দেয়
free []string
}
func NewConnectionPool(size int) *ConnectionPool {
p := &ConnectionPool{slots: make(chan struct{}, size)}
for i := 0; i < size; i++ {
p.free = append(p.free, fmt.Sprintf("conn-%d", i))
}
return p
}
func (p *ConnectionPool) Acquire(timeout time.Duration) (string, error) {
select {
case p.slots <- struct{}{}: // acquire: জায়গা থাকলে সাথে সাথে ঢোকো
case <-time.After(timeout):
return "", errors.New("পুলে কোনো কানেকশন খালি নেই")
}
p.mu.Lock()
defer p.mu.Unlock()
c := p.free[len(p.free)-1]
p.free = p.free[:len(p.free)-1]
return c, nil
}
func (p *ConnectionPool) Release(c string) {
p.mu.Lock()
p.free = append(p.free, c)
p.mu.Unlock()
<-p.slots // release: একটা জায়গা খালি করো
}
func main() {
pool := NewConnectionPool(3)
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
conn, err := pool.Acquire(2 * time.Second)
if err != nil {
fmt.Printf("req-%02d: %v\n", i, err)
return
}
defer pool.Release(conn) // panic হলেও ফেরত দাও
fmt.Printf("req-%02d পেল %s\n", i, conn)
time.Sleep(time.Duration(200+rand.Intn(300)) * time.Millisecond) // query চলছে
}(i)
}
wg.Wait()
}
কী দেখবে
একসাথে কখনো ৩টার বেশি রিকোয়েস্ট কানেকশন পায় না। বাকিরা অপেক্ষা করে।
slots চ্যানেল semaphore হিসেবে গোনে কয়টা নেওয়া হয়েছে, আর mu নিশ্চিত করে স্লাইস থেকে দুজন একই কানেকশন না তোলে। দুটো আলাদা কাজ।
এবার ভাঙো
defer pool.Release(conn) সরিয়ে দাও, আর মাঝে মাঝে আগেই return করো। কানেকশন leak হয়ে পুল শুকিয়ে যাওয়া দেখো।
mu সরিয়ে দিয়ে go run -race চালাও। race detector কী বলে?
শর্টকাট: chan string-এ সরাসরি কানেকশন রাখলে সেটা একাই semaphore আর স্টোরেজ দুটোর কাজ করে। সেভাবে আবার লেখো।
B4 · কনসেপ্ট ১১
ইচ্ছে করে Deadlock
দুটো ট্রান্সফার, দুটো লক, উল্টো ক্রমে নেওয়া।
package main
import (
"fmt"
"sync"
"time"
)
var lockA, lockB sync.Mutex // অ্যাকাউন্ট A আর অ্যাকাউন্ট B
func transferAtoB() {
lockA.Lock()
defer lockA.Unlock()
fmt.Println("T1: A ধরলাম, এবার B চাই...")
time.Sleep(100 * time.Millisecond)
lockB.Lock()
defer lockB.Unlock()
fmt.Println("T1: দুটোই পেলাম")
}
func transferBtoA() {
lockB.Lock()
defer lockB.Unlock()
fmt.Println("T2: B ধরলাম, এবার A চাই...")
time.Sleep(100 * time.Millisecond)
lockA.Lock()
defer lockA.Unlock()
fmt.Println("T2: দুটোই পেলাম")
}
func main() {
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); transferAtoB() }()
go func() { defer wg.Done(); transferBtoA() }()
wg.Wait() // এখানে চিরকাল আটকে থাকবে
fmt.Println("এই লাইন কখনো প্রিন্ট হবে না")
}
কী দেখবে
দুটো "ধরলাম" লাইন প্রিন্ট হবে, "দুটোই পেলাম" কখনো না।
তারপর Go runtime নিজেই প্রোগ্রাম থামিয়ে দেবে: fatal error: all goroutines are asleep - deadlock!
সাবধান: runtime এটা ধরতে পারে শুধু যখন সব goroutine আটকে থাকে। সত্যিকারের সার্ভারে HTTP listener-এর মতো অন্য goroutine চলতে থাকে, তখন deadlock চুপচাপ ঝুলে থাকে, কোনো error আসে না।
এবার সারাও
Lock ordering: দুটো ফাংশনেই আগে lockA, পরে lockB নাও।
TryLock:lockB.TryLock() ব্যর্থ হলে lockA ছেড়ে দিয়ে একটু পরে আবার চেষ্টা করো। দুজনেই একই ছন্দে চেষ্টা করলে লাইভলক দেখতে পারো কি? র্যান্ডম sleep দিলে কী হয়?
B5 · কনসেপ্ট ১৫, ১৬, ১৮
Bounded কিউসহ নিজের Thread Pool
৩টা worker, ৫ ঘরের কিউ, ২০টা কাজ। কিউ ভর্তি হলে submit আটকে যাবে।
package main
import (
"errors"
"fmt"
"sync"
"time"
)
var ErrQueueFull = errors.New("429 Too Many Requests")
type Task func()
type BoundedPool struct {
tasks chan Task // bounded কিউ
wg sync.WaitGroup
}
func NewBoundedPool(workers, queueSize int) *BoundedPool {
p := &BoundedPool{tasks: make(chan Task, queueSize)}
for i := 0; i < workers; i++ {
p.wg.Add(1)
go p.worker(fmt.Sprintf("w%d", i))
}
return p
}
func (p *BoundedPool) worker(name string) {
defer p.wg.Done()
for task := range p.tasks { // কিউ close হলে লুপ শেষ
func() {
defer func() { // একটা কাজের panic-এ worker যেন না মরে
if r := recover(); r != nil {
fmt.Printf("%s panic: %v\n", name, r)
}
}()
task()
}()
}
}
// Submit: কিউ ভর্তি থাকলে caller অপেক্ষা করে (backpressure)
func (p *BoundedPool) Submit(t Task) { p.tasks <- t }
// TrySubmit: কিউ ভর্তি থাকলে সাথে সাথে error (reject)
func (p *BoundedPool) TrySubmit(t Task) error {
select {
case p.tasks <- t:
return nil
default:
return ErrQueueFull
}
}
func (p *BoundedPool) Shutdown() {
close(p.tasks) // worker-দের বলো আর কাজ আসবে না
p.wg.Wait() // হাতে থাকা কাজ শেষ হওয়া পর্যন্ত অপেক্ষা
}
func main() {
pool := NewBoundedPool(3, 5)
for i := 0; i < 20; i++ {
start := time.Now()
pool.Submit(func() {
time.Sleep(300 * time.Millisecond)
fmt.Printf("শেষ হলো job-%d\n", i)
})
if waited := time.Since(start); waited > 50*time.Millisecond {
fmt.Printf(" submit job-%d আটকে ছিল %.2fs ← backpressure\n", i, waited.Seconds())
}
}
pool.Shutdown()
}
কী দেখবে
প্রথম ৮টা কাজ (৩টা চলছে + ৫টা কিউতে) সাথে সাথে জমা হয়।
এরপর প্রতিটা submit কোনো worker খালি না হওয়া পর্যন্ত আটকে থাকে। কাজ জমা দেওয়ার গতি প্রসেস করার গতির সাথে মিলে যায়।
এবার ভাঙো
Submit-এর বদলে TrySubmit ব্যবহার করো আর error পেলে "429 Too Many Requests" প্রিন্ট করো। কয়টা কাজ বাদ পড়ে?
Pool বাদ দিয়ে প্রতিটা কাজ go task() দিয়ে চালাও, আর runtime.NumGoroutine() প্রিন্ট করো। ২০-এর বদলে ১০ লাখ কাজ দিলে কী হবে?
কাজের ভেতরে একই pool-এ আরেকটা কাজ Submit করো। সব worker যদি এভাবে আটকে যায়, কী হবে? Go runtime কি এটা ধরতে পারে?
শেষ কথা
কনকারেন্সির প্রায় সব সমস্যার মূল একটা প্রশ্ন: কোন ডেটা শেয়ার হচ্ছে, আর কে সেটা বদলাচ্ছে? প্রথমে শেয়ার করা কমাও (immutability, প্রতি থ্রেডে আলাদা ডেটা, মেসেজ পাঠানো)। যেটুকু শেয়ার করতেই হবে, সেটাকে atomic অপারেশন বা লক দিয়ে পাহারা দাও। আর প্রতিটা কিউ, পুল ও retry-এর একটা সীমা রাখো।
উপরের ৫টা প্রোগ্রাম নিজের হাতে ভাঙার পর ডেডলক বা রেস কন্ডিশন আর রহস্য মনে হবে না। তখন বুঝবে কোথায় খুঁজতে হবে।