کتاب مقدماتی SMP برای Android

نسخه‌های پلاتفرم Android 3.0 و بالاتر برای پشتیبانی از معماری‌های چندپردازنده‌ای بهینه‌سازی شده‌اند. این سند مشکلاتی را معرفی می‌کند که می‌تواند هنگام نوشتن کد چندرشته‌ای برای سیستم‌های چندپردازنده‌ای متقارن در C،‏ C++‎، و زبان برنامه‌نویسی Java (که ازاین‌پس برای اختصار به‌سادگی «Java» نامیده می‌شود) ایجاد شود. این راهنما به‌عنوان مقدمه‌ای برای توسعه‌دهندگان برنامه Android درنظر گرفته شده است، نه به‌عنوان بحثی کامل درباره این موضوع.

مقدمه

SMP مخفف «Symmetric Multi-Processor» (چندپردازنده متقارن) است. این اصطلاح طرحی را توصیف می‌کند که در آن دو یا چند هسته واحد پردازش مرکزی یکسان دسترسی به حافظه اصلی را به‌طور مشترک استفاده می‌کنند. تا چند سال پیش، همه دستگاه‌های Android از نوع UP (تک‌پردازنده) بودند.

اکثر دستگاه‌های Android (اگر نگوییم همه آن‌ها) همیشه چند CPU داشته‌اند، اما در گذشته فقط از یکی از آن‌ها برای اجرای برنامه‌ها استفاده می‌شد و بقیه CPUها بخش‌های مختلف سخت‌افزار دستگاه (مثلاً رادیو) را مدیریت می‌کردند. ممکن است واحدهای پردازش مرکزی معماری‌های متفاوتی داشته باشند و برنامه‌هایی که روی آن‌ها اجرا می‌شوند نتوانند از حافظه اصلی برای برقراری ارتباط با یکدیگر استفاده کنند.

اکثر دستگاه‌های Android که امروزه فروخته می‌شوند براساس طراحی‌های SMP ساخته شده‌اند، که کار را برای توسعه‌دهندگان نرم‌افزار کمی پیچیده‌تر می‌کند. شرایط رقابتی در برنامه چندرشته‌ای ممکن است مشکلات قابل‌مشاهده‌ای در تک‌پردازنده ایجاد نکند، اما ممکن است وقتی دو یا چند رشته شما به‌طور هم‌زمان در هسته‌های مختلف اجرا می‌شوند، به‌طور منظم با شکست مواجه شوند. علاوه‌براین، کد ممکن است هنگام اجرا در معماری‌های مختلف پردازنده یا حتی در پیاده‌سازی‌های مختلف از یک معماری، کم‌وبیش مستعد خرابی باشد. کدی که به‌طور کامل در x86 آزمایش شده است ممکن است در ARM به‌شدت خراب شود. ممکن است کد هنگام بازهمگردانی با همگردان مدرن‌تر با مشکل مواجه شود.

بقیه این سند دلیل آن را توضیح می‌دهد و به شما می‌گوید که برای اطمینان از عملکرد صحیح کدتان چه کاری باید انجام دهید.

مدل‌های سازگاری حافظه: چرا SMPها کمی متفاوت هستند

این یک نمای کلی سریع و جذاب از موضوعی پیچیده است. برخی‌از بخش‌ها ناقص خواهد بود، اما هیچ‌کدام نباید گمراه‌کننده یا اشتباه باشد. همان‌طور که در بخش بعدی خواهید دید، جزئیات اینجا معمولاً مهم نیستند.

برای راهنمایی درباره بررسی دقیق‌تر موضوع، به مطالعه بیشتر در انتهای سند مراجعه کنید.

مدل‌های سازگاری حافظه، یا اغلب فقط «مدل‌های حافظه»، تضمین‌هایی را که زبان برنامه‌نویسی یا معماری سخت‌افزار درباره دسترسی به حافظه ارائه می‌دهد توصیف می‌کنند. برای مثال، اگر مقداری را در نشانی A بنویسید و سپس مقداری را در نشانی B بنویسید، مدل ممکن است تضمین کند که هر هسته CPU آن نوشتن‌ها را به همان ترتیب می‌بیند.

مدلی که اکثر برنامه‌نویسان به آن عادت دارند سازگاری ترتیبی است که به این صورت شرح داده می‌شود (Adve & Gharachorloo):

  • به‌نظر می‌رسد همه عملیات‌های حافظه یکی‌یکی اجرا می‌شوند
  • به‌نظر می‌رسد همه عملیات در یک رشته به‌ترتیب شرح‌داده‌شده توسط برنامه آن پردازنده اجرا می‌شوند.

بیایید موقتاً فرض کنیم که یک کامپایلر یا مفسر بسیار ساده داریم که هیچ شگفتی‌ای ایجاد نمی‌کند: این کامپایلر تخصیص‌ها را در کد منبع به دستورالعمل‌های بارگذاری و ذخیره‌سازی دقیقاً به همان ترتیب مربوطه ترجمه می‌کند، یک دستورالعمل برای هر دسترسی. همچنین برای سادگی فرض می‌کنیم که هر رشته روی پردازنده خودش اجرا می‌شود.

اگر به بخشی از کد نگاه کنید و ببینید که برخی خواندن‌ها و نوشتن‌ها را از حافظه انجام می‌دهد، در معماری CPU با سازگاری متوالی می‌دانید که کد این خواندن‌ها و نوشتن‌ها را به ترتیب موردانتظار انجام خواهد داد. ممکن است CPU درواقع دستورالعمل‌ها را بازآرایی کند و خواندن و نوشتن را به‌تأخیر بیندازد، اما هیچ راهی وجود ندارد که کد درحال اجرا در دستگاه بتواند تشخیص دهد CPU کاری غیراز اجرای دستورالعمل‌ها به‌صورت مستقیم انجام می‌دهد. (ورودی/خروجی درایور دستگاه نگاشت‌شده در حافظه را نادیده می‌گیریم.)

برای توضیح این نکات، بهتر است قطعه‌های کوچک کد را درنظر بگیرید، که معمولاً به آن‌ها آزمون تورنسل می‌گویند.

در اینجا نمونه ساده‌ای با کد درحال اجرا در دو رشته آورده شده است:

رشته ۱ رشته ۲
A = 3
B = 5
reg0 = B
reg1 = A

در این مثال و همه مثال‌های لیتموس آینده، مکان‌های حافظه با حروف بزرگ (A،‏ B،‏ C) نشان داده می‌شوند و ثبات‌های واحد پردازش مرکزی با «reg» شروع می‌شوند. همه حافظه در ابتدا صفر است. دستورالعمل‌ها از بالا به پایین اجرا می‌شوند. در اینجا، رشته ۱ مقدار ۳ را در مکان «الف» و سپس مقدار ۵ را در مکان «ب» ذخیره می‌کند. رشته ۲ مقدار را از مکان B در reg0 بار می‌کند و سپس مقدار را از مکان A در reg1 بار می‌کند. (توجه داشته باشید که ما به یک ترتیب می‌نویسیم و به ترتیب دیگری می‌خوانیم.)

فرض می‌شود که رشته ۱ و رشته ۲ در هسته‌های CPU متفاوتی اجرا می‌شوند. هنگام فکر کردن درباره کد چندرشته‌ای، همیشه باید این فرض را درنظر بگیرید.

سازگاری ترتیبی تضمین می‌کند که پس‌از اتمام اجرای هر دو رشته، ثبات‌ها در یکی از حالت‌های زیر خواهند بود:

ثبت‌نام‌ها ایالت‌ها
reg0=5, reg1=3 ممکن (رشته ۱ اول اجرا شد)
reg0=0, reg1=0 ممکن (رشته ۲ اول اجرا شد)
reg0=0, reg1=3 ممکن (اجرای هم‌زمان)
reg0=5, reg1=0 هرگز

برای رسیدن به وضعیتی که در آن B=5 را قبل‌از دیدن ذخیره در A ببینیم، یا خواندن‌ها یا نوشتن‌ها باید خارج از ترتیب اتفاق بیفتند. در یک ماشین سازگار متوالی، این اتفاق نمی‌تواند بیفتد.

تک‌پردازنده‌ها، ازجمله x86 و ARM، معمولاً به‌طور متوالی سازگار هستند. رشته‌ها به‌صورت درهم‌بافته اجرا می‌شوند، زیرا هسته سیستم‌عامل بین آن‌ها جابه‌جا می‌شود. اکثر سیستم‌های SMP، ازجمله x86 و ARM، سازگاری متوالی ندارند. برای مثال، معمولاً سخت‌افزارها ذخیره‌سازی‌ها را در مسیرشان به حافظه میان‌گیری می‌کنند تا بلافاصله به حافظه نرسند و برای هسته‌های دیگر قابل‌مشاهده شوند.

جزئیات به‌طور قابل‌توجهی متفاوت است. برای مثال، x86، اگرچه ازنظر ترتیبی سازگار نیست، اما همچنان تضمین می‌کند که reg0 = 5 و reg1 = 0 غیرممکن باقی بماند. فروشگاه‌ها بافر می‌شوند، اما ترتیب آن‌ها حفظ می‌شود. ازسوی‌دیگر، ARM این کار را نمی‌کند. ترتیب ذخیره‌سازی‌های میان‌گیرشده حفظ نمی‌شود و ممکن است ذخیره‌سازی‌ها هم‌زمان به همه هسته‌های دیگر نرسند. این تفاوت‌ها برای برنامه‌نویسان اسمبلی مهم است. بااین‌حال، همان‌طور که در زیر خواهیم دید، برنامه‌نویسان C،‏ C++‎، یا Java می‌توانند و باید به‌گونه‌ای برنامه‌نویسی کنند که چنین تفاوت‌های معماری‌ای پنهان شود.

تاکنون، به‌طور غیرواقع‌بینانه‌ای فرض کرده‌ایم که فقط سخت‌افزار است که دستورالعمل‌ها را بازآرایی می‌کند. درواقع، کامپایلر دستورالعمل‌ها را نیز برای بهبود عملکرد بازآرایی می‌کند. در مثال ما، کامپایلر ممکن است تصمیم بگیرد که برخی‌از کدهای بعدی در «رشته ۲» قبل‌از نیاز به reg0 به مقدار reg1 نیاز دارند و بنابراین ابتدا reg1 را بار می‌کند. یا ممکن است برخی‌از کدهای قبلی قبلاً A را بار کرده باشند و کامپایلر تصمیم بگیرد به‌جای بار کردن مجدد A، از آن مقدار دوباره استفاده کند. در هر دو حالت، بارگذاری‌ها در reg0 و reg1 ممکن است دوباره مرتب شوند.

