آیا می‌توان Concurrency در Go را در C پیاده‌سازی کرد؟
ﺯﻣﺎﻥ ﻣﻄﺎﻟﻌﻪ: 21 دقیقه

آیا می‌توان Concurrency در Go را در C پیاده‌سازی کرد؟

اگر از برنامه‌نویسان بپرسید چه چیزی زبان Go را از بسیاری از زبان‌های دیگر متمایز می‌کند، احتمالا یکی از اولین پاسخ‌ها Concurrency خواهد بود. قابلیت‌هایی مانند Goroutine و Channel باعث شده‌اند نوشتن برنامه‌های هم‌زمان در Go ساده‌تر، خواناتر و کم‌خطاتر باشد.

در مقابل، زبان C چنین امکاناتی را به‌صورت پیش‌فرض در اختیار برنامه‌نویس قرار نمی‌دهد. اگر بخواهید چند کار را به‌طور هم‌زمان اجرا کنید، باید مستقیما با Threadها، Mutexها، Semaphoreها و سایر ابزارهای سطح پایین سیستم‌عامل کار کنید؛ ابزارهایی که قدرت زیادی دارند، اما استفاده نادرست از آن‌ها می‌تواند به باگ‌هایی منجر شود که پیدا کردن و رفع آن‌ها بسیار دشوار است.

اما آیا این یعنی C ذاتا برای برنامه‌نویسی هم‌زمان مناسب نیست؟

پاسخ کوتاه، خیر است. اگرچه C قابلیت‌هایی مانند Goroutine یا Channel ندارد، اما می‌توان با استفاده از کتابخانه‌ها و طراحی مناسب، مفاهیمی مشابه را در این زبان پیاده‌سازی کرد و تجربه‌ای نزدیک به Go به دست آورد.

در این مطلب ابتدا تفاوت رویکرد C و Go در برنامه‌نویسی هم‌زمان را بررسی می‌کنیم و سپس می‌بینیم چگونه می‌توان با تکیه بر قابلیت‌های موجود در C، ساختاری شبیه به Concurrency زبان Go ایجاد کرد، بدون اینکه خود زبان تغییری کرده باشد.

تفاوت رویکرد C و Go در Concurrency

برای درک بهتر اینکه چگونه می‌توان قابلیت‌های مشابه Go را در C ایجاد کرد، ابتدا باید تفاوت فلسفه این دو زبان در مدیریت اجرای هم‌زمان را بررسی کنیم.

در زبان گولنگ، Concurrency بخشی از طراحی اصلی زبان است. برنامه‌نویس به جای ایجاد و مدیریت مستقیم Threadهای سیستم‌عامل، می‌تواند یک Goroutine ایجاد کند. Goroutineها بسیار سبک‌تر از Threadهای معمولی هستند و Runtime زبان Go مسئول زمان‌بندی، اجرای آن‌ها و تقسیم منابع پردازشی بین آن‌ها است.

برای مثال، در Go اجرای یک تابع در یک مسیر مستقل فقط با اضافه کردن کلمه کلیدی go انجام می‌شود:

go processTask()

اما در C چنین مفهومی وجود ندارد. زبان C یک زبان سطح پایین‌تر است و بیشتر مسئولیت‌ها را به برنامه‌نویس واگذار می‌کند. اگر بخواهید کاری را هم‌زمان اجرا کنید، معمولا باید از Threadهای سیستم‌عامل استفاده کنید، برای مثال در محیط‌های Unix از pthread استفاده می‌شود.

ایجاد یک Thread در C معمولا شامل تعریف تابع، مدیریت آرگومان‌ها و کنترل چرخه زندگی Thread است:

pthread_create(&thread, NULL, function, argument);

این قدرت و کنترل بیشتر، هزینه خودش را دارد. Threadها منابع بیشتری مصرف می‌کنند و هماهنگ کردن آن‌ها نیازمند دقت زیادی است. برنامه‌نویس باید خودش مشکلاتی مانند:

  • دسترسی هم‌زمان به داده‌ها
  • Race Condition
  • Deadlock
  • مدیریت حافظه مشترک

را کنترل کند.

در Go، بسیاری از این پیچیدگی‌ها توسط Runtime مدیریت می‌شوند. در C، اگر بخواهیم تجربه‌ای شبیه Go داشته باشیم، باید لایه‌ای بالاتر روی Threadهای موجود بسازیم. یعنی ابزاری ایجاد کنیم که مدیریت اجرای کارهای هم‌زمان را ساده‌تر کند.

همین ایده، پایه بسیاری از کتابخانه‌هایی است که تلاش می‌کنند امکاناتی شبیه Goroutine و Channel را به زبان‌هایی مانند C اضافه کنند.

پیاده‌سازی Concurrency به سبک Go با pthread

طبیعتا Threadهای معمولی سیستم‌عامل نمی‌توانند از نظر مصرف منابع و مقیاس‌پذیری دقیقا با Goroutineهای سبک Go رقابت کنند. هر Thread واقعی هزینه‌هایی مانند مصرف حافظه، زمان ایجاد و سربار زمان‌بندی دارد. اما سوال مهم‌تر این است که این هزینه‌ها در عمل چقدر هستند؟ از چه نقطه‌ای به یک مشکل تبدیل می‌شوند؟ و آیا راهی وجود دارد که بتوان این محدودیت‌ها را کاهش داد؟

