کامپایل بومی بدون ماشین مجازی و زباله‌روب: سودها، هزینه‌ها و کارایی در زبان برنامه‌نویسی سلام

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

در این مقاله
  1. بومی، AOT، JIT و ماشین مجازی: اصطلاح‌ها
  2. ماشین مجازی و زباله‌روب به چه کاری می‌آیند
  3. زبان برنامه‌نویسی سلام چگونه یک برنامه‌ی بومی می‌سازد
  4. حافظه بدون زباله‌روب
  5. سود کامپایل بومی بدون زباله‌روب
  6. هزینه‌ی آن
  7. کارایی زبان برنامه‌نویسی سلام، با اندازه‌گیری دقیق
  8. جاهایی که سلام از محیط زمان اجرا با زباله‌روب استفاده می‌کند
  9. پرسش‌های پرتکرار

کامپایل بومی (Native Compilation) یعنی برنامه پیش از اجرا به کد ماشینِ خود پردازنده ترجمه شود تا سیستم‌عامل بتواند آن را مستقیم اجرا کند، بی‌آنکه ماشین مجازی یا مفسری در میان باشد. زبان برنامه‌نویسی سلام به همین شکل و پیش از اجرا کامپایل می‌شود و بدون زباله‌روب (Garbage Collector) هم اجرا می‌شود: حافظه‌ای که برنامه می‌گیرد در نقطه‌هایی پس داده می‌شود که برنامه (یا کامپایلر از طرف آن) تعیین می‌کند، نه هر وقت که زباله‌روب فرصت کند. این همان ترکیبی است که C، C++، Rust و Zig دارند و با جاوا، C#، پایتون، جاوااسکریپت و Go فرق دارد. این مقاله توضیح می‌دهد از این ترکیب چه به دست می‌آورید، چه از دست می‌دهید و کارایی اندازه‌گیری‌شده‌ی سلام را چگونه باید خواند.

بومی، AOT، JIT و ماشین مجازی: اصطلاح‌ها

چند اصطلاح اغلب با هم قاطی می‌شوند، پس این‌جا کنار هم آمده‌اند:

اصطلاحمعنانمونه‌ها
مفسر (Interpreter)برنامه (یا بایت‌کد آن) را می‌خواند و گام به گام اجرا می‌کندCPython، PHP، salam exec
ماشین مجازی (VM)محیط زمان اجرایی که بایت‌کد قابل حمل را اجرا می‌کند، معمولاً با JIT و زباله‌روبJVM، .NET CLR
کامپایل درجا (JIT)بایت‌کد را در حین اجرای برنامه، بر اساس آنچه مشاهده می‌کند، به کد ماشین ترجمه می‌کندHotSpot، V8، RyuJIT
کامپایل پیش از اجرا (AOT)پیش از اجرای برنامه به کد ماشین ترجمه می‌کندC، C++، Rust، Go، Zig، سلام
زباله‌روب (GC)حافظه‌ای را که برنامه دیگر به آن دسترسی ندارد پیدا و خودکار آزاد می‌کندجاوا، C#، Go، جاوااسکریپت، پایتون

این دو محور از هم مستقل‌اند. Go پیش از اجرا به کد بومی کامپایل می‌شود اما زباله‌روب و یک محیط زمان اجرا با زمان‌بند گوروتین‌های خودش دارد. جاوا و C# امروز گزینه‌های AOT دارند (GraalVM Native Image و .NET Native AOT) که زباله‌روب را نگه می‌دارند. Swift بومی کامپایل می‌شود و به جای زباله‌روب ردیاب از شمارش ارجاع استفاده می‌کند. سلام کنار C، C++، Rust و Zig می‌نشیند: کد بومی، پیش از اجرا، بدون زباله‌روب.

ماشین مجازی و زباله‌روب به چه کاری می‌آیند

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

  • زباله‌روب دسته‌های کاملی از باگ‌ها را حذف می‌کند. استفاده پس از آزادسازی و آزادسازی دوباره در کد ایمن جاوا، C# یا Go نمی‌تواند رخ دهد، و نشت حافظه به داده‌هایی محدود می‌شود که هنوز در دسترس‌اند.
  • نوشتن کد را ساده‌تر می‌کند. هیچ کس لازم نیست تصمیم بگیرد صاحب یک رشته کیست یا یک فهرست کی آزاد می‌شود.
  • JIT می‌تواند برای آنچه واقعاً رخ می‌دهد بهینه کند. می‌بیند کدام شاخه‌ها داغ‌اند و کدام گونه‌ها در یک محل فراخوانی ظاهر می‌شوند، و می‌تواند کد را برای آن‌ها ویژه کند.
  • ماشین مجازی قابلیت حمل و ابزار می‌دهد. یک فایل بایت‌کد روی هر دستگاهی که محیط زمان اجرا را داشته باشد اجرا می‌شود، همراه با پروفایلرها، اشکال‌زداها و بازتاب (Reflection) غنی.