تغییر ترتیب دسترسی به مکان‌های مختلف حافظه، چه در سخت‌افزار و چه در کامپایلر، مجاز است، زیرا بر اجرای یک رشته تأثیر نمی‌گذارد و می‌تواند عملکرد را به‌طور قابل‌توجهی بهبود بخشد. همان‌طور که خواهیم دید، با کمی دقت، می‌توانیم از تأثیر آن بر نتایج برنامه‌های چندرشته‌ای نیز جلوگیری کنیم.

ازآنجایی‌که کامپایلرها نیز می‌توانند دسترسی به حافظه را مجدداً ترتیب دهند، این مشکل درواقع برای SMPها جدید نیست. حتی در یک تک‌پردازنده، کامپایلر می‌تواند بارگذاری‌ها را به reg0 و reg1 در مثال ما تغییر دهد و رشته ۱ می‌تواند بین دستورالعمل‌های تغییریافته زمان‌بندی شود. اما اگر کامپایلر ما ترتیب را تغییر ندهد، ممکن است هرگز متوجه این مشکل نشویم. در اکثر ARM SMPها، حتی بدون بازآرایی کامپایلر، احتمالاً بازآرایی دیده خواهد شد، احتمالاً پس‌از تعداد بسیار زیادی اجرای موفق. مگر اینکه درحال برنامه‌نویسی به زبان هم‌گذاری باشید، SMPها معمولاً فقط احتمال اینکه مشکلاتی را که از ابتدا وجود داشته‌اند ببینید بیشتر می‌کنند.

برنامه‌نویسی بدون رقابت داده‌ها

خوشبختانه معمولاً راه آسانی برای اجتناب از فکر کردن به هرکدام از این جزئیات وجود دارد. اگر از چند قانون ساده پیروی کنید، معمولاً می‌توانید همه بخش‌های قبلی را به‌جز بخش «سازگاری ترتیبی» فراموش کنید. متأسفانه، اگر به‌طور تصادفی آن قوانین را نقض کنید، ممکن است پیچیدگی‌های دیگر نمایان شوند.

زبان‌های برنامه‌نویسی مدرن سبک برنامه‌نویسی‌ای را که با نام «بدون رقابت داده» شناخته می‌شود ترویج می‌کنند. تا زمانی که قول دهید «رقابت داده‌ها» را معرفی نکنید و از چند ساختار که به کامپایلر خلاف آن را می‌گویند اجتناب کنید، کامپایلر و سخت‌افزار قول می‌دهند نتایج سازگار متوالی ارائه دهند. این واقعاً به این معنی نیست که آن‌ها از مرتب‌سازی مجدد دسترسی به حافظه اجتناب می‌کنند. این یعنی اگر از قوانین پیروی کنید، نمی‌توانید متوجه شوید که دسترسی‌های حافظه درحال بازترتیب‌دهی هستند. این خیلی شبیه به این است که به شما بگویند سوسیس غذایی خوشمزه و اشتهاآور است، به شرطی که قول بدهید از کارخانه سوسیس‌سازی بازدید نکنید. رقابت داده‌ها چیزی است که حقیقت زشت درباره بازآرایی حافظه را آشکار می‌کند.

«رقابت داده» چیست؟

رقابت داده زمانی رخ می‌دهد که حداقل دو رشته به‌طور هم‌زمان به داده‌های معمولی یکسانی دسترسی پیدا کنند و حداقل یکی از آن‌ها آن را تغییر دهد. منظور از «داده‌های معمولی» چیزی است که به‌طور خاص شیء همگام‌سازی درنظرگرفته‌شده برای ارتباط رشته‌ای نیست. انحصار متقابل، متغیرهای شرطی، متغیرهای فرار جاوا، یا اشیای اتمی ++C داده‌های معمولی نیستند و دسترسی به آن‌ها می‌تواند رقابتی باشد. درواقع از آن‌ها برای جلوگیری از رقابت داده‌ها در سایر اشیاء استفاده می‌شود.

برای تعیین اینکه آیا دو رشته به‌طور هم‌زمان به یک مکان حافظه دسترسی دارند یا نه، می‌توانیم بحث بازآرایی حافظه را که در بالا مطرح شد نادیده بگیریم و فرض کنیم که سازگاری ترتیبی وجود دارد. برنامه زیر رقابت داده ندارد اگر A و B متغیرهای بولی معمولی باشند که در ابتدا نادرست هستند:

رشته ۱ رشته ۲
if (A) B = true if (B) A = true

ازآنجایی‌که عملیات‌ها دوباره مرتب نمی‌شوند، هر دو شرط نادرست ارزیابی می‌شوند و هیچ‌کدام از متغیرها هرگز به‌روز نمی‌شوند. بنابراین، رقابت داده‌ای نمی‌تواند وجود داشته باشد. نیازی نیست به این فکر کنید که اگر بارگیری از A و ذخیره در B در «رشته ۱» به‌نحوی دوباره مرتب شوند، چه اتفاقی ممکن است بیفتد. کامپایلر مجاز نیست «رشته ۱» را با بازنویسی آن به‌صورت «B = true; if (!A) B = false» بازآرایی کند. این کار مثل درست کردن سوسیس در وسط شهر در روز روشن است.

رقابت داده‌ها به‌طور رسمی در انواع داخلی پایه مانند اعداد صحیح و ارجاعات یا اشاره‌گرها تعریف می‌شود. اختصاص دادن به int درحالی‌که هم‌زمان در رشته دیگری خوانده می‌شود، به‌وضوح یک رقابت داده است. اما هم کتابخانه استاندارد ++C و هم کتابخانه‌های Java Collections به‌گونه‌ای نوشته شده‌اند که به شما امکان می‌دهند درباره رقابت داده‌ها در سطح کتابخانه نیز استدلال کنید. آن‌ها قول می‌دهند که رقابت داده‌ای ایجاد نکنند، مگر اینکه دسترسی‌های هم‌زمان به یک محفظه وجود داشته باشد و حداقل یکی از آن‌ها آن را به‌روز کند. به‌روزرسانی set<T> در یک رشته درحالی‌که به‌طور هم‌زمان در رشته دیگری خوانده می‌شود به کتابخانه اجازه می‌دهد تا رقابت داده را معرفی کند، و بنابراین می‌توان آن را به‌طور غیررسمی به‌عنوان «رقابت داده در سطح کتابخانه» درنظر گرفت. برعکس، به‌روزرسانی یک set<T> در یک رشته، درحین خواندن یک رشته دیگر در رشته‌ای دیگر، منجر به رقابت داده نمی‌شود، زیرا کتابخانه قول می‌دهد که در آن مورد رقابت داده (سطح پایین) ایجاد نکند.

معمولاً دسترسی‌های هم‌زمان به فیلدهای مختلف در ساختار داده نمی‌تواند باعث ایجاد رقابت داده شود. بااین‌حال، یک استثنای مهم برای این قانون وجود دارد: توالی‌های مجاور فیلدهای بیتی در C یا C++‎ به‌عنوان یک «مکان حافظه» واحد درنظر گرفته می‌شوند. دسترسی به هر فیلد بیتی در چنین توالی‌ای به‌عنوان دسترسی به همه آن‌ها برای تعیین وجود رقابت داده درنظر گرفته می‌شود. این نشان‌دهنده ناتوانی سخت‌افزار رایج در به‌روزرسانی بیت‌های تکی بدون خواندن و بازنویسی بیت‌های مجاور است. برنامه‌نویسان جاوا نگرانی‌های مشابهی ندارند.

اجتناب از رقابت داده‌ها

زبان‌های برنامه‌نویسی مدرن تعدادی سازوکار همگام‌سازی برای جلوگیری از رقابت داده‌ها ارائه می‌دهند. ابزارهای اصلی عبارت‌اند از:

قفل‌ها یا Mutexها
Mutexes (C++11 std::mutex, or pthread_mutex_t), or synchronized blocks in Java can be used to ensure that certain section of code do not run concurrently with other sections of code accessing the same data. به این امکانات و امکانات مشابه دیگر به‌طور کلی «قفل» می‌گوییم. کسب مداوم یک قفل خاص قبل‌از دسترسی به ساختار داده مشترک و رها کردن آن پس‌از آن، از رقابت داده‌ها هنگام دسترسی به ساختار داده جلوگیری می‌کند. همچنین تضمین می‌کند که به‌روزرسانی‌ها و دسترسی‌ها اتمی هستند، یعنی هیچ به‌روزرسانی دیگری در ساختار داده نمی‌تواند در میان اجرا شود. این ابزار به‌حق رایج‌ترین ابزار برای جلوگیری از رقابت داده‌ها است. استفاده از بلوک‌های Java synchronized یا C++ lock_guard یا unique_lock اطمینان حاصل می‌کند که قفل‌ها درصورت بروز استثنا به‌درستی آزاد می‌شوند.
متغیرهای فرار/اتمی
Java فیلدهای volatile را ارائه می‌دهد که از دسترسی هم‌زمان بدون ایجاد رقابت داده پشتیبانی می‌کند. از سال ۲۰۱۱، C و C++‎ از متغیرها و فیلدهای atomic با معناشناسی مشابه پشتیبانی می‌کنند. این‌ها معمولاً سخت‌تر از قفل‌ها استفاده می‌شوند، زیرا فقط اطمینان می‌دهند که دسترسی‌های فردی به یک متغیر اتمی هستند. (در C++‎ این معمولاً به عملیات ساده خواندن-اصلاح-نوشتن، مانند افزایش، گسترش می‌یابد. Java به فراخوانی‌های روش خاصی برای این کار نیاز دارد.) برخلاف قفل‌ها، متغیرهای volatile یا atomic را نمی‌توان مستقیماً برای جلوگیری از تداخل رشته‌های دیگر با توالی‌های کد طولانی‌تر استفاده کرد.

توجه به این نکته مهم است که volatile در C++‎ و Java معانی بسیار متفاوتی دارد. در C++‎،‏ volatile از رقابت داده‌ها جلوگیری نمی‌کند، اگرچه کدهای قدیمی‌تر اغلب از آن به‌عنوان راه‌حلی برای نبود اشیاء atomic استفاده می‌کنند. این دیگر توصیه نمی‌شود؛ در C++‎، از atomic<T> برای متغیرهایی استفاده کنید که می‌توانند به‌طور هم‌زمان توسط چندین رشته قابل‌دسترسی باشند. ‫C++‎ volatile برای ثبت‌های دستگاه و موارد مشابه درنظر گرفته شده است.

از متغیرهای C/C++‎ atomic یا متغیرهای Java volatile می‌توان برای جلوگیری از رقابت داده‌ها در متغیرهای دیگر استفاده کرد. اگر flag از نوع atomic<bool> یا atomic_bool (C/C++) یا volatile boolean (Java) اعلام شود، و در ابتدا نادرست باشد، قطعه کد زیر بدون رقابت داده است:

رشته ۱ رشته ۲
A = ...
  flag = true
