تایپ‌اسکریپت و گولنگ: داستان یک تغییر معماری

۱۱ مارس ۲۰۲۵، خبر مهاجرت کامپایلر تایپ‌اسکریپت از جاوااسکریپت به Go با وعده‌ی «۱۰ برابر سرعت بیشتر» توسط مایکروسافت منتشر شد و خیلی زود همه‌جا دست‌به‌دست شد. اما آیا این عدد چشمگیر واقعاً همان چیزی است که به نظر می‌رسد؟ پشت این تیتر پرهیاهو، داستانی نهفته است درباره‌ی انتخاب‌های معماری، محدودیت‌های اجرا و درس‌هایی که می‌تواند برای هر پروژه‌ی نرم‌افزاری الهام‌بخش باشد.

Typescript and Golang Icons

حتی اگر به کامپایلرها علاقه‌ای نداشته باشید، درس‌های ارزشمندی برای طراحی سیستم‌ها در این ماجرا نهفته است؛ درس‌هایی مثل:

  • فراتر رفتن از ادعاهای پرزرق‌وبرقِ عملکرد
  • انتخاب فناوری متناسب با دامنه‌ی مسئله
  • درک دقیق مدل اجرایی (Runtime)
  • بازنگری در پایه‌ها و معماری پروژه هنگام رشد و تکامل

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

«تایپ اسکریپت با ۱۰ برابر سرعت بیشتر» – واقعی؟

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

“A 10x Faster TypeScript.”

اما این دقیقا به چه معناست؟ به این معناست که اپلیکیشن شما تا قبل از این خبر ۱۰ برابر کُندتر کار می‌کرد و الان که تایپ اسکریپت با زبان برنامه‌نویسی گولنگ بازنویسی شده، ۱۰ برابر سریع‌تر اجرا می‌شود؟

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

درست شبیه به این قضیه که یک سازنده ماشین بگوید از این به بعد ماشین شما ۱۰ برابر سریع‌تر است! اما قضیه اصلی این نیست که ماشین شما ۱۰ برابر از قبل سریع‌تر می‌رود بلکه فرایند ساخت ماشین ۱۰ برابر سریع‌تر شده است. البته این قضیه کوچک و کم اهمیتی نیست و بجای اینکه ماه‌ها صبر کنید تا ماشین را تحویل بگیرید در چند هفته ماشین تحویل شما داده خواهد شد.

فراتر از تیتر «۱۰ برابر سریع‌تر»

اعلامیه‌ی اندرس هایلسبرگ (سازنده تایپ اسکریپت) اعداد چشمگیری را به نمایش گذاشت.

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

اما چنین آمار خیره‌کننده‌ای نیاز به بررسی دقیق‌تر دارد، چرا که این جهش عملکردی حاصل عوامل متعددی است و نمی‌توان آن را صرفاً به «سریع‌تر بودن Go نسبت به جاوااسکریپت» خلاصه کرد.

بیایید صادق باشیم: هر وقت با ادعای «۱۰ برابر سریع‌تر» مواجه می‌شوید، بهتر است با کمی شک و تردید به آن نگاه کنید. واکنش اولیه‌ی من این بود: «خب، قبلاً چه چیزی به شکل غیربهینه پیاده‌سازی شده بود؟» چون چنین بهبودهای بزرگی معمولاً از ناکجاآباد ظاهر نمی‌شوند، اغلب نشانه‌ی این هستند که در طراحی یا پیاده‌سازی قبلی ضعف‌هایی وجود داشته است.

یک باور رایج وجود دارد که «Node.js کند است»؛ اما این بیشتر یک کلیشه‌ی تکراری است تا یک حقیقت مطلق. در برخی موارد ممکن است درست باشد، اما نمی‌توان آن را به طور کلی بیان کرد.

اگر کسی بگوید «Node.js کند است»، تقریباً مثل این است که بگوید «C و ++C کند هستند». چرا؟

