همزمانی را به روانترین راه پیش ببرید

1. مشکل من اینجاست
در اینجا باید اعتراف کنم: برنامه نویسی همزمان یکی از بزرگترین سردردهایی است که من در حین یادگیری علوم کامپیوتر دارم. اما از زمانی که مطالعه Go را شروع کردم، متوجه شدم که باید این چالش را غلبه کنم تا واقعا زبان را بفهمم. بنابراین، من این مقاله را می نویسم تا آن را برای خودم – یا شخص دیگری که ممکن است به این موضوع علاقه مند باشد – به گونه ای توضیح دهم که حتی یک بچه 5 ساله هم می تواند بفهمد (ممکن است اغراق کنم).
2. اول چیزها
هر مقاله در مورد برنامه نویسی همزمان با توضیح تفاوت بین همزمانی و موازی شروع می شود، اما ما در اینجا این کار را انجام نمی دهیم، پس این را فراموش کنید. بیایید، در عوض، با یک سلام ساده لوحانه، جهان شروع کنیم! برنامه در Go:
func main() {
var message string
func() {
message = "Hello, World!"
}()
fmt.Println("Output:", message)
}
خروجی: سلام، جهان!
در این کد، من عمداً از یک تابع ناشناس برای اختصاص رشته “Hello, World!” استفاده کردم. به متغیر پیام سپس، پیام را روی صفحه چاپ کردم. نکته کلیدی در اینجا این است که این برنامه به صورت تک اجرا می شود گوروتین، یعنی کد در یک خط اجرا اجرا می شود. بنابراین، میتوانیم فرض کنیم که هر خط کد به صورت متوالی، یکی پس از دیگری اجرا میشود (بدون توجه به جزئیات سطح پایین).
گوروتین ها خطوط اجرای یک برنامه Go هستند (چیزی مانند نخ ها در سطح بالاتری از انتزاع). هر برنامه Go دارای یک گوروتین اصلی است که مانند نقطه شروع عمل می کند و مدت زمان اجرای برنامه را کنترل می کند، اما اگر می خواهیم علاوه بر اصلی، گوروتین های بیشتری داشته باشیم، باید آنها را به صورت دستی ایجاد کنیم. وجود گوروتینهای بیشتر ممکن است به ما اجازه دهد تا هستههای متعدد پردازندههای مدرن را بررسی کنیم و عملکرد برنامهمان را بهبود ببخشیم، اما از آنجایی که خطوط اجرای بیشتری را به کد خود اضافه میکنیم، اتفاقات عجیبی رخ میدهد:
func main() { // main goroutine starts here
var message string
go func() { // new goroutine starts here
message = "Hello, World!"
}()
fmt.Println("Output:"message)
}
خروجی:
تنها تغییری که در کد بالا نسبت به مثال قبلی ایجاد شد، اضافه کردن کلمه کلیدی go قبل از تابع ناشناس است (کلمه کلیدی go همیشه قبل از فراخوانی تابع برای راهاندازی آنها به عنوان گوروتین استفاده میشود). این یک گوروتین جدید ایجاد می کند که فقط حاوی تابع ناشناس است که همزمان با گوروتین اصلی اجرا می شود. با این حال، همانطور که مشاهده کردید، “سلام، جهان!” پیام در خروجی وجود ندارد این به این دلیل رخ می دهد که گوروتین اصلی منتظر نمی ماند تا گوروتین جدید کار خود را انجام دهد (تخصیص یک مقدار به متغیر پیام) و ابتدا اجرا را به پایان می رساند. اگر روال اصلی را مجبور کنیم قبل از اجرای Println یک ثانیه دیگر منتظر بماند، این رفتار حتی آشکارتر می شود.
func main() {
var message string
go func() {
message = "Hello, World!"
}()
time.Sleep(time.Second) // wait one second before print
fmt.Println("Output:", message)
}
خروجی: سلام، جهان!
اما افزودن یک زمان. خواب به گوروتین اصلی ما راه درستی برای انجام کارها نیست، در عوض، میتوانیم از کانالهایی استفاده کنیم که راه پیشفرض برای انتقال پیامها بین خطوط اجرا در Go هستند.
3. کانال ها
هدف ما در اینجا این است که اطمینان حاصل کنیم که گوروتین اصلی قبل از اجرای تابع Println منتظر میماند تا گوروتین جدید ایجاد شده کار خود را کامل کند. این به ما امکان می دهد “سلام، جهان!” پیام نمایش داده شده بر روی صفحه نمایش بدون توسل به زمان تقلب هیئت منصفه. رویکرد خواب. یکی از راههای رسیدن به این هدف، ایجاد کانالی است که ارتباط بین دو گوروتین را با انتقال رشته از یکی به دیگری تسهیل میکند.
func main() {
channel := make(chan string)
go func() {
channel <- "Hello, World!"
close(channel)
}()
fmt.Println("Output:", <-channel)
}
خروجی: سلام، جهان!
در این کد از تابع make برای ایجاد یک کانال استفاده می کنیم (نام کانال در اینجا کانال است، اما می تواند هر نامی باشد) که می تواند رشته ها را ارسال و دریافت کند.
channel := make(chan string)
در گوروتین جدید ایجاد شده، ما “سلام، جهان!” پیام از طریق کانال (فلش جهت جریان داده را نشان می دهد). سپس کانال را می بندیم، به این معنی که ارسال داده ها به پایان رسیده است. این به گیرنده اطلاع می دهد که هیچ اطلاعات بیشتری ارسال نخواهد شد و به او اجازه می دهد منتظر پیام های اضافی نباشد.
channel <- "Hello, World!"
close(channel)
اکنون در گوروتین اصلی، در آرگومان Println، رشته ای را که از گوروتین جدید ارسال شده است، دریافت می کنیم.
fmt.Println("Output:", <-channel)
این کد به درستی عمل می کند زیرا به گوروتین اصلی دستور داده می شود قبل از اجرای Println منتظر داده های کانال باشد. بنابراین توجه داشته باشید که کانال ها این قدرت را دارند که اجرای گوروتین را در حین انتظار برای ارسال یا دریافت داده متوقف کنند. به این ترتیب، کانال به عنوان یک مکانیسم همگام سازی در زمینه ناهمزمان عمل می کند و از دریافت پیام در زمان مناسب اطمینان می دهد.
3.1 کانال های بافر
کانال های Go به طور پیش فرض ظرفیت حافظه ندارند، به این معنی که فقط داده ها را ارسال و دریافت می کنند، اما آنها را ذخیره نمی کنند. با این حال، گاهی اوقات ما می خواهیم داده ها را برای کنترل جریان داده در میان گوروتین ها نگه داریم. برای رسیدن به این هدف می توانیم از بافرها استفاده کنیم.
func main() {
channel := make(chan string)
channel <- "Output:"
channel <- "Hello, World"
fmt.Println(<-channel, <-channel)
}
خطای مهلک: همه گوروتین ها خواب هستند – بن بست!
وقتی کد بالا را اجرا می کنیم، یک خطای بن بست دریافت می کنیم. این به این دلیل است که وقتی کانال رشته “Output:” را دریافت می کند، اجرا متوقف می شود. سپس کانال منتظر میماند تا این دادهها را به جای دیگری بفرستد (به یاد داشته باشید، کانالها فاقد حافظه داخلی هستند و نمیتوانند دادهها را نگهداری کنند)، اما، از آنجایی که در لحظهای که منتظر دریافت آن هستند، هیچ مشکلی وجود ندارد، انتظار بیهوده است. در نتیجه، خطی که باید “سلام، جهان!” رشته هرگز اجرا نمی شود و برنامه دچار وحشت می شود. برای حل این مشکل، ما به سادگی نیاز داریم که بافرهایی را به کانال اضافه کنیم که به آن اجازه می دهد قبل از ارسال داده ها را به طور موقت ذخیره کند.
func main() {
channel := make(chan string, 2) // the second argument here is the capacity
channel <- "Output:"
channel <- "Hello, World"
fmt.Println(<-channel, <-channel)
}
خروجی: سلام، جهان!
برای ایجاد کانال های بافر، به سادگی اندازه بافر مورد نظر را به عنوان آرگومان دوم تابع make مشخص می کنیم. در این مثال، استفاده از شماره 2 به کانال اجازه می دهد تا دو رشته را ذخیره کند و از مسدود شدن جریان اجرای عادی برنامه جلوگیری کند. بافرها مکانیزمی را برای کنترل حداکثر مقدار داده ای که می توان در صف قرار داد ارائه می دهد که به ویژه در سناریوهایی مانند سرورهای وب که حجم بالایی از درخواست ها را مدیریت می کنند مفید است.
3.2 بیانیه را انتخاب کنید
همانطور که قبلا ذکر شد، کانال ها می توانند در حالی که منتظر ارسال یا دریافت داده هستند، اجرا را متوقف کنند. در برخی موارد، به ویژه هنگام کار با چندین کانال، این رفتار ممکن است مطلوب نباشد. ممکن است نخواهید یک کانال اجرای مربوط به کانال دیگر را مسدود کند. برای رفع این مشکل، Go دستور select را ارائه میکند، ساختاری که به کانالها اجازه میدهد بدون اینکه یکی دیگری را مسدود کند، با هم کار کنند. قبل از فرو رفتن در دستور select، اجازه دهید نمونه ای از کد بدون آن را بررسی کنیم.
func main() {
oneSecond := make(chan string)
fiveSeconds := make(chan string)
go func() {
for i := 0; i < 20; i++ {
time.Sleep(time.Second)
oneSecond <- "One second"
}
}()
go func() {
for i := 0; i < 20; i++ {
time.Sleep(time.Second * 5)
fiveSeconds <- "Five seconds"
}
}()
for i := 0; i < 20; i++ {
fmt.Println(<-oneSecond)
fmt.Println(<-fiveSeconds)
}
}
یک ثانیه
پنج ثانیه
یک ثانیه
پنج ثانیه
یک ثانیه
کد بالا برای نمایش “یک ثانیه” در هر ثانیه و “پنج ثانیه” هر پنج ثانیه در نظر گرفته شده است. با این حال، در حال حاضر، “یک ثانیه” تنها هر پنج ثانیه نمایش داده می شود. این به این دلیل اتفاق می افتد که دریافت از یک کانال بدون داده، اجرا را تا رسیدن داده مسدود می کند. در این حالت، کانال برای “پنج ثانیه” برنامه را به مدت پنج ثانیه متوقف می کند و در واقع کانال را برای “یک ثانیه” متوقف می کند. از آنجایی که ما از گوروتین ها استفاده می کنیم، می خواهیم به جای منتظر ماندن برای اجرای متوالی توابع، از همزمانی استفاده کنیم. برای رفع این مشکل، دستور select را معرفی می کنیم.
func main() {
oneSecond := make(chan string)
fiveSeconds := make(chan string)
go func() {
for i := 0; i < 20; i++ {
time.Sleep(time.Second)
oneSecond <- "One second"
}
}()
go func() {
for i := 0; i < 20; i++ {
time.Sleep(time.Second * 5)
fiveSeconds <- "Five seconds"
}
}()
for i := 0; i < 20; i++ {
select {
case <-oneSecond:
fmt.Println(<-oneSecond)
case <-fiveSeconds:
fmt.Println(<-fiveSeconds)
}
}
}
یک ثانیه
یک ثانیه
پنج ثانیه
یک ثانیه
یک ثانیه
یک ثانیه
پنج ثانیه
با استفاده از دستور select در داخل حلقه for، میتوانیم پیام را دقیقاً در لحظهای که کانال آن را دریافت میکند، چاپ کنیم، کاری که از یک کد همزمان انتظار میرود.
4. سایر ابزارهای همزمانی Go
گوروتین ها، کانال ها و دستور select بالاترین سطح انتزاع را در Go برای همزمانی تشکیل می دهند. اینها ابزارهایی هستند که ما بیشتر تشویق به استفاده روزانه از آنها می شویم. با این حال، ممکن است شرایطی وجود داشته باشد که ابزارهای سطح پایین تر مانند mutexes و گروه های انتظار ضروری شوند. در حالی که بحث در مورد این الگوها از تمرکز این مقاله منحرف میشود، من همه را تشویق میکنم که این موضوعات را بررسی کنند تا درک عمیقتری از همزمانی و موازیسازی به دست آورند.