Futex — কীভাবে একটা mutex আসলে বানানো হয়
Futex and Lock Implementation
গত লেসন পর্যন্ত mutex ছিল একটা black box — ডাকলাম, ব্লক হলাম, জাগলাম। আজ সেই box খুলে দেখব। শুরু হবে hardware-এর atomic instruction থেকে, তারপর একটা spinlock হাতে লিখব, আর দেখব কেন সেটা প্রায় কখনোই userspace-এ সঠিক পছন্দ নয়। কেন্দ্রে থাকবে futex — Linux-এর সেই চতুর নকশা যা নিশ্চিত করে uncontended lock/unlock-এ কখনো kernel-এ ঢুকতেই হয় না — আর একটা সম্পূর্ণ mutex আমরা শূন্য থেকে বানাব, raw futex syscall দিয়ে।
আগে এটা বুঝি
pthread_mutex_lock(&m) — এই একটা লাইন কোটি কোটি বার ডাকা
হয়, প্রতিদিন, প্রতিটা multithreaded প্রোগ্রামে। কিন্তু এর
ভেতরে ঠিক কী ঘটে?
সহজ উত্তর: “একটা flag চেক করে, ব্যস্ত থাকলে অপেক্ষা করে”। কিন্তু সেই “অপেক্ষা করা” ঠিক কীভাবে হয়? Kernel-কে ডাকতে হয় কি না? যদি হয়, প্রতিবার? যদি না হয়, তাহলে “জাগানো” কীভাবে কাজ করে?
আজকের লেসনের কেন্দ্রীয় প্রশ্ন একটাই, আর উত্তরটা একটা পুরো design philosophy বহন করে:
যখন একটা lock-এ কোনো contention নেই (কেউ অপেক্ষা করছে না), lock/unlock-এর খরচ কী হওয়া উচিত?
উত্তর: শূন্যের কাছাকাছি — কোনো syscall নয়। আর এই একটা লক্ষ্য থেকেই futex-এর পুরো নকশা বেরিয়ে আসে।
মূল ধারণা
হার্ডওয়্যার থেকে শুরু — atomic instruction
counter++-এর race condition (গত লেসন) ঘটে কারণ read-modify-write
তিনটা আলাদা ধাপ। সমাধান একটা instruction যা এই তিনটা ধাপ
একটা অবিভাজ্য unit হিসেবে সম্পন্ন করে — কোনো অন্য CPU
মাঝখানে ঢুকতে পারে না।
x86-এ: lock cmpxchg
; cmpxchg [mem], new_value — যদি [mem] == eax হয়, [mem] = new_value
; lock prefix নিশ্চিত করে পুরো operation atomic
lock cmpxchg [counter], ebxcmpxchg (compare-and-swap) নেয় একটা “expected” মান (implicit
eax-এ) আর একটা “new” মান। যদি memory-তে থাকা মান expected-এর
সমান হয়, সেটা new value দিয়ে replace হয় — একটা atomic
ধাপে। যদি সমান না হয়, কিছুই বদলায় না, আর eax-এ প্রকৃত
বর্তমান মান রেখে দেওয়া হয়।
ARM-এ: LL/SC (Load-Linked/Store-Conditional)
ldrex r0, [ptr] ; load, আর memory-টা "monitor" করা শুরু
; ... গণনা করুন নতুন মান ...
strex r1, r2, [ptr] ; store, কিন্তু শুধু যদি কেউ মাঝে touch না করে
; r1 = 0 মানে সফল, 1 মানে ব্যর্থ (retry দরকার)x86-এর মতো একটা single instruction নয়, বরং একটা জোড়া — কিন্তু একই গ্যারান্টি দেয়: হয় load আর store-এর মাঝে কেউ ছুঁয়নি (সফল), নয় কেউ ছুঁয়েছে (ব্যর্থ, আবার চেষ্টা করুন)।
একটা pure spinlock — ১০ লাইনে
#include <stdatomic.h>
typedef struct { atomic_flag locked; } spinlock_t;
void spin_lock(spinlock_t *l) {
while (atomic_flag_test_and_set_explicit(&l->locked, memory_order_acquire))
__builtin_ia32_pause(); /* PAUSE — hyperthread-কে সাহায্য করে */
}
void spin_unlock(spinlock_t *l) {
atomic_flag_clear_explicit(&l->locked, memory_order_release);
}atomic_flag_test_and_set একটা atomic “নাও আর আগের মান ফেরত
দাও” operation — যদি আগে থেকেই set ছিল (অন্য কেউ ধরে আছে),
loop চলতে থাকে। যদি clear ছিল, এখন set হয়ে গেল, আর loop থেকে
বেরিয়ে যায় — lock পাওয়া গেছে।
এটা কেন সাধারণত ভুল userspace-এ: loop-টা CPU পুড়িয়েই যায়, lock না পাওয়া পর্যন্ত। যদি lock-ধারী thread একই core-এ থাকা অন্য thread হয় (scheduler preempt করেছে), spinner তার পুরো quantum ধরে অর্থহীনভাবে spin করে যাবে, আর সেই সময়টায় lock-ধারী thread-কে চালানোর কোনো সুযোগই CPU দিচ্ছে না — পরিস্থিতি আরো খারাপ হচ্ছে, ভালো না।
Spinlock তখনই যুক্তিসঙ্গত যখন প্রত্যাশিত wait time একটা context switch-এর (~১-১০ μs, Level 4-এর context-switching লেসন) চেয়ে কম — kernel-এ (যেখানে preemption নিয়ন্ত্রণ করা যায়) এটা সাধারণ, userspace-এ প্রায় কখনো নয়।
Futex-এর মূল অন্তর্দৃষ্টি
Uncontended path কখনো kernel-এ ঢোকে না।
এটাই পুরো নকশার কেন্দ্র। ধারণা করুন: বেশিরভাগ lock/unlock call-এ কোনো contention নেই — কেউ সেই মুহূর্তে সেই lock-এর জন্য অপেক্ষা করছে না। তাহলে সেই সাধারণ ক্ষেত্রে kernel-কে জড়ানোর কোনো কারণ নেই।
Futex (fast userspace mutex) একটা hybrid: userspace-এ একটা
plain atomic_int, আর একটা syscall (futex()) যা শুধু
তখন ডাকা হয় যখন সত্যিই ব্লক করতে হবে (বা কাউকে জাগাতে হবে)।
lock():
যদি atomic CAS দিয়ে unlocked→locked করা যায় → সম্পন্ন, কোনো syscall নেই
নাহলে → syscall(FUTEX_WAIT, ...)
unlock():
যদি কোনো waiter নেই (একটা flag/count দেখে জানা যায়) → সম্পন্ন, কোনো syscall নেই
নাহলে → syscall(FUTEX_WAKE, ...)Uncontended lock/unlock = দুইটা atomic instruction, শূন্য
syscall। এটাই যা glibc-র প্রতিটা pthread_mutex_t আসলে
বাস্তবায়ন করে।
ভেতরে কী ঘটছে
তিন-অবস্থার মডেল — কেন্দ্রীয় ডিজাইন
Ulrich Drepper-এর glibc-শৈলীর futex-based mutex ঠিক তিনটা
অবস্থা রাখে একটা single int-এ:
| মান | অর্থ |
|---|---|
0 | Unlocked |
1 | Locked, কোনো waiter নেই |
2 | Locked, এক বা একাধিক thread অপেক্ষা করছে |
কেন তিনটা, দুইটা নয় (শুধু locked/unlocked):
দুই-অবস্থার মডেলে (0/1) unlock()-কে সবসময় FUTEX_WAKE
ডাকতে হতো — কারণ সে জানে না কেউ অপেক্ষা করছে কি না। এটাই
uncontended path-কে kernel-এ ঠেলে দিত, ঠিক যা আমরা এড়াতে চাই।
তৃতীয় অবস্থা (2) এই তথ্যটা encode করে: “আমি জানি অন্তত একজন
অপেক্ষা করছে”। এখন unlock() FUTEX_WAKE ডাকে শুধু যদি
অবস্থা 2 হয়।
- 0 → 1 CAS চেষ্টাatomic compare-and-swap, unlocked ধরে নিয়ে
- সফল হলেlock পাওয়া গেছে — return, কোনো syscall নেই
- ব্যর্থ হলে (আগে থেকেই locked)অবস্থা 1 বা 2 থেকে 2-এ পাল্টান (XCHG)
- syscall(FUTEX_WAIT, &state, 2)kernel: state এখনো 2 হলে ঘুমাও
- জেগে উঠলে (FUTEX_WAKE পেয়ে)আবার 0 → 2 CAS চেষ্টা করুন (loop)
#include <stdatomic.h>
#include <linux/futex.h>
#include <sys/syscall.h>
#include <unistd.h>
#define UNLOCKED 0
#define LOCKED_NO_WAITERS 1
#define LOCKED_WAITERS 2
static long futex(atomic_int *uaddr, int op, int val) {
return syscall(SYS_futex, uaddr, op, val, NULL, NULL, 0);
}
void my_mutex_lock(atomic_int *state) {
int expected = UNLOCKED;
/* ধাপ ১ — সরল CAS, uncontended ক্ষেত্রে এখানেই শেষ, কোনো syscall না */
if (atomic_compare_exchange_strong(state, &expected, LOCKED_NO_WAITERS))
return;
/* ধাপ ২ — lock ইতিমধ্যে ধরা আছে; contention আছে জানিয়ে দিন */
do {
int prev = atomic_exchange(state, LOCKED_WAITERS);
if (prev == UNLOCKED)
return; /* মাঝখানে unlock হয়ে গিয়েছিল, lock পেয়ে গেলাম */
/* ধাপ ৩ — kernel-কে বলুন: state এখনো LOCKED_WAITERS হলে ঘুমাও */
futex(state, FUTEX_WAIT, LOCKED_WAITERS);
/* জেগে উঠে আবার চেষ্টা — অন্য কেউ আগেই নিয়ে যেতে পারে (loop আবশ্যক) */
} while (1);
}
void my_mutex_unlock(atomic_int *state) {
/* ধাপ ১ — যদি কোনো waiter না থাকে, শুধু 0 করে দিন, কোনো syscall না */
if (atomic_exchange(state, UNLOCKED) == LOCKED_WAITERS) {
/* ধাপ ২ — শুধু তখনই wake করুন যখন waiter থাকতে পারত */
futex(state, FUTEX_WAKE, 1);
}
}FUTEX_WAIT-এর একটা সূক্ষ্ম কিন্তু জরুরি detail: এটা
একটা val প্যারামিটার নেয় — kernel প্রথমে যাচাই করে
*uaddr == val কি না, আর যদি না হয়, ঘুমায় না, সাথে সাথে
ফিরে আসে। এটা একটা lost-wakeup race আটকায়: যদি unlock()
ঠিক FUTEX_WAIT-এর syscall চালু হওয়ার আগমুহূর্তে ঘটে যায়
(state ইতিমধ্যে বদলে গেছে), thread ভুলভাবে ঘুমিয়ে পড়বে না —
kernel value check করে সেটা ধরে ফেলে।
Priority-inheritance futex — সংক্ষেপে
গত লেসনে Mars Pathfinder-এর priority inversion সমস্যা দেখা
হয়েছিল, priority inheritance সমাধান হিসেবে। Linux-এ এর একটা
বিশেষ futex variant আছে — PI futex (FUTEX_LOCK_PI,
FUTEX_UNLOCK_PI)।
সাধারণ futex-এ kernel জানে না কে lock ধরে আছে — শুধু
জানে কেউ অপেক্ষা করছে। PI futex এই তথ্যটাও track করে, তাই
kernel প্রয়োজনে lock-ধারীর priority সাময়িকভাবে বাড়িয়ে দিতে
পারে (waiter-এর priority-তে) — ঠিক priority inheritance-এর
প্রয়োজনীয় mechanism। pthread_mutexattr_setprotocol()-এ
PTHREAD_PRIO_INHERIT সেট করলে glibc ভেতরে এই variant ব্যবহার
করে।
এই ধারণাটা সর্বত্র — শুধু Linux futex নয়
| ভাষা/প্ল্যাটফর্ম | সমতুল্য primitive |
|---|---|
| Go runtime | নিজস্ব semaphore (futex-এর উপর বানানো, Linux-এ) |
| Java | LockSupport.park()/unpark() |
| Windows | WaitOnAddress()/WakeByAddressSingle() |
| C++20 | std::atomic<T>::wait()/notify_one() |
| macOS | os_unfair_lock (ভেতরে futex-সদৃশ kernel primitive) |
সব কয়টা একই নীতি অনুসরণ করে — একটা userspace atomic variable দিয়ে fast path, আর একটা syscall শুধু তখনই যখন সত্যিই ব্লক/wake করতে হবে। এই লেসনের মডেলটা শুধু Linux-নির্দিষ্ট tricks নয়, এটা একটা universal design pattern।
উদাহরণ
Uncontended বনাম contended — একটা সংখ্যায় পার্থক্য
ধরুন একটা লাইব্রেরি log-এর জন্য একটা mutex ব্যবহার করে, একটা single-threaded প্রোগ্রামে (কোনো contention নেই, শুধু safety-র জন্য lock নেওয়া হচ্ছে)।
দুই-অবস্থার (naive) মডেলে:
প্রতিটা unlock() → সবসময় FUTEX_WAKE ডাকে (জানে না waiter আছে কি না)
১০ লক্ষ log call → ১০ লক্ষ syscall, শুধু unlock-এর জন্যতিন-অবস্থার (glibc-শৈলী) মডেলে:
প্রতিটা lock/unlock → শুধু 2টা atomic instruction, রাষ্ট্র 1↔0
কখনো state 2-তে পৌঁছায়ইনি (কোনো contention নেই)
১০ লক্ষ log call → 0টা syscallএই পার্থক্যটাই futex-এর পুরো মূল্য। নিচের experiment-এ এটা সরাসরি মাপা হবে।
নিজে চালিয়ে দেখুন
strace দিয়ে futex-এর ডিজাইন দেখুন — এই লেসনের কেন্দ্রীয় প্রমাণ
#include <pthread.h>
#include <stdio.h>
#define N 1000000
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
int main(void) {
for (int i = 0; i < N; i++) {
pthread_mutex_lock(&m);
pthread_mutex_unlock(&m);
}
printf("সম্পন্ন — %d বার lock/unlock\n", N);
return 0;
}gcc -pthread -O2 -o uncontended uncontended.c
strace -c ./uncontendedOutput-এর গুরুত্বপূর্ণ অংশ:
সম্পন্ন — 1000000 বার lock/unlock
% time seconds usecs/call calls syscall
------ ----------- ----------- --------- ----------------
100.00 0.000012 12 1 write
0.00 0.000000 0 1 exit_group
------ ----------- ----------- --------- ----------------
100.00 0.000012 2 totalদশ লক্ষ lock/unlock call — শূন্য futex syscall। শুধু
write (প্রিন্ট করার জন্য) আর exit_group।
এখন একটা contended সংস্করণ:
#include <pthread.h>
#include <stdio.h>
#define N 200000
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
volatile long shared = 0;
void *worker(void *arg) {
for (int i = 0; i < N; i++) {
pthread_mutex_lock(&m);
shared++; /* সামান্য কাজ, contention বাড়াতে */
for (volatile int j = 0; j < 100; j++) {}
pthread_mutex_unlock(&m);
}
return NULL;
}
int main(void) {
pthread_t t[4];
for (int i = 0; i < 4; i++) pthread_create(&t[i], NULL, worker, NULL);
for (int i = 0; i < 4; i++) pthread_join(t[i], NULL);
printf("shared = %ld\n", shared);
return 0;
}gcc -pthread -O2 -o contended contended.c
strace -f -c ./contended 2>&1 | tail -15shared = 800000
% time seconds usecs/call calls syscall
------ ----------- ----------- --------- ----------------
94.21 0.089234 3 28471 futex
...এবার হাজার হাজার futex syscall — কারণ ৪টা thread একই
lock-এর জন্য প্রতিযোগিতা করছে, বারবার FUTEX_WAIT/FUTEX_WAKE
লাগছে।
Uncontended mutex লক-আনলকে সত্যিই শূন্য syscall লাগে, আর contention শুরু হলেই futex syscall দেখা যায় — এটাই সরাসরি প্রমাণ করে futex-এর কেন্দ্রীয় দাবি।
Contention cliff — thread সংখ্যা বনাম throughput
#include <pthread.h>
#include <stdio.h>
#include <time.h>
#include <stdlib.h>
#define OPS_PER_THREAD 500000
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
volatile long counter = 0;
void *worker(void *arg) {
for (int i = 0; i < OPS_PER_THREAD; i++) {
pthread_mutex_lock(&m);
counter++;
pthread_mutex_unlock(&m);
}
return NULL;
}
double run(int nthreads) {
pthread_t threads[64];
struct timespec t0, t1;
counter = 0;
clock_gettime(CLOCK_MONOTONIC, &t0);
for (int i = 0; i < nthreads; i++) pthread_create(&threads[i], NULL, worker, NULL);
for (int i = 0; i < nthreads; i++) pthread_join(threads[i], NULL);
clock_gettime(CLOCK_MONOTONIC, &t1);
double sec = (t1.tv_sec - t0.tv_sec) + (t1.tv_nsec - t0.tv_nsec) / 1e9;
return (nthreads * OPS_PER_THREAD) / sec / 1e6; /* million ops/sec */
}
int main(void) {
printf("%6s %14s\n", "থ্রেড", "M ops/sec");
for (int n = 1; n \<= 16; n *= 2)
printf("%6d %14.2f\n", n, run(n));
return 0;
}gcc -pthread -O2 -o cliff cliff.c && ./cliffসাধারণ ফলাফল (৮-core মেশিনে):
থ্রেড M ops/sec
1 45.32
2 12.87
4 6.21
8 3.84
16 2.11১ thread-এ (uncontended) — সর্বোচ্চ থ্রুপুট। ২ থ্রেডেই নাটকীয় পতন — এখন প্রতিটা lock-এর জন্য প্রতিযোগিতা, futex slow path সক্রিয়। আরো thread যোগ করলে আরো খারাপ হতেই থাকে — কারণ একটামাত্র lock, প্রতিটা thread একে অপরের কাজে বাধা দিচ্ছে (cache line bouncing-ও যোগ হয়, Level 11-এ MESI আলোচনায় ফিরব)।
এটাই lock-এর সবচেয়ে ব্যবহারিক শিক্ষা: একটামাত্র lock দিয়ে সব thread-কে serialize করা কখনোই “সমান্তরাল” প্রোগ্রাম নয় — এটা একটা সিরিয়াল প্রোগ্রাম, শুধু বাড়তি context-switch overhead সহ। প্রকৃত scaling-এর জন্য lock granularity কমানো (একাধিক ছোট lock), বা lock-free/per-thread ডেটা structure লাগে।
Thread সংখ্যা বাড়ার সাথে সাথে lock throughput একটা নির্দিষ্ট বিন্দুর পর নাটকীয়ভাবে কমতে শুরু করে — futex slow path-এর খরচ সরাসরি চোখে দেখা যায়।
নিজে বানান
নিজের mutex, glibc-র সাথে বেঞ্চমার্ক
- উপরের তিন-অবস্থার mutex-টা একটা সম্পূর্ণ, পুনর্ব্যবহারযোগ্য মডিউলে সাজান
- একটা multi-thread stress test দিয়ে সঠিকতা যাচাই করুন (lost update নেই)
- একই workload-এ pthread_mutex_t-এর সাথে throughput তুলনা করুন
- strace দিয়ে দুটোরই syscall pattern পাশাপাশি দেখুন
#define _GNU_SOURCE
#include <stdatomic.h>
#include <linux/futex.h>
#include <sys/syscall.h>
#include <unistd.h>
#include <pthread.h>
#include <stdio.h>
#include <time.h>
/* ── আমাদের mutex ─────────────────────────────────────────── */
#define UNLOCKED 0
#define LOCKED_NO_WAITERS 1
#define LOCKED_WAITERS 2
typedef struct { atomic_int state; } my_mutex_t;
static long futex(atomic_int *uaddr, int op, int val) {
return syscall(SYS_futex, uaddr, op, val, NULL, NULL, 0);
}
void my_mutex_init(my_mutex_t *m) { atomic_init(&m->state, UNLOCKED); }
void my_mutex_lock(my_mutex_t *m) {
int expected = UNLOCKED;
if (atomic_compare_exchange_strong(&m->state, &expected, LOCKED_NO_WAITERS))
return;
do {
int prev = atomic_exchange(&m->state, LOCKED_WAITERS);
if (prev == UNLOCKED) return;
futex(&m->state, FUTEX_WAIT, LOCKED_WAITERS);
} while (1);
}
void my_mutex_unlock(my_mutex_t *m) {
if (atomic_exchange(&m->state, UNLOCKED) == LOCKED_WAITERS)
futex(&m->state, FUTEX_WAKE, 1);
}
/* ── সঠিকতা পরীক্ষা ───────────────────────────────────────── */
#define NTHREADS 8
#define OPS_PER_THREAD 200000
my_mutex_t test_mutex;
volatile long shared_counter = 0;
void *stress_worker(void *arg) {
for (int i = 0; i < OPS_PER_THREAD; i++) {
my_mutex_lock(&test_mutex);
shared_counter++; /* race-প্রবণ operation */
my_mutex_unlock(&test_mutex);
}
return NULL;
}
/* ── benchmark harness ────────────────────────────────────── */
double bench_mine(int nthreads) {
pthread_t t[64];
struct timespec t0, t1;
my_mutex_init(&test_mutex);
shared_counter = 0;
clock_gettime(CLOCK_MONOTONIC, &t0);
for (int i = 0; i < nthreads; i++) pthread_create(&t[i], NULL, stress_worker, NULL);
for (int i = 0; i < nthreads; i++) pthread_join(t[i], NULL);
clock_gettime(CLOCK_MONOTONIC, &t1);
long expected = (long)nthreads * OPS_PER_THREAD;
if (shared_counter != expected)
printf(" *** সঠিকতা ব্যর্থ! আশা %ld, পেলাম %ld ***\n", expected, shared_counter);
return (t1.tv_sec - t0.tv_sec) + (t1.tv_nsec - t0.tv_nsec) / 1e9;
}
pthread_mutex_t glibc_mutex = PTHREAD_MUTEX_INITIALIZER;
void *glibc_worker(void *arg) {
for (int i = 0; i < OPS_PER_THREAD; i++) {
pthread_mutex_lock(&glibc_mutex);
shared_counter++;
pthread_mutex_unlock(&glibc_mutex);
}
return NULL;
}
double bench_glibc(int nthreads) {
pthread_t t[64];
struct timespec t0, t1;
shared_counter = 0;
clock_gettime(CLOCK_MONOTONIC, &t0);
for (int i = 0; i < nthreads; i++) pthread_create(&t[i], NULL, glibc_worker, NULL);
for (int i = 0; i < nthreads; i++) pthread_join(t[i], NULL);
clock_gettime(CLOCK_MONOTONIC, &t1);
return (t1.tv_sec - t0.tv_sec) + (t1.tv_nsec - t0.tv_nsec) / 1e9;
}
int main(void) {
printf("=== সঠিকতা যাচাই (%d thread × %d op) ===\n", NTHREADS, OPS_PER_THREAD);
double t = bench_mine(NTHREADS);
printf("নিজের mutex: %ld/%ld সঠিক, %.3f সেকেন্ড\n\n",
shared_counter, (long)NTHREADS * OPS_PER_THREAD, t);
printf("=== throughput তুলনা ===\n");
printf("%8s %14s %14s\n", "থ্রেড", "নিজের (s)", "glibc (s)");
for (int n = 1; n <= 8; n *= 2) {
double mine = bench_mine(n);
double glibc = bench_glibc(n);
printf("%8d %14.3f %14.3f\n", n, mine, glibc);
}
return 0;
}gcc -pthread -O2 -o mymutex mymutex.c && ./mymutexপ্রত্যাশিত: সঠিকতা যাচাই পাশ (কোনো lost update নেই — আমাদের
mutex সঠিকভাবে mutual exclusion দিচ্ছে), আর throughput
pthread_mutex_t-এর কাছাকাছি (হয়তো সামান্য ধীর, কারণ
glibc-র implementation বছরের পর বছর ধরে fine-tuned, আমাদেরটা
teaching-grade)।
strace -c ./mymutex 2>&1 | grep futexআপনার নিজের mutex-ও একই pattern দেখাবে — কম thread-এ কম
futex call, বেশি thread-এ বেশি — ঠিক যেভাবে glibc-র
mutex আচরণ করে, কারণ একই মৌলিক নকশা।
নিজে বাড়ান:
- Spin-then-block hybrid যোগ করুন — সরাসরি
FUTEX_WAIT-এ না গিয়ে প্রথমে কয়েকবার CAS retry করুন (adaptive mutex, গত-গত লেসনের ধারণা) FUTEX_WAKEcount1-এর বদলেINT_MAXকরে thundering herd তৈরি করুন, আর throughput-এর প্রভাব মাপুন- একটা reader-writer lock বানান futex দিয়ে — একটা counter (reader সংখ্যা) আর একটা writer flag মিলিয়ে
FUTEX_LOCK_PI/FUTEX_UNLOCK_PIব্যবহার করে একটা priority-inheritance সংস্করণ বানান, আর গত লেসনের priority inversion পরিস্থিতিতে পরীক্ষা করুন
বাস্তব সিস্টেমে
Futex ও lock implementation যেখানে সরাসরি প্রভাব ফেলে
প্রতিটা pthread_mutex_t, প্রতিটা std::mutex। এই লেসনের
তিন-অবস্থার মডেলটাই glibc-র প্রকৃত বাস্তবায়ন (সামান্য বেশি
optimization সহ) — Linux-এ চলা প্রতিটা C/C++ প্রোগ্রামের প্রতিটা
mutex এই একই নীতিতে চলে।
Go-র runtime। Go-র নিজস্ব scheduler-এর জন্য semaphore
primitive Linux-এ futex ব্যবহার করে (runtime/lock_futex.go)।
Goroutine-দের channel operation-এর নিচেও এই একই mechanism আছে।
Database connection pool locking। PostgreSQL-এর shared memory-তে backend process-দের মধ্যে synchronization (buffer pool lock, lock manager) নিজস্ব spinlock + futex-হাইব্রিড ব্যবহার করে — ঠিক এই লেসনের adaptive strategy।
Redis-এর single-threaded design-এর যুক্তি। Redis মূলত একটা মূল কমান্ড-execution thread ব্যবহার করে ইচ্ছাকৃতভাবে — কারণ multi-threaded হলে প্রতিটা ডেটা access-এ lock লাগত, আর আজকের contention-cliff experiment যা দেখিয়েছে (thread বাড়লে throughput কমা), সেটাই এড়াতে চেয়েছিল তাদের নকশা।
Java-র synchronized। JVM-এর ভেতরে biased locking,
thin lock, fat lock — তিন-স্তরের একটা escalation,
futex-এর তিন-অবস্থার মডেলের সাথে দার্শনিকভাবে সমান্তরাল:
uncontended-এ প্রায় বিনামূল্যে, contention বাড়লে ধীরে ধীরে
ভারী mechanism-এ escalate করা।
Rust-এর std::sync::Mutex (Linux-এ)। সরাসরি futex-ভিত্তিক,
এবং Rust-এর ownership system নিশ্চিত করে compile-time-এ একটা
lock ধরে রাখা অবস্থায় সেটা আবার lock করা যাবে না — একটা bug
class সম্পূর্ণ বাদ, যা C/C++-এ runtime-এ ধরা পড়ে (বা পড়েই না)।
যে ভুলগুলো সবাই করে
“Atomic instruction মানেই lock-free, তাই সবসময় দ্রুত।”
lock cmpxchg নিজেই একটা lock ধরে না (কোনো blocking নেই)
এই অর্থে “lock-free”, কিন্তু এটা বিনামূল্যে নয়।
Cache coherence protocol-এর কারণে, যদি সেই memory line অন্য core-এর cache-এ থাকে, atomic instruction-টা একটা cross-core communication ঘটায় — যা কয়েক দশ থেকে কয়েকশো cycle নিতে পারে, সাধারণ non-atomic memory access-এর চেয়ে বহুগুণ ধীর।
উচ্চ-contention পরিস্থিতিতে (অনেক thread একই atomic variable-এ
বারবার write করছে — যেমন আজকের contention-cliff experiment-এ
shared counter), সেই cache line ক্রমাগত এক core থেকে আরেক
core-এ “bounce” করে (cache line ping-pong), আর atomic
instruction-এর খরচ নাটকীয়ভাবে বেড়ে যায়।
lock-free মানে “কোনো thread কখনো block হবে না”, কিন্তু এর মানে এই নয় যে এটা সবসময় দ্রুত — বিশেষত উচ্চ contention-এ, কখনো কখনো একটা ভালোভাবে ডিজাইন করা mutex-ভিত্তিক সমাধান lock-free সমাধানের চেয়ে দ্রুত হতে পারে, কারণ mutex contention-এ thread-কে ঘুমাতে পাঠায় (CPU খালি করে), যেখানে lock-free retry loop CPU পুড়িয়েই যায়।
“Futex মানে একটা নতুন synchronization primitive, mutex-এর বিকল্প।”
Futex নিজে কোনো mutex নয় — এটা একটা নিচু-স্তরের building block: “যদি একটা মান এখনো নির্দিষ্ট থাকে, ঘুমাও” আর “এই address-এ অপেক্ষারত কাউকে জাগাও”। এই দুইটা primitive দিয়েই mutex, semaphore, condition variable, read-write lock — সবকিছু বানানো যায়।
futex() syscall নিজে সরাসরি অ্যাপ্লিকেশন কোডে ব্যবহার করা
প্রায় কখনো উচিত নয় — এটা library-writer-দের জন্য একটা primitive,
সাধারণ প্রোগ্রামারদের জন্য নয়। এই লেসনে আমরা এটা সরাসরি
ব্যবহার করেছি শিক্ষার জন্য, বাস্তব কোডে pthread_mutex_t
বা ভাষার নিজস্ব primitive ব্যবহার করাই সঠিক — সেগুলো futex-এর
উপর সঠিকভাবে বানানো, edge case handle করা (signal interruption,
spurious wakeup, memory ordering)।
“Spinlock সবসময় mutex-এর চেয়ে খারাপ পছন্দ userspace-এ।”
বেশিরভাগ ক্ষেত্রে সত্য, কিন্তু নিরঙ্কুশ নয়। যদি critical section সত্যিই খুব ছোট হয় (কয়েক instruction), আর CPU-র সংখ্যা thread-এর সংখ্যার চেয়ে বেশি বা সমান (তাই preemption-এর সম্ভাবনা কম), spinlock একটা mutex-এর চেয়ে দ্রুত হতে পারে — কারণ mutex-এর slow path-এ syscall-এর খরচ (যদি contention হয়) স্পিন করার চেয়ে বেশি হতে পারে যদি wait time খুবই ছোট হয়।
এই কারণেই কিছু high-performance library (lock-free data structure-এর ভেতরে, বা kernel-এর নিজের spinlock) নির্দিষ্ট, সাবধানে যাচাই করা পরিস্থিতিতে spinlock ব্যবহার করে। কিন্তু এই সিদ্ধান্ত সবসময় পরিমাপ করে নেওয়া উচিত, অনুমান করে নয় — আজকের experiment-এর মতো পদ্ধতিতে actual workload-এ দুটো তুলনা করে।
Default অবস্থান এখনো mutex/futex থাকা উচিত — spinlock একটা বিশেষায়িত optimization, সাধারণ নিয়ম নয়।
“FUTEX_WAIT একটা busy-wait, তাই এটাও spinlock-এর মতো CPU পোড়ায়।”
সম্পূর্ণ বিপরীত — এটাই futex-এর পুরো বিন্দু। FUTEX_WAIT
thread-কে kernel-এর একটা wait queue-তে রাখে আর সেই
thread-কে সম্পূর্ণভাবে block/sleep করে দেয় — ঠিক যেমন
একটা read() disk I/O-র জন্য block করে। CPU সম্পূর্ণ মুক্ত
হয়ে যায়, scheduler অন্য কোনো runnable thread চালাতে পারে।
এই কারণেই futex-ভিত্তিক mutex দুইটা জগতের সেরাটা পায়: uncontended-এ spinlock-এর মতো সস্তা (কোনো syscall নেই, শুধু একটা atomic instruction), contended-এ traditional blocking lock-এর মতো CPU-বান্ধব (thread ঘুমিয়ে পড়ে, CPU নষ্ট হয় না)।
FUTEX_WAIT-এর নামে “busy” শব্দ না থাকলেও অনেকে ধরে নেন এটা
একটা polling loop — বাস্তবে এটা kernel-এর wait_event-ভিত্তিক
সত্যিকারের sleep, exactly যেমন condition variable বা semaphore
ব্যবহার করে।
বুঝেছেন কি না দেখুন
1FUTEX_WAIT-এ একটা val প্যারামিটার পাঠানো হয়, আর kernel
প্রথমে *uaddr == val কি না যাচাই করে, তারপরই ঘুমায়। এই
check-টা না থাকলে ঠিক কী race condition ঘটতে পারত?
যুক্তি
FUTEX_WAIT-এ একটা val প্যারামিটার পাঠানো হয়, আর kernel
প্রথমে *uaddr == val কি না যাচাই করে, তারপরই ঘুমায়। এই
check-টা না থাকলে ঠিক কী race condition ঘটতে পারত?Lost wakeup race — একটা classic synchronization bug, আর এই check ছাড়া futex ব্যবহারযোগ্যই হতো না।
কল্পনা করুন val check ছাড়া একটা সরলীকৃত FUTEX_WAIT:
void naive_wait(atomic_int *state) {
/* এখানে কোনো check নেই — সরাসরি ঘুমাতে যাচ্ছে */
syscall(SYS_futex, state, FUTEX_WAIT, NULL, NULL, 0);
}এখন race scenario:
Thread A (lock করতে চায়): Thread B (unlock করছে):
state পড়ে — মান LOCKED_WAITERS
state = UNLOCKED (exchange)
FUTEX_WAKE ডাকে — কিন্তু
A এখনো ঘুমায়ইনি, তাই এই
wake "হারিয়ে যায়" — কেউ
শুনছে না তখনও
FUTEX_WAIT ডাকে — এখন ঘুমাতে যাচ্ছে,
কিন্তু lock ইতিমধ্যে UNLOCKED!
এখন A চিরকাল ঘুমিয়ে থাকবে — যদিও
lock আসলে মুক্ত, কেউ তাকে জাগাবে নাসমস্যাটা: “state পড়া” আর “ঘুমাতে যাওয়া” — এই দুইটা ধাপের
মাঝখানে একটা window আছে, আর সেই window-এ যদি unlock()
ঘটে যায় এবং FUTEX_WAKE ডাকা হয়ে যায় (কিন্তু তখনও A ঘুমায়নি,
তাই wake-এর কিছু “শোনার” নেই), সেই wake signal হারিয়ে যায়।
A পরে ঘুমাতে গেলে, তাকে জাগানোর কেউ নেই।
val check কীভাবে এটা রোধ করে:
syscall(SYS_futex, state, FUTEX_WAIT, LOCKED_WAITERS, NULL, NULL, 0);Kernel atomically — একটাই ধাপে, kernel-এর নিজস্ব lock
নিয়ে — *state == LOCKED_WAITERS যাচাই করে এবং thread-কে
wait queue-তে যোগ করে। যদি unlock() ইতিমধ্যে state-কে
UNLOCKED করে ফেলে থাকে, kernel এই check-এ সেটা ধরে ফেলবে
আর FUTEX_WAIT সাথে সাথে ফিরে আসবে (EAGAIN দিয়ে), thread
কখনো ঘুমাবেই না — lost wakeup সম্পূর্ণ এড়ানো গেল।
সাধারণ নীতি: “check যে অবস্থা এখনো বৈধ” আর “সেই অবস্থার
উপর ভিত্তি করে ব্লক করা” — এই দুইটা ধাপ atomically একসাথে
হতেই হবে। গত-গত লেসনে condition variable-এর pthread_cond_wait()
একই কারণে mutex ও sleep-কে একটা atomic operation-এ বান্ডিল
করে — এটা একই সমস্যার একই সমাধান, ভিন্ন primitive-এ।
2strace -c দিয়ে দেখলেন একটা প্রোগ্রামে ১০ লক্ষ
pthread_mutex_lock/unlock call হয়েছে, কিন্তু futex syscall
মাত্র ২টা। এটা কীভাবে সম্ভব, আর এই সংখ্যা থেকে প্রোগ্রামের
threading pattern সম্পর্কে কী অনুমান করা যায়?
প্রয়োগ
strace -c দিয়ে দেখলেন একটা প্রোগ্রামে ১০ লক্ষ
pthread_mutex_lock/unlock call হয়েছে, কিন্তু futex syscall
মাত্র ২টা। এটা কীভাবে সম্ভব, আর এই সংখ্যা থেকে প্রোগ্রামের
threading pattern সম্পর্কে কী অনুমান করা যায়?এটা সম্পূর্ণ প্রত্যাশিত, আর এটাই futex ডিজাইনের সাফল্যের প্রমাণ — বেশিরভাগ lock/unlock uncontended (fast path), শুধু খুব বিরল ২টা ক্ষেত্রে contention ঘটেছে।
তিনটা প্রশংসনীয় ব্যাখ্যা:
১. Single-threaded প্রোগ্রাম, বা কার্যত single-threaded।
যদি প্রোগ্রামে মাত্র একটা thread lock ব্যবহার করছে (অন্য
thread-রা সেই lock কখনো ছোঁয় না), তাহলে কখনো কোনো contention
ঘটবেই না — তাত্ত্বিকভাবে futex syscall সংখ্যা শূন্য
হওয়া উচিত।
২. দুইটা futex call কেন তাহলে আছে? সবচেয়ে সম্ভাব্য
ব্যাখ্যা: প্রোগ্রাম শুরুতে বা শেষে একবার contention ঘটেছে —
হয়তো pthread_create() করার ঠিক পরে, main thread আর নতুন
thread একটা init lock-এ সংক্ষিপ্তভাবে প্রতিদ্বন্দ্বিতা করেছে
(startup race), অথবা shutdown-এর সময় একটা cleanup lock-এ।
৩. Thread-রা মূলত ভিন্ন সময়ে কাজ করছে (temporal separation)। যদি একাধিক thread থাকে কিন্তু তারা lock-টা এমনভাবে ব্যবহার করে যে বাস্তবে কখনোই একসাথে সেটার জন্য প্রতিযোগিতা করে না (যেমন, একটা thread শুধু সকালে কাজ করে, আরেকটা রাতে — একটা কৃত্রিম উদাহরণ, কিন্তু নীতিটা বাস্তব: যদি critical section এত ছোট ও বিরল হয় যে দুইটা thread কখনো একই মুহূর্তে সেখানে পৌঁছায় না), তাহলে গাণিতিকভাবে বহু lock ব্যবহারেও প্রায় শূন্য contention সম্ভব।
যা এই সংখ্যা থেকে বলা যায়:
- এই lock bottleneck নয় — futex-এর slow path প্রায় কখনো সক্রিয় হয়নি, তাই এই lock নিয়ে performance optimization করার কোনো প্রয়োজন নেই
- Contention-cliff experiment-এর বিপরীত পরিস্থিতি — সেখানে হাজার হাজার futex call দেখা গিয়েছিল কারণ সত্যিকারের heavy contention ছিল
ব্যবহারিক প্রয়োগ: যদি একটা প্রোগ্রামের perf/strace
profile দেখায় নির্দিষ্ট একটা lock-এ প্রচুর futex call
(contended), সেটাই optimization-এর লক্ষ্য — lock granularity
কমানো, বা critical section ছোট করা, বা lock-free ডেটা
structure বিবেচনা করা। কিন্তু যদি futex call সংখ্যা কম হয়,
সেই lock নিয়ে চিন্তা করার দরকার নেই — আরো গুরুত্বপূর্ণ জায়গায়
সময় দেওয়া উচিত।
3আপনার একটা high-throughput counter দরকার যা লক্ষ লক্ষ thread
থেকে বাড়ানো হবে প্রতি সেকেন্ডে (একটা web server-এর request
counter)। pthread_mutex_t + counter++ ব্যবহার করলে
contention-cliff experiment-এর মতো throughput ধসে পড়বে। বিকল্প
নকশা প্রস্তাব করুন।
ডিজাইন
pthread_mutex_t + counter++ ব্যবহার করলে
contention-cliff experiment-এর মতো throughput ধসে পড়বে। বিকল্প
নকশা প্রস্তাব করুন।এই সমস্যায় মূল ধারণা — একটা কেন্দ্রীয় lock/counter সরিয়ে দেওয়া, contention-এর উৎসটাই বাদ দেওয়া, শুধু faster lock খোঁজা নয়।
সমাধান ১ — একটা atomic counter (lock-ই বাদ)।
atomic_long counter = 0;
atomic_fetch_add_explicit(&counter, 1, memory_order_relaxed);Mutex-এর বদলে সরাসরি একটা lock xadd (atomic add) — কোনো
futex syscall কখনোই লাগবে না, কারণ hardware-স্তরেই এই
operation atomic। কিন্তু উচ্চ contention-এ (অনেক core একই
cache line-এ লিখছে) এখনো cache-line bouncing-এর খরচ থেকে
যায় — গত misconception-এ যা আলোচনা করা হয়েছিল।
সমাধান ২ — Per-thread (বা per-CPU) counter, শেষে merge।
#define NCPU 64
atomic_long per_cpu_counter[NCPU]; /* cache-line-aligned! */
void increment(void) {
int cpu = sched_getcpu();
atomic_fetch_add_explicit(&per_cpu_counter[cpu], 1, memory_order_relaxed);
}
long total(void) {
long sum = 0;
for (int i = 0; i < NCPU; i++)
sum += atomic_load(&per_cpu_counter[i]);
return sum;
}এখন প্রতিটা CPU তার নিজের cache line-এ লিখছে — কোনো
cross-core contention নেই increment-এ। শুধু total() ডাকলে
(যা কম ঘন ঘন হয়, হয়তো শুধু monitoring-এর জন্য প্রতি কয়েক
সেকেন্ডে) সব যোগ করা হয়।
গুরুত্বপূর্ণ সতর্কতা — false sharing। per_cpu_counter
array-টা যদি প্রতিটা long একে অপরের ঠিক পাশে থাকে, একই
cache line-এ একাধিক CPU-র counter পড়তে পারে — তখন ভিন্ন
counter হলেও, একই cache line-এ লেখালেখি করলে সেই line-টাই
bounce করবে (false sharing)। সমাধান: প্রতিটা counter-কে
alignas(64) (সাধারণ cache line size) দিয়ে আলাদা করা।
struct alignas(64) padded_counter { atomic_long value; };
struct padded_counter per_cpu_counter[NCPU];সমাধান ৩ — Sharded counter with dynamic sizing (production-grade)।
Java-র LongAdder, বা Go-র sharded counter library-গুলো
এই ধারণাটা আরো উন্নত করে — thread সংখ্যা অনুযায়ী shard
সংখ্যা dynamically বাড়ায়/কমায়, high contention-এ বেশি shard,
low contention-এ কম (memory সাশ্রয়ের জন্য)।
কখন কোনটা:
| পদ্ধতি | কখন উপযুক্ত |
|---|---|
| Single atomic | কম-মাঝারি contention (কয়েকটা thread) |
| Per-CPU, padded | উচ্চ contention, সরল ব্যবহার |
| Dynamic sharding | production library, ব্যবহারের ধরন অজানা/পরিবর্তনশীল |
মূল শিক্ষা: এই লেসনের futex optimization “একটা lock-কে দ্রুততর করা” — কিন্তু সবচেয়ে বড় performance জয় প্রায়ই আসে “lock-এর দরকারই নেই এমন নকশা” থেকে। Contention কমানোর সবচেয়ে কার্যকর উপায় প্রায়ই আর্কিটেকচার বদলানো, primitive optimize করা নয়।
4কেন lock cmpxchg (compare-and-swap) সবচেয়ে জনপ্রিয় atomic
primitive, lock add বা lock xchg-এর বদলে — যখন এই সবগুলোই
atomic instruction?
যুক্তি
lock cmpxchg (compare-and-swap) সবচেয়ে জনপ্রিয় atomic
primitive, lock add বা lock xchg-এর বদলে — যখন এই সবগুলোই
atomic instruction?কারণ CAS-এর “compare” অংশটা একটা optimistic concurrency
প্যাটার্ন সম্ভব করে যা অন্য simpler atomic instruction-এ করা
যায় না — এটা একমাত্র সাধারণ primitive যা দিয়ে “শুধু যদি এখনো
আমার ধারণা করা মানই থাকে, তবেই বদলাও” — এই যুক্তি প্রকাশ করা
যায়।
lock add আর lock xchg-এর সীমাবদ্ধতা:
lock add [counter], 1 ; সবসময় সফল, কিন্তু শুধু "যোগ করো" করতে পারে
lock xchg [state], eax ; সবসময় সফল, পুরনো মান রিটার্ন করেদুটোই unconditional — এরা সবসময় কাজটা করেই ফেলে, কোনো শর্ত ছাড়াই। এটা counter increment-এর জন্য যথেষ্ট, কিন্তু জটিল data structure আপডেটের জন্য নয়।
CAS-এর শক্তি — শর্তসাপেক্ষ আপডেট:
int expected = old_value;
int success = atomic_compare_exchange_strong(&var, &expected, new_value);
/* শুধু var == old_value হলেই var = new_value হবে */এই “যদি এখনো একই থাকে” শর্তটাই lock-free data structure-এর ভিত্তি — একটা linked list-এর head pointer আপডেট করতে চাইলে:
node_t *old_head = atomic_load(&list_head);
node_t *new_node = create_node(data);
new_node->next = old_head;
/* যদি এই মুহূর্তে অন্য কোনো thread ইতিমধ্যে head বদলে ফেলে,
আমাদের CAS ব্যর্থ হবে — আমরা আবার চেষ্টা করব (retry loop) */
while (!atomic_compare_exchange_weak(&list_head, &old_head, new_node)) {
new_node->next = old_head; /* old_head এখন নতুন প্রকৃত মান — CAS নিজেই আপডেট করেছে */
}এই প্যাটার্ন — read, compute, CAS, ব্যর্থ হলে retry —
হলো lock-free algorithm-এর সার্বজনীন কাঠামো। এটাই futex-এর
নিজের mutex implementation-এও ব্যবহৃত হয়েছে (আজকের
my_mutex_lock-এর প্রথম ধাপ, atomic_compare_exchange_strong)।
আরেকটা কারণ CAS জনপ্রিয় — এটা “universal” primitive
হিসেবে প্রমাণিত। Computer science-এর একটা তত্ত্ব
(Herlihy-র consensus hierarchy) দেখায় CAS দিয়ে যেকোনো
lock-free data structure বানানো যায়, যেখানে add/xchg-এর
মতো সরল atomic instruction (যাদের “consensus number” কম)
দিয়ে তা সম্ভব নয় বহু thread-এর ক্ষেত্রে। এই কারণেই আধুনিক
CPU সবাই cmpxchg-কে একটা প্রথম-শ্রেণির instruction হিসেবে
রাখে, আর প্রায় সব high-level atomic API (C11 stdatomic.h,
C++ std::atomic, Java AtomicInteger) CAS-কে কেন্দ্রীয়
primitive হিসেবে প্রকাশ করে।
Level 6-এ আমরা lock-free data structure (queue, stack) নিজে বানাব ঠিক এই CAS-retry প্যাটার্ন দিয়ে।
5আপনার তিন-অবস্থার mutex-এ unlock()-এ যদি ভুল করে
atomic_store (plain, exchange নয়) ব্যবহার করা হতো state-কে
UNLOCKED করতে, তাহলে কী সমস্যা হতো?
void buggy_unlock(my_mutex_t *m) {
atomic_store(&m->state, UNLOCKED); /* exchange-এর বদলে store */
futex(&m->state, FUTEX_WAKE, 1); /* সবসময় wake ডাকা হচ্ছে */
}
প্রয়োগ
unlock()-এ যদি ভুল করে
atomic_store (plain, exchange নয়) ব্যবহার করা হতো state-কে
UNLOCKED করতে, তাহলে কী সমস্যা হতো?void buggy_unlock(my_mutex_t *m) {
atomic_store(&m->state, UNLOCKED); /* exchange-এর বদলে store */
futex(&m->state, FUTEX_WAKE, 1); /* সবসময় wake ডাকা হচ্ছে */
}দুইটা সমস্যা — একটা performance, একটা সম্ভাব্য race।
সমস্যা ১ — performance-এর ক্ষতি, correctness নয়।
atomic_store পুরনো মান রিটার্ন করে না — তাই আমরা জানতেই
পারছি না আগের state LOCKED_NO_WAITERS (কোনো waiter ছিল না)
নাকি LOCKED_WAITERS (waiter ছিল)। তাই কোড সবসময়
FUTEX_WAKE ডাকতে বাধ্য হচ্ছে, “নিরাপদ” থাকার জন্য।
এটা সরাসরি এই লেসনের কেন্দ্রীয় optimization নষ্ট করে দেয় —
uncontended path-এও এখন প্রতিটা unlock() একটা syscall করছে।
আজকের প্রথম experiment-এ যে “শূন্য futex call” দেখেছিলাম,
এই bug-যুক্ত সংস্করণে সেটা হতো “প্রতিবার একটা futex call” —
দশ লক্ষ syscall, যেখানে সঠিক সংস্করণে শূন্য ছিল।
সমস্যা ২ — সম্ভাব্য race, correctness-এও প্রভাব।
আরো সূক্ষ্ম সমস্যা: atomic_store আর তারপরের FUTEX_WAKE-এর
মাঝখানে একটা window আছে যেখানে অন্য কোনো thread ইতিমধ্যে
এই lock-টা নতুন করে নিয়ে নিতে পারে (যদি LOCKED_NO_WAITERS-এ
CAS করে), তারপর সেই নতুন lock-ধারী নিজেই দ্রুত unlock()
ডেকে ফেলতে পারে — এবং তখন দুইটা FUTEX_WAKE call হতে
পারে একটা প্রকৃত unlock-এর জন্য, যা নিরাপদ (harmless বাড়তি
wake) কিন্তু অপ্রয়োজনীয় ওভারহেড।
সঠিক সংস্করণ কেন atomic_exchange ব্যবহার করে:
void my_mutex_unlock(atomic_int *state) {
if (atomic_exchange(state, UNLOCKED) == LOCKED_WAITERS)
futex(state, FUTEX_WAKE, 1);
}atomic_exchange একটা atomic ধাপে দুইটা কাজ করে: নতুন
মান বসায়, এবং পুরনো মান রিটার্ন করে। এই তথ্যটাই ব্যবহার
করে সিদ্ধান্ত নেওয়া হয় FUTEX_WAKE লাগবে কি না — কোনো বাড়তি
read-then-write দরকার নেই (যা নিজেই একটা নতুন race window
তৈরি করত)।
সাধারণ শিক্ষা — lock-free/futex কোড লেখার সময় “কোন atomic operation ঠিক কী তথ্য দেয়” এটা মনোযোগ দিয়ে বাছতে হয়:
| Operation | কী রিটার্ন করে | কখন দরকার |
|---|---|---|
atomic_store | কিছুই না | শুধু লেখা, পুরনো মান অপ্রাসঙ্গিক হলে |
atomic_exchange | পুরনো মান | “কী ছিল, আর এখন কী” — উভয়ই দরকার হলে |
atomic_compare_exchange | সফল/ব্যর্থ + প্রকৃত মান | শর্তসাপেক্ষ আপডেট |
এই তিনটার মধ্যে সঠিকটা না বাছলে — যেমন এই প্রশ্নের bug-টা — হয় শুধু performance নষ্ট হয় (এই ক্ষেত্রে), অথবা আরো খারাপ ক্ষেত্রে প্রকৃত correctness bug (lost wakeup, race condition) তৈরি হতে পারে। প্রতিটা atomic operation বাছার সময় প্রশ্ন করুন: “আমার এই মুহূর্তে ঠিক কোন তথ্যটা দরকার — শুধু লিখতে, নাকি পুরনো মানও জানতে, নাকি একটা শর্ত যাচাই করতে?”
এরপর কী
পরের লেসন — Namespace ও cgroups
আজকের লেসন পর্যন্ত আমরা একটা মেশিনের ভেতরে thread ও process কীভাবে সহাবস্থান করে, একে অপরের সাথে নিরাপদে synchronize করে, আর কীভাবে সেই synchronization হার্ডওয়্যার থেকে শুরু করে বাস্তবায়িত হয় — এই পুরো গল্পটা দেখেছি।
পরের লেসনে আমরা একটা সম্পূর্ণ ভিন্ন প্রশ্নে যাব: একটা process-কে কীভাবে সম্পূর্ণভাবে বিভ্রম দেওয়া যায় যে সে একাই একটা মেশিনে আছে — যখন আসলে সে শত শত অন্য process-এর সাথে একই kernel, একই hardware শেয়ার করছে।
এই “বিভ্রম তৈরি করা”-ই আধুনিক container-এর সম্পূর্ণ ভিত্তি — Docker, Kubernetes, LXC — সবকিছুই দুইটা kernel feature-এর উপর দাঁড়িয়ে: namespace (process কী দেখতে পায় তা নিয়ন্ত্রণ) আর cgroups (process কতটা ব্যবহার করতে পারে তা নিয়ন্ত্রণ)।
সবচেয়ে গুরুত্বপূর্ণ punchline যা আমরা প্রমাণ করব: একটা
“container” কোনো kernel object নয় — kernel-এ struct container
বলে কিছু নেই। এটা শুধু একটা সাধারণ process, কিছু অস্বাভাবিক
namespace আর cgroup সেটিং সহ। আর সেই সরল সত্যটাই ব্যাখ্যা করে
কেন container escape মূলত একটা kernel-privilege সমস্যা,
আলাদা কোনো “container security” সমস্যা নয়।
আরও পড়ুন
- Futexes Are Tricky — Ulrich Drepper · futex-এর মূল ডিজাইন ডকুমেন্ট — glibc-র বাস্তব বাস্তবায়নের ভিত্তি, এই লেসনের কাঠামোর সবচেয়ে কাছের উৎস
- futex(2) man page · FUTEX_WAIT, FUTEX_WAKE-এর প্রামাণ্য semantics
- Intel 64 and IA-32 Architectures SDM, Volume 3A, §8.1 — Locked Atomic Operations — Intel Corporation · lock prefix ও cmpxchg-এর হার্ডওয়্যার সংজ্ঞা