هزینه‌ها هم به همان اندازه واقعی‌اند:

  • حافظه‌ی اضافه. زباله‌روب ردیاب وقتی جای خالی کافی داشته باشد بهتر کار می‌کند، پس برنامه‌ای که زباله‌روب دارد معمولاً حافظه‌ای محسوساً بیشتر از داده‌ای که به کار می‌برد نگه می‌دارد.
  • مکث‌ها و کار پس‌زمینه. زباله‌روب‌های امروزی مکث‌ها را کوتاه نگه می‌دارند و بیشتر کارشان را هم‌زمان انجام می‌دهند، اما آن کار همچنان وقت پردازنده می‌گیرد و پیش‌بینی تأخیر را در دنباله‌ی توزیع (Tail Latency) دشوارتر می‌کند.
  • راه‌اندازی و گرم شدن. ماشین مجازی باید راه بیفتد، کلاس‌ها را بار کند و، با JIT، تا وقتی کد داغ کامپایل شود آهسته اجرا شود. این برای ابزارهای خط فرمان، تابع‌های بدون سرور (Serverless) و هر چیزی که چند میلی‌ثانیه اجرا می‌شود اهمیت دارد.
  • محیط زمان اجرایی که باید همراه برود. برنامه به ماشین مجازیِ نصب‌شده یا بسته‌بندی‌شده نیاز دارد و این استقرار را بزرگ‌تر می‌کند.

زبان برنامه‌نویسی سلام چگونه یک برنامه‌ی بومی می‌سازد

salam build برنامه را بررسی می‌کند و سپس از راه یکی از سه پشتوانه کد ماشین تولید می‌کند: C (که به یک کامپایلر C سپرده می‌شود)، LLVM، یا تولیدکننده‌ی کد بومی خود سلام با پیونددهنده‌ی داخلی. مقاله‌ی سه راه برای تولید کد این سه را مقایسه می‌کند. نتیجه یک فایل اجرایی معمولی است: ELF روی لینوکس، PE روی ویندوز و Mach-O روی مک.

درون آن چیست؟ کد خود برنامه، بخش‌هایی از کتابخانه‌ی استاندارد که به کار رفته و کتابخانه‌ی C (روی لینوکس glibc یا musl). کار راه‌اندازی پیش از ریشه کوچک است: برنامه آرگومان‌هایش را ثبت می‌کند، مدیریت سیگنال را برای بافر خروجی‌اش برپا می‌کند و بخش‌هایی از کتابخانه‌ی استاندارد را که وارد کرده مقداردهی اولیه می‌کند. نه بارکننده‌ی بایت‌کدی هست، نه JIT، نه نخ زباله‌روب و نه زمان‌بند وظیفه. اندازه‌ی فایل هم همین را نشان می‌دهد: یک برنامه‌ی «سلام دنیا» که با پشتوانه‌ی بومی ساخته شود روی لینوکس x86-64 ۱۱٬۴۸۸ بایت است، یا ۲۴٬۹۴۴ بایت به شکل فایل اجرایی کاملاً ایستا با musl که روی هر لینوکس x86-64 بدون نصب هیچ چیزی اجرا می‌شود.

برنامه‌هایی که C را فراخوانی می‌کنند هم هزینه‌ی اضافه‌ای نمی‌پردازند. یک تابع درون‌داد (در انگلیسی extern) در سلام فراخوانی مستقیم تابع C است، بی‌آنکه لایه‌ی تبدیلی میان یک هیپ مدیریت‌شده و حافظه‌ی بومی در کار باشد. درس کار با C و مقاله‌ی فراخوانی C از سلام روش کار را نشان می‌دهند.