این پرسش‌ها زمانی مطرح شدند که توسعه‌دهنده پروژه Solod در حال اضافه کردن قابلیت‌های Concurrency به این زبان بود. Solod یک زیرمجموعه محدود از Go است که به کد C ساده ترجمه می‌شود و برخلاف گولنگ، Runtime اختصاصی و Garbage Collector ندارد.

نتیجه این تجربه نشان داد که با وجود محدودیت‌های C، می‌توان با استفاده از pthreadها یک مدل Concurrency قابل استفاده ایجاد کرد، البته به شرط اینکه محدودیت‌ها و هزینه‌های این انتخاب را به‌درستی بشناسیم.

در ادامه، مدل مبتنی بر POSIX Thread را بررسی می‌کنیم، مدلی که تلاش می‌کند بخشی از سادگی Concurrency در Go را به دنیای C بیاورد، اما همچنان با واقعیت‌های معماری سیستم‌عامل و محدودیت‌های Threadهای سنتی روبه‌رو است.

Mutex و Condition Variable: پایه‌های Concurrency در C

تمام ساختار Concurrency در پروژه So بر پایه دو قابلیت اصلی POSIX Thread ساخته شده است: Mutex و Condition Variable.

در زبان Go، ابزارهایی مانند sync.Mutex برای هماهنگ کردن دسترسی چند Goroutine به داده‌های مشترک استفاده می‌شوند. در So نیز همین مفهوم وجود دارد، با این تفاوت که در نهایت همه چیز به کد C و APIهای pthread تبدیل می‌شود.

در ساده‌ترین حالت، sync.Mutex در So فقط یک Wrapper روی pthread_mutex_t است:

type Mutex struct {
    mu pthread_mutex_t
}

func (m *Mutex) Lock() {
    rc := pthread_mutex_lock(&m.mu)
    if rc != 0 {
        panic("sync: Mutex.Lock failed")
    }
}

از آنجا که So به C تبدیل می‌شود، خروجی نهایی تقریبا همان ساختار را حفظ می‌کند:

typedef struct sync_Mutex {
    pthread_mutex_t mu;
} sync_Mutex;

void sync_Mutex_Lock(sync_Mutex* m) {
    int rc = pthread_mutex_lock(&m->mu);
    if (rc != 0) {
        so_panic("sync: Mutex.Lock failed");
    }
}

همان‌طور که مشخص است، این ترجمه بسیار مستقیم است. در واقع Mutex در So چیزی بیشتر از یک ساختار شامل pthread_mutex_t و چند تابع کمکی نیست.

وظیفه Mutex ساده است: وقتی چند Thread می‌خواهند به یک داده مشترک دسترسی پیدا کنند، Mutex تضمین می‌کند که در هر لحظه فقط یک Thread بتواند آن بخش از کد را اجرا کند.

اما Mutex به‌تنهایی کافی نیست. گاهی یک Thread باید منتظر رخ دادن یک اتفاق خاص بماند. برای مثال، یک Thread تولیدکننده ممکن است منتظر خالی شدن فضای یک صف باشد و یک Thread مصرف‌کننده منتظر دریافت داده جدید باشد.

برای چنین شرایطی از Condition Variable استفاده می‌شود.

در So، ابزار sync.Cond نیز یک Wrapper روی pthread_cond_t است:

type Cond struct

func (c *Cond) Wait()
func (c *Cond) Signal()
func (c *Cond) Broadcast()

معادل این ساختار در C:

typedef struct sync_Cond {
    pthread_cond_t cond;
    sync_Mutex* mu;
} sync_Cond;

متدهای اصلی آن نیز مستقیما به توابع pthread متصل می‌شوند:

  • Wait() برای منتظر ماندن تا زمانی که یک شرط برقرار شود.
  • Signal() برای بیدار کردن یک Thread منتظر.
  • Broadcast() برای بیدار کردن تمام Threadهای منتظر.

این دو ابزار، یعنی Mutex و Condition Variable، پایه تمام ابزارهای سطح بالاتر در سیستم Concurrency پروژه So هستند.

ابزارهایی مانند:

  • Once برای اجرای یک عملیات فقط یک‌بار
  • Thread Pool برای مدیریت Threadها
  • Channelها برای ارتباط بین وظایف

همگی در نهایت با ترکیبی از Mutex و Condition Variable ساخته شده‌اند.

البته این انتخاب، تاثیر مستقیمی روی عملکرد سیستم دارد. استفاده از Threadهای واقعی و Primitiveهای سیستم‌عامل، قدرت و کنترل زیادی فراهم می‌کند، اما هزینه‌هایی نیز دارد که در ادامه بررسی خواهیم کرد.

Atomicها: وقتی نیازی به Lock نداریم

همه عملیات‌های هم‌زمان نیازمند استفاده از Lock نیستند. گاهی اوقات می‌توان با استفاده از Atomic Operationها، داده‌ها را بدون ایجاد قفل بین Threadها به‌صورت امن مدیریت کرد.

در So، بسته sync/atomic تقریبا مشابه نسخه موجود در Go طراحی شده است. این بسته انواع مختلفی را ارائه می‌دهد:

  • Bool
  • Int32
  • Int64
  • Uint32
  • Uint64
  • Pointer[T]