معماری Node.js

معماری نودجی‌اس

Node.js بر پایه‌ی موتور جاوااسکریپت V8 گوگل ساخته شده است، همان موتور پرقدرتی که مرورگر کروم را نیز پشتیبانی می‌کند. خود موتور V8 به زبان ++C نوشته شده و Node.js در واقع یک محیط اجرایی (Runtime Environment) پیرامون آن فراهم می‌آورد. این معماری نقش کلیدی در درک عملکرد Node.js دارد:

  • موتور V8: جاوااسکریپت را با استفاده از تکنیک‌های Just-In-Time (JIT) به کد ماشین کامپایل می‌کند.
  • libuv: یک کتابخانه‌ی C که وظیفه‌ی مدیریت عملیات ورودی/خروجی غیرهمزمان (Asynchronous I/O) را بر عهده دارد.
  • کتابخانه‌های اصلی: بسیاری از آن‌ها به زبان C و ++C نوشته شده‌اند تا عملکرد بالاتری داشته باشند. به همین دلیل تفاوت چشمگیری میان سرعت کانکتورهای دیتابیس در Node.js، گول و Rust وجود ندارد.
  • رابط‌های جاوااسکریپت (JavaScript APIs): لایه‌های بسیار نازکی هستند که روی این پیاده‌سازی‌های بومی قرار گرفته‌اند و دسترسی به آن‌ها را ساده می‌کنند.

وقتی درباره‌ی Node.js صحبت می‌کنیم، بسیاری از افراد متوجه نیستند که در واقع درباره‌ی سیستمی حرف می‌زنند که بخش‌های حیاتی آن توسط کدهای بسیار بهینه‌ی C و ++C اجرا می‌شود. جاوااسکریپت شما اغلب فقط نقش هماهنگ‌کننده‌ی فراخوانی این پیاده‌سازی‌های بومی را دارد.

نکته‌ی جالب: Node.js سال‌ها جزو سریع‌ترین فناوری‌های وب‌سرور بوده است. در زمان معرفی، در بنچمارک‌ها عملکردی بهتر از بسیاری وب‌سرورهای سنتی مبتنی بر Thread داشت، به‌ویژه در بارهای کاری با همزمانی بالا و محاسبات سبک. این برتری تصادفی نبود؛ بلکه نتیجه‌ی طراحی آگاهانه‌ی آن بود.

عملیات Memory-Bound در برابر CPU-Bound

عملیات مبتنی بر cpu در مقابل حافظه اصلی

Node.js به‌طور خاص برای وب‌سرورها و اپلیکیشن‌های شبکه‌ای طراحی شده است؛ جایی که بار اصلی بیشتر Memory-bound و I/O-bound است تا CPU-bound.

Memory-bound: شامل جابه‌جایی داده‌ها، تغییر شکل آن‌ها یا ذخیره‌سازی/بازیابی داده‌ها می‌شود. نمونه‌ها:

  • پردازش (Parsing) داده‌های JSON
  • تغییر ساختار داده‌ها
  • مسیردهی درخواست‌های HTTP
  • قالب‌بندی داده‌های پاسخ

I/O-bound: شامل انتظار برای سیستم‌های خارجی است. نمونه‌ها:

  • کوئری‌های دیتابیس
  • درخواست‌های شبکه
  • عملیات سیستم فایل
  • فراخوانی APIهای خارجی

در وب‌اپلیکیشن‌های معمولی، بیشتر زمان صرف انتظار برای تکمیل عملیات I/O می‌شود. برای مثال یک جریان رایج درخواست به این شکل است:

  • دریافت درخواست HTTP ا (Memory-bound)
  • پردازش داده‌های درخواست (Memory-bound)
  • اجرای کوئری دیتابیس (I/O-bound، عمدتاً در حالت انتظار)
  • پردازش نتایج (Memory-bound)
  • قالب‌بندی پاسخ (Memory-bound)
  • ارسال پاسخ HTTP ا (I/O-bound)