حافظه بدون زباله‌روب

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

  • مقدارهای ساده روی پشته زندگی می‌کنند. عددها، منطقی‌ها، ساختارها و آرایه‌های با اندازه‌ی ثابت مقدارند: هنگام مقداردهی رونوشت می‌شوند و با پایان حوزه‌شان ناپدید می‌شوند. نه تخصیصی می‌خواهند و نه آزادسازی‌ای.
  • ظرف‌های هیپ صریحاً یا به دست کامپایلر آزاد می‌شوند. یک وکتور، یک نگاشت یا یک رشته‌ی ساخته‌شده روی هیپ زندگی می‌کند. آن را با .آزادکن() آزاد می‌کنید، که معمولاً درست پس از ساختنش به شکل دیرکن و۱.آزادکن() نوشته می‌شود تا در هر مسیر خروج اجرا شود. وقتی یک ظرف محلی هرگز ذخیره، برگردانده یا به اشتراک گذاشته نشود، کامپایلر خودش آزادسازی را در پایان حوزه‌اش می‌گذارد.
  • کامپایلر اشتباه‌های رایج را بررسی می‌کند. استفاده از حافظه پس از آزاد شدن یا آزاد کردن دوباره‌ی آن، اگر در همه‌ی مسیرها رخ دهد در زمان کامپایل خطا گزارش می‌شود و اگر در برخی مسیرها رخ دهد هشدار.

این در برنامه‌ای دیده می‌شود که به جای ساختن بافر تازه برای هر دسته از خوانش‌ها، یک بافر را دوباره به کار می‌گیرد. پاک‌سازی() (در انگلیسی clear()) وکتور را خالی می‌کند اما فضای ذخیره‌اش را نگه می‌دارد، پس پس از دسته‌ی نخست اصلاً تخصیصی رخ نمی‌دهد، و دیرکن آن فضا را یک بار، در پایان ریشه، آزاد می‌کند:

سلام
تابع پرکردن خوانش‌ها(بافر &: وکتور<اعشار۶۴>، ساعت: صحیح):
    بافر.پاک‌سازی()
    تکرار ۶ در ای:
        بافر.بیفزا(۲۰.۰ + ((ساعت * ۲ + ای % ۳) برگردان اعشار۶۴) * ۰.۵)
    پایان
پایان

تابع میانگین(مقدارها: وکتور<اعشار۶۴>): اعشار۶۴:
    ناپایا جمع := ۰.۰
    هر م در مقدارها:
        جمع += م
    پایان
    برگشت جمع / مقدارها.طول() برگردان اعشار۶۴
پایان

تابع ریشه:
    ناپایا بافر := وکتور {} برگردان وکتور<اعشار۶۴>
    دیرکن بافر.آزادکن()
    بافر.رزرو(۶)
    تکرار ۳ در ساعت:
        پرکردن خوانش‌ها(بافر، ساعت)
        سرچاپ "ساعت"، ساعت، "میانگین"، میانگین(بافر)، "از"، بافر.طول()، "خوانش"
    پایان
پایان
خروجیساعت 0 میانگین 20.5 از 6 خوانش ساعت 1 میانگین 21.5 از 6 خوانش ساعت 2 میانگین 22.5 از 6 خوانش

اگر این برنامه را با salam memcheck اجرا کنید، گزارش definitely lost: 0 bytes in 0 blocks را می‌دهد. هیچ چیز در مصرف حافظه‌ی آن به زمان‌بندی یک زباله‌روب بستگی ندارد: بافر از سطر دوم ریشه تا سطر آخرش وجود دارد و تمام ماجرا همین است. قاعده‌های کامل، از جمله این‌که چه کسی رشته‌ی برگشتی را آزاد می‌کند، در مقاله‌ی مدیریت حافظه آمده است.

سود کامپایل بومی بدون زباله‌روب

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

ردپای حافظه‌ی کوچک و پیش‌بینی‌پذیر. برنامه همان چیزی را نگه می‌دارد که تخصیص داده و نه بیشتر. نه فضای اضافه‌ای برای زباله‌روب باید تنظیم کرد و نه پرچم اندازه‌ی هیپ را درست گذاشت.

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

استقرار ساده. یک فایل اجرایی، در صورت تمایل کاملاً ایستا، که روی یک سرور یا کانتینر کپی می‌شود. نسخه‌های منتشرشده‌ی سلام کامپایل متقابل هم دارند: یک فایل اجرایی ویندوز را می‌توان روی لینوکس با سیسروت MinGW که درون کامپایلر جاسازی شده ساخت، و پشتوانه‌ی بومی فایل اجرایی مک را هم می‌نویسد. مقاله‌ی کامپایل متقابل سلام مقصدها را توضیح می‌دهد.