هرکدام از این نوع‌ها نیز عملیات‌هایی مانند:

  • Load برای خواندن مقدار
  • Store برای نوشتن مقدار جدید
  • Swap برای جایگزینی مقدار
  • CompareAndSwap برای تغییر شرطی مقدار

را پشتیبانی می‌کنند.

نکته مهم این است که برخلاف Mutex و Condition Variable ،اتومیک‌ها به pthread وابسته نیستند. این عملیات‌ها مستقیما به قابلیت‌های Atomic داخلی کامپایلر C یعنی __atomic builtins تبدیل می‌شوند.

این همان روشی است که کامپایلر Go نیز برای تولید دستورهای سطح پایین پردازنده استفاده می‌کند. بنابراین از نظر تئوری، عملیات Atomic در So نباید کندتر از Go باشد، و در عمل نیز همین اتفاق افتاده است.

مقایسه زمان اجرای یک عملیات Atomic روی یک Thread:

عملیات Go So نتیجه
Load ۲ نانوثانیه ۲ نانوثانیه تقریبا برابر
Store ۲ نانوثانیه ۲ نانوثانیه تقریبا برابر
CompareAndSwap ۱۳ نانوثانیه ۱۳ نانوثانیه تقریبا برابر

این موضوع نشان می‌دهد که برای برخی عملیات‌های هم‌زمان، داشتن Runtime پیچیده مانند Go الزاما به معنی عملکرد بهتر نیست. وقتی یک قابلیت مستقیما به دستورهای سخت‌افزاری پردازنده تبدیل شود، می‌توان به عملکرد مشابهی دست پیدا کرد.

یکی از نمونه‌های خوب استفاده از Atomicها، پیاده‌سازی sync.Once است.

هدف Once این است که یک تابع فقط یک‌بار اجرا شود؛ حتی اگر چند Thread هم‌زمان تلاش کنند آن را فراخوانی کنند.

ساختار آن به شکل زیر است:

type Once struct {
    mu   Mutex
    done atomic.Bool
}

در حالت معمول، هر بار که متد Do فراخوانی می‌شود، ابتدا بررسی می‌کند که آیا عملیات قبلا انجام شده است یا نه:

func (o *Once) Do(f func()) {
    if o.done.Load() {
        return
    }
    // slow path...
}

اگر مقدار Atomic برابر با true باشد، تابع بدون گرفتن هیچ Lockای بازمی‌گردد. این مسیر که به آن Fast Path گفته می‌شود، بسیار سریع است زیرا هیچ قفلی ایجاد نمی‌کند.

تنها زمانی که تابع برای اولین بار اجرا می‌شود، برنامه وارد مسیر کندتر (Slow Path) می‌شود و از Mutex برای هماهنگی بین Threadها استفاده می‌کند.

نسخه C تقریبا همان ساختار را حفظ می‌کند:

typedef struct sync_Once {
    sync_Mutex mu;
    atomic_Bool done;
} sync_Once;

این طراحی یک الگوی مهم در برنامه‌نویسی هم‌زمان را نشان می‌دهد:
کارهای پرتکرار باید تا حد امکان بدون Lock انجام شوند و فقط مسیرهای خاص به هماهنگ‌سازی سنگین نیاز داشته باشند.

Worker Pool: اجرای هم‌زمان با تعداد محدودی Thread

برای اجرای واقعی کد به‌صورت هم‌زمان، در نهایت به Threadهای سیستم‌عامل نیاز داریم. در So، نوع conc.Thread یک Wrapper ساده روی pthread_t است.

این ساختار امکان ایجاد Thread، منتظر ماندن برای پایان اجرای آن و جدا کردن منابع Thread را فراهم می‌کند:

type Thread struct

func (th Thread) Wait() any
func (th Thread) Detach()

معادل این کد در C:

typedef struct conc_Thread {
    pthread_t t;
} conc_Thread;

void* conc_Thread_Wait(conc_Thread th);
void  conc_Thread_Detach(conc_Thread th);

تابع conc.Go نیز شبیه دستور go در Go به نظر می‌رسد:

func Go(entry func(any) any, arg any) Thread {
    var th Thread
    rc := pthread_create(&th.t, nil, entry, arg)
    // ...
}

اما یک تفاوت بسیار مهم وجود دارد.

در Go، دستور go یک Goroutine سبک ایجاد می‌کند که توسط Runtime مدیریت می‌شود. اما conc.Go در So یک Thread واقعی سیستم‌عامل ایجاد می‌کند.

نسخه C آن تقریبا به شکل زیر است:

conc_Thread conc_Go(void* (*entry)(void*), void* arg) {
    conc_Thread th = {0};
    int rc = pthread_create(&th.t, NULL, entry, arg);
    // ...
}

استفاده از آن نیز مشابه است:

func work(arg any) any {
    acc := arg.(*Account)
    // ...
}

func main() {
    var acc Account
    th := conc.Go(work, &acc)

    // انجام کارهای دیگر به‌صورت هم‌زمان

    th.Wait()
}

اما پشت این ظاهر شبیه به Go، تفاوت معماری مهمی وجود دارد. زمانی که conc.Go فراخوانی می‌شود، یک Thread واقعی ساخته می‌شود و منابع سیستم‌عامل مصرف می‌کند.

