مهدی خانزادی
۲۶ تیر ۱۴۰۵
با پکیج استاندارد sync آشنا میشویم و قسمت هایی از این پکیج که بیشتر مورد استفاده قرار میگیرند را بررسی میکنیم
پکیج sync در Go یکی از مهمترین پکیجهای استاندارد برای همگامسازی (Synchronization) بین چند goroutine است.
قبل از استفاده از پکیج sync بهتر است با مفهوم race condition آشنا شویم.
از نظر فنی، Race Condition وضعیتی است که در آن چند جریان اجرایی (Thread، Goroutine یا Process) به یک منبع مشترک دسترسی پیدا میکنند و نتیجه نهایی برنامه به ترتیب یا زمانبندی اجرای آنها وابسته میشود. به عبارت دیگر، اگر به هر دلیلی ترتیب اجرای این جریانها را تغییر کند، رفتار برنامه نیز ممکن است تغییر کند.
نکته مهم این است که Race Condition فقط به «همزمان اجرا شدن» مربوط نیست؛ بلکه به عدم هماهنگی (Synchronization) بین عملیات روی یک منبع مشترک مربوط میشود.
برای به وجود آمدن Race Condition, سه شرط معمولاً باید برقرار باشد:
اگر هر سه شرط برقرار باشند، نتیجه برنامه ممکن است غیرقابل پیشبینی شود!
چون جریانهای اجرایی انگار در یک مسابقه (Race) هستند تا زودتر به منبع مشترک برسند و عملیات خود را انجام دهند. هر بار ممکن است برنده مسابقه متفاوت باشد؛ بنابراین نتیجه برنامه نیز ممکن است در هر اجرا متفاوت باشد.
ریس کاندیشن یا race condition دارای انواع مختلفی است که میتوان آن را به صورت زیر دسته بندی کرد:
در ادامه هر کدام از انواع مختلف race condition را به صورت خلاصه وار بررسی میکنیم.
یکی از رایج ترین حالت های race condition از نوع data race است.
نتیجه این است که مقدار نهایی متغیر مشخص نیست!
برای مثال کد زیر را اگر چندین بار اجرا کنید, در هر بار اجرا ممکن است نتیجه مختلفی دریافت کنید:
وقتی چند goroutine به صورت همزمان بر روی یک داده و یا یک ریسورس (resource) مشترک کار میکنند، ممکن است با مشکلاتی مثل Data Race مواجه شوید!
مفهوم data race و race condition معمولا به صورت اشتباه به جای یکدیگر استفاده میشوند. هر Data Race یک Race Condition است، اما هر Race Condition الزاماً Data Race نیست.
در Go ابزار go run -race دقیقاً برای پیدا کردن همین نوع Race طراحی شده است! کافی است هنگام اجرا یا تست برنامه از فلگ -race استفاده کنید تا یکسری چک های اضافه انجام شود و اگر race رخ دهد به شما اعلام شود.
در این حالت برنامه ابتدا یک شرط را بررسی میکند و سپس بر اساس آن تصمیم میگیرد. مشکل اینجاست که بین بررسی شرط و اجرای عمل ممکن است وضعیت داده تغییر کند. در نتیجه تصمیمی که گرفته شده دیگر معتبر نیست.
در این نوع، برنامه فرض میکند عملیاتها باید با ترتیب خاصی انجام شوند، اما هیچ تضمینی برای این ترتیب وجود ندارد. در نتیجه ممکن است یک عملیات قبل از آماده شدن داده اجرا شود یا وابستگیها رعایت نشوند.
با استفاده از پکیج sync میتوانیم race condition را برطرف کنیم. در ادامه به صورت خلاصه به بررسی هرکدام موارد زیر از پکیج sync می پردازیم:
sync.WaitGroupوظیفه اصلی sync.WaitGroup این است که یک Goroutine را تا پایان اجرای چند Goroutine دیگر منتظر نگهدارد. به عبارت دیگر، sync.WaitGroup مکانیزمی فراهم میکند تا بتوان برای یک گوروتین مشخص کرد چه زمانی مجموعهای از گوروتینهای دیگری, کار خود را به پایان رساندهاند.
sync.WaitGroup :هر زمان نیاز است برای تکمیل کار گوروتین ها صبر کنیم, میتوانیم از sync.WaitGroup استفاده کنیم. درون sync.WaitGroup یک شمارنده وجود دارد. برای افزایش شمارنده, قبل از اجرای هر گوروتین از متد Add استفاده میکنیم و درون هر گوروتین با اجرای متد Done شمارنده را کاهش میدهیم که نشانه تمام شدن کار آن گوروتین هست.
در جایی که نیاز است برای اتمام کار گوروتین ها صبر کنیم از متد Wait استفاده میکنیم. این متد تا هنگامی که مقار شمارنده برابر با صفر نشده است به صورت بلاکینگ عمل میکند و مانع از اجرای خطوط بعدی کد میشود.
در ورژن ۱.۲۶ گولنگ متد Go به sync.WaitGroup اضافه شد. بر حسب شرایط, میتوانید تصمیم بگیرید از متد های Add و Done استفاده کنید و یا اینکه از متد Go استفاده کنید که خودش کار فراخوانی Add و Done را برای شما انجام میدهد.
با استفاده از متد Go کد بالا را میتوانیم به صورت زیر بازنویسی کنیم:
sync.Mutex و sync.RWMutexMutex کوتاهشدهی عبارت Mutual Exclusion به معنای «انحصار متقابل» است و یکی از ابزارهای مهم در برنامهنویسی همزمان (Concurrency) برای همگامسازی (Synchronization) محسوب میشود.
Mutex با ایجاد یک مکانیزم قفل (Lock) تضمین میکند که در هر لحظه تنها یک نخ (Thread) یا Goroutine بتواند به یک منبع یا دادهی مشترک دسترسی داشته باشد. این کار از دسترسی همزمان و ناسازگار به داده جلوگیری کرده و مانع بروز مشکلاتی مانند Race Condition و خراب شدن وضعیت دادهها میشود.
بدین ترتیب، زمانی که چندین Goroutine به یک دادهی مشترک دسترسی دارند، میتوان با استفاده از Mutex یا RWMutex دسترسی به آن داده را کنترل کرد تا از اجرای همزمان تغییر اطلاعات جلوگیری شود. در نتیجه، بسته به نوع قفل، یا تنها یک Goroutine در هر لحظه اجازه دسترسی به داده را خواهد داشت (Mutex)، یا چند Goroutine میتوانند بهصورت همزمان داده را بخوانند و تنها عملیاتهای نوشتن به شکل انحصاری انجام شوند (RWMutex).
از sync.Mutex و sync.RWMutex برای محافظت از دادههای مشترک در محیط همروند (concurrent) استفاده میشود؛ اینکار را برای جلوگیری از Data Race انجام میدهیم.
تفاوت اصلی Mutex و RWMutex در نوع دسترسیای است که به Goroutineها اجازه میدهند:
sync.Mutex: در هر لحظه فقط یک Goroutine میتواند وارد بخش محافظتشده (Critical Section) شود. فرقی نمیکند این دسترسی برای خواندن باشد یا نوشتن؛ سایر Goroutineها تا زمان آزاد شدن قفل باید منتظر بمانند.
sync.RWMutex: بین دسترسی خواندن (Read) و نوشتن (Write) تفاوت قائل میشود. چندین Goroutine میتوانند بهصورت همزمان فقط داده را بخوانند، اما زمانی که یک گوروتین قصد تغییر دادن داده را داشته باشد، دسترسی بهصورت انحصاری در اختیار همان گوروتین قرار میگیرد و سایر خوانندهها و نویسندهها تا پایان عملیات نوشتن منتظر میمانند.
در مثال های قبل با هر بار اجرای برنامه ممکن است نتیجه ی اجرای برنامه متفاوت باشد و به دلیل data race خروجی به صورت صحیح محاسبه نشود (مجموع دو عدد ۱۰ و ۱۰۰۰ باید ۱۰۱۰ شود). مثال های قبل را میتوانیم به صورت زیر بازنویسی کنیم تا مشکل حل شود:
در مثال بالا با استفاده از sync.Mutex در هر زمان, دسترسی به x (نوشتن و خواندن) را به صورت انحصاری در اختیار یک گوروتین قرار میدهیم. بدین ترتیب چندین گوروتین نمیتوانند به صورت همزمان به x دسترسی داشته باشند. هر گوروتین با شروع کار خود Lock را انجام میدهد. این عمل بلاکینگ هست. هر گوروتینی که زودتر عمل قبل کردن را انجام دهد باعث میشود باقی گوروتین ها در خط lock.Lock() منتظر بمانند. هر گاه گوروتینی که Lock یا قفل را در اختیار دارد آن را ازاد کند, یکی از گوروتین ها میتوانند قفل را در اختیار بگیرند و به همین صورت با یک قفل اشتراکی میتوان کاری کرد که تنها یکی از گوروتین ها در هر زمان دسترسی به x داشته باشد.
sync.Onceدر برخی مواقع نیاز داریم یک عملیات مشخص در طول چرخهی اجرای یک برنامه، تنها یکبار و بدون توجه به تعداد دفعات فراخوانی آن اجرا شود. برای این منظور میتوان از sync.Once استفاده کرد. این دستور تضمین میکند که یک تابع یا عملیات مشخص فقط یکبار اجرا شود، حتی اگر چندین گوروتین بهصورت همزمان تلاش کنند آن را فراخوانی کنند!
هر متغیر از نوع sync.Once باعث میشود تابعی که به متد Do میدهیم فقط یکبار اجرا شود. اگر بعداً دوباره همان Do را با همان متغیر صدا بزنیم، آن تابع دیگر اجرا نمیشود و فراخوانیهای بعدی نادیده گرفته میشوند.
قسمت قبل: استاندارد پکیج time | گولنگ به زبان ساده
قسمت بعد: معرفی gofix | گولنگ به زبان ساده