while (!flag) {}
... = A

چون «رشته ۲» منتظر تنظیم شدن flag است، دسترسی به A در «رشته ۲» باید بعداز واگذاری به A در «رشته ۱» اتفاق بیفتد و نه هم‌زمان با آن. بنابراین، هیچ رقابت داده‌ای در A وجود ندارد. رقابت در flag به‌عنوان رقابت داده‌ای محسوب نمی‌شود، زیرا دسترسی‌های فرار/اتمی «دسترسی‌های حافظه معمولی» نیستند.

پیاده‌سازی برای جلوگیری یا پنهان کردن مرتب‌سازی مجدد حافظه به‌اندازه کافی لازم است تا کدی مانند آزمون تورنسل قبلی طبق انتظار رفتار کند. این کار معمولاً دسترسی به حافظه فرار/اتمی را به‌طور قابل‌توجهی گران‌تر از دسترسی‌های معمولی می‌کند.

اگرچه مثال قبلی بدون رقابت داده است، قفل‌ها همراه با Object.wait() در Java یا متغیرهای شرطی در C/C++ معمولاً راه‌حل بهتری ارائه می‌دهند که شامل انتظار در حلقه درحین تخلیه شارژ باتری نمی‌شود.

وقتی بازآرایی خاطرات نمایان می‌شود

برنامه‌نویسی بدون رقابت داده‌ها معمولاً ما را از برخورد صریح با مشکلات ترتیب‌بندی مجدد دسترسی به حافظه نجات می‌دهد. بااین‌حال، چند مورد وجود دارد که در آن‌ها تغییر ترتیب قابل‌مشاهده می‌شود:
  1. اگر برنامه‌تان اشکالی داشته باشد که منجر به رقابت داده‌ای ناخواسته شود، تبدیل‌های سخت‌افزاری و کامپایلر ممکن است نمایان شوند و رفتار برنامه‌تان ممکن است غافلگیرکننده باشد. برای مثال، اگر فراموش کنیم که flag را در مثال قبلی فرار اعلام کنیم، ممکن است «رشته ۲» یک A مقداردهی‌نشده را ببیند. یا کامپایلر ممکن است تصمیم بگیرد که پرچم احتمالاً نمی‌تواند درطول حلقه «رشته ۲» تغییر کند و برنامه را به این شکل تبدیل کند
    رشته ۱ رشته ۲
    A = ...
      flag = true
    reg0 = flag; while (!reg0) {}
    ... = A
    هنگام اشکال‌زدایی، ممکن است ببینید که حلقه با وجود اینکه flag درست است، همچنان ادامه دارد.
  2. C++‎ امکاناتی برای کاهش صریح سازگاری ترتیبی فراهم می‌کند، حتی اگر هیچ رقابتی وجود نداشته باشد. عملیات اتمی می‌تواند متغیرهای مستقل صریح memory_order_... را بپذیرد. به‌طور مشابه، بسته java.util.concurrent.atomic مجموعه محدودتری از امکانات مشابه، به‌ویژه lazySet()، را ارائه می‌دهد. و برنامه‌نویسان Java گاهی از رقابت داده‌های عمدی برای تأثیر مشابه استفاده می‌کنند. همه این‌ها بهبود عملکرد را با هزینه زیادی در پیچیدگی برنامه‌نویسی ارائه می‌دهند. در زیر فقط به‌طور خلاصه درباره آن‌ها صحبت می‌کنیم.
  3. برخی‌از کدهای C و C++‎ به سبک قدیمی‌تری نوشته شده‌اند که کاملاً با استانداردهای زبان فعلی مطابقت ندارد، که در آن به‌جای متغیرهای atomic از متغیرهای volatile استفاده می‌شود و ترتیب حافظه با درج به‌اصطلاح حصار یا مانع صریحاً غیرمجاز است. این امر مستلزم استدلال صریح درباره دسترسی به ترتیب مجدد و درک مدل‌های حافظه سخت‌افزاری است. سبک کدنویسی در این خطوط هنوز در هسته لینوکس استفاده می‌شود. نباید در برنامه‌های جدید Android استفاده شود و در اینجا نیز بیشتر درباره آن بحث نمی‌شود.

تمرین

اشکال‌زدایی مشکلات سازگاری حافظه می‌تواند بسیار دشوار باشد. اگر نبودن اعلامیه قفل، atomic، یا volatile باعث شود برخی‌از کدها داده‌های قدیمی را بخوانند، ممکن است نتوانید با بررسی کردن ریزش‌های حافظه با اشکال‌زدا دلیل آن را متوجه شوید. تا زمانی که بتوانید یک پُرسمان اشکال‌زدا صادر کنید، ممکن است هسته‌های واحد پردازش مرکزی همه مجموعه کامل دسترسی‌ها را مشاهده کرده باشند و محتویات حافظه و ثبات‌های واحد پردازش مرکزی در وضعیت «غیرممکن» ظاهر شوند.

کارهایی که نباید در C انجام داد

در اینجا چند نمونه از کد نادرست را به‌همراه روش‌های ساده برای رفع آن‌ها ارائه می‌دهیم. قبل‌از انجام این کار، باید درباره استفاده از ویژگی زبان پایه صحبت کنیم.

C/C++‎ و «volatile»

اعلامیه‌های C و C++ volatile ابزاری با هدف بسیار خاص هستند. آن‌ها از مرتب‌سازی مجدد یا حذف دسترسی‌های فرار توسط کامپایلر جلوگیری می‌کنند. این کار می‌تواند برای کدی که به ثبت‌های دستگاه سخت‌افزاری دسترسی دارد، حافظه‌ای که به بیش‌از یک مکان نگاشت شده است، یا در ارتباط با setjmp مفید باشد. اما C و C++‎ volatile، برخلاف Java volatile، برای ارتباط رشته‌ای طراحی نشده‌اند.

در C و C++‎، دسترسی به داده‌های volatile ممکن است با دسترسی به داده‌های غیرفرار بازآرایی شود و هیچ تضمینی برای اتمی بودن وجود ندارد. بنابراین، نمی‌توان از volatile برای هم‌رسانی داده‌ها بین رشته‌ها در کد قابل‌حمل استفاده کرد، حتی در یک تک‌پردازنده. C volatile معمولاً از مرتب‌سازی مجدد دسترسی توسط سخت‌افزار جلوگیری نمی‌کند، بنابراین به‌خودی‌خود در محیط‌های SMP چندرشته‌ای حتی کمتر مفید است. به همین دلیل است که C11 و C++11 از اشیاء atomic پشتیبانی می‌کنند. باید از آن‌ها استفاده کنید.

بسیاری از کدهای قدیمی‌تر C و C++‎ هنوز از volatile برای ارتباط رشته‌ای سوءاستفاده می‌کنند. این روش اغلب برای داده‌هایی که در ثبات ماشین جا می‌شوند به‌درستی کار می‌کند، مشروط بر اینکه با حصارهای صریح یا در مواردی که ترتیب حافظه مهم نیست استفاده شود. اما تضمینی نیست که با کامپایلرهای آینده به‌درستی کار کند.

مثال‌ها

در بیشتر موارد، بهتر است از قفل (مانند pthread_mutex_t یا C++11 std::mutex) به‌جای عملیات اتمی استفاده کنید، اما ما از دومی استفاده خواهیم کرد تا نشان دهیم چگونه در یک موقعیت عملی استفاده می‌شوند.

MyThing* gGlobalThing = NULL;  // Wrong!  See below.
void initGlobalThing()    // runs in Thread 1
{
    MyStruct* thing = malloc(sizeof(*thing));
    memset(thing, 0, sizeof(*thing));
    thing->x = 5;
    thing->y = 10;
    /* initialization complete, publish */
    gGlobalThing = thing;
}
void useGlobalThing()    // runs in Thread 2
{
    if (gGlobalThing != NULL) {
        int i = gGlobalThing->x;    // could be 5, 0, or uninitialized data
        ...
    }
}

ایده این است که ساختاری را اختصاص می‌دهیم، فیلدهای آن را مقداردهی اولیه می‌کنیم، و در پایان آن را با ذخیره کردن در متغیر سراسری «منتشر» می‌کنیم. در آن نقطه، هر رشته دیگری می‌تواند آن را ببیند، اما این مشکلی ندارد زیرا کاملاً مقداردهی اولیه شده است، درست است؟

مشکل این است که ذخیره در gGlobalThing ممکن است قبل‌از مقداردهی اولیه فیلدها مشاهده شود، معمولاً به این دلیل که کامپایلر یا پردازنده ترتیب ذخیره در gGlobalThing و thing->x را تغییر داده‌اند. رشته دیگری که از thing->x می‌خواند می‌تواند ۵، ۰، یا حتی داده‌های مقداردهی‌نشده را ببیند.

مشکل اصلی در اینجا رقابت داده‌ها در gGlobalThing است. اگر «رشته ۱» initGlobalThing() را فراخوانی کند درحالی‌که «رشته ۲» useGlobalThing() را فراخوانی می‌کند، gGlobalThing می‌تواند درحین نوشته شدن خوانده شود.

این مشکل را می‌توان با اعلام gGlobalThing به‌عنوان اتمی برطرف کرد. در C++11:

atomic<MyThing*> gGlobalThing(NULL);

این کار تضمین می‌کند که نوشتن‌ها برای رشته‌های دیگر به‌ترتیب مناسب قابل‌مشاهده خواهد بود. همچنین تضمین می‌کند که از برخی حالت‌های خرابی دیگر که درغیراین‌صورت مجاز هستند اما احتمال وقوع آن‌ها در سخت‌افزار واقعی Android کم است جلوگیری شود. برای مثال، این کار تضمین می‌کند که ما نمی‌توانیم اشاره‌گر gGlobalThing را که فقط به‌طور جزئی نوشته شده است ببینیم.

کارهایی که نباید در جاوا انجام داد

درباره برخی‌از ویژگی‌های مرتبط زبان Java بحث نکرده‌ایم، بنابراین ابتدا نگاهی سریع به آن‌ها خواهیم داشت.

ازنظر فنی، Java نیازی به کد برای عاری بودن از رقابت داده ندارد. و مقدار کمی کد Java با دقت بسیار بالا نوشته شده است که در حضور رقابت داده‌ها به‌درستی کار می‌کند. بااین‌حال، نوشتن چنین کدی بسیار دشوار است و در زیر فقط به‌طور خلاصه درباره آن بحث می‌کنیم. بدتر از همه اینکه کارشناسانی که معنی چنین کدی را مشخص کرده‌اند دیگر معتقد نیستند که این مشخصات درست باشد. (این مشخصات برای کد بدون رقابت داده مناسب است.)