بنابراین باید در نهایت یکی از این دو کار انجام شود:

  • با Wait منتظر پایان Thread بمانیم.
  • با Detach مدیریت پایان آن را به سیستم‌عامل بسپاریم.

در غیر این صورت منابع Thread آزاد نمی‌شوند و ممکن است باعث نشت منابع (Resource Leak) شوند.

چرا نباید برای هر کار یک Thread بسازیم؟

Threadهای سیستم‌عامل نسبت به Goroutineها سنگین هستند.

هر Thread معمولا نیازمند:

  • حافظه Stack اختصاصی
  • اطلاعات مدیریتی در Kernel
  • زمان برای ایجاد و حذف
  • هزینه در زمان Context Switch

است.

در مقابل، Goroutineهای Go بسیار سبک‌تر هستند و Runtime می‌تواند تعداد زیادی از آن‌ها را روی چند Thread محدود اجرا کند.

به همین دلیل، ایجاد Thread در یک حلقه برای انجام کارهای کوتاه یا پرتکرار، معمولا طراحی مناسبی نیست.

راهکار بهتر استفاده از Worker Pool است.

Worker Pool چیست؟

ایده Worker Pool این است که به جای ساختن یک Thread جدید برای هر Task، تعداد مشخصی Thread ایجاد کنیم و وظایف را بین آن‌ها تقسیم کنیم.

ساختار کلی به این شکل است:

┌────────┐ ┌────────┐   ┌────────┐
│ Task 1 │ │ Task 2 │...│ Task M │
└────────┘ └────────┘   └────────┘
              │
              ▼
┌────────────────────────────┐
│        Worker Pool         │
│        مدیریت Taskها       │
└────────────────────────────┘
              │
              ▼
┌────────┐ ┌────────┐   ┌────────┐
│Thread 1│ │Thread 2│...│Thread N│
└────────┘ └────────┘   └────────┘
              │
              ▼
        OS Scheduler

در این مدل، تعداد Taskها می‌تواند بسیار بیشتر از تعداد Threadها باشد.

برای مثال، ممکن است هزار Task داشته باشیم، اما فقط ۴ یا ۸ Thread واقعی برای اجرای آن‌ها ایجاد کنیم.

پیاده‌سازی Pool در So

استفاده از Pool در So به شکل زیر است:

type Task struct {
    in  int
    out int
}

func square(arg any) {
    task := arg.(*Task)
    task.out = task.in * task.in
}

func main() {
    tasks := make([]Task, 10)

    opts := conc.PoolOpts{
        NumThreads: 2,
    }

    pool := conc.NewPool(mem.System, opts)
    defer pool.Free()

    for i := range tasks {
        tasks[i].in = i
        pool.Go(square, &tasks[i])
    }

    pool.Wait()
}

در این مثال:

  • ۱۰ Task ساخته می‌شود.
  • فقط ۲ Thread برای اجرای آن‌ها وجود دارد.
  • Pool وظیفه تقسیم Taskها بین Threadها را بر عهده می‌گیرد.

ساختار داخلی Worker Pool

در پشت صحنه، Pool یک صف مشترک از Taskها دارد و Threadهای Worker از این صف کار دریافت می‌کنند.

ساختار اصلی آن شامل موارد زیر است:

type Pool struct {
    alloc mem.Allocator

    mu       sync.Mutex
    notEmpty sync.Cond
    notFull  sync.Cond
    allDone  sync.Cond

    workers []Thread
    queue   []task

    active  int
    stopped bool
}

اجزای مهم:

  • mu: محافظت از داده‌های مشترک
  • notEmpty: بیدار کردن Worker زمانی که Task جدید وارد صف می‌شود
  • notFull: جلوگیری از پر شدن بیش از حد صف
  • allDone: اطلاع دادن به Wait زمانی که همه Taskها تمام شده‌اند
  • workers: لیست Threadهای فعال
  • queue: صف Taskها
  • active: تعداد Taskهایی که هنوز پایان نیافته‌اند

این ساختار در واقع همان الگوی کلاسیک Producer-Consumer است.

حلقه اجرای Workerها

قلب Worker Pool، حلقه‌ای است که روی هر Thread اجرا می‌شود:

func workerMain(arg any) any {
    p := arg.(*Pool)

    for {
        p.mu.Lock()

        for p.qempty() && !p.stopped {
            p.notEmpty.Wait()
        }

        if p.qempty() && p.stopped {
            p.mu.Unlock()
            break
        }

        t := p.qpop()

        p.notFull.Signal()
        p.mu.Unlock()

        t.fn(t.arg)

        p.mu.Lock()

        p.active--

        if p.active == 0 {
            p.allDone.Broadcast()
        }

        p.mu.Unlock()
    }

    return nil
}

فرآیند کار:

  1. Worker قفل Mutex را می‌گیرد.
  2. اگر Taskای وجود نداشته باشد، منتظر می‌ماند.
  3. یک Task از صف خارج می‌کند.
  4. قفل را آزاد می‌کند.
  5. Task را اجرا می‌کند.
  6. پس از پایان، تعداد Taskهای فعال را کاهش می‌دهد.
  7. اگر آخرین Task تمام شده باشد، منتظرها را بیدار می‌کند.

