عملیات اتمیک و ترتیب حافظه
برنامهنویسی بدون قفل با std::atomic را درک کنید. بر ترتیبهای حافظه (relaxed، acquire، release، seq_cst) تسلط یابید، یک spinlock با atomic_flag پیادهسازی کنید و الگوی مقایسه-و-جایگزینی (CAS) را بیاموزید.
عملیات اتمیک چیست؟
یک عملیات اتمیک (atomic) تقسیمناپذیر است — یا بهطور کامل انجام میشود یا اصلاً انجام نمیشود، و هیچ حالت میانی برای نخهای دیگر قابل مشاهده نیست. std::atomic دسترسی اتمیک به مقداری از نوع T را بدون نیاز به mutex فراهم میکند.
یک ++counter ساده روی یک int معمولی که بین نخها مشترک است را در نظر بگیرید. این در واقع سه مرحله است: بارگذاری، افزایش، ذخیره. نخ دیگری میتواند بین این مراحل قرار بگیرد و مسابقه داده (data race) ایجاد کند (رفتار نامعین). با std::atomic، افزایش یک عملیات اتمیک واحد است — بدون تداخل، بدون UB.
اما اتمیکها فراتر از صرفاً «عملیات تقسیمناپذیر» هستند. آنها همچنین ترتیب حافظه (memory ordering) را کنترل میکنند — قوانینی که تعیین میکنند چه زمانی نوشتنهای یک نخ برای نخهای دیگر قابل مشاهده میشوند. اینجاست که اتمیکها هم قدرتمند و هم ظریف میشوند.
مبانی std::atomic
رایجترین عملیات اتمیک load()، store()، exchange() و fetch_add()/fetch_sub() هستند. همچنین میتوانید از عملگرهای ++، --، += و غیره استفاده کنید.
#include <iostream>
#include <atomic>
#include <thread>
#include <vector>
std::atomic<int> counter{0}; // atomic integer, initialized to 0
void increment(int n) {
for (int i = 0; i < n; ++i) {
counter.fetch_add(1, std::memory_order_relaxed);
// or simply: ++counter; (uses seq_cst by default)
}
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 10; ++i) {
threads.emplace_back(increment, 100'000);
}
for (auto& t : threads) t.join();
std::cout << "Counter: " << counter.load() << "\n";
// Always prints 1000000 — no data race, no mutex needed
return 0;
}توضیح ترتیب حافظه
پردازندهها و کامپایلرهای مدرن عملیات حافظه را برای عملکرد بهتر بازترتیب میدهند. در x86 این نسبتاً ملایم است، اما در ARM/RISC-V تهاجمی است. ترتیبهای حافظه به کامپایلر و پردازنده میگویند کدام بازترتیبها مجازند.
memory_order_seq_cst (پیشفرض) — قویترین ترتیب. همه نخها همه عملیات seq_cst را در یک ترتیب کلی یکسان میبینند. ساده و امن اما ممکن است بهینهسازی را محدود کند.
memory_order_acquire — روی بارگذاریها (load) استفاده میشود. هیچ خواندن/نوشتن حافظهای در نخ فعلی نمیتواند قبل از این بارگذاری بازترتیب شود. اینطور فکر کنید: «هر چه بعد از این نقطه بخوانم، حداقل آنچه نخ آزادکننده نوشته را میبیند.»
memory_order_release — روی ذخیرهسازیها (store) استفاده میشود. هیچ خواندن/نوشتن حافظهای در نخ فعلی نمیتواند بعد از این ذخیرهسازی بازترتیب شود. اینطور فکر کنید: «هر چه قبل از این نقطه نوشتم، برای نخ گیرنده (acquire) قابل مشاهده است.»
memory_order_acq_rel — ترکیب acquire و release. روی عملیات خواندن-تغییر-نوشتن مانند compare_exchange استفاده میشود.
memory_order_relaxed — هیچ تضمین ترتیبی فراتر از اتمیک بودن ندارد. عملیات اتمیک است اما میتواند آزادانه بازترتیب شود. فقط برای شمارندهها یا آمار مستقل که ترتیب اهمیتی ندارد استفاده کنید.
Acquire-Release در عمل
الگوی acquire-release ابزار اصلی برنامهنویسی بدون قفل (lock-free) است. یک ذخیرهسازی release در یک نخ با یک بارگذاری acquire در نخ دیگر همزمانسازی میشود و یک رابطه happens-before ایجاد میکند.
#include <iostream>
#include <atomic>
#include <thread>
#include <cassert>
std::atomic<bool> data_ready{false};
int payload = 0; // non-atomic — protected by acquire-release
void producer() {
payload = 42; // write payload BEFORE the release store
data_ready.store(true, std::memory_order_release);
// release: all writes before this store are visible
// to a thread that does an acquire load of data_ready
}
void consumer() {
while (!data_ready.load(std::memory_order_acquire)) {
// spin — acquire: sees all writes before the release store
}
// guaranteed to see payload == 42
assert(payload == 42); // never fires
std::cout << "Payload: " << payload << "\n";
}
int main() {
std::thread t1(producer);
std::thread t2(consumer);
t1.join();
t2.join();
return 0;
}الگوی مقایسه-و-جایگزینی (CAS)
مقایسه-و-جایگزینی (Compare-and-Swap) (compare_exchange_weak/compare_exchange_strong) بلوک ساختمانی بنیادین ساختارهای داده بدون قفل است. بهطور اتمیک بررسی میکند آیا مقدار برابر مقدار مورد انتظار است و اگر چنین باشد، آن را با مقدار مطلوب جایگزین میکند.
#include <iostream>
#include <atomic>
#include <thread>
#include <vector>
std::atomic<int> max_value{0};
void update_max(int new_val) {
int current = max_value.load(std::memory_order_relaxed);
// CAS loop: keep trying until we succeed or new_val is not bigger
while (new_val > current) {
if (max_value.compare_exchange_weak(
current, // expected (updated on failure)
new_val, // desired
std::memory_order_relaxed)) {
break; // success
}
// On failure, 'current' is updated with the actual value
// Loop re-checks if new_val > current (maybe another thread won)
}
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 100; ++i) {
threads.emplace_back(update_max, i);
}
for (auto& t : threads) t.join();
std::cout << "Max: " << max_value.load() << "\n"; // always 99
return 0;
}Spinlock با std::atomic_flag
std::atomic_flag ابتداییترین نوع اتمیک است — تضمین شده بدون قفل (lock-free) روی هر پلتفرمی. فقط test_and_set() و clear() را پشتیبانی میکند و برای یک spinlock ساده عالی است.
#include <iostream>
#include <atomic>
#include <thread>
#include <vector>
class SpinLock {
std::atomic_flag flag_ = ATOMIC_FLAG_INIT;
public:
void lock() {
while (flag_.test_and_set(std::memory_order_acquire)) {
// spin — consider adding a pause/yield hint:
// __builtin_ia32_pause() or std::this_thread::yield()
}
}
void unlock() {
flag_.clear(std::memory_order_release);
}
};
SpinLock spin;
int shared_data = 0;
void work(int n) {
for (int i = 0; i < n; ++i) {
spin.lock();
++shared_data; // protected by spinlock
spin.unlock();
}
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 4; ++i) {
threads.emplace_back(work, 100'000);
}
for (auto& t : threads) t.join();
std::cout << "shared_data: " << shared_data << "\n";
// prints 400000
return 0;
}memory_order_relaxed هیچ تضمین ترتیبی فراتر از خود اتمیک بودن فراهم نمیکند. استفاده از آن برای محافظت از دادههای مشترک غیراتمیک (مانند یک پرچم محافظ بار) یک باگ است:
// باگ: ذخیره/بارگذاری relaxed بار را همزمانسازی نمیکند
payload = 42;
ready.store(true, memory_order_relaxed); // ممکن است قبل از payload=42 بازترتیب شود!
// مصرفکننده:
while (!ready.load(memory_order_relaxed)) {}
assert(payload == 42); // ممکن است روی ARM/POWER شکست بخورداز memory_order_relaxed فقط وقتی استفاده کنید که متغیر اتمیک بهتنهایی قرار دارد و نیازی به همزمانسازی دسترسی به دادههای دیگر ندارد. برای الگوهای انتشار-مصرف، از release/acquire استفاده کنید.
با memory_order_seq_cst (پیشفرض) شروع کنید و فقط وقتی پروفایلگیری ثابت کرد ضروری است به ترتیبهای ضعیفتر تغییر دهید. ترتیبهای حافظه نادرست باگهایی تولید میکنند که بازتولید آنها روی x86 تقریباً غیرممکن است اما روی ARM ظاهر میشوند. برای حالت مشترک پیچیده mutex را ترجیح دهید — اتمیکها برای متغیرهای مشترک ساده و ساخت ابزارهای بدون قفل هستند. همیشه فرضیات بدون قفل بودن را با std::atomic تأیید کنید.
std::atomicعملیات تقسیمناپذیر بدون mutex فراهم میکند — برای خواندن و نوشتن همزمان امن است- ترتیب حافظه (memory ordering) کنترل میکند چگونه نوشتنهای یک نخ برای نخهای دیگر قابل مشاهده میشوند — فقط درباره اتمیک بودن نیست
- Acquire-release الگوی کلیدی است: ذخیرهسازی release داده را منتشر میکند، بارگذاری acquire آن را مصرف میکند
compare_exchange_weak/strong(CAS) بلوک ساختمانی الگوریتمهای بدون قفل استstd::atomic_flagتنها نوعی است که تضمین شده روی همه پلتفرمها بدون قفل (lock-free) است- بهطور پیشفرض از
seq_cstاستفاده کنید و فقط وقتی پروفایلگیری مطالبه میکند آن را ضعیف کنید — باگهای ظریف برای اشکالزدایی بسیار سخت هستند
آزمون — دانش خود را بسنجید
(25 امتیاز)1. `memory_order_acquire` روی عملیات بارگذاری (load) چه تضمینی میدهد؟
2. چرا `compare_exchange_weak` معمولاً در یک حلقه استفاده میشود؟
3. چه زمانی استفاده از `memory_order_relaxed` امن است؟