For now we will adhere to the data-race-free model, for which Java provides essentially the same guarantees as C and C++. Again, the language provides some primitives that explicitly relax sequential consistency, notably the lazySet() and weakCompareAndSet() calls in java.util.concurrent.atomic. همانند C و C++‎، درحال‌حاضر این موارد را نادیده می‌گیریم.

کلیدواژه‌های «synchronized» و «volatile» در جاوا

کلیدواژه «synchronized» سازوکار قفل کردن داخلی زبان Java را ارائه می‌دهد. هر شیء یک «نمایشگر» مرتبط دارد که می‌تواند برای ارائه دسترسی متقابلاً انحصاری استفاده شود. اگر دو رشته سعی کنند روی یک شیء «همگام‌سازی» شوند، یکی از آن‌ها منتظر می‌ماند تا دیگری تکمیل شود.

همان‌طور که در بالا اشاره کردیم، volatile T در Java مشابه atomic<T> در C++11 است. دسترسی‌های هم‌زمان به فیلدهای volatile مجاز است و منجر به رقابت داده‌ها نمی‌شود. با نادیده گرفتن lazySet() و غیره و رقابت داده‌ها، وظیفه ماشین مجازی Java است که مطمئن شود نتیجه همچنان به‌طور متوالی سازگار به‌نظر می‌رسد.

به‌ویژه، اگر رشته ۱ در فیلد volatile بنویسد و رشته ۲ متعاقباً از همان فیلد بخواند و مقدار جدیداً نوشته‌شده را ببیند، تضمین می‌شود که رشته ۲ همه نوشته‌های قبلی رشته ۱ را نیز ببیند. ازنظر اثر حافظه، نوشتن در یک فرار مشابه انتشار مانیتور است، و خواندن از یک فرار مانند دریافت مانیتور است.

یک تفاوت قابل‌توجه با atomic در C++‎ وجود دارد: اگر volatile int x; را در Java بنویسیم، x++ با x = x + 1 یکسان است؛ بارگذاری اتمی را انجام می‌دهد، نتیجه را افزایش می‌دهد، و سپس ذخیره اتمی را انجام می‌دهد. برخلاف C++‎، افزایش به‌طور کلی اتمی نیست. درعوض، عملیات افزایش اتمی توسط java.util.concurrent.atomic ارائه می‌شود.

مثال‌ها

در اینجا یک پیاده‌سازی ساده و نادرست از شمارنده یکنواخت آورده شده است: (نظریه و عمل Java: مدیریت فراریت).

class Counter {
    private int mValue;
    public int get() {
        return mValue;
    }
    public void incr() {
        mValue++;
    }
}

فرض کنید get() و incr() از چند رشته فراخوانی می‌شوند و می‌خواهیم مطمئن شویم که هر رشته هنگام فراخوانی get()، تعداد فعلی را می‌بیند. واضح‌ترین مشکل این است که mValue++ درواقع سه عملیات است:

  1. reg = mValue
  2. reg = reg + 1
  3. mValue = reg

اگر دو رشته به‌طور هم‌زمان در incr() اجرا شوند، ممکن است یکی از به‌روزرسانی‌ها ازدست برود. برای اتمی کردن افزایش، باید incr() «همگام‌سازی‌شده» را اعلام کنیم.

بااین‌حال، هنوز خراب است، به‌ویژه در SMP. هنوز رقابت داده وجود دارد، به این صورت که get() می‌تواند هم‌زمان با incr() به mValue دسترسی داشته باشد. طبق قوانین Java، ممکن است به‌نظر برسد که فراخوانی get() نسبت‌به کدهای دیگر بازآرایی شده است. برای مثال، اگر دو شمارنده را پشت‌سرهم بخوانیم، ممکن است نتایج ناسازگار به‌نظر برسند زیرا تماس‌های get() را ازطریق سخت‌افزار یا کامپایلر بازآرایی کرده‌ایم. می‌توانیم با اعلام get() به‌عنوان همگام‌سازی‌شده، مشکل را برطرف کنیم. با این تغییر، کد به‌وضوح درست است.

متأسفانه، احتمال رقابت قفل را معرفی کرده‌ایم که می‌تواند عملکرد را مختل کند. به‌جای اعلام get() برای همگام‌سازی، می‌توانیم mValue را با «volatile» اعلام کنیم. (توجه داشته باشید که incr() همچنان باید از synchronize استفاده کند، زیرا درغیراین‌صورت mValue++ یک عملیات اتمی واحد نیست.) این کار همچنین از همه رقابت‌های داده‌ای جلوگیری می‌کند، بنابراین سازگاری متوالی حفظ می‌شود. incr() تاحدودی کندتر خواهد بود، زیرا هم سربار ورود/خروج مانیتور را متحمل می‌شود و هم سربار مرتبط با ذخیره‌سازی فرار، اما get() سریع‌تر خواهد بود، بنابراین حتی درصورت عدم وجود رقابت، اگر تعداد خواندن‌ها بسیار بیشتر از نوشتن‌ها باشد، این یک برد است. (همچنین به AtomicInteger برای روشی برای حذف کامل بلوک همگام‌سازی‌شده مراجعه کنید.)

در اینجا مثال دیگری آورده شده است که ازنظر شکل شبیه به مثال‌های C قبلی است:

class MyGoodies {
    public int x, y;
}
class MyClass {
    static MyGoodies sGoodies;
    void initGoodies() {    // runs in thread 1
        MyGoodies goods = new MyGoodies();
        goods.x = 5;
        goods.y = 10;
        sGoodies = goods;
    }
    void useGoodies() {    // runs in thread 2
        if (sGoodies != null) {
            int i = sGoodies.x;    // could be 5 or 0
            ....
        }
    }
}

این همان مشکل کد C را دارد، یعنی اینکه یک رقابت داده در sGoodies وجود دارد. بنابراین، ممکن است تخصیص sGoodies = goods قبل‌از مقداردهی اولیه فیلدها در goods مشاهده شود. اگر sGoodies را با کلیدواژه volatile اعلام کنید، سازگاری متوالی بازیابی می‌شود و همه‌چیز طبق انتظار کار خواهد کرد.

توجه داشته باشید که فقط خود مرجع sGoodies فرار است. دسترسی به فیلدهای داخل آن نیست. وقتی sGoodies volatile باشد و ترتیب حافظه به‌درستی حفظ شود، نمی‌توان به‌طور هم‌زمان به فیلدها دسترسی داشت. عبارت z = sGoodies.x بارگذاری فرار MyClass.sGoodies را انجام می‌دهد و سپس بارگذاری غیرفرار sGoodies.x را انجام می‌دهد. اگر ارجاع محلی MyGoodies localGoods = sGoodies ایجاد کنید، z = localGoods.x بعدی هیچ بارگذاری فراری انجام نخواهد داد.

اصطلاح رایج‌تر در برنامه‌نویسی جاوا «قفل‌گذاری دوباره‌سنجی‌شده» بدنام است:

class MyClass {
    private Helper helper = null;
    public Helper getHelper() {
        if (helper == null) {
            synchronized (this) {
                if (helper == null) {
                    helper = new Helper();
                }
            }
        }
        return helper;
    }
}

ایده این است که می‌خواهیم یک نمونه واحد از شیء Helper داشته باشیم که با نمونه‌ای از MyClass مرتبط باشد. باید آن را فقط یک‌بار ایجاد کنیم، بنابراین آن را ازطریق تابع اختصاصی getHelper() ایجاد و برمی‌گردانیم. برای جلوگیری از رقابت بین دو رشته برای ایجاد نمونه، باید ایجاد شیء را همگام‌سازی کنیم. اما نمی‌خواهیم برای بلوک «همگام‌سازی‌شده» در هر تماس، سربار پرداخت کنیم، بنابراین فقط درصورتی آن بخش را انجام می‌دهیم که helper درحال‌حاضر تهی باشد.

این مورد در فیلد helper رقابت داده دارد. می‌توان آن را هم‌زمان با helper == null در رشته دیگری تنظیم کرد.

برای اینکه ببینید این کار چگونه می‌تواند با شکست مواجه شود، همان کد را که کمی بازنویسی شده است درنظر بگیرید، گویی به زبانی شبیه C کامپایل شده است (چند فیلد عدد صحیح برای نشان دادن فعالیت سازنده Helper’s اضافه کرده‌ام):

if (helper == null) {
    synchronized() {
        if (helper == null) {
            newHelper = malloc(sizeof(Helper));
            newHelper->x = 5;
            newHelper->y = 10;
            helper = newHelper;
        }
    }
    return helper;
}

هیچ‌چیز مانع سخت‌افزار یا کامپایلر نمی‌شود که ذخیره در helper را با ذخیره در فیلدهای x/y بازآرایی کند. رشته دیگری می‌تواند helper غیرتهی را پیدا کند اما فیلدهای آن هنوز تنظیم نشده و آماده استفاده نیست. برای جزئیات بیشتر و حالت‌های خرابی بیشتر، به پیوند «اعلامیه «قفل‌گذاری بررسی‌شده دوگانه خراب است»» در پیوست برای جزئیات بیشتر، یا مورد ۷۱ («استفاده عاقلانه از مقداردهی اولیه تنبل») در جاوا مؤثر، ویرایش دوم اثر جاش بلوخ مراجعه کنید.

دو روش برای رفع این مشکل وجود دارد:

  1. کار ساده را انجام دهید و بررسی بیرونی را حذف کنید. این کار تضمین می‌کند که ما هرگز مقدار helper را خارج از یک بلوک همگام‌سازی‌شده بررسی نمی‌کنیم.
  2. helper را فرار اعلام کنید. با این تغییر کوچک، کد در مثال J-3 به‌درستی در Java 1.5 و نسخه‌های جدیدتر کار خواهد کرد. (شاید بخواهید یک دقیقه وقت بگذارید تا خودتان را متقاعد کنید که این درست است.)

در اینجا نمونه دیگری از رفتار volatile آورده شده است:

class MyClass {
    int data1, data2;
    volatile int vol1, vol2;
    void setValues() {    // runs in Thread 1
        data1 = 1;
        vol1 = 2;
        data2 = 3;
    }
    void useValues() {    // runs in Thread 2
        if (vol1 == 2) {
            int l1 = data1;    // okay
            int l2 = data2;    // wrong
        }
    }
}

با نگاه کردن به useValues()، اگر «رشته ۲» هنوز به‌روزرسانی vol1 را مشاهده نکرده باشد، نمی‌تواند بداند که آیا data1 یا data2 تنظیم شده است یا نه. وقتی به‌روزرسانی vol1 را می‌بیند، متوجه می‌شود که data1 می‌تواند به‌طور ایمن دردسترس قرار گیرد و به‌درستی خوانده شود بدون اینکه رقابت داده‌ای ایجاد شود. بااین‌حال، نمی‌تواند هیچ فرضی درباره data2 داشته باشد، زیرا آن ذخیره‌سازی بعداز ذخیره‌سازی فرار انجام شده است.