نکته مهم این است که اجرای Task خارج از Lock انجام می‌شود. اگر Worker هنگام اجرای Task قفل را نگه دارد، تمام Threadهای دیگر متوقف می‌شوند و هیچ Concurrency واقعی اتفاق نمی‌افتد.

انتظار برای پایان Taskها

متد Wait فقط زمانی بازمی‌گردد که تعداد Taskهای فعال به صفر برسد:

func (p *Pool) Wait() {
    p.mu.Lock()

    for p.active != 0 {
        p.allDone.Wait()
    }

    p.mu.Unlock()
}

یعنی:

  • هر Task هنگام اضافه شدن، مقدار active را افزایش می‌دهد.
  • هر Worker بعد از پایان Task، آن را کاهش می‌دهد.
  • آخرین Worker، همه منتظرها را بیدار می‌کند.

محدودیت Worker Pool در مقایسه با Goroutine

Worker Pool بخش زیادی از مزایای Concurrency را فراهم می‌کند، اما محدودیت مهمی دارد. تعداد Workerها ثابت است.

در Go، اگر هزاران Goroutine منتظر عملیات I/O باشند، Runtime می‌تواند Threadهای دیگر را برای اجرای کارهای جدید استفاده کند. اما در So اگر همه Workerها منتظر یک عملیات Blocking باشند، کل Pool متوقف می‌شود. برای مثال، اگر Pool فقط ۸ Thread داشته باشد و هر ۸ Thread منتظر پاسخ شبکه باشند، Task جدیدی اجرا نخواهد شد تا یکی از آن‌ها آزاد شود.

بنابراین برخلاف Go، برنامه‌نویس باید خودش تعداد مناسب Workerها را بر اساس نوع workload انتخاب کند. این دقیقا یکی از تفاوت‌های اصلی بین یک Runtime کامل مانند Go و یک لایه سبک‌تر روی Threadهای سیستم‌عامل مانند So است.

Channel: ارتباط بین Threadها به سبک Go

یکی از مهم‌ترین بخش‌های مدل Concurrency در گولنگ، Channelها هستند. Channelها به Goroutineها اجازه می‌دهند بدون نیاز به اشتراک مستقیم حافظه، داده‌ها را بین یکدیگر منتقل کنند.

So نیز ابزاری مشابه با نام conc.Chan[T] ارائه می‌دهد. این Channelها از نظر رفتار بسیار شبیه Channelهای Go هستند:

  • داده‌ها هنگام ارسال کپی می‌شوند.
  • دو نوع دارند:
    • Buffered Channel
    • Unbuffered Channel

برای مثال، ساخت یک Channel با ظرفیت ۲:

ch := conc.NewChan[int](mem.System, 2)
defer ch.Free()

این Channel می‌تواند دو مقدار را بدون اینکه گیرنده‌ای وجود داشته باشد، در خود نگه دارد.

یک Thread برای تولید داده ایجاد می‌شود:

prod := producer{ch: &ch, n: 5}

thr := conc.Go(produce, &prod)
defer thr.Wait()

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

var v int

for ch.Recv(&v) {
    fmt.Printf("received %d\n", v)
}

نسخه C همین منطق را حفظ می‌کند:

conc_Chan ch = conc_NewChan(so_int, mem_System, 2);

producer prod = (producer){
    .ch = &ch,
    .n = 5
};

conc_Thread thr = conc_Go(produce, &prod);

so_int v = 0;

for (; conc_Chan_Recv(so_int, &ch, &v);) {
    fmt_Printf("received %d\n", v);
}

conc_Thread_Wait(thr);
conc_Chan_Free(so_int, &ch);

Buffered Channel: صفی با ظرفیت محدود

اگر هنگام ساخت Channel مقدار ظرفیت بیشتر از صفر باشد، So از یک ساختار شبیه صف حلقوی (Ring Buffer) استفاده می‌کند.

ساختار داخلی آن:

type Buffer struct {
    alloc mem.Allocator

    mu       sync.Mutex
    notEmpty sync.Cond
    notFull  sync.Cond

    buf    mem.Array
    closed bool
}

این ساختار بسیار شبیه صف داخل Worker Pool است.

اجزای مهم:

  • mu برای محافظت از داده‌های مشترک
  • notEmpty برای بیدار کردن گیرنده زمانی که داده جدید وارد می‌شود
  • notFull برای بیدار کردن فرستنده زمانی که فضای خالی ایجاد می‌شود
  • buf برای نگهداری داده‌ها
  • closed برای مشخص کردن بسته شدن Channel

عملیات ارسال (Send) ابتدا Lock را می‌گیرد:

func (ch *Buffer) Send(v any) {
    ch.mu.Lock()

    for ch.bfull() {
        ch.notFull.Wait()
    }

    ch.bpush(v)

    ch.notEmpty.Signal()

    ch.mu.Unlock()
}

اگر Buffer پر باشد، Thread فرستنده متوقف می‌شود تا زمانی که یک گیرنده مقداری از داده‌ها را مصرف کند.

این مکانیزم یک مفهوم مهم در سیستم‌های هم‌زمان ایجاد می‌کند:

Back Pressure

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

نسخه C همین رفتار را پیاده می‌کند:

void conc_Buffer_Send(conc_Buffer* ch, void* v) {
    sync_Mutex_Lock(&ch->mu);

    for (; conc_Buffer_bfull(ch);) {
        sync_Cond_Wait(&ch->notFull);
    }

    conc_Buffer_bpush(ch, v);

    sync_Cond_Signal(&ch->notEmpty);

    sync_Mutex_Unlock(&ch->mu);
}

