طرح‌چه

برنامه نویسی چند نخی (multi thread) و حل مشکل race condition

۲۸ مرداد ۱۴۰۴

برنامه نویسی چند نخی (multi thread) و حل مشکل race condition
بررسی برنامه نویسی چند نخی و مشکل خطای رقابتی (race condition) با یک مثال

برنامه نویسی چند نخی (Multi-Thread) چیست؟

در بسیاری از سیستم‌عامل‌های قدیمی، تنها واحد اجراییِ رسمی فرآیند بود و اگر برنامه‌نویس می‌خواست چند کار را هم‌زمان انجام دهد، چندین فرآیند جداگانه ایجاد می‌کرد و آن‌ها با مکانیسم‌های ارتباط بین‌فرآیندی (IPC) با هم حرف می‌زدند.

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

تاریخچهٔ کوتاه

  • فرآیند (Process): واحد اجراییِ مستقل با فضای آدرس جدا، دسته‌های فایل و منابع مخصوص به خودش. مدل غالب در یونیکس‌های اولیه و بسیاری از سیستم‌ها.

  • نخ (Thread): واحد سبک‌تری داخل یک فرآیند که فضای آدرس مشترک دارد. استانداردهایی مثل POSIX Threads (pthreads) و APIهای سیستم‌عامل‌ها بعدها استفاده از نخ را رایج کردند.

در دوره‌ای که نخ‌ها یا نبودند یا پایدار و استاندارد نشده بودند، راه عملیِ هم‌زمان‌سازی، چندفرآیندی بود.

 

چندفرآیندی چگونه کار می‌کرد؟

برنامه یک فرآیند اصلی داشت که برای هر وظیفهٔ موازی، یک فرآیند فرزند می‌ساخت (در یونیکس با fork()، در ویندوز با CreateProcess و…).
برای تبادل داده بین آن‌ها از IPC استفاده می‌شد

این روش ایزولاسیون خوبی داشت، اما سنگین‌تر از نخ‌ها بود: ایجاد/نابودسازی گران‌تر، تعویض متن (Context Switch) پرهزینه‌تر، و اشتراک داده‌ها دشوارتر. (یکی از علت های آن نیاز به ایجاد مجدد متغیر های حافظه برای هر فرآیند و عدم اشتراک حافظه بود).

 

چرا نخ‌ها مطرح شدند؟

  • کارایی: ساخت و جابه‌جایی بین نخ‌ها ارزان‌تر از فرآیندهاست.

  • حافظهٔ مشترک: همهٔ نخ‌های یک فرآیند به داده‌های مشترک دسترسی دارند، بنابراین تبادل داده ساده‌تر و کم‌هزینه‌تر است.

  • مدل برنامه‌نویسی: در بسیاری از مسائل (مثلاً سرورهایی با اتصالات فراوان) نگه‌داشتن منطق در یک فضای آدرس مشترک و استفاده از نخ برای هر کار، ساده‌تر است.

البته حافظهٔ مشترک به معنای لزوم همگام‌سازی دقیق (قفل‌ها، شرط‌ها، اتمیک‌ها) و توجه به خطاهای رقابتی(race conditions) است که در ادامه با یک مثال این مورد را بررسی میکنیم.

مشکلات برنامه نویسی چند نخی و Race Condition

فرض کنید میخواهیم اعداد بین 1 تا 1000000 را با هم جمع کنیم اما برای افزایش سرعت عملیات میخواهیم این کار را در دو نخ(thread) بطور همزمان انجام دهیم. جواب درستی که انتظار داریم 500000500000 است. کد آن را به شکل زیر در golang مینویسیم:

package main

import (
	"fmt"
	"sync"
)

var a int64 // متغیر مشترک در thread ها

func threadFunc(wg *sync.WaitGroup) {
	for i := int64(1); i < 500000; i++ {
		a = a + i
	}
	wg.Done() //  پایان کار این goroutine. شمارنده WaitGroup یک واحد کم می‌شود.
}

