یک دستور واحد میتواند مانند یک کنش تجزیهناپذیر به نظر برسد، در حالی که اصلاً چنین نیست. افزودن پنج به یک مقدار در حافظه را در نظر بگیرید:
add qword [rel counter], 5
در زیر ظاهر، پردازنده راهی برای افزودن مستقیم به حافظه ندارد. این یک دستور را به سه گام کوچکتر به نام ریزعمل میشکند:
mov rax, qword [rel counter] ; 1. load the current value
add rax, 5 ; 2. add five
mov qword [rel counter], rax ; 3. store the result back
این الگویی رایج است که خواندن-تغییر-نوشتن (RMW) نامیده میشود.
توجه کنید که خواندن (یا بارگذاری) و نوشتن (یا ذخیره) رویدادهای جداگانهاند، بنابراین پنجرهای زمانی میان آن دو وجود دارد.
ما معمولاً این را متوجه نمیشویم، چون روی یک هسته تضمین شده است که هر دستور اثرش را بهطور کامل پیش از دستور بعدی تولید کند.
بنابراین آن پنجره نامرئی است و add مانند یک واحد یکپارچه رفتار میکند.
با این حال، پردازندههای جدید بهندرت تکهستهایاند و برنامهها اغلب روی هستههای متعدد و همزمان اجرا میشوند، بدون هیچ ترتیببندی میان آنها.
وقتی چندین هسته در همان لحظهی واحد در حال اجرا هستند، هستهای دیگر میتواند counter را درون آن پنجره بخواند یا بنویسد، پس از بارگذاری این هسته و پیش از ذخیرهاش.
دو رشته هر کدام همان مقدار قدیمی را بارگذاری میکنند، هر کدام پنج میافزایند و هر کدام نتیجهشان را ذخیره میکنند.
دو بار جمع رخ داد، اما مقدار فقط پنج واحد جابهجا شد.
یک بهروزرسانی بیصدا از دست رفت.
این یک مسابقهی داده است و مسئلهای رایج در کد چندرشتهای است.
هر مقداری اینگونه در معرض خطر نیست.
هر رشته ثباتهای خودش و پشتهی خودش را دارد، بنابراین مقداری که در یک ثبات نگه داشته میشود، یا متغیری محلی روی پشتهی یک رشته، اختصاصی همان رشته است و نمیتواند دچار مسابقه شود.
فقط حافظهی اشتراکی رشتهها، مانند counter بالا، نیاز به محافظت دارد.
معماری x86-64 مجموعهای از دستورها را برای حل این مشکل ارائه میدهد، با تجزیهناپذیر کردن یک دستور. این دستور نهفقط برای هستهای که آن را پردازش میکند، بلکه برای هر هستهی دیگر نیز مانند یک واحد یکپارچه رفتار میکند. عملیاتی که یکپارچه میماند و هیچ هستهی دیگری نمیتواند آن را از هم بپاشد، اتمیک نامیده میشود.
رایج است که به برنامههایی که روی هستههای متعدد اجرا میشوند، چندرشتهای گفته شود. با این حال، یک رشته مانند یک هسته نیست. دو رشته ممکن است بهطور همروند روی یک هسته اجرا شوند و با یکدیگر درهمبافته شوند، یا بهطور موازی روی هستههای مختلف.
رشتههای درهمبافته از همین حالا میتوانند روی یک خواندن-تغییر-نوشتن دچار مسابقه شوند، اگر آن به چندین دستور تقسیم شده باشد، چون سیستمعامل میتواند بین هر دو دستور رشتهها را عوض کند. اما پنجرهی درون یک دستور یگانه فقط توسط کد موازی واقعی در معرض دید قرار میگیرد. چون سیستمعامل فقط بین دستورها رشتهها را عوض میکند و هرگز درون یک دستور، یک دستور واحد روی یک هسته بهطور ذاتی ایمن است.
اتمیک بودن واقعی در چند هسته همان چیزی است که دستورهای زیر فراهم میکنند.
دستور xchg دو عملوند را جابهجا میکند.
عملوند مقصد برابر با مقدار پیشین در عملوند مبدأ میشود، در حالی که عملوند مبدأ برابر با مقدار پیشین در عملوند مقصد میشود.
از نظر مفهومی میتوان آن را مانند دو دستور mov در نظر گرفت که همزمان رخ میدهند.
طبق معمول، میتوان آن را با دو عملوند ثبات یا با یک عملوند حافظه و یک عملوند ثبات به کار برد:
mov eax, 1
xchg dword [rdi], eax ; [rdi] = 1, eax = the old value of [rdi]
mov ecx, 2
mov edx, 3
xchg edx, ecx ; edx = 2, ecx = 3
وقتی با یک عملوند حافظه به کار میرود، xchg همیشه اتمیک است.
xchg وقتی یکی از عملوندها یک مکان حافظه باشد، بهطور خودکار اتمیک است.
این یعنی در آن حالت عملیات بسیار کندتر هم هست.
اگر به اتمیک بودن نیازی ندارید، جابهجایی را از طریق یک ثبات ذخیره به کمک دستورهای سادهی mov انجام دهید.
lock
رایجترین راه اتمیک کردن یک دستور در x86-64 افزودن پیشوند lock است.
این پیشوند خواندن، تغییر و نوشتن را در یک گام تجزیهناپذیر یکی میکند.
یعنی هسته در سراسر این فرایند حافظه را بهطور انحصاری در اختیار دارد، بنابراین هیچ هستهی دیگری نمیتواند در این فاصله آن مکان را بخواند یا بنویسد.
lock add qword [rel counter], 5 ; the read, the modify, and the write are one step
lock فقط وقتی کار میکند که مقصد حافظه باشد، و فقط روی دستورهایی که آن حافظه را میخوانند-تغییر-مینویسند:
add، sub، inc، dec، neg؛and، or، xor، not؛bts، btr، btc؛xadd و cmpxchg که در ادامه توضیح داده میشوند.در انحصار نگه داشتن یک مکان و بیرون نگه داشتن هر هستهی دیگر رایگان نیست.
یک عملیات با پیشوند lock بهطور محسوسی کندتر از شکل سادهاش است، و وقتی چند هسته بر سر همان مکان رقابت کنند کندتر هم میشود.
این پیشوند باید برای حافظهای کنار گذاشته شود که انتظار میرود بیش از یک رشته آن را تغییر دهد. اگر حافظه اشتراکی نیست یا فقط خوانده میشود، از به کار بردن آن بپرهیزید.
یک lock add ساده حافظه را بهروزرسانی میکند اما مقدار قدیمی را دور میریزد.
اغلب همان مقدار قدیمی دقیقاً چیزی است که میخواهیم، مثلاً برای دادن یک شمارهی بلیت مجزا به هر رشته.
دستور xadd (حرف x به معنای تبادل) هنگام افزودن، مقدار پیشین را برمیگرداند.
جمع را در مقصد مینویسد و مقدار اصلی مقصد را در ثبات مبدأ باقی میگذارد.
mov rax, 1
lock xadd qword [rdi], rax ; [rdi] = [rdi] + rax = [rdi] + 1
; rax = the old value of [rdi]
با پیشوند lock این یک واکشی-و-افزودن اتمیک است.
وقتی رشتههای متعدد آن را روی یک شمارندهی یکسان اجرا میکنند، هر فراخوانی مقدار قدیمی متفاوتی برمیگرداند.
مانند lock add، شمارنده در پایان دقیقاً برابر تعداد فراخوانیها است.
اما برخلاف lock add، هر مقدار میانی نیز برگردانده میشود، یکی به هر فراخوان.
xadd میافزاید و xchg بازنویسی میکند، اما هیچکدام نمیتوانند مقدار جدید را وابسته به مقدار کنونی کنند و آن را فقط در صورتی اعمال کنند که چیزی در زیر آن تغییر نکرده باشد.
همین بهروزرسانی شرطی است که cmpxchg، یعنی مقایسه-و-تبادل، فراهم میکند و کلیترین این عملیاتهای پایه است.
cmpxchg dest, src از rax بهعنوان یک انبارهی ضمنی استفاده میکند و آن را با dest میسنجد:
dest == rax، آنگاه dest = src و ZF = 1.dest != rax، آنگاه rax = dest و ZF = 0.توجه کنید که dest فقط وقتی بهروزرسانی میشود که با مقدار مورد انتظار، که پیشتر در rax بارگذاری شده، برابر باشد.
این برابری تضمین میکند که dest هنوز همان مقداری را دارد که مقدار جدید از آن محاسبه شده است، بنابراین بهروزرسانیای که بر پایهی خواندنی کهنه باشد هرگز اعمال نمیشود.
همین cmpxchg را به بلوک سازندهی یک بهروزرسانی اتمیک تبدیل میکند که با نام مقایسه-و-جابهجایی (CAS) نیز شناخته میشود:
mov rax, qword [rdi] ; rax = the value we expect to find
.retry:
lea rcx, [rax + 10] ; rcx = the new value we want to install
lock cmpxchg qword [rdi], rcx ; if [rdi] still equals rax, store rcx and set ZF
; otherwise reload rax with the current value, clear ZF
jnz .retry ; ZF is cleared, so another thread won the race. Recompute and retry
این حلقهی تلاش مجدد قلب بهروزرسانیهای بدون قفل است.
پنجرهی میان خواندن و مقایسه-و-تبادل دقیقاً همان زمانی است که ممکن است رشتهای دیگر دخالت کند، و cmpxchg با خودداری از ذخیرهی مقداری که از خواندنی کهنه محاسبه شده، آن را میگیرد.
هر عملیات تا اینجا فقط یک مکان را لمس کرده است. وقتی رشتهها از طریق بیش از یک مکان با هم هماهنگ میشوند، پرسش تازهای پدید میآید: نوشتههای یک رشته به چه ترتیبی برای دیگری مرئی میشوند. قواعدی که به این پرسش پاسخ میدهند، ترتیب حافظهی پردازندهاند.
مفهوم کد بدون انشعاب این ایده را معرفی کرد که یک هستهی جدید دستورها را یکییکی و بهکندی پیش نمیبرد. بلکه بسیاری را همزمان در جریان نگه میدارد و هرجا بتواند جلوتر میرود. یعنی ممکن است یک نوشتن دیرتر از آنچه برنامه نشان میدهد برای هستههای دیگر مرئی شود، در حالی که دستورهای پس از آن پیشتر رفتهاند.
معماری x86-64 در میان بارگذاریها و ذخیرههای معمولی یک ترتیب حافظهی قوی را حفظ میکند، بهگونهای که روی هر هسته:
تنها بازچینش ممکن این است که یک ذخیره بهگونهای به نظر برسد که پس از یک بارگذاری متأخر از نشانی متفاوت کامل شده است.
یک دستور با پیشوند lock، یا یک xchg با عملوند حافظه، یک سد کامل است: هیچ چیز به نظر نمیرسد که از آن به هر دو جهت عبور کند.
به همین دلیل است که برای تضمین ترتیب کامل در بیشتر موقعیتها کافیاند.
pause
دستورهایی که پرچمی را تنظیم میکنند و همزمان حالت پیشین آن را برمیگردانند، آزمون-و-تنظیم نامیده میشوند. میتوان از آنها بهعنوان پایهی یک قفل چرخشی استفاده کرد و تضمین میکنند که یک هسته دسترسی انحصاری به بخشی از کد داشته باشد.
الگوریتم کلی چنین است، با استفاده از دستور xchg و یک پرچم دودویی:
0 شروع میشود.1 جابهجا میکند.1 باشد، یعنی قفل توسط هستهای دیگر در اختیار است.
هستهی کنونی سپس منتظر میماند و دوباره تلاش میکند قفل را تصاحب کند.0 باشد، یعنی قفل آزاد بود.
xchg اکنون آن را به 1 تنظیم کرده است و هستههای دیگر منتظر میمانند تا این هسته آن را آزاد کند.0 قفل را آزاد میکند.acquire:
mov eax, 1
xchg dword [rdi], eax ; try to take the lock; eax = its old value
test eax, eax
jnz .held ; old value was 1: someone else holds it
ret ; old value was 0: the lock is ours
.held:
pause ; wait before trying again
jmp acquire
دستور pause در حلقهی انتظار تغییری در آنچه کد محاسبه میکند نمیدهد.
به پردازنده اشاره میکند که این یک انتظار چرخشی است.
پردازنده میتواند آنگاه مصرف توان را در رشتهی منتظر کاهش دهد و به رشتهای خواهر که همان هسته را به اشتراک میگذارد واگذار کند.
یک حلقهی چرخشی بدون pause همچنان درست است، فقط پرخرج.
وقتی رشته کارش را تمام کرد، میتواند قفل را با یک ذخیرهی سادهی 0 به کمک mov آزاد کند.
چیزی بیش از یک mov لازم نیست، چون در x86-64 بارگذاریها و ذخیرهها هرگز بعد از یک ذخیرهی متأخر بازچینش نمیشوند.
به ذخیرهای که هرگز از دسترسیهای پیش از خود جلو نمیافتد، گفته میشود که ترتیب آزادسازی دارد و در x86 هر ذخیرهی ساده آن را دارا است.
هر دستوری که یک مکان حافظه را بهطور اتمیک بیازماید و تنظیم کند، میتواند برای یک قفل چرخشی به کار رود.
برای نمونه، میتوان بهجای xchg از lock bts استفاده کرد، تا بیتی مشخص را تنظیم کند و در همان حال بررسی کند که آیا پیشتر تنظیم شده بود یا نه.
توجه کنید که پرچمها، مانند CF که توسط bts تغییر میکند، بخشی از rflags هستند، یک ثبات.
یعنی انحصاری همان رشتهاند.
شب افتتاحیه در گرند مارکی است و تمام کارکنان یکجا مشغول کارند. جمعیت از هر دری سرازیر میشود، چند باجهی فروش بلیت از یک رول بلیت مشترک میفروشند، بلیتهای نمایش افتتاحیه نزدیک به تمام شدن است و بالا در اتاق پروجکشن، حلقههای فیلم باید مدام بچرخند.
هر چیزی که کارکنان به اشتراک میگذارند در یک آدرس حافظه زندگی میکند و تستها توابع شما را بهطور همزمان از چند ریسمان فراخوانی میکنند. مقداری که در چند مرحلهی جداگانه خوانده، تغییر داده و دوباره نوشته شود، زیر آن بار، بهروزرسانیهایش را از دست میدهد؛ پس هر تابع باید بهروزرسانیاش را «اتمی» انجام دهد.
همهی مقادیر مشترک در این تمرین، اعداد صحیح علامتدار ۶۴ بیتی هستند.
پنج کار در پیش دارید.
بهروزرسانیها در این تمرین باید با دستورهای «اتمی» انجام شوند. تستها توابع شما را بهطور همزمان اجرا میکنند و بررسی میکنند که هیچ بهروزرسانیای از دست نرود، بنابراین یک خواندن-تغییر-نوشتن غیراتمی به احتمال زیاد در آنها شکست میخورد.
درها باز هستند و در هر یک از آنها، یک راهنما بلیتها را پاره میکند. هر گروهی که عبور داده میشود، در یک شمارش مشترک پذیرشها شمرده میشود؛ و با اینکه هر در بهطور همزمان میشمارد، شمارش کل سالن باید همچنان دقیق از آب دربیاید.
تابع admit_group را پیادهسازی کنید که اندازهی یک گروه را بهصورت اتمی به شمارش اضافه میکند.
این تابع، به ترتیب، این آرگومانها را میگیرد:
admissions: آدرس حافظهی شمارش مشترک پذیرشها، یک عدد صحیح علامتدار ۶۴ بیتی.group_size: تعداد افراد گروه، یک عدد صحیح علامتدار ۶۴ بیتی، همیشه بزرگتر از 0.admissions = 120, group_size = 4
// admissions is now 124
تستها گروهها را بهطور همزمان از چند در عبور میدهند. شمارش نهایی باید برابر با مجموع همهی گروههایی باشد که عبور داده شدهاند.
این تابع مقدار بازگشتی ندارد. شمارش باید در جای خودش بهروزرسانی شود.
پروجکتور فقط یک حلقهی فیلم را نصب میکند. نصب حلقهی بعدی و برداشتن حلقهی فعلی باید یک حرکت واحد باشد: با دو مسئول پروجکشن پشت دستگاه، دو مرحلهی جداگانه میتواند یک حلقهی یکسان را به هر دوی آنها بدهد، یا حلقهای را روی زمین بیندازد.
تابع change_reel را پیادهسازی کنید که بهصورت اتمی حلقهی بعدی را نصب میکند و حلقهی قبلی را برمیگرداند.
این تابع، به ترتیب، این آرگومانها را میگیرد:
mounted: آدرس حافظهی شناسهی حلقهی نصبشده، یک عدد صحیح علامتدار ۶۴ بیتی.next_reel: شناسهی حلقهای که باید نصب شود، یک عدد صحیح علامتدار ۶۴ بیتی.mounted = 7, next_reel = 9
// => 7, and mounted is now 9
این تابع شناسهی حلقهی نصبشدهی قبلی را بهصورت یک عدد صحیح علامتدار ۶۴ بیتی برمیگرداند. تستها چند ریسمان را وادار میکنند که بهطور همزمان حلقهها را از طریق همان جایگاه نصب عوض کنند و بررسی میکنند که هر حلقهی تحویلشده دقیقاً یک بار بیرون بیاید.
هر باجه از یک شمارش مشترک بلیتهای فروختهشده میفروشد. افزودن به یک شمارش، همانطور که درها انجام میدهند، اینجا کافی نیست: هر فروش باید شمارهای را هم که به آن داده شده بداند. هیچ دو فروشی نباید شمارهی یکسانی داشته باشند و پس از هجوم جمعیت، شمارش باید دقیقاً برابر با تعداد بلیتهای فروختهشده باشد.
تابع sell_ticket را پیادهسازی کنید که بهصورت اتمی یک واحد به شمارش اضافه میکند و شمارهی بلیتی را که همین حالا فروخته شده برمیگرداند.
شمارهی بلیت، شمارش پس از این فروش است: وقتی شمارش 41 بود، این فروش بلیت 42 است.
این تابع یک آرگومان میگیرد:
tickets_sold: آدرس حافظهی شمارش مشترک بلیتهای فروختهشده، یک عدد صحیح علامتدار ۶۴ بیتی.tickets_sold = 0
// => 1, and tickets_sold is now 1
tickets_sold = 41
// => 42, and tickets_sold is now 42
این تابع شمارهی بلیت را بهصورت یک عدد صحیح علامتدار ۶۴ بیتی برمیگرداند. تستها بهطور همزمان از چند باجه میفروشند: هر شمارهی بازگشتی باید یگانه باشد و شمارش نهایی باید برابر با تعداد فروشها باشد.
نمایش افتتاحیه تعداد ثابتی صندلی دارد و هر باجه در حال فروش آن است. یک فروش فقط تا زمانی که صندلیای باقی مانده باشد میتواند انجام شود و سالن نمایش هرگز نباید بیش از ظرفیتش فروخته شود، حتی وقتی هر باجه در یک لحظه برای آخرین صندلی به تکاپو میافتد. بررسی شمارش و سپس افزودن به آن، بهعنوان دو مرحلهی جداگانه، میانشان یک شکاف باقی میگذارد؛ پس بررسی و بهروزرسانی باید با هم موفق یا با هم ناموفق شوند.
تابع claim_seat را پیادهسازی کنید که وقتی صندلیای هنوز آزاد است، صندلی بعدی را بهصورت اتمی رزرو میکند.
وقتی صندلیای رزرو میشود، تابع شمارهی آن صندلی را برمیگرداند که همان شمارش صندلیهای گرفتهشده پس از این رزرو است.
وقتی بلیتهای نمایش کاملاً فروخته شده باشد، تابع 0 را برمیگرداند و شمارش را بدون تغییر میگذارد.
این تابع، به ترتیب، این آرگومانها را میگیرد:
seats_taken: آدرس حافظهی شمارش صندلیهای گرفتهشده، یک عدد صحیح علامتدار ۶۴ بیتی، هرگز بیشتر از capacity.capacity: تعداد صندلیهای نمایش، یک عدد صحیح علامتدار ۶۴ بیتی، همیشه بزرگتر از 0.seats_taken = 0, capacity = 150
// => 1, and seats_taken is now 1
seats_taken = 82, capacity = 150
// => 83, and seats_taken is now 83
seats_taken = 149, capacity = 150
// => 150, and seats_taken is now 150
seats_taken = 150, capacity = 150
// => 0, and seats_taken stays 150
این تابع شمارهی صندلی، یا 0 را وقتی بلیتهای نمایش فروخته شده باشد، بهصورت یک عدد صحیح علامتدار ۶۴ بیتی برمیگرداند.
اتاق پروجکشن جا برای یک نفر دارد.
یک واژه در حافظه مشخص میکند که آیا اشغال است: 0 برای آزاد، 1 برای اشغال.
کارکنان تمام شب سر میزنند و هر کدام کاری برای انجام دادن در داخل دارند: امضای دفتر ثبت، نخ کردن پروجکتور، بایگانی یک حلقهی فیلم.
هرچه این کار باشد، قانون این اتاق یکی است و اجرای آن به عهدهی شماست: هر بار فقط یک نفر در داخل، از آغاز کارش تا پایان آن.
تابع visit_booth را پیادهسازی کنید که:
تا زمانی که اتاق در دست شخص دیگری است، باید با pause مؤدبانه منتظر بماند.
این تابع، به ترتیب، این آرگومانها را میگیرد:
booth: آدرس حافظهی واژهی اشغال، یک عدد صحیح علامتدار ۶۴ بیتی که یا 0 است یا 1.task: کار بازدیدکننده، تابعی که هیچ پارامتری نمیگیرد و مقدار بازگشتی ندارد.توجه کنید که تستها کارهایی را اجرا میکنند که به اتاق و به یک دفتر ثبت مشترک نگاه میکنند. این یعنی اتاق باید در طول تمام آن کار اشغال بماند، هر بازدید باید کارش را دقیقاً یک بار اجرا کند و با چند بازدیدکنندهی همزمان، دفتر ثبت باید دقیق از آب دربیاید.
کار بازدیدکننده یک تابع معمولی است که از قرارداد فراخوانی پیروی میکند:
نقض هر یک از این دو قاعده ممکن است بهجای شکست در بررسی، بهصورت یک کرش ظاهر شود.
در Exercism ثبتنام کنید تا x86-64 Assembly را همراه با 22 مفهوم130 تمرین و مربیگری انسانی واقعی یاد بگیرید و در آن استاد شوید، همهی اینها رایگان.