عملیات Recv دقیقا برعکس عمل می‌کند:

  1. اگر Buffer خالی باشد، منتظر می‌ماند.
  2. اولین مقدار را دریافت می‌کند.
  3. به فرستنده اطلاع می‌دهد که یک فضای خالی ایجاد شده است.

Unbuffered Channel: ارسال مستقیم بین فرستنده و گیرنده

اگر ظرفیت Channel صفر باشد، So از مدل دیگری استفاده می‌کند که به آن Rendezvous گفته می‌شود.

در این حالت، هیچ Bufferای وجود ندارد.

فرستنده تا زمانی که گیرنده آماده نباشد، متوقف می‌شود.

ساختار آن:

type Rendezvous struct {
    alloc mem.Allocator

    vsize int

    mu   sync.Mutex
    cond sync.Cond

    src     any
    full    bool
    claimed bool
    closed  bool
}

ایده اصلی این است:

  • فرستنده مقدار را منتشر می‌کند.
  • گیرنده آن را دریافت می‌کند.
  • سپس فرستنده اجازه ادامه اجرا پیدا می‌کند.

متد Send ابتدا منتظر می‌ماند تا انتقال قبلی تمام شود:

func (ch *Rendezvous) Send(v any) {
    ch.mu.Lock()

    for ch.full {
        ch.cond.Wait()
    }

    ch.src = v
    ch.full = true
    ch.claimed = false

    ch.cond.Broadcast()

    for !ch.claimed {
        ch.cond.Wait()
    }

    ch.src = nil
    ch.full = false

    ch.cond.Broadcast()

    ch.mu.Unlock()
}

پس از قرار دادن مقدار، فرستنده منتظر می‌ماند تا گیرنده آن را بردارد.

نسخه C:

void conc_Rendezvous_Send(conc_Rendezvous* ch, void* v) {
    sync_Mutex_Lock(&ch->mu);

    for (; ch->full;) {
        sync_Cond_Wait(&ch->cond);
    }

    ch->src = v;
    ch->full = true;
    ch->claimed = false;

    sync_Cond_Broadcast(&ch->cond);

    for (; !ch->claimed;) {
        sync_Cond_Wait(&ch->cond);
    }

    ch->full = false;
    ch->src = NULL;

    sync_Cond_Broadcast(&ch->cond);

    sync_Mutex_Unlock(&ch->mu);
}

چرا کپی مستقیم از Stack امن است؟

در نگاه اول ممکن است عجیب باشد که چرا مقدار مستقیما از Stack فرستنده خوانده می‌شود. معمولا وقتی یک تابع تمام شود، حافظه Stack آن دیگر معتبر نیست. اما در اینجا چنین اتفاقی نمی‌افتد. دلیلش این است که فرستنده بعد از ارسال مقدار، متوقف می‌شود:

for !ch.claimed {
    ch.cond.Wait()
}

تا زمانی که گیرنده مقدار را دریافت نکند، تابع فرستنده تمام نمی‌شود. بنابراین Stack مربوط به آن همچنان وجود دارد.

زمانی که گیرنده مقدار را خواند:

  1. مقدار claimed تغییر می‌کند.
  2. فرستنده بیدار می‌شود.
  3. تابع ادامه پیدا می‌کند.
  4. Stack آزاد می‌شود.

در نتیجه نیازی به ایجاد یک Buffer مشترک برای نگهداری داده نیست.

دو بیدار شدن برای یک انتقال

در مدل Rendezvous، هر ارسال نیازمند دو مرحله بیدار شدن است:

  1. فرستنده مقدار را قرار می‌دهد و گیرنده را بیدار می‌کند.
  2. گیرنده مقدار را دریافت می‌کند و فرستنده را بیدار می‌کند.

این طراحی باعث می‌شود انتقال مستقیم بین دو Thread امکان‌پذیر شود، اما در مقابل هزینه Synchronization بیشتری نسبت به Buffered Channel دارد.

شباهت زیاد به Channelهای Go

API ارائه‌شده توسط So بسیار شبیه Go است، اما تفاوت اصلی همچنان پابرجاست:

در گولنگ، Channelها بین Goroutineهای سبک Runtime کار می‌کنند.

در So، مکانیزم Channelها بین Threadهای واقعی سیستم‌عامل اجرا می‌شوند.

بنابراین اگرچه ظاهر API مشابه است، هزینه واقعی پشت صحنه متفاوت خواهد بود.

در ادامه، بررسی می‌کنیم که این مدل در مقایسه با Go چه عملکردی دارد و هزینه واقعی استفاده از pthreadها چقدر است.

عملکرد: هزینه واقعی Concurrency در C

تا اینجا دیدیم که می‌توان بسیاری از ایده‌های Concurrency در Go را با استفاده از ابزارهای موجود در C بازسازی کرد. So توانسته مفاهیمی مانند Thread ،Worker Pool و Channel را با تکیه بر pthreadها و Primitiveهای ساده‌ای مانند Mutex و Condition Variable پیاده‌سازی کند.

اما سوال اصلی این است:

این مدل از نظر عملکرد چقدر به Go نزدیک است؟