توجه داشته باشید که نمی‌توان از volatile برای جلوگیری از مرتب‌سازی مجدد دسترسی‌های حافظه دیگر که با یکدیگر رقابت می‌کنند استفاده کرد. تضمینی وجود ندارد که دستورالعمل حصار حافظه ماشین تولید کند. می‌توان از آن برای جلوگیری از رقابت داده‌ها با اجرای کد فقط زمانی که رشته دیگری شرط خاصی را برآورده کرده است استفاده کرد.

چه کاری باید انجام داد

در C/C++‎، کلاس‌های همگام‌سازی C++11 را ترجیح دهید، مانند std::mutex. اگر نه، از عملیات pthread مربوطه استفاده کنید. این شامل حصارهای حافظه مناسب، ارائه رفتار صحیح (به‌طور متوالی سازگار مگر اینکه خلاف آن مشخص شده باشد) و کارآمد در همه نسخه‌های پلاتفرم Android است. حتماً از آن‌ها به‌درستی استفاده کنید. برای مثال، به‌خاطر داشته باشید که انتظار متغیر شرط ممکن است به‌طور کاذب بدون سیگنال برگردد و بنابراین باید در یک حلقه ظاهر شود.

بهتر است از استفاده مستقیم از توابع اتمی اجتناب کنید، مگراینکه ساختار داده‌ای که پیاده‌سازی می‌کنید بسیار ساده باشد، مانند یک شمارنده. قفل کردن و باز کردن قفل pthread mutex هرکدام به یک عملیات اتمی نیاز دارند، و اغلب هزینه کمتری نسبت‌به یک خطای حافظه نهان دارند، اگر رقابتی وجود نداشته باشد، بنابراین با جایگزین کردن فراخوانی‌های mutex با عملیات اتمی، صرفه‌جویی زیادی نخواهید کرد. طراحی‌های بدون قفل برای ساختارهای داده غیربدیهی به مراقبت بسیار بیشتری نیاز دارند تا اطمینان حاصل شود که عملیات سطح بالاتر روی ساختار داده به‌صورت اتمی (به‌طور کلی، نه فقط قطعات صریحاً اتمی آن‌ها) ظاهر می‌شوند.

اگر از عملیات اتمی استفاده می‌کنید، کاهش ترتیب با memory_order... یا lazySet() ممکن است مزایای عملکردی داشته باشد، اما به درک عمیق‌تری نسبت‌به آنچه تاکنون منتقل کرده‌ایم نیاز دارد. بعداً مشخص می‌شود که بخش بزرگی از کد موجود که از این‌ها استفاده می‌کند دارای اشکال است. درصورت امکان از این موارد اجتناب کنید. اگر مورد استفاده شما دقیقاً با یکی از موارد بخش بعدی مطابقت ندارد، مطمئن شوید که خودتان متخصص هستید یا با یک متخصص مشورت کرده‌اید.

از volatile برای ارتباط رشته‌ای در C/C++‎ استفاده نکنید.

در Java، مشکلات همزمانی اغلب بااستفاده از کلاس سودمند مناسب از بسته java.util.concurrent به بهترین شکل حل می‌شوند. کد به‌خوبی نوشته شده و به‌خوبی در SMP آزمایش شده است.

شاید امن‌ترین کاری که می‌توانید انجام دهید این است که اشیای خود را تغییرناپذیر کنید. اشیاء از کلاس‌هایی مانند String و Integer در Java داده‌هایی را نگه می‌دارند که پس‌از ایجاد شیء قابل‌تغییر نیستند، و از هرگونه احتمال رقابت داده‌ها در آن اشیاء جلوگیری می‌کنند. کتاب Effective Java, 2nd Ed. دستورالعمل‌های خاصی در «مورد ۱۵: به‌حداقل رساندن تغییرپذیری» دارد. به‌ویژه به اهمیت اعلام فیلدهای Java به‌عنوان «نهایی» توجه کنید (Bloch).

حتی اگر شیء تغییرناپذیر باشد، به‌خاطر داشته باشید که انتقال آن به رشته‌ای دیگر بدون هیچ‌گونه همگام‌سازی، رقابت داده‌ای است. این کار گاهی اوقات می‌تواند در Java قابل‌قبول باشد (به زیر مراجعه کنید)، اما به دقت زیادی نیاز دارد و احتمالاً منجر به کد شکننده می‌شود. اگر عملکرد آن بسیار حیاتی نیست، یک volatile اعلامیه اضافه کنید. در ++C، برقراری ارتباط با اشاره‌گر یا ارجاع به شیء تغییرناپذیر بدون همگام‌سازی مناسب، مانند هر رقابت داده‌ای، یک اشکال است. در این حالت، احتمالاً منجر به ازکارافتادن‌های متناوب می‌شود زیرا، برای مثال، رشته گیرنده ممکن است به‌دلیل بازآرایی ذخیره، اشاره‌گر جدول روش مقداردهی‌نشده را ببیند.

اگر نه کلاس کتابخانه موجود و نه کلاس تغییرناپذیر مناسب باشد، باید از عبارت Java synchronized یا C++ lock_guard / unique_lock برای محافظت از دسترسی به هر فیلدی که می‌تواند توسط بیش از یک رشته قابل دسترسی باشد استفاده شود. اگر انحصار متقابل برای وضعیت شما کار نمی‌کند، باید فیلدهای مشترک را volatile یا atomic اعلام کنید، اما باید بسیار دقت کنید تا تعاملات بین رشته‌ها را درک کنید. این اعلان‌ها شما را از اشتباهات رایج برنامه‌نویسی هم‌زمان نجات نمی‌دهند، اما به شما کمک می‌کنند از خرابی‌های مرموز مرتبط با بهینه‌سازی کامپایلرها و مشکلات SMP جلوگیری کنید.

باید از «انتشار» ارجاع به شیء، یعنی دردسترس قرار دادن آن برای رشته‌های دیگر، در سازنده آن اجتناب کنید. این موضوع در ++C یا اگر به توصیه ما مبنی بر «عدم رقابت داده‌ها» در Java پایبند باشید، اهمیت کمتری دارد. اما همیشه توصیه خوبی است و اگر کد Java شما در زمینه‌های دیگری اجرا شود که مدل امنیتی Java در آن‌ها اهمیت دارد، حیاتی می‌شود و کد غیرقابل‌اعتماد ممکن است با دسترسی به آن مرجع شیء «لو رفته»، رقابت داده ایجاد کند. همچنین اگر تصمیم بگیرید هشدارهای ما را نادیده بگیرید و از برخی‌از تکنیک‌های بخش بعدی استفاده کنید، این موضوع بسیار مهم است. برای جزئیات، به (تکنیک‌های ساخت ایمن در Java) مراجعه کنید

کمی بیشتر درباره ترتیب‌های حافظه ضعیف

C++11 و نسخه‌های جدیدتر سازوکارهای صریحی برای کاهش تضمین‌های سازگاری متوالی برای برنامه‌های بدون رقابت داده ارائه می‌دهند. آرگومان‌های صریح memory_order_relaxed، memory_order_acquire (فقط بارگذاری)، و memory_order_release (فقط ذخیره‌سازی) برای عملیات اتمی هرکدام تضمین‌های به‌شدت ضعیف‌تری نسبت‌به memory_order_seq_cst پیش‌فرض، که معمولاً ضمنی است، ارائه می‌دهند. memory_order_acq_rel هم تضمین‌های memory_order_acquire و هم memory_order_release را برای عملیات‌های اتمی خواندن-اصلاح نوشتن ارائه می‌دهد. memory_order_consume هنوز به‌اندازه کافی مشخص یا پیاده‌سازی نشده است تا مفید باشد و فعلاً باید نادیده گرفته شود.

روش‌های lazySet در Java.util.concurrent.atomic مشابه فروشگاه‌های memory_order_release C++‎ است. متغیرهای معمولی Java گاهی به‌عنوان جایگزینی برای دسترسی‌های memory_order_relaxed استفاده می‌شوند، هرچند درواقع حتی ضعیف‌تر هستند. برخلاف ++C، هیچ سازوکار واقعی برای دسترسی‌های نامرتب به متغیرهایی که به‌عنوان volatile اعلام شده‌اند وجود ندارد.

به‌طور کلی باید از این موارد اجتناب کنید، مگراینکه دلایل عملکردی مبرمی برای استفاده از آن‌ها وجود داشته باشد. در معماری‌های ماشین با ترتیب ضعیف مانند ARM، استفاده از آن‌ها معمولاً در حدود چند ده چرخه ماشین برای هر عملیات اتمی صرفه‌جویی می‌کند. در x86، بهبود عملکرد به فروشگاه‌ها محدود می‌شود و احتمالاً کمتر قابل‌توجه است. تاحدودی برخلاف انتظار، این مزیت ممکن است با تعداد هسته‌های بیشتر کاهش یابد، زیرا سیستم حافظه بیشتر به یک عامل محدودکننده تبدیل می‌شود.

معناشناسی کامل اتمیک‌های با ترتیب ضعیف پیچیده است. به‌طور کلی، این موارد نیازمند درک دقیق قوانین زبان هستند که در اینجا به آن‌ها نمی‌پردازیم. برای مثال:

  • کامپایلر یا سخت‌افزار می‌تواند دسترسی‌های memory_order_relaxed را به بخش بحرانی که با کسب و رهاسازی قفل محدود شده است منتقل کند (اما نمی‌تواند از آن خارج کند). این یعنی ممکن است دو memory_order_relaxed ذخیره به‌صورت نامرتب نمایان شوند، حتی اگر با بخش بحرانی از هم جدا شده باشند.
  • یک متغیر معمولی جاوا، وقتی به‌عنوان یک شمارنده مشترک مورد سوءاستفاده قرار می‌گیرد، ممکن است برای یک رشته دیگر به نظر برسد که کاهش می‌یابد، حتی اگر فقط توسط یک رشته دیگر افزایش یابد. اما این موضوع برای اتمی C++‎ صادق نیست memory_order_relaxed.

با این هشدار، در اینجا تعداد کمی از اصطلاحات را ارائه می‌دهیم که به‌نظر می‌رسد بسیاری از موارد استفاده برای اتمیک‌های با ترتیب ضعیف را پوشش می‌دهند. بسیاری از این موارد فقط برای C++‎ قابل‌اجرا هستند.

دسترسی‌های غیرمسابقه‌ای