دامنه. بدون محیط زمان اجرایی که باید حمل شود، همان کامپایلر می‌تواند WebAssembly و حتی کد مستقل از سیستم‌عامل (Bare Metal) را هدف بگیرد، جایی که هیچ سیستم‌عاملی برای میزبانی ماشین مجازی وجود ندارد. مقاله‌ی سلام بدون سیستم‌عامل به آنجا می‌رود.

هزینه‌ی آن

سوی دیگر ترازو، بی‌پرده:

  • آزاد کردن حافظه‌ی هیپ مسئولیت شماست. اگر آزادکن را برای مقداری فراموش کنید که کامپایلر نمی‌تواند محلی بودنش را ثابت کند، برنامه نشت می‌کند. سروری که در هر درخواست کمی نشت کند سرانجام حافظه کم می‌آورد.
  • همه‌ی اشتباه‌ها گرفته نمی‌شوند. بررسی‌های مالکیت زمان کامپایل در سلام باگ‌های رایج استفاده پس از آزادسازی و آزادسازی دوباره را بدون حاشیه‌نویسی می‌گیرند، اما اثبات نیستند. وارسی‌گر قرض Rust ایمنی حافظه را برای Rust ایمن ثابت می‌کند؛ سلام وارسی‌گر قرض ندارد و باگی که از چشم تحلیل پنهان بماند همچنان می‌تواند حافظه را در زمان اجرا خراب کند. ساخت توسعه اندیس‌ها را در زمان اجرا بررسی می‌کند و --release این بررسی‌ها را برای سرعت برمی‌دارد.
  • ابزارها جای تور ایمنی را می‌گیرند. salam memcheck برنامه را زیر Valgrind یا AddressSanitizer اجرا می‌کند تا نشت‌ها و دسترسی‌های نادرست را پیدا کند. این عادتی است که باید ساخت، نه چیزی که رایگان به دست بیاید.
  • ویژه‌سازی در زمان اجرا وجود ندارد. کامپایلر AOT برای همه‌ی ورودی‌های ممکن بهینه می‌کند. JIT در اصل می‌تواند برای ورودی‌هایی که واقعاً می‌بیند ویژه‌سازی کند، و روی برخی بارهای کاری طولانی این مزیتی برای JVM یا V8 است.
  • یک ساخت برای هر مقصد. فایل اجرایی بومی روی یک سیستم‌عامل و یک خانواده‌ی پردازنده اجرا می‌شود. کامپایل متقابل این کار را ارزان می‌کند، اما مثل یک فایل JAR یک محصول قابل حمل واحد در کار نیست.

کارایی زبان برنامه‌نویسی سلام، با اندازه‌گیری دقیق

محک (Benchmark) خود پروژه ۳۹ برنامه را که در C، C++، Rust، Go، پایتون، PHP و سلام الگوریتم یکسان دارند مقایسه می‌کند. در اندازه‌گیری ۲۷ اکتبر ۲۰۲۶، با سلامی که با پشتوانه‌ی LLVM و -O3 ساخته شده بود، میانگین‌ها این‌ها بودند:

زبانمیانگین زمان
سلام۱۰٫۶ میلی‌ثانیه
Rust۱۱٫۳ میلی‌ثانیه
C۱۱٫۷ میلی‌ثانیه
C++۱۳٫۱ میلی‌ثانیه
Go۱۴٫۲ میلی‌ثانیه
PHPحدود ۹٫۶ برابر زمان سلام
پایتونحدود ۵۶ برابر زمان سلام

در برابر سریع‌ترینِ C، C++، Rust و Go در هر برنامه، سلام در ۶ برنامه جلوتر، در ۲۷ برنامه برابر و در ۶ برنامه عقب‌تر بود. عقب‌ماندگی‌ها در فیبوناچی بازگشتی (۳٫۱ میلی‌ثانیه در برابر ۲٫۴ میلی‌ثانیه برای C)، n وزیر، جست‌وجوی دودویی، اعداد تصادفی LCG، یک حلقه‌ی شمارش و چاپ یک سطر یک میلیون بار (۸٫۲ میلی‌ثانیه در برابر ۲٫۶ میلی‌ثانیه برای Rust) بود.

این عددها را به معنای هم‌رده بودن با C و Rust بخوانید، نه یک رتبه‌بندی. بیشتر برنامه‌ها در ۲ تا ۳۰ میلی‌ثانیه تمام می‌شوند، پس راه‌اندازی فرایند بخشی از هر زمان است، و یک ساخت ساده با tcc و بدون بهینه‌ساز روی همان برنامه‌ها میانگین ۱۱٫۰ میلی‌ثانیه داشت. فاصله‌های کمتر از حدود ۵ درصد روی ماشین مجازی مشترک GitHub Actions که مجموعه آنجا اجرا شد از اجرایی به اجرای دیگر عوض می‌شوند. برنامه‌ها کوچک‌اند و برنامه‌های بزرگ یا بارهای کاری حافظه‌بر را نمی‌سنجند. مقایسه با Go و زبان‌های تفسیری جایی است که نبودِ ماشین مجازی و زباله‌روب بیش از همه پیداست.

صفحه‌ی سرعت یک محک HTTP هم دارد: سرور net.http سلام در برابر کتابخانه‌ی استاندارد Go، Fiber، Gin، Echo، nginx، Apache، Node.js با Express و PHP، روی یک دستگاه مشترک. سلام روی هر ۱۱ مسیر درخواست بیشتری در ثانیه از Fiber پاسخ داد، بسته به بار دستگاه بین ۱۶ تا ۵۲ درصد بیشتر در میانگین هندسی. یک دستگاه و یک ابزار سنجش است، پس پیش از برنامه‌ریزی ظرفیت بر اساس آن، روی سخت‌افزار خودتان دوباره اجرایش کنید. مقاله‌ی آیا سلام از C و Rust سریع‌تر است؟ هر دو محک را با جزئیات می‌خواند.

جاهایی که سلام از محیط زمان اجرا با زباله‌روب استفاده می‌کند

دو ابزار سلام، آگاهانه، استثنا هستند:

  • مفسر. salam exec برنامه‌ها را بدون مجموعه‌ابزار C اجرا می‌کند و برای این کار هر تابع را به بایت‌کدِ یک ماشین مجازی کوچک درون کامپایلر کامپایل می‌کند. مقدارهای آن در هیپ خود مفسر زندگی می‌کنند که یک زباله‌روب علامت‌گذاری و جاروب (Mark and Sweep) آن را تمیز می‌کند. کدی که برای زبان کامپایلی نوشته شده، همراه با آزادسازی‌هایش، بدون تغییر آنجا اجرا می‌شود. مقاله‌ی salam run، exec و build به آن می‌پردازد.
  • خروجی جاوااسکریپت. salam js و زبان صفحه به جاوااسکریپت کامپایل می‌شوند که مثل هر اسکریپت دیگری زیر زباله‌روب مرورگر اجرا می‌شود.

زبان کامپایلی، یعنی آنچه salam build تولید می‌کند، هیچ‌کدام از این دو را ندارد.

پرسش‌های پرتکرار

آیا زبان سلام زباله‌روب دارد؟

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

آیا کد بومی همیشه از ماشین مجازی سریع‌تر است؟

نه. معمولاً سریع‌تر راه می‌افتد و حافظه‌ی کمتری مصرف می‌کند، اما یک JIT پخته روی برخی بارهای کاری طولانی می‌تواند به پای کد AOT برسد یا از آن جلو بزند. در محک سلام، این زبان هم‌رده‌ی C و Rust است، در میانگین جلوتر از Go، و بسیار جلوتر از پایتون و PHP.

کامپایل AOT چیست؟

کامپایل پیش از اجرا (Ahead-of-Time) برنامه را پیش از اجرا به کد ماشین ترجمه می‌کند و یک فایل اجرایی می‌سازد. نقطه‌ی مقابلش کامپایل درجا (Just-in-Time) است که در حین اجرای برنامه ترجمه می‌کند. سلام، C، Rust، Go و Zig پیش از اجرا کامپایل می‌شوند.

آیا زبانی که زباله‌روب ندارد ناامن است؟

می‌تواند باشد، اگر هیچ چیز استفاده از حافظه را بررسی نکند. سلام خطر را با بررسی‌های مالکیت در زمان کامپایل، بررسی مرز در زمان اجرا در ساخت‌های توسعه و salam memcheck کم می‌کند، اما ایمنی حافظه را آن‌طور که وارسی‌گر قرض Rust ثابت می‌کند ثابت نمی‌کند.

کامپایلر و سرعت

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

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

خواندن مقاله
کامپایلر و سرعت

کامپایل متقابل در زبان سلام: ساخت برنامه برای لینوکس، مک، ویندوز و ARM از یک دستگاه

گزینه‌ی --target در سلام، سه تولیدکننده‌ی کد پشت آن، sysrootها و پیونددهنده‌ی درون کامپایلر، و فهرستی صادقانه از هدف‌هایی که در CI اجرا می‌شوند و هدف‌هایی که فقط ساخته می‌شوند.

خواندن مقاله