در این جریان کاری، محاسبات سنگینِ CPU بسیار اندک هستند. بیشتر وب‌اپلیکیشن‌ها حدود ۸۰ تا ۹۰ درصد زمان خود را صرف انتظار برای تکمیل عملیات I/O می‌کنند.

Node.js دقیقاً برای چنین سناریویی بهینه‌سازی شده است، زیرا:

  • I/O غیرمسدودکننده (Non-blocking I/O): هنگام انتظار برای عملیات I/O، می‌تواند درخواست‌های دیگر را مدیریت کند.
  • پایه‌ی ++C: عملیات مربوط به حافظه به پیاده‌سازی‌های کارآمد ++C واگذار می‌شوند.
  • کارایی حلقه‌ی رویداد (Event Loop Efficiency): توانایی بالایی در هماهنگ‌سازی تعداد زیادی عملیات همزمان با حداقل سربار دارد.

چیزی که بسیاری از افراد متوجه نمی‌شوند این است که عملیات‌های I/O در Node.js تقریباً با سرعتی در سطح C اجرا می‌شوند. وقتی در Node.js یک درخواست شبکه ارسال می‌کنید یا فایلی را می‌خوانید، در واقع در حال فراخوانی توابع C هستید که تنها یک لایه‌ی نازک جاوااسکریپت روی آن‌ها قرار گرفته است.

این معماری Node.js را به یک فناوری انقلابی برای وب‌سرورها تبدیل کرد. در صورت استفاده‌ درست، یک فرایند Node.js می‌تواند هزاران اتصال همزمان را به‌طور کارآمد مدیریت کند و در بسیاری از بارهای کاری وب، عملکردی بهتر از مدل‌های سنتی «یک Thread برای هر درخواست» داشته باشد.

ایده‌ی اصلی پشت این طراحی این بود که چندوظیفگی، همانند کارهای انسانی، همیشه بهترین راه برای مدیریت وظایف نیست. هماهنگ‌سازی چند وظیفه و جابه‌جایی بین زمینه‌ها (Context Switching) سربار ایجاد می‌کند و همیشه نتیجه‌ بهتری به همراه ندارد. همه‌چیز بستگی دارد به اینکه آیا یک وظیفه را می‌توان به بخش‌های کوچک‌تر تقسیم کرد که به‌طور همزمان قابل اجرا باشند یا نه.

جایی که Node.js با چالش روبه‌رو می‌شود: عملیات‌های CPU-Bound

چالش‌های واقعی عملکرد در Node.js مربوط به وظایف سنگین CPU هستند: مانند کامپایل کردن تایپ‌اسکریپت! این نوع بارهای کاری ویژگی‌هایی کاملاً متفاوت از سناریوهای وب‌سروری دارند که Node.js برای آن‌ها بهینه‌سازی شده است.

عملیات‌های CPU-bound شامل محاسبات سنگین با حداقل زمان انتظار هستند:

  • الگوریتم‌ها و محاسبات پیچیده
  • پردازش و تحلیل فایل‌های بزرگ
  • پردازش تصویر و ویدئو
  • کامپایل کردن کد

در چنین سناریوهایی، گلوگاه اصلی (bottleneck) منتظر سیستم‌های خارجی نیست، بلکه درگیر توان محاسباتی خام و میزان کارایی محیط اجرایی در اجرای الگوریتم‌هاست.

نکته: «Bottleneck» در مهندسی نرم‌افزار و سیستم‌ها به نقطه‌ای گفته می‌شود که سرعت یا ظرفیت کل سیستم را محدود می‌کند. یعنی جایی که عملکرد کل فرایند به خاطر یک بخش کند یا محدود، کاهش پیدا می‌کند. به زبان ساده، اگر همه‌ اجزای سیستم سریع باشند اما یک بخش نتواند هم‌پای بقیه پیش برود، همان بخش تبدیل به گلوگاه (Bottleneck) می‌شود و کل سیستم را کند می‌کند.