اینکه متغیری اتمی باشد نسبتاً رایج است زیرا گاهی اوقات هم‌زمان با نوشتن خوانده می‌شود، اما همه دسترسی‌ها این مشکل را ندارند. برای مثال، ممکن است لازم باشد متغیری اتمی باشد چون خارج از بخش بحرانی خوانده می‌شود، اما همه به‌روزرسانی‌ها با قفل محافظت می‌شوند. در این حالت، خواندنی که اتفاقاً با همان قفل محافظت می‌شود نمی‌تواند رقابت کند، زیرا نمی‌تواند نوشتن هم‌زمان وجود داشته باشد. در چنین موردی، دسترسی غیررقابتی (در این مورد، بارگیری) را می‌توان با memory_order_relaxed حاشیه‌نویسی کرد بدون اینکه درستی کد ++C تغییر کند. پیاده‌سازی قفل ازقبل ترتیب حافظه موردنیاز را دررابطه‌با دسترسی توسط رشته‌های دیگر اعمال می‌کند و memory_order_relaxed مشخص می‌کند که اساساً هیچ محدودیت ترتیب اضافی‌ای لازم نیست برای دسترسی اتمی اعمال شود.

هیچ مشابه واقعی برای این در جاوا وجود ندارد.

برای صحت به نتیجه اتکا نمی‌شود

وقتی از بارگذاری رقابتی فقط برای تولید راهنمایی استفاده می‌کنیم، معمولاً مشکلی ندارد که هیچ ترتیب حافظه‌ای برای بارگذاری اعمال نشود. اگر مقدار قابل‌اعتماد نباشد، نمی‌توانیم به‌طور قابل‌اعتماد از نتیجه برای استنباط چیزی درباره متغیرهای دیگر استفاده کنیم. Thus it's OK if memory ordering is not guaranteed, and the load is supplied with a memory_order_relaxed argument.

یک نمونه رایج از این مورد، استفاده از C++ compare_exchange برای جایگزینی اتمی x با f(x) است. بار اولیه x برای محاسبه f(x) نیازی نیست قابل‌اعتماد باشد. اگر اشتباه کنیم، compare_exchange ناموفق خواهد بود و دوباره امتحان خواهیم کرد. برای بارگیری اولیه x، استفاده از آرگومان memory_order_relaxed مشکلی ندارد؛ فقط ترتیب حافظه برای compare_exchange واقعی اهمیت دارد.

داده‌های تغییریافته به‌صورت اتمی اما خوانده‌نشده

گاهی‌اوقات داده‌ها به‌طور موازی توسط چندین رشته اصلاح می‌شوند، اما تا زمانی که محاسبات موازی کامل نشود، بررسی نمی‌شوند. نمونه خوبی از این مورد، شمارنده‌ای است که به‌صورت اتمی (مثلاً بااستفاده از fetch_add() در C++‎ یا atomic_fetch_add_explicit() در C) توسط چندین رشته به‌طور موازی افزایش می‌یابد، اما نتیجه این فراخوانی‌ها همیشه نادیده گرفته می‌شود. مقدار حاصل فقط در پایان، پس‌از تکمیل همه به‌روزرسانی‌ها، خوانده می‌شود.

در این حالت، هیچ راهی برای تشخیص اینکه آیا دسترسی به این داده‌ها دوباره مرتب شده است وجود ندارد، و بنابراین کد ++C ممکن است از آرگومان memory_order_relaxed استفاده کند.

شمارنده‌های رویداد ساده نمونه رایجی از این مورد هستند. ازآنجایی‌که این مورد بسیار رایج است، ارزش دارد که چند نکته را درباره آن مطرح کنیم:

  • استفاده از memory_order_relaxed عملکرد را بهبود می‌بخشد، اما ممکن است مهم‌ترین مشکل عملکرد را برطرف نکند: هر به‌روزرسانی به دسترسی انحصاری به خط حافظه نهان حاوی شمارنده نیاز دارد. این باعث می‌شود هربار که رشته جدیدی به شمارنده دسترسی پیدا می‌کند، حافظه نهان ازدست برود. اگر به‌روزرسانی‌ها مکرر باشند و بین رشته‌ها جابه‌جا شوند، برای جلوگیری از به‌روزرسانی شمارنده مشترک در هر بار، استفاده از شمارنده‌های محلی رشته و جمع کردن آن‌ها در پایان بسیار سریع‌تر است.
  • این تکنیک با بخش قبلی قابل ترکیب است: امکان خواندن هم‌زمان مقادیر تقریبی و غیرقابل‌اعتماد درحین به‌روزرسانی آن‌ها وجود دارد، و همه عملیات‌ها از memory_order_relaxed استفاده می‌کنند. اما مهم است که مقادیر حاصل را کاملاً غیرقابل‌اعتماد درنظر بگیرید. Just because the count appears to have been incremented once does not mean another thread can be counted on to have reached the point at which the increment has been performed. درعوض، ممکن است ترتیب افزونه با کد قبلی تغییر کرده باشد. (همان‌طور که در مورد مشابهی که قبلاً ذکر کردیم، C++‎ تضمین می‌کند که بارگذاری دوم چنین شمارنده‌ای مقداری کمتر از بارگذاری قبلی در همان رشته برنمی‌گرداند. مگر اینکه البته شمارنده سرریز شده باشد.)
  • معمولاً کدی پیدا می‌شود که سعی می‌کند مقادیر شمارنده تقریبی را با انجام خواندن و نوشتن اتمی (یا غیراتمی) جداگانه محاسبه کند، اما افزایش را به‌صورت اتمی کامل انجام نمی‌دهد. استدلال معمول این است که این مقدار برای شمارنده‌های عملکرد یا موارد مشابه «به‌اندازه کافی نزدیک» است. معمولاً این‌طور نیست. وقتی به‌روزرسانی‌ها به‌اندازه کافی مکرر باشند (موردی که احتمالاً برایتان مهم است)، معمولاً بخش بزرگی از شمارش‌ها ازدست می‌رود. در دستگاه چهار هسته‌ای، معمولاً ممکن است بیش‌از نیمی از شمارش‌ها ازدست برود. (تمرین ساده: سناریویی با دو رشته بسازید که در آن شمارنده یک میلیون بار به‌روز شود، اما مقدار نهایی شمارنده یک باشد.)

ارتباط پرچم ساده

یک ذخیره memory_order_release (یا عملیات خواندن-اصلاح-نوشتن) تضمین می‌کند که اگر متعاقباً یک بارگذاری memory_order_acquire (یا عملیات خواندن-اصلاح-نوشتن) مقدار نوشته‌شده را بخواند، آنگاه همچنین هر ذخیره‌ای (معمولی یا اتمی) را که قبل‌از ذخیره A memory_order_release بوده است مشاهده خواهد کرد. برعکس، هر بارگذاری‌ای که قبل‌از memory_order_release باشد، هیچ‌کدام از ذخیره‌سازی‌های پس‌از بارگذاری memory_order_acquire را مشاهده نخواهد کرد. برخلاف memory_order_relaxed، این امکان را فراهم می‌کند که از چنین عملیات اتمی‌ای برای انتقال پیشرفت یک رشته به رشته دیگر استفاده شود.

برای مثال، می‌توانیم نمونه قفل‌گذاری با بررسی مضاعف از بالا را در C++‎ به‌صورت زیر بازنویسی کنیم

class MyClass {
  private:
    atomic<Helper*> helper {nullptr};
    mutex mtx;
  public:
    Helper* getHelper() {
      Helper* myHelper = helper.load(memory_order_acquire);
      if (myHelper == nullptr) {
        lock_guard<mutex> lg(mtx);
        myHelper = helper.load(memory_order_relaxed);
        if (myHelper == nullptr) {
          myHelper = new Helper();
          helper.store(myHelper, memory_order_release);
        }
      }
      return myHelper;
    }
};

The acquire load and release store ensure that if we see a non-null helper, then we will also see its fields correctly initialized. همچنین این مشاهده قبلی را که بارهای غیررقابتی می‌توانند از memory_order_relaxed استفاده کنند لحاظ کرده‌ایم.

یک برنامه‌نویس جاوا می‌تواند helper را به‌عنوان یک java.util.concurrent.atomic.AtomicReference<Helper> نمایش دهد و از lazySet() به‌عنوان فروشگاه انتشار استفاده کند. عملیات بارگیری همچنان از تماس‌های ساده get() استفاده می‌کند.

در هر دو مورد، بهینه‌سازی عملکرد ما بر مسیر مقداردهی اولیه متمرکز بود که بعید است برای عملکرد حیاتی باشد. سازش خواناتر ممکن است این باشد:

    Helper* getHelper() {
      Helper* myHelper = helper.load(memory_order_acquire);
      if (myHelper != nullptr) {
        return myHelper;
      }
      lock_guard&ltmutex> lg(mtx);
      if (helper == nullptr) {
        helper = new Helper();
      }
      return helper;
    }

This provides the same fast path, but resorts to default, sequentially-consistent, operations on the non-performance-critical slow path.

حتی در اینجا، helper.load(memory_order_acquire) احتمالاً همان کد را در معماری‌های فعلی پشتیبانی‌شده توسط Android به‌عنوان یک مرجع ساده (با سازگاری متوالی) به helper تولید می‌کند. واقعاً مفیدترین بهینه‌سازی در اینجا ممکن است معرفی myHelper برای حذف بار دوم باشد، اگرچه یک کامپایلر آینده ممکن است این کار را به‌طور خودکار انجام دهد.

ترتیب کسب/انتشار مانع از تأخیر قابل‌مشاهده فروشگاه‌ها نمی‌شود و تضمین نمی‌کند که فروشگاه‌ها با ترتیبی یکسان برای رشته‌های دیگر قابل‌مشاهده شوند. درنتیجه، از الگوی کدنویسی پیچیده اما نسبتاً رایجی که الگوریتم محرومیت متقابل دکر نمونه‌ای از آن است پشتیبانی نمی‌کند: همه رشته‌ها ابتدا پرچمی را تنظیم می‌کنند که نشان می‌دهد می‌خواهند کاری انجام دهند؛ اگر رشته t سپس متوجه شود که هیچ رشته دیگری سعی نمی‌کند کاری انجام دهد، می‌تواند با اطمینان از اینکه تداخلی وجود نخواهد داشت، ادامه دهد. هیچ رشته دیگری نمی‌تواند ادامه دهد، زیرا پرچم t هنوز تنظیم شده است. اگر پرچم بااستفاده از ترتیب کسب/رهاسازی دردسترس قرار گیرد، این کار ناموفق خواهد بود، زیرا این کار مانع از این نمی‌شود که پرچم رشته دیرتر برای دیگران نمایان شود، یعنی بعداز اینکه آن‌ها به‌اشتباه ادامه داده‌اند. memory_order_seq_cst پیش‌فرض از آن جلوگیری می‌کند.

فیلدهای تغییرناپذیر

