میلاد ابراهیمی
میلاد ابراهیمی
خواندن ۷ دقیقه·۳ سال پیش

استفاده از Mutex یا پکیج atomic؟ مسئله این است.


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

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

تعریف مسئله

خب مسئله‌ خیلی ساده است. می‌خوایم یه counter داشته باشیم و یه چیزی (هر چیزی می‌تونه باشه، تعداد request، تعداد خرید و ...) رو بشمریم. نکته مهم این مسئله اینه که این متغیر شمارنده ما ممکنه توسط goroutine های مختلف خونده بشن یا تغییر داده بشن.

پس یک مشکل کلاسیک جلوی ما هست. Race Condition

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

  1. استفاده از Mutex (که داخل پکیج sync استاندارد لایبرری وجود داره)
  2. استفاده توابع از پکیج sync/atomic استاندارد لابرری

استفاده از Mutex

خب اینجا نمی‌خوام نحوه‌ی کارکرد و فلسفه‌ی Mutex رو توضیح بدم. صرفا یه تیکه کد کوچیک برای مسئله‌ی counter می‌ذارم.

برای ساده‌تر شدن مسئله،‌ چند تا goroutine ایجاد نمی‌کنم. صرفا متغیر رو تعریف می‌کنم و یک بار با Mutex مقدارش رو زیاد می‌کنم.

خب با این حال کدش خیلی خیلی ساده میشه:

package main import ( &quotfmt&quot &quotsync&quot ) func main() { var counter int32 = 0 mu := sync.Mutex{} mu.Lock() counter++ mu.Unlock() fmt.Println(counter) }

خروجی این تیکه کد قاعدتا ۱ خواهد بود.

استفاده از توابع پکیج sync/atomic

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

package main import ( &quotfmt&quot &quotsync/atomic&quot ) func main() { var counter int32 = 0 atomic.AddInt32(&counter, 1) fmt.Println(counter) }

خروجی این کد هم قاعدتا ۱ خواهد بود.

ولی چه عجیب! کمتر نیازه کد بزنیم اینجوری :) خب همیشه همینطوری کد بزنیم دیگه!

نه یکم صبر کنید می‌فهمید داستان چیه :)

تفاوت دو روش

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

package main import ( &quotsync&quot &quotsync/atomic&quot &quottesting&quot ) func BenchmarkAtomic(b *testing.B) { var counter uint32 = 0 for i := 0; i < b.N; i++ { atomic.AddUint32(&counter, 1) } } func BenchmarkMutex(b *testing.B) { var counter uint32 = 0 mu := sync.Mutex{} for i := 0; i < b.N; i++ { mu.Lock() counter++ mu.Unlock() } }

با کامند زیر میشه بنچمارک رو اجرا کرد.

go test -bench=.

خروجی این کامند رو‌ی لپ‌تاپ من شد این:

BenchmarkAtomic-8 268115684 4.372 ns/op
BenchmarkMutex-8 100000000 10.22 ns/op

همنطور که می‌بینید مقدار نهایی counter تو حالت atomic شده ۲۶۸۱۱۵۶۸۴ ولی تو حالت Mutex شده یه دونه یک با هشت تا صفر!

یعنی هر operation تو حالت atomic حدود ۴ نانو ثانیه طول کشیده ولی تو حالت Mutex حدود ۱۰ نانو ثانیه.

پس انگار atomic خیلی سریع تره. حدود ۲.۵ برابر!

خب اینطوری که atomic هم سریع‌تره و تعداد خط کد کمتری داره. همیشه از این استفاده می‌کنیم پس! بازم نه. یکم صبر :)

به نظرتون این تفاوت سرعت از کجا ناشی میشه؟

برای اینکه بهتر متوجه بشیم بیاید که کار غیر معمول انجام بدیم. خروجی اسمبلی کامپایلر گولنگ برای دو تا کد رو مقایسه کنیم!

مگه میشه؟ آره میشه. با کامند زیر می‌تونید خروجی اسمبلی رو ببینید.