func threadFunc2(wg *sync.WaitGroup) {
	for i := int64(500000); i <= 1000000; i++ {
		a = a + i
	}
	wg.Done() //  پایان کار این goroutine. شمارنده WaitGroup یک واحد کم می‌شود.
}

func main() {
	expected := 500000500000
	var wg sync.WaitGroup
	wg.Add(2)
	go threadFunc(&wg)
	go threadFunc2(&wg)
	wg.Wait()
	fmt.Printf("result: %d (expected without race: %d)\n", a, expected)
}

با اجرای هربار این کد جواب متفاوتی میگیریم که هیچکدام جواب درست نیست. علت آن همزمانی در ذخیره سازی متغیر a است. یعنی زمانی که فانکشن اول میخواهد مقدار جدید a را ذخیره کند بطور همزمان فانکشن دوم مقدار جدید را مجدد ذخیره میکند و اینجا مقادیر بطور ترتیبی و درست ذخیره نمیشوند. برای بررسی دقیق تر با یک مثال جلو میریم. فرض کنید دو actor داریم بنام علی و حامد که هرکدام وظیفه افزایش عدد نوشته شده روی یک میز(که در کد قبلی register پردازنده است) را دارند. روند وقوع به این ترتیب است:

  1. عدد روی کاغذ ۱۰ است.

  2. علی عدد را مشاهده می‌کند: «عدد ۱۰ است؛ آن را به ۱۱ تبدیل می‌کنم»، اما هنوز اقدامی روی کاغذ انجام نداده است.

  3. در همان زمان حامد نیز می‌رسد و عدد را می‌بیند: «عدد ۱۰ است؛ من نیز آن را به ۱۱ تبدیل می‌کنم.»

  4. حامد قبل از علی می‌نویسد و کاغذ به ۱۱ تغییر می‌کند.

  5. سپس علی بازمی‌گردد و با اتکا به مشاهده‌ی قبلی خود (۱۰)، مقدار ۱۱ را می‌نویسد.

نتیجه‌ی نهایی; عدد روی کاغذ ۱۱ باقی می‌ماند. در حالی که انتظار می‌رفت پس از انجام کار هر دو، مقدار به ۱۲ برسد. این همان پدیده‌ی race condition است، کارها به می‌افتند و یکی از افزایش‌ها عملا از دست می‌رود. عملیات خواندن و نوشتن دقیقا در خط a = a + i اتفاق می‌افتد. به این خط ناحیه بحرانی(Critical Section) گفته می‌شود. اما راه حل چیست؟

استفاده از Mutual Exclusion برای حل مشکل Race Condition

برای حل این مشکل در مثال قبل ناحیه بحرانی را محافظت کنیم تا بطور همزمان اجرا نشود. بنابراین کد ما به شکل زیر تغییر می‌کند:

package main

import (
    "fmt"
    "sync"
)
var (
    a int64
    mu sync.Mutex
)

func threadFunc(wg *sync.WaitGroup) {
    defer wg.Done()
    for i := int64(1); i < 500000; i++ {
        mu.Lock()
        a = a + i // عملیات خواندن و بروزرسانی را محافظت میکنیم
        mu.Unlock()
    }
}

func threadFunc2(wg *sync.WaitGroup) {
    defer wg.Done()
    for i := int64(500000); i <= 1000000; i++ {
        mu.Lock()
        a = a + i // عملیات خواندن و بروزرسانی را محافظت میکنیم
        mu.Unlock()
    }
}

func main() {
    expected := int64(500000500000)
    var wg sync.WaitGroup
    wg.Add(2)
    go threadFunc(&wg)
    go threadFunc2(&wg)
    wg.Wait()
    fmt.Printf("result: %d (expected without race: %d)\n", a, expected)
}

 

دیدگاه ها