If an object field is initialized on first use and then never changed, it may be possible to initialize and subsequently read it using weakly ordered accesses. در C++‎، می‌توان آن را به‌صورت atomic اعلام کرد و بااستفاده از memory_order_relaxed به آن دسترسی پیدا کرد، یا در Java، می‌توان آن را بدون volatile اعلام کرد و بدون اقدامات خاص به آن دسترسی پیدا کرد. این امر مستلزم آن است که همه موارد زیر برقرار باشد:

  • باید بتوان از مقدار خود فیلد تشخیص داد که آیا قبلاً مقداردهی اولیه شده است یا نه. برای دسترسی به فیلد، مقدار آزمایش و بازگشت مسیر سریع باید فیلد را فقط یک‌بار بخواند. در جاوا، مورد دوم ضروری است. حتی اگر فیلد به‌عنوان مقداردهی اولیه آزمایش شود، بارگیری دوم ممکن است مقدار مقداردهی‌نشده قبلی را بخواند. در C++‎ قانون «یک‌بار خواندن» صرفاً تمرین خوبی است.
  • هم بار کردن اولیه و هم بار کردن‌های بعدی باید اتمی باشند، به این معنی که به‌روزرسانی‌های جزئی نباید قابل‌مشاهده باشند. برای Java، فیلد نباید long یا double باشد. برای C++‎، تخصیص اتمی لازم است؛ ساخت آن در محل کار نخواهد کرد، زیرا ساخت atomic اتمی نیست.
  • مقداردهی‌های اولیه تکراری باید ایمن باشند، زیرا چندین رشته ممکن است به‌طور هم‌زمان مقدار مقداردهی‌نشده را بخوانند. در ++C، این موضوع معمولاً از الزام «به‌سادگی قابل‌کپی» که برای همه انواع اتمی اعمال می‌شود پیروی می‌کند؛ انواع دارای اشاره‌گرهای تودرتو به تخصیص‌زدایی در سازنده کپی نیاز دارند و به‌سادگی قابل‌کپی نیستند. برای Java، برخی‌از انواع مرجع قابل‌قبول است:
  • ارجاع‌های Java محدود به انواع تغییرناپذیر است که فقط حاوی فیلدهای نهایی است. سازنده نوع تغییرناپذیر نباید مرجعی به شیء منتشر کند. در این مورد، قوانین فیلد نهایی Java تضمین می‌کند که اگر خواننده مرجع را ببیند، فیلدهای نهایی مقداردهی‌شده را نیز خواهد دید. C++‎ هیچ آنالوگی برای این قوانین ندارد و اشاره‌گرها به اشیاء تحت مالکیت نیز به همین دلیل غیرقابل‌قبول هستند (علاوه‌بر نقض الزامات «قابل‌کپی کردن به‌صورت بدیهی»).

یادداشت‌های پایانی

While this document does more than merely scratch the surface, it doesn’t manage more than a shallow gouge. این موضوع بسیار گسترده و عمیق است. برخی زمینه‌ها برای کاوش بیشتر:

  • مدل‌های حافظه واقعی Java و C++‎ برحسب رابطه happens-before بیان می‌شوند که مشخص می‌کند چه زمانی تضمین می‌شود دو کنش به ترتیب خاصی رخ دهند. وقتی رقابت داده را تعریف کردیم، به‌طور غیررسمی درباره دو دسترسی به حافظه که «هم‌زمان» اتفاق می‌افتند صحبت کردیم. به‌طور رسمی این‌گونه تعریف می‌شود که هیچ‌کدام قبل‌از دیگری اتفاق نیفتاده است. یادگیری تعاریف واقعی happens-before و synchronizes-with در مدل حافظه Java یا C++‎ آموزنده است. اگرچه مفهوم شهودی «هم‌زمان» به‌طور کلی به‌اندازه کافی خوب است، این تعاریف آموزنده هستند، به‌ویژه اگر قصد دارید از عملیات اتمی با ترتیب ضعیف در C++ استفاده کنید. (مشخصات فعلی Java فقط lazySet() را به‌صورت بسیار غیررسمی تعریف می‌کند.)
  • ببینید کامپایلرها هنگام مرتب‌سازی مجدد کد مجاز به انجام چه کارهایی هستند و چه کارهایی را نمی‌توانند انجام دهند. (مشخصات JSR-133 چند نمونه عالی از تبدیل‌های قانونی دارد که منجر به نتایج غیرمنتظره می‌شود.)
  • با نحوه نوشتن کلاس‌های تغییرناپذیر در Java و ++C آشنا شوید. (این موضوع فراتر از «هیچ‌چیز را پس‌از ساخت تغییر ندهید» است.)
  • توصیه‌های بخش «همزمانی» در Effective Java, 2nd Edition را درونی‌سازی کنید. (برای مثال، باید از فراخوانی روش‌هایی که قرار است در داخل بلوک همگام‌سازی‌شده لغو شوند اجتناب کنید.)
  • java.util.concurrent و java.util.concurrent.atomic API را بخوانید تا ببینید چه چیزهایی دردسترس است. استفاده از حاشیه‌نویسی‌های همزمانی مانند @ThreadSafe و @GuardedBy (از net.jcip.annotations) را درنظر بگیرید.

بخش مطالعه بیشتر در پیوست حاوی پیوندهایی به اسناد و وب‌سایت‌هایی است که این موضوعات را بهتر روشن می‌کنند.

ضمیمه

درحال پیاده‌سازی فروشگاه‌های همگام‌سازی

(این چیزی نیست که اکثر برنامه‌نویسان خودشان را درحال پیاده‌سازی آن ببینند، اما بحث روشنگر است.)

برای انواع کوچک توکار مانند int و سخت‌افزار پشتیبانی‌شده توسط Android، دستورالعمل‌های بارگیری و ذخیره معمولی تضمین می‌کنند که یک ذخیره به‌طور کامل یا اصلاً برای پردازنده دیگری که همان مکان را بارگیری می‌کند، قابل مشاهده خواهد بود. بنابراین برخی‌از مفاهیم پایه «تجزیه‌ناپذیری» به‌طور رایگان ارائه می‌شود.

همان‌طور که قبلاً دیدیم، این کافی نیست. In order to ensure sequential consistency we also need to prevent reordering of operations, and to ensure that memory operations become visible to other processes in a consistent order. It turns out that the latter is automatic on Android-supported hardware, provided we make judicious choices for enforcing the former, so we largely ignore it here.

ترتیب عملیات حافظه با جلوگیری از بازآرایی توسط کامپایلر و جلوگیری از بازآرایی توسط سخت‌افزار حفظ می‌شود. در اینجا روی مورد دوم تمرکز می‌کنیم.

ترتیب حافظه در ARMv7،‏ x86، و MIPS با دستورالعمل‌های «حصار» اعمال می‌شود که تقریباً از نمایان شدن دستورالعمل‌های پس‌از حصار قبل‌از دستورالعمل‌های پیش‌از حصار جلوگیری می‌کند. (این دستورالعمل‌ها معمولاً «مانع» نیز نامیده می‌شوند، اما این نام‌گذاری ممکن است با موانع سبک pthread_barrier اشتباه گرفته شود، که کارهای بسیار بیشتری انجام می‌دهند.) The precise meaning of fence instructions is a fairly complicated topic that has to address the way in which guarantees provided by multiple different kinds of fences interact, and how these combine with other ordering guarantees usually provided by the hardware. این یک نمای کلی سطح بالا است، بنابراین ما این جزئیات را نادیده می‌گیریم.

ابتدایی‌ترین نوع تضمین ترتیب، تضمینی است که توسط عملیات اتمی C++‎ memory_order_acquire و memory_order_release ارائه می‌شود: عملیات حافظه قبل‌از ذخیره انتشار باید پس‌از بارگیری اکتساب قابل‌مشاهده باشد. در ARMv7، این مورد توسط این‌ها اعمال می‌شود:

  • دستورالعمل ذخیره را با دستورالعمل حصار مناسبی پیش‌از آن قرار دهید. این کار از مرتب‌سازی مجدد همه دسترسی‌های قبلی به حافظه با دستور ذخیره جلوگیری می‌کند. (همچنین به‌طور غیرضروری از سفارش مجدد با دستورالعمل بعدی فروشگاه جلوگیری می‌کند.)
  • دنبال کردن دستورالعمل بارگیری با دستورالعمل حصار مناسب، جلوگیری از مرتب‌سازی مجدد بارگیری با دسترسی‌های بعدی. (And once again providing unneeded ordering with at least earlier loads.)

این دو باهم برای ترتیب کسب/انتشار ++C کافی هستند. این‌ها برای Java volatile یا C++‎ سازگار متوالی atomic لازم هستند، اما کافی نیستند.

برای اینکه ببینیم به چه چیز دیگری نیاز داریم، قطعه الگوریتم دکر را درنظر بگیرید که قبلاً به‌طور خلاصه به آن اشاره کردیم. flag1 و flag2 متغیرهای C++‎ atomic یا Java volatile هستند که هر دو در ابتدا false هستند.

رشته ۱ رشته ۲
flag1 = true
if (flag2 == false)
    critical-stuff
flag2 = true
if (flag1 == false)
    critical-stuff

سازگاری متوالی به این معنی است که یکی از تخصیص‌ها به flagn باید ابتدا اجرا شود و توسط آزمایش در رشته دیگر دیده شود. بنابراین، هرگز نخواهیم دید که این رشته‌ها «موارد بحرانی» را به‌طور هم‌زمان اجرا کنند.

اما حصارکشی موردنیاز برای ترتیب کسب-انتشار فقط حصارهایی را در ابتدا و انتهای هر رشته اضافه می‌کند که در اینجا کمکی نمی‌کند. علاوه‌براین، باید مطمئن شویم که اگر یک ذخیره volatile/atomic با یک بارگیری volatile/atomic دنبال شود، ترتیب این دو تغییر نکند. این معمولاً با افزودن یک حصار نه تنها قبل از یک فروشگاه سازگار متوالی، بلکه بعداز آن نیز اعمال می‌شود. (این نیز بسیار قوی‌تر از حد نیاز است، زیرا این حصار معمولاً همه دسترسی‌های قبلی به حافظه را نسبت به همه دسترسی‌های بعدی مرتب می‌کند.)

درعوض می‌توانیم حصار اضافی را با بارهای سازگار متوالی مرتبط کنیم. ازآنجایی‌که فروشگاه‌ها کمتر رایج هستند، قراردادی که شرح دادیم رایج‌تر است و در Android استفاده می‌شود.

همان‌طور که در بخش قبلی دیدیم، باید یک مانع ذخیره/بارگذاری بین دو عملیات درج کنیم. کد اجراشده در ماشین مجازی برای دسترسی فرار چیزی شبیه به این خواهد بود:

بار فرار فروشگاه فرار
reg = A
fence for "acquire" (1)
fence for "release" (2)
A = reg
fence for later atomic load (3)