go tool compile -S file.go > file.s

از اونجایی که خروجی اسمبلی همین چند خط کد خیلی بلنده، فقط به کپی‌کردن قسمت‌های اصلی اون بسنده می‌کنم.

این قطعه کد اسمبلی معادل حالت Mutex (خط ۱۰ تا ۱۳،‌ساخت Mutex تا Unlock کردن آن) هست:

0x0026 00038 (mutex.go:10) MOVQ AX, CX 0x0029 00041 ($GOROOT/src/sync/mutex.go:74) XORL AX, AX 0x002b 00043 ($GOROOT/src/sync/mutex.go:74) MOVL $1, DX 0x0030 00048 ($GOROOT/src/sync/mutex.go:74) LOCK 0x0031 00049 ($GOROOT/src/sync/mutex.go:74) CMPXCHGL DX, (CX) 0x0034 00052 ($GOROOT/src/sync/mutex.go:74) SETEQ BL 0x0037 00055 ($GOROOT/src/sync/mutex.go:74) TESTB BL, BL 0x0039 00057 ($GOROOT/src/sync/mutex.go:37) JNE 82 0x003b 00059 (mutex.go:10) MOVQ CX, &quot&quot..autotmp_25+40(SP) 0x0040 00064 ($GOROOT/src/sync/mutex.go:81) MOVQ CX, AX 0x0043 00067 ($GOROOT/src/sync/mutex.go:81) PCDATA $1, $1 0x0043 00067 ($GOROOT/src/sync/mutex.go:81) CALL sync.(*Mutex).lockSlow(SB) 0x0048 00072 ($GOROOT/src/sync/mutex.go:186) MOVQ &quot&quot..autotmp_25+40(SP), CX 0x004d 00077 ($GOROOT/src/sync/mutex.go:186) MOVL $1, DX 0x0052 00082 (mutex.go:13) PCDATA $1, $-1 0x0052 00082 (mutex.go:13) XCHGL AX, AX 0x0053 00083 ($GOROOT/src/sync/mutex.go:186) MOVL $-1, SI 0x0058 00088 ($GOROOT/src/sync/mutex.go:186) LOCK 0x0059 00089 ($GOROOT/src/sync/mutex.go:186) XADDL SI, (CX) 0x005c 00092 ($GOROOT/src/sync/mutex.go:186) LEAL -1(SI), BX 0x005f 00095 ($GOROOT/src/sync/mutex.go:186) NOP 0x0060 00096 ($GOROOT/src/sync/mutex.go:187) TESTL BX, BX 0x0062 00098 ($GOROOT/src/sync/mutex.go:187) JEQ 113 0x0064 00100 ($GOROOT/src/sync/mutex.go:190) MOVQ CX, AX 0x0067 00103 ($GOROOT/src/sync/mutex.go:190) PCDATA $1, $0 0x0067 00103 ($GOROOT/src/sync/mutex.go:190) CALL sync.(*Mutex).unlockSlow(SB) 0x006c 00108 ($GOROOT/src/sync/mutex.go:190) MOVL $1, DX

فهمیدن اینا واقعا سخته. اصلا زبون آدمیزاد نیست! ولی بیاید همین کار رو هم واسه حالت atomic انجام بدیم ببینیم چی میشه!

خروجی این شکلی خواهد بود! (فقط خط ۱۰ که عمل اضافه‌کردن به طور atomic انجام میشه)

0x0025 00037 (atomic.go:10) MOVL $1, CX 0x002a 00042 (atomic.go:10) LOCK 0x002b 00043 (atomic.go:10) XADDL CX, (AX)

میزان تفاوت شگفت انگیزه!

الان می‌تونیم حدس بزنیم چرا atomic سریع تره! میشه گفت توابع پکیج atomic از قابلیت‌های cpu برای انجام اعمال استفاده می‌کنه و این باعث میشه تعداد instruction های اسمبلی تولید شده خیلی کمتر باشه.