پاسخ کوتاه این است که بستگی به نوع عملیات دارد. برای برخی بخش‌ها، مانند Atomic Operationها، تفاوت تقریبا وجود ندارد، اما در بخش‌هایی که نیازمند ایجاد Thread یا هماهنگ‌سازی بین آن‌ها هستند، هزینه بیشتری پرداخت می‌شود.

هزینه ایجاد Thread

بزرگ‌ترین تفاوت بین Goroutine و Thread واقعی، هزینه ایجاد آن‌ها است. در Go، ایجاد یک Goroutine بسیار ارزان است. Runtime می‌تواند هزاران Goroutine را مدیریت کند، زیرا هر Goroutine تنها مقدار کمی حافظه نیاز دارد و روی Threadهای موجود زمان‌بندی می‌شود.

اما در C، هر فراخوانی pthread_create یک Thread واقعی سیستم‌عامل ایجاد می‌کند. این عملیات شامل:

  • تخصیص حافظه برای Stack
  • ثبت اطلاعات Thread در Kernel
  • آماده‌سازی Scheduler سیستم‌عامل

است.

به همین دلیل، ایجاد Thread برای هر Task کوچک معمولا ایده خوبی نیست.

این دقیقا دلیل وجود Worker Pool است. به جای اینکه برای هر کار یک Thread بسازیم، تعداد محدودی Thread ایجاد می‌کنیم و کارها را بین آن‌ها تقسیم می‌کنیم.

هزینه Channelها

Channelهای So از Mutex و Condition Variable استفاده می‌کنند. این طراحی ساده و قابل اعتماد است، اما هر عملیات ارسال یا دریافت می‌تواند شامل:

  • گرفتن Lock
  • بررسی وضعیت Buffer
  • منتظر ماندن در صورت نیاز
  • بیدار کردن Thread دیگر

باشد.

در نتیجه، هزینه آن‌ها به نوع Channel بستگی دارد.

Buffered Channel

در Channelهای دارای Buffer، اگر فضای خالی وجود داشته باشد، ارسال داده نسبتا سریع است. اما وقتی Buffer پر شود، فرستنده باید منتظر بماند.

این انتظار باعث Context Switch بین Threadها می‌شود که هزینه بیشتری نسبت به یک تغییر وضعیت ساده در حافظه دارد.

Unbuffered Channel

در مدل Rendezvous، هزینه بیشتر است، زیرا هر انتقال نیازمند هماهنگی مستقیم بین فرستنده و گیرنده است.

برای هر مقدار:

  1. فرستنده منتظر گیرنده می‌شود.
  2. گیرنده مقدار را دریافت می‌کند.
  3. فرستنده دوباره بیدار می‌شود.

این طراحی برای هماهنگی دقیق مناسب است، اما برای انتقال حجم بالای داده ممکن است محدودیت ایجاد کند.

مقایسه با Go

تفاوت اصلی اینجا مشخص می‌شود:

در گولنگ، Runtime زبان بخشی از بار مدیریت Concurrency را بر عهده می‌گیرد.

برای مثال:

  • زمان‌بندی Goroutineها
  • جابه‌جایی بین Goroutineها
  • مدیریت انتظارهای I/O

توسط Runtime انجام می‌شود.

اما در So، این مسئولیت‌ها بر عهده سیستم‌عامل و pthreadها هستند.

یعنی:

ویژگی Go So با pthread
واحد اجرای سبک Goroutine Thread
مدیریت زمان‌بندی Runtime OS Scheduler
هزینه ایجاد بسیار کم بیشتر
تعداد وظایف هم‌زمان بسیار زیاد محدودتر
کنترل سطح پایین کمتر بیشتر

چه زمانی این مدل مناسب است؟

مدل مبتنی بر pthread در C برای همه سناریوها مناسب نیست، اما در برخی کاربردها انتخاب خوبی است.

مناسب برای:

  • پردازش‌های CPU-bound
  • برنامه‌های سیستم‌سطح پایین
  • نرم‌افزارهایی که کنترل دقیق روی منابع نیاز دارند
  • پروژه‌هایی که نمی‌خواهند Runtime پیچیده داشته باشند

اما برای برنامه‌هایی با تعداد بسیار زیاد عملیات هم‌زمان، مخصوصا عملیات I/O، مدل Go معمولا بهتر عمل می‌کند.

برای مثال، یک سرور شبکه ممکن است هزاران اتصال هم‌زمان داشته باشد. در Go، هر اتصال می‌تواند یک Goroutine داشته باشد. اما در مدل Thread-based، ایجاد هزاران Thread هزینه زیادی خواهد داشت.

نتیجه آزمایش‌ها

تجربه So نشان می‌دهد که فاصله بین یک Runtime کامل مانند Go و یک پیاده‌سازی مبتنی بر pthread همیشه یک فاصله بزرگ نیست.

اگر طراحی با دقت انجام شود:

  • Atomicها می‌توانند عملکرد مشابه داشته باشند.
  • Worker Pool می‌تواند هزینه ایجاد Thread را کاهش دهد.
  • Channelها می‌توانند یک مدل ارتباطی تمیز ارائه دهند.

اما باید محدودیت اصلی را پذیرفت:

Threadهای سیستم‌عامل جایگزین کامل Goroutine نیستند.