محدودیت تک‌ریسمانی (Single-Threaded Limitation)

جاوااسکریپت با مدل حلقه‌ی رویداد تک‌ریسمانی طراحی شده است. این مدل برای مدیریت همزمانی در عملیات‌های I/O (جایی که بیشتر زمان صرف انتظار می‌شود) بسیار کارآمد است، اما در مواجهه با وظایف سنگین و محاسباتیِ CPU مشکلاتی ایجاد می‌کند.

// Pseudocode of how the Node.js event loop works
while (thereAreEvents()) {
  const event = getNextEvent();

  processEvent(event);  // If this takes a long time, 
                        // everything else waits
}

وقتی یک وظیفه‌ سنگینِ CPU اجرا می‌شود، تک‌ریسمان را کاملاً در اختیار می‌گیرد. در آن مدت، Node.js نمی‌تواند رویدادهای دیگر را پردازش کند، درخواست‌های جدید را مدیریت کند یا حتی به درخواست‌های موجود پاسخ دهد. در عمل، همه‌چیز تا پایان محاسبه مسدود می‌شود.

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

حلقه‌ی رویداد (Event Loop)

نوشتن کدهای کارآمد و سنگینِ CPU در جاوااسکریپت نیازمند درک و رعایت اصول حلقه‌ی رویداد است. کد باید به گونه‌ای ساختاربندی شود که در طول اجرا کنترل را واگذار کند و به عملیات‌های دیگر اجازه‌ی پیشرفت دوره‌ای بدهد.

//////////////////////////////////////////////
// Naive approach - blocks the event loop
/////////////////////////////////////////////

function processLargeData(data) {
  for (let i = 0; i < data.length; i++) {
    // Heavy computation that might take seconds
    processItem(data[i]);
  }
  return results
}
//////////////////////////////////
// Event-loop friendly approach
//////////////////////////////////