ولی ساختن یک Mutex عام‌منظوره که به درد همه‌ی سناریو‌های ساده و پیچیده بخوره خیلی سخت‌تره و کلی کار از cpu می‌کشه. از ساختش بگیر، تا Lock و Unlock کردنش!


پس همیشه از atomic استفاده کنیم؟

آره دیگه انگار همه‌ی شواهد میگه این کار رو بکنید!

ولی نه! یه لحظه وایسید بریم یه سر به داکیومنت رسمی گولنگ برای این پکیج بزنیم. (لینک)

یه جمله‌ی خیلی خیلی عجیب همون اولا نوشته:

These functions require great care to be used correctly. Except for special, low-level applications, synchronization is better done with channels or the facilities of the sync package. Share memory by communicating; don't communicate by sharing memory.

صراحتا داره می‌گه اگه نمی‌دونی داری چیکار می‌کنی ازش استفاده نکن و فقط زمانی ازش استفاده کن که می‌دونی داری چیکار می‌کنی!

انگار ورق برگشت. ولی خب یعنی چی که «فقط زمانی ازش استفاده کن که می‌دونی داری چیکار می‌کنی»؟

اگر این سوال برای شما هم مطرحه باید بگم که جواب این سوال اینه: اگه این سوال واستون وجود داره، احتمالا نمی‌دونید دقیقا دارید چیکار می‌کنید یا چیکار می‌خواید بکنید :))

به نظرم این داکیومنت خیلی خیلی ناقصه و سازندگان گولنگ باید بیشتر توضیح می‌دادن!

من خیلی جست‌و‌جو کردم که بفهمم داستان چیه! و به یه چیزایی رسیدم که با شما به اشتراک می‌ذارم.

  1. توابع پکیج‌ atomic تا زمانی safe هستن که صرفا برای خوندن و نوشتن داده‌های آماری ازش استفاده بشه و نمیشه روی این توابع برای فهمیدن state اپلیکیشن استفاده کرد. مثلا استفاده کردن از این توابع برای ساخت متریک‌های Prometheus اشکالی نداره (واقعا همینطوری پیاده‌سازی شدن.)
  2. دستورات atomic تنها این گارانتی رو میدن که مقدار درستی توی حافظه نوشته بشه و integrity حافظه و کش cpu و ... حفط بشه. ولی هیچ گارانتی ‌ای مبنی بر این که گوروتین‌های دیگه وقتی دارن اون محل حافظه رو می‌خونن یا حتی می‌نویسن ( با روش‌های دیگه) مقدار درستی می‌خونن یا می‌نویسن نداره! به عبارتی وقتی شما از توابع atomic استفاده می‌کنید صرفا یک قسمتی از حافظه رو lock می‌کنید. اون هم در یک بازی زمانی بسیار کوتاه. ولی زمانی که از Mutex استفاده می‌کنید، یک بلوک کد رو lock می‌کنید. این باعث میشه بتونید برای عملیات‌های read و write خودتون اولویت هم بتونید بذارید. (مثلا استفاده از RWmutex) ولی این کار رو نمی‌تونید با توابع atomic انجام بدید.

این نکاتی که نوشتم برداشت خودم بوده و ممکنه نقایصی داشته باشه یا حتی بعضی جاهاش غلط باشه! برای همین یک لینک تو قسمت منابع می‌ذارم که اگر علاقه داشتید برید و بحث‌ها رو عمیق تر دنبال کنید.


نتیجه

این همه توضیح،‌ می‌رسیم به این جمله که «تا زمانی که دقیقا نمی‌دونید دارید چیکار می‌کنید از atomic استفاده نکنید» :)) (البته این باز نظر شخصی منه. هر طور خودتون راحتید)


منابع

https://groups.google.com/g/golang-nuts/c/0uPDCRiBWqc

https://pkg.go.dev/sync/atomic







گولنگconcurrencygolangبرنامه نویسیlock
مهندس نرم‌افزار ساده
شاید از این پست‌ها خوشتان بیاید