می‌توان بسیاری از ایده‌های Go را در C پیاده کرد، اما بخشی از سادگی و مقیاس‌پذیری Go نتیجه وجود Runtimeای است که مسئولیت‌های پیچیده را از برنامه‌نویس پنهان می‌کند.

در نهایت، انتخاب بین این دو رویکرد به نیاز پروژه بستگی دارد. C با pthreadها کنترل بیشتری می‌دهد، در حالی که Go تلاش می‌کند پیچیدگی مدیریت Concurrency را تا حد ممکن از دید برنامه‌نویس پنهان کند.

تصمیم‌های طراحی در مدل Concurrency سولود

پیاده‌سازی Concurrency در Solod بر پایه چند انتخاب مهم انجام شده است، انتخاب‌هایی که بیشتر از اینکه به دنبال بیشترین کارایی باشند، روی سادگی و قابل فهم بودن تمرکز دارند.

Pthread به جای Fiber

یکی از راه‌های نزدیک شدن به مدل Goroutine در Go، استفاده از Fiber یا Coroutineهای سطح کاربر است. این روش می‌تواند هزینه جابه‌جایی بین وظایف را کاهش دهد، اما نیازمند پیاده‌سازی یک Scheduler اختصاصی است.

توسعه‌دهنده Solod تصمیم گرفت از این مسیر استفاده نکند و به جای آن از Pthread استفاده کند. دلیل اصلی این انتخاب، سادگی بود. Thread ،Mutex و Condition Variable ابزارهایی هستند که بیشتر برنامه‌نویسان C با آن‌ها آشنا هستند.

این تصمیم البته هزینه دارد، Threadهای واقعی برای Taskهای بسیار کوچک به اندازه Goroutineهای Go بهینه نیستند، اما با استفاده از Worker Pool می‌توان این محدودیت را تا حد زیادی کاهش داد.

کتابخانه استاندارد به جای خود زبان

در Go، قابلیت‌هایی مانند Goroutine و Channel بخشی از خود زبان هستند. اما در Solod این امکانات در قالب کتابخانه استاندارد ارائه شده‌اند.

این تصمیم دو مزیت دارد:

  • کنترل بهتر حافظه: در Solod هیچ تخصیص حافظه‌ای پنهان نیست و برنامه‌نویس همیشه مشخص می‌کند حافظه از چه Allocatorای تامین شود.
  • انعطاف بیشتر: می‌توان چند Worker Pool با تنظیمات متفاوت برای بخش‌های مختلف برنامه ایجاد کرد، بدون اینکه به یک Scheduler مرکزی وابسته بود.

Timeout به جای Select

Go با دستور select اجازه می‌دهد یک برنامه هم‌زمان منتظر چند Channel بماند و هرکدام که آماده شد، اجرا شود.

پیاده‌سازی چنین قابلیتی در Solod پیچیدگی زیادی ایجاد می‌کرد، زیرا نیازمند مدیریت چندین انتظار هم‌زمان بود.

به همین دلیل Solod به جای select از قابلیت‌های ساده‌تر مانند SendTimeout و RecvTimeout استفاده می‌کند. این روش بسیاری از نیازهای رایج مانند «منتظر بمان، اما فقط تا یک زمان مشخص» یا «اگر آماده نیست، ادامه بده» را پوشش می‌دهد.

البته محدودیت آن این است که نمی‌توان هم‌زمان روی چند Channel مختلف منتظر ماند. این یک انتخاب آگاهانه برای حفظ سادگی طراحی است.

جمع‌بندی

مدل Concurrency در Solod نشان می‌دهد که می‌توان بسیاری از قابلیت‌های زبان‌هایی مانند Go را با استفاده از ابزارهای پایه C مانند Pthread ،Mutex و Condition Variable پیاده‌سازی کرد.

این رویکرد به اندازه Goroutineهای Go سبک و مقیاس‌پذیر نیست، اما در عوض طراحی ساده‌تر، کنترل بیشتر روی منابع و وابستگی کمتر به Runtime دارد.

تصمیم‌های طراحی Solod بر یک اصل مهم استوار هستند: سادگی در برابر پیچیدگی و بهینه‌سازی حداکثری. استفاده از Threadهای واقعی به جای Fiber، قرار دادن قابلیت‌های Concurrency در کتابخانه استاندارد و جایگزینی select با Timeout باعث شده این سیستم قابل فهم‌تر و قابل کنترل‌تر باشد.

تجربه Solod نشان می‌دهد که برای ساخت سیستم‌های هم‌زمان همیشه به یک Runtime پیچیده نیاز نیست، گاهی ترکیب درست چند ابزار ساده می‌تواند یک مدل کاربردی و قابل اعتماد ایجاد کند.

چه امتیازی برای این مقاله میدهید؟

خیلی بد
بد
متوسط
خوب
عالی
در انتظار ثبت رای

/@arastoo
ارسطو عباسی
کارشناس تست نرم‌افزار و مستندات

...

دیدگاه و پرسش
برای ارسال دیدگاه لازم است وارد شده یا ثبت‌نام کنید ورود یا ثبت‌نام

در حال دریافت نظرات از سرور، لطفا منتظر بمانید

در حال دریافت نظرات از سرور، لطفا منتظر بمانید

ارسطو عباسی

کارشناس تست نرم‌افزار و مستندات