معماری‌های واقعی ماشین معمولاً انواع مختلفی از حصارها را ارائه می‌دهند که انواع مختلف دسترسی‌ها را مرتب می‌کنند و ممکن است هزینه‌های متفاوتی داشته باشند. انتخاب بین این دو ظریف است و تحت تأثیر نیاز به اطمینان از اینکه فروشگاه‌ها برای هسته‌های دیگر به ترتیبی ثابت قابل‌مشاهده هستند و اینکه ترتیب حافظه تحمیل‌شده توسط ترکیب چندین حصار به‌درستی ترکیب می‌شود، قرار دارد. برای جزئیات بیشتر، لطفاً به صفحه «دانشگاه کمبریج» با نگاشت‌های جمع‌آوری‌شده اتمی به پردازنده‌های واقعی مراجعه کنید.

در برخی معماری‌ها، به‌ویژه x86، موانع «کسب» و «انتشار» غیرضروری هستند، زیرا سخت‌افزار همیشه به‌طور ضمنی ترتیب کافی را اعمال می‌کند. بنابراین در x86 فقط آخرین حصار (۳) واقعاً تولید می‌شود. به‌طور مشابه در x86، عملیات اتمی خواندن-اصلاح-نوشتن به‌طور ضمنی شامل یک حصار قوی است. بنابراین این‌ها هرگز به حصار نیاز ندارند. در ARMv7، همه حصارهایی که در بالا بحث کردیم لازم است.

ARMv8 دستورالعمل‌های LDAR و STLR را ارائه می‌دهد که مستقیماً الزامات بارگذاری‌ها و ذخیره‌سازی‌های متوالی سازگار Java volatile یا C++‎ را اعمال می‌کنند. این کار از محدودیت‌های غیرضروری تغییر ترتیب که در بالا ذکر کردیم جلوگیری می‌کند. کد Android‏ ۶۴ بیتی در ARM از این‌ها استفاده می‌کند؛ ما تصمیم گرفتیم در اینجا بر جای‌گذاری حصار ARMv7 تمرکز کنیم زیرا الزامات واقعی را بهتر روشن می‌کند.

مطالعه بیشتر

صفحه‌های وب و اسنادی که عمق یا گستردگی بیشتری ارائه می‌دهند. مقاله‌هایی که به‌طور کلی مفیدتر هستند در بالای فهرست قرار دارند.

مدل‌های سازگاری حافظه مشترک: آموزش
این مقاله که در سال ۱۹۹۵ توسط Adve و Gharachorloo نوشته شده است، نقطه شروع خوبی برای کسانی است که می‌خواهند عمیق‌تر با مدل‌های سازگاری حافظه آشنا شوند.
http://www.hpl.hp.com/techreports/Compaq-DEC/WRL-95-7.pdf
موانع حافظه
مقاله کوتاه و خوبی که مشکلات را خلاصه می‌کند.
https://en.wikipedia.org/wiki/Memory_barrier
مبانی Threads
مقدمه‌ای بر برنامه‌نویسی چندرشته‌ای در C++‎ و جاوا، نوشته هانس بوهم. بحث درباره رقابت داده‌ها و روش‌های همگام‌سازی پایه.
http://www.hboehm.info/c++mm/threadsintro.html
Java Concurrency In Practice
این کتاب که در سال ۲۰۰۶ منتشر شده است، طیف گسترده‌ای از موضوعات را با جزئیات کامل پوشش می‌دهد. برای هرکسی که کد چندرشته‌ای در جاوا می‌نویسد به‌شدت توصیه می‌شود.
http://www.javaconcurrencyinpractice.com
پرسشگان JSR-133 (مدل حافظه جاوا)
مقدمه‌ای ملایم بر مدل حافظه Java، شامل توضیح همگام‌سازی، متغیرهای فرار، و ساخت فیلدهای نهایی. (کمی قدیمی است، به‌ویژه وقتی درباره زبان‌های دیگر بحث می‌کند.)
http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html
Validity of Program Transformations in the Java Memory Model
توضیحی نسبتاً فنی درباره مشکلات باقی‌مانده با مدل حافظه Java. این مشکلات برای برنامه‌های بدون رقابت داده اعمال نمی‌شود.
http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.112.1790&rep=rep1&type=pdf
نمای کلی بسته java.util.concurrent
مستندات بسته java.util.concurrent. نزدیک پایین صفحه، بخشی با عنوان «ویژگی‌های سازگاری حافظه» وجود دارد که تضمین‌های ارائه‌شده توسط کلاس‌های مختلف را توضیح می‌دهد.
java.util.concurrent خلاصه بسته
نظریه و تمرین جاوا: تکنیک‌های ساخت ایمن در جاوا
This article examines in detail the perils of references escaping during object construction, and provides guidelines for thread-safe constructors.
http://www.ibm.com/developerworks/java/library/j-jtp0618.html
نظریه و عمل جاوا: مدیریت فراریت
مقاله خوبی که توضیح می‌دهد چه کارهایی را می‌توانید و چه کارهایی را نمی‌توانید با فیلدهای فرار در Java انجام دهید.
http://www.ibm.com/developerworks/java/library/j-jtp06197.html
اعلامیه «قفل کردن با بررسی مجدد خراب است»
توضیح دقیق بیل پو درباره روش‌های مختلفی که در آن قفل‌گذاری با بررسی مضاعف بدون volatile یا atomic خراب می‌شود. شامل C/C++‎ و Java.
http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html
[ARM] Barrier Litmus Tests and Cookbook
بحثی درباره مشکلات ARM SMP، که با تکه‌های کوتاهی از کد ARM روشن شده است. اگر مثال‌های این صفحه را خیلی غیرخاص می‌دانید یا می‌خواهید شرح رسمی دستورالعمل DMB را بخوانید، این را بخوانید. همچنین دستورالعمل‌های استفاده‌شده برای موانع حافظه در کد اجرایی را شرح می‌دهد (احتمالاً اگر درحال تولید کد در لحظه هستید مفید است). توجه داشته باشید که این امر به قبل‌از ARMv8 برمی‌گردد که از دستورالعمل‌های ترتیب حافظه اضافی نیز پشتیبانی می‌کند و به مدل حافظه تاحدودی قوی‌تر منتقل شده است. (برای جزئیات، به «ARM® Architecture Reference Manual ARMv8, for ARMv8-A architecture profile» مراجعه کنید.)
http://infocenter.arm.com/help/topic/com.arm.doc.genc007826/Barrier_Litmus_Tests_and_Cookbook_A08.pdf
موانع حافظه هسته Linux
مستندات مربوط به موانع حافظه هسته Linux. شامل چند نمونه مفید و هنر ASCII.
http://www.kernel.org/doc/Documentation/memory-barriers.txt
ISO/IEC JTC1 SC22 WG21 (استانداردهای C++‎) ‏14882 (زبان برنامه‌نویسی C++‎)، بخش ۱.۱۰ و بند ۲۹ («کتابخانه عملیات اتمی»)
استاندارد پیش‌نویس برای ویژگی‌های عملیات اتمی ++C. این نسخه به استاندارد C++14 نزدیک است که شامل تغییرات جزئی در این زمینه از C++11 است.
http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2015/n4527.pdf
(مقدمه: http://www.hpl.hp.com/techreports/2008/HPL-2008-56.pdf)
ISO/IEC JTC1 SC22 WG14 (استانداردهای C) ‏9899 (زبان برنامه‌نویسی C) فصل ۷.۱۶ («Atomics <stdatomic.h>‎»)
استاندارد پیش‌نویس برای ویژگی‌های عملیات اتمی ISO/IEC 9899-201x C. برای جزئیات، گزارش‌های نقص بعدی را نیز بررسی کنید.
http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1570.pdf
نگاشت‌های C/C++11 به پردازنده‌ها (دانشگاه کمبریج)
مجموعه ترجمه‌های Jaroslav Sevcik و Peter Sewell از اتمیک‌های C++‎ به مجموعه‌های مختلف دستورالعمل‌های پردازنده رایج.
http://www.cl.cam.ac.uk/~pes20/cpp/cpp0xmappings.html
الگوریتم دکر
«اولین راه‌حل صحیح شناخته‌شده برای مشکل انحصار متقابل در برنامه‌نویسی هم‌زمان». مقاله ویکی‌پدیا الگوریتم کامل را دارد، با بحثی درباره اینکه چگونه باید به‌روز شود تا با کامپایلرهای بهینه‌سازی مدرن و سخت‌افزار SMP کار کند.
https://en.wikipedia.org/wiki/Dekker's_algorithm
نظرات درباره ARM درمقابل Alpha و وابستگی‌های نشانی
ایمیلی در فهرست پستی arm-kernel از Catalin Marinas. شامل خلاصه خوبی از وابستگی‌های نشانی و کنترل است.
http://linux.derkeiler.com/Mailing-Lists/Kernel/2009-05/msg11811.html
آنچه هر برنامه‌نویس باید درباره حافظه بداند
مقاله‌ای بسیار طولانی و مفصل درباره انواع مختلف حافظه، به‌ویژه حافظه‌های پنهان CPU، نوشته اولریش درپر.
http://www.akkadia.org/drepper/cpumemory.pdf
Reasoning about the ARM weakly consistent memory model
این مقاله توسط چونگ و اشتیاق از ARM، Ltd نوشته شده است. این مقاله تلاش می‌کند تا مدل حافظه ARM SMP را به شیوه‌ای دقیق اما قابل دسترس توصیف کند. تعریف «مشاهده‌پذیری» که در اینجا استفاده شده است از این مقاله گرفته شده است. مجدداً، این به قبل‌از ARMv8 برمی‌گردد.
http://portal.acm.org/ft_gateway.cfm?id=1353528&type=pdf&coll=&dl=&CFID=96099715&CFTOKEN=57505711
The JSR-133 Cookbook for Compiler Writers
داگ لی این را به‌عنوان همراهی برای مستندات JSR-133 (مدل حافظه جاوا) نوشت. این مقاله حاوی مجموعه اولیه دستورالعمل‌های پیاده‌سازی برای مدل حافظه Java است که توسط بسیاری از نویسندگان کامپایلر استفاده شده است و هنوز به‌طور گسترده به آن ارجاع داده می‌شود و احتمالاً بینش‌هایی ارائه می‌دهد. متأسفانه، چهار نوع حصار مورد بحث در اینجا برای معماری‌های پشتیبانی‌شده توسط Android مناسب نیستند، و نگاشت‌های C++11 فوق اکنون منبع بهتری برای دستورالعمل‌های دقیق هستند، حتی برای Java.
http://g.oswego.edu/dl/jmm/cookbook.html
x86-TSO: A Rigorous and Usable Programmer’s Model for x86 Multiprocessors
شرح دقیقی از مدل حافظه x86. متأسفانه شرح دقیق مدل حافظه ARM بسیار پیچیده‌تر است.
http://www.cl.cam.ac.uk/~pes20/weakmemory/cacm.pdf