async function processLargeDataChunked(data) {
  const results = []
  const CHUNK_SIZE = 1000
  
  for (let i = 0; i < data.length; i += CHUNK_SIZE) {
    const chunk = data.slice(i, i + CHUNK_SIZE)
    // Process one chunk
    for (const item of chunk) {
      results.push(processItem(item))
    }
    // Yield to the event loop before processing the next chunk
    await new Promise(resolve => setTimeout(resolve, 0))
  }
  
  return results

این رویکرد «تکه‌تکه‌سازی» (Chunking) می‌تواند جواب‌گو باشد، اما پیچیدگی‌هایی را وارد می‌کند و اساساً نحوه‌ ساختاربندی کد را تغییر می‌دهد. این مدل برنامه‌نویسی کاملاً متفاوت از زبان‌هایی با پشتیبانی ذاتی از چندریسمانی است، جایی که می‌توان کدهای سنگین و محاسباتی CPU را به‌طور مستقیم نوشت بدون نگرانی از مسدود شدن سایر عملیات‌ها.

برای یک اپلیکیشن پیچیده مانند کامپایلر TypeScript، این هماهنگی دائمی با حلقه‌ی رویداد (Event Loop) به مرور و با بزرگ‌تر شدن کدبیس، مدیریت آن به‌طور فزاینده‌ای دشوار می‌شود.

برای مطالعه بیشتر در این رابطه به مستندات زیر مراجعه کنید:

کامپایلرها: هیولای CPU-محور

کامپایلرها نمونه‌ بارز بارهای کاری سنگین CPU هستند. آن‌ها باید:

  • کد منبع را به توکن‌ها و درخت‌های نحوی انتزاعی (AST) تجزیه کنند
  • بررسی و استنتاج نوع‌های پیچیده را انجام دهند
  • تبدیل‌ها و بهینه‌سازی‌ها را اعمال کنند
  • کد خروجی تولید کنند

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

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

اهمیت مدل‌های رشته‌ای: حلقه‌ی رویداد در برابر همزمانی بومی

شکاف عملکرد میان پیاده‌سازی‌های جاوااسکریپت و Go صرفاً به سرعت خام زبان مربوط نمی‌شود، بلکه اساساً به مدل‌های رشته‌ای (Threading Models) و میزان تطابق آن‌ها با حوزه‌ی مسئله برمی‌گردد.

همان‌طور که اشاره شد، Node.js بر پایه‌ی مدل حلقه‌ی رویداد (Event Loop) عمل می‌کند:

   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘

این رویکرد تک‌ریسمانی به این معناست که برای کارهای سنگین CPU مثل کامپایل کردن TypeScript باید کدی بنویسید که کل رشته را در انحصار خود نگیرد. در عمل، این کار شامل شکستن وظیفه به بخش‌های کوچک‌تر است تا بتوانند کنترل را دوباره به حلقه‌ی رویداد واگذار کنند و به عملیات‌های دیگر اجازه‌ی اجرا بدهند.

برای یک کامپایلر، این موضوع چالش‌های طراحی قابل‌توجهی ایجاد می‌کند:

  • تکه‌تکه‌سازی مصنوعی (Artificial Fragmentation): جریان طبیعی فازهای کامپایلر (تجزیه ← تحلیل ← تبدیل ← تولید) باید به مراحل کوچک‌تر شکسته شود تا امکان واگذاری کنترل وجود داشته باشد.
  • مدیریت پیچیده‌ی وضعیت (Complex State Management): از آن‌جا که پردازش در تکرارهای حلقه‌ی رویداد تکه‌تکه می‌شود، وضعیت کامپایلر باید با دقت مدیریت و بین واگذاری‌ها حفظ شود.
  • اختلال در محلیّت (Locality Disruption): وقتی حلقه‌ی رویداد وظایف نامرتبط را بین عملیات‌های کامپایلر پردازش می‌کند، مزایای محلیّت کش CPU از بین می‌رود و عملکرد آسیب می‌بیند.
  • چالش‌های وابستگی (Dependency Challenges): کامپایلرها وابستگی‌های پیچیده‌ای بین اجزای خود دارند. شکستن یک فرایند ذاتاً ترتیبی برای سازگاری با حلقه‌ی رویداد اغلب نیازمند منطق هماهنگی پیچیده است.

Go: همزمانی بومی با Goroutines

در مقابل، زبان Go قابلیت goroutine‌ها را ارائه می‌دهد، رشته‌های سبک‌وزنی که توسط ران‌تایم Go مدیریت می‌شوند:

┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│Goroutine│ │Goroutine│ │Goroutine│ │Goroutine│ ...  more
└────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘
     │           │           │           │
     └───────────┴───────────┴───────────┘
                       │
              ┌────────┴────────┐
              │   Go Scheduler  │
              └────────┬────────┘
                       │
                ┌──────┴──────┐
                │  OS Threads │
                └─────────────┘

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

  • موازی‌سازی طبیعی (Natural Parallelism): فایل‌های مختلف می‌توانند همزمان تجزیه و بررسی نوع شوند.
  • دسترسی مستقیم به رشته‌ها (Direct Thread Access): عملیات‌های سنگین CPU می‌توانند مستقیماً روی رشته‌ها اجرا شوند بدون نیاز به واگذاری کنترل.
  • هماهنگی کارآمد (Efficient Coordination): کانال‌ها و سازوکارهای همگام‌سازی در Go برای هماهنگ‌سازی کارهای همزمان طراحی شده‌اند.
  • کارایی حافظه (Memory Efficiency): هر goroutine تنها چند کیلوبایت حافظه مصرف می‌کند، در حالی که رشته‌های سیستم‌عامل چندین مگابایت نیاز دارند.

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

برای مطالعه بیشتر در این رابطه به مطالب زیر مراجعه کنید:

همان کد، مدل اجرایی متفاوت

Anders Hejlsberg گفته است که تیم TypeScript چندین زبان را ارزیابی کرده و Go آسان‌ترین گزینه برای انتقال کدبیس بوده است. ظاهراً تیم TypeScript ابزاری ساخته که کد Go تولید می‌کند و در بسیاری از بخش‌ها این انتقال تقریباً خط‌به‌خط معادل است.

اما این موضوع ممکن است این تصور را ایجاد کند که «کد دقیقاً همان کار را انجام می‌دهد»، در حالی که این یک برداشت اشتباه است. دلیلش این است که مدل اجرایی در Go و جاوااسکریپت کاملاً متفاوت است، حتی اگر کد منبع از نظر ظاهری مشابه باشد، نحوه‌ی زمان‌بندی، مدیریت رشته‌ها و اجرای عملیات‌ها در دو محیط تفاوت بنیادین دارد.

دقیقاً همین‌جاست که تفاوت بنیادین بین مدل اجرایی جاوااسکریپت و مدل همزمانی Go خودش را نشان می‌دهد:

در جاوااسکریپت

  • همه‌ی کد روی یک رشته‌ی واحد با حلقه‌ی رویداد اجرا می‌شود.
  • عملیات‌های طولانی باید شکسته شوند یا به Worker Threadها سپرده شوند.
  • اجرای همزمان نیازمند مدیریت دقیق است تا حلقه‌ی رویداد مسدود نشود.

در Go

  • کد به‌طور طبیعی روی چندین goroutine (رشته‌های سبک‌وزن) اجرا می‌شود.
  • عملیات‌های طولانی می‌توانند بدون مسدود کردن سایر کارها اجرا شوند.
  • زبان و زمان‌اجرا برای اجرای همزمان طراحی شده‌اند.

بنابراین وقتی کدی را بدون تغییر ساختار از جاوااسکریپت به Go منتقل می‌کنید، در واقع مدل اجرایی آن را تغییر داده‌اید. عملیاتی که در جاوااسکریپت حلقه‌ی رویداد را مسدود می‌کردند، در Go می‌توانند با کمترین تلاش به‌طور همزمان اجرا شوند. این همان دلیلی است که باعث می‌شود یک کد ظاهراً مشابه، در دو زبان رفتار کاملاً متفاوتی داشته باشد.

دقیقاً همین‌جا می‌توان به یک جمع‌بندی رسید: وقتی یک انتقال مستقیم (Port) منجر به بهبود چشمگیر عملکرد می‌شود، این نشانه است که پیاده‌سازی اولیه به‌طور کامل با مدل اجرایی جاوااسکریپت سازگار و بهینه نشده بوده است.

نوشتن جاوااسکریپت واقعاً کارآمد یعنی پذیرش ذات غیرهمزمان (Asynchronous) آن و محدودیت‌های حلقه‌ی رویداد (Event Loop). این موضوع در پروژه‌های پیچیده‌ای مثل یک کامپایلر به‌طور فزاینده‌ای دشوار می‌شود، زیرا هرچه کدبیس بزرگ‌تر و وابستگی‌ها پیچیده‌تر شوند، هماهنگی با این مدل اجرایی نیازمند طراحی و معماری بسیار دقیق‌تر خواهد بود.

به بیان دیگر: در جاوااسکریپت، عملکرد بالا تنها با احترام به ماهیت تک‌ریسمانی و طراحی الگوریتم‌ها به‌گونه‌ای که با واگذاری کنترل و همزمانی سازگار باشند به دست می‌آید—و این همان نقطه‌ای است که تفاوت بنیادین با زبان‌هایی مثل Go آشکار می‌شود.

گولنگ گوروتین در مقابل ورکر تردز نودجی‌اس

چرا Worker Threads نه؟!

سؤال کاملاً منطقی است: Node.js از نسخه‌ی 10 ماژول worker_threads را معرفی کرد (و از نسخه‌ی 12 در سال 2019 پایدار شد)، چرا تیم TypeScript به جای مهاجرت به Go از آن استفاده نکرد؟

  • مزیت اصلی: Worker Threads در Node.js امکان اجرای واقعی Parallelism را فراهم می‌کنند. حال وظایف سنگین CPU می‌توانند روی رشته‌های جداگانه اجرا شوند، بدون اینکه حلقه‌ی رویداد اصلی مسدود شود.
  • چالش‌ها:
    • مدیریت Worker Threads نیازمند طراحی دقیق برای تقسیم وظایف و هماهنگی بین رشته‌هاست.
    • ارتباط بین رشته‌ها از طریق پیام‌رسانی (message passing) انجام می‌شود که می‌تواند سربار و پیچیدگی اضافه ایجاد کند.
    • برخلاف goroutine‌های Go که بسیار سبک و یکپارچه با Runtime هستند، Worker Threads در Node.js به اندازه‌ی آن‌ها روان و کم‌هزینه نیستند.

نحوه‌ی کار Worker Threads در Node.js

برخلاف حلقه‌ی رویداد تک‌ریسمانی که مشخصه‌ی بیشتر برنامه‌های Node.js است، Worker Threads اجازه می‌دهند جاوااسکریپت به‌صورت موازی اجرا شود. نحوه کار آن‌ها به این صورت است:

// main.js
const { Worker } = require('worker_threads')

function runWorker(workerData) {
  return new Promise((resolve, reject) => {
    const worker = new Worker('./worker.js', { workerData })
    worker.on('message', resolve)
    worker.on('error', reject)
    worker.on('exit', code => {
      if (code !== 0) {
        reject(new Error(`Worker stopped with exit code ${code}`))
      }
    })
  })
}

// Run multiple CPU-intensive tasks in parallel
Promise.all([
  runWorker({ chunk: data.slice(0, middleIndex) }),
  runWorker({ chunk: data.slice(middleIndex) })
]).then(results => {
  // Combine results
}) 
// worker.js
const { parentPort, workerData } = require('worker_threads')

// Perform CPU-intensive work
const result = processData(workerData.chunk)

// Send the result back to the main thread
parentPort.postMessage(result)

هر ورکر به‌صورت مستقل اجرا می‌شود و حافظهٔ جداگانهٔ مخصوص خودش را دارد. به همین دلیل، کارها واقعاً به‌طور هم‌زمان انجام می‌شوند. ورکرها برای ارتباط با ترد اصلی یا با یکدیگر پیام ردوبدل می‌کنند و در این پیام‌ها می‌توان بعضی داده‌ها را بدون کپی‌کردن، مستقیماً منتقل کرد.

چرا Worker Threads راه‌حل مناسبی برای TypeScript نبود؟

چرا تیم TypeScript به‌جای بازطراحی کامپایلر با Worker Threads، به Go مهاجرت کرد؟ پاسخ قطعی را نمی‌دانیم، اما چند دلیل منطقی وجود دارد:

  1. مشکلات کدبیس قدیمی
    کامپایلر TypeScript بیش از ده سال قدمت دارد و از ابتدا برای اجرای تک نخی طراحی شده است. اضافه‌کردن معماری چندتردی به چنین کدبیس بالغی معمولاً سخت‌تر از نوشتن دوباره آن است. Worker Threads مبتنی بر ارسال پیام هستند و این یعنی باید نحوه ارتباط اجزای کامپایلر از پایه بازطراحی شود.
  2. پیچیدگی اشتراک داده
    ورکرها امکان اشتراک مستقیم حافظهٔ محدودی دارند. در حالی‌که یک کامپایلر با ساختارهای داده بسیار درهم‌تنیده مثل AST و سیستم نوع‌ها سروکار دارد که به‌راحتی نمی‌توان آن‌ها را به بخش‌های مستقل برای پردازش موازی تقسیم کرد.
  3. هزینه کارایی
    هر ورکر یک نمونه جدا از V8 و حافظه مستقل دارد و داده‌هایی که بین تردها ردوبدل می‌شوند معمولاً باید سریالایز و دی‌سریالایز شوند. از نظر سبکی و هزینه، قابل‌مقایسه با تردهای سیستمی یا goroutineهای Go نیستند.
  4. عدم تطابق زمانی
    زمانی که کامپایلر TypeScript طراحی شد (حدود ۲۰۱۲)، Worker Threads در Node.js وجود نداشتند. تصمیم‌های معماری اولیه بر اساس مدل تک‌تردی گرفته شده بود و همین موضوع موازی‌سازی را در سال‌های بعد دشوار کرد.
  5. بن‌بست فنی
    احتمالاً تیم به این جمع‌بندی رسیده که حتی با Worker Threads هم محدودیت‌های ذاتی جاوااسکریپت برای این نوع workload باقی می‌ماند و در آینده دوباره به گلوگاه تبدیل می‌شود.
  6. هم‌راستایی مهارتی و سازمانی
    انتخاب Go ممکن است با تخصص تیم، ابزارهای داخلی و مسیر بلندمدت توسعهٔ ابزارهای توسعه‌دهندگان هم‌خوان‌تر بوده باشد.

خلاصهٔ بی‌پرده: Worker Threads از نظر تئوری جذاب‌اند، اما برای یک کامپایلر بزرگ، قدیمی و پیچیده مثل TypeScript، احتمالاً راه‌حلی پرهزینه با دستاورد محدود بوده‌اند.

مسئلهٔ تکامل

وقتی تایپ‌اسکریپت در سال ۲۰۱۲ شروع شد، انتخاب‌های فنی تیم کاملاً منطقی بود و با شرایط آن زمان هم‌خوانی داشت:

  • TypeScript یک پروژهٔ مایکروسافتی برای گسترش JavaScript بود، پس استفاده از خود JavaScript طبیعی‌ترین انتخاب بود.
  • دامنه و پیچیدگی پروژه در ابتدا بسیار محدودتر بود.
  • زبان‌هایی مثل Go و Rust هنوز در مراحل ابتدایی رشد بودند و یا فعلا بوجود نیامده بودند و در نتیجه گزینه‌های جاافتاده‌ای محسوب نمی‌شدند.
  • نیازهای کارایی و مقیاس‌پذیری در آن زمان چندان بالا نبود.

اما با گذشت زمان، TypeScript از یک افزونهٔ ساده JavaScript به زبانی پیچیده و قدرتمند تبدیل شد؛،زبانی با سیستم نوع پیشرفته، جنریک‌ها، تایپ‌های شرطی و قابلیت‌های سنگین دیگر. کامپایلر هم همراه با این رشد بزرگ‌تر و پیچیده‌تر شد، اما همچنان روی زیرساختی بنا ماند که برای مسئله‌ای بسیار ساده‌تر طراحی شده بود.

این دقیقاً همان مشکلی است که بسیاری از نرم‌افزارهای موفق با آن روبه‌رو می‌شوند: رشد فراتر از پیش‌بینی‌های اولیه. هرچه کامپایلر TypeScript پیچیده‌تر شد و روی پروژه‌های بزرگ‌تر به کار رفت، محدودیت‌های ذاتی JavaScript بیش از پیش خودش را نشان داد و به مانع تبدیل شد.

منبع

مطلبی که خواندید ترجمه و بازنویسی از مقاله TypeScript Migrates to Go: What’s Really Behind That 10x Performance Claim? به نوشته Oskar Dudycz بود.

یک پاسخ به “تایپ‌اسکریپت و گولنگ: داستان یک تغییر معماری”

  1. پگاه میرزایی نیم‌رخ
    پگاه میرزایی

    ممنون بابت مطلب عالی.
    واقعاً به درد خورد دمتون گرم!
    حتماً بازم از این مطالب می‌ذارید؟

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *