درک مکانیکی

مطلبی که می‌خوانید ترجمه‌ی قسمت ۲۰۱ از رادیو مهندسی نرم‌افزار است. رادیو مهندسی نرم‌افزار هر یکی دو هفته یک بار مصاحبه‌ای درباره‌ی یکی از موضوعات حوزه‌ی مهندسی نرم‌افزار با افراد خبره و با تجربه در موضوع مورد بحث ترتیب می‌دهد.

در این قسمت رابرت بلومن با مارتین تامسون که یکی از متخصصان پیش‌رو در تنظیم و بهبود کارایی سیستم‌های کامپیوتری است مصاحبه می‌کند. در این قسمت بحث می‌شود که چطور داشتن پیش‌زمینه و درک از عملکرد سخت‌افزاری و مکانیک کامپیوتر می‌تواند شما را در طراحی و توسعه‌ی مؤثرتر سیستم‌های نرم‌افزاری یاری دهد. مارتین یک سخنران در کنفرانس‌های فناوری، بنیان‌گذار شرکت LMAX، یکی از بنیانگذاران پروژه متن بازِ Disruptor، و صاحب بلاگی به نام درک مکانیکی (Mechanical Sympathy) است. او در حال حاضر صاحب Real Logic Ltd. است که یک شرکت مشاوره در زمینه‌ی سیستم‌های کامپیوتری در لندن است.

مارتین لطفاً کمی در مورد سوابق خود به شنوندگان ما بگویید. چطور به مبحث کارایی علاقه‌مند شدید؟

[این موضوع] خیلی جالب است. من کارهای زیادی در طول دهه‌های اخیر انجام دادم. اما همیشه به سمتی کشیده می‌شدم که کارایی کامپیوتر افزایش پیدا می‌کرد. در آنجا فرصت‌هایی ایجاد می‌شد که قبلاً وجود نداشت. فکر می‌کنم اولین کاری که در این زمینه انجام دادم به سال ٩٢ بر می‌گردد که اطلاعات فرابورس را می‌گرفتم. در آن زمان مجبور بودیم همه چیز را روی Main Frame های IBM پردازش کنیم. این اطلاعات روی دو هارد دیسک که روی یک کامپیوتر نصب شده بودند، جا می‌شد. آن موقع اندازه‌ی هارد دیسک‌ها حدود 250 مگابایت بود. اما امروز ما نهان‌گاه‌ها (Cache) را برای نگه‌داری این [حجم] اطلاعات داریم و این فرصت‌هایی را در کسب و کار ایجاد می‌کند که قبلاً امکان‌پذیر نبود.

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

اسم بلاگ شما درک مکانیکی است، منظورتان از آن چیست؟

درک مکانیکی اصطلاح جالبی است. من از طرفداران مسابقات موتورسواری هستم؛ این اصطلاح از آن‌جا نشأت گرفت. Jackie Stewart نام موتورسواری در دهه‌های 60 و 70 است که موتورسوار فوق‌العاده موفقی بود و در زمین‌های مرطوب خیلی خوب عمل می‌کرد. او در مورد داشتن حس درک مکانیکی با موتوری که سوارش بود صحبت می‌کرد. زمانی که آن وسیله را درک می‌کرد، نسبت به بقیه‌ی موتور سوارها بهتر عمل می‌کرد و این در زمین‌های مرطوب به خوبی نشان داده می‌شد. در یک جمله درک مکانیکی یعنی به کارگیری قابلیت‌ها تا مرز توانایی آن و نه فراتر از آن، تا بهترین نتیجه حاصل شود. برای او درک مکانیکی به معنی هماهنگی در کار کردن با موتورش بود و برای من هم چیزی شبیه به این در سخت‌افزار و نرم‌افزار است. هر زمان که نرم‌افزار نحوه‌ی کارکرد سخت‌افزار و بستر سخت‌افزاری را درک کند، با آن هماهنگ می‌شود و بهترین نتیجه حاصل می‌شود.

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

می‌شود یک نمای کلی از مواردی که به نظرتان توسعه‌دهندگان نرم‌افزار به آن آگاهی ندارند، به ما بگویید؟

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

اگر دسترسی به خانه‌های پشت سر هم در حافظه باشد، سخت‌افزار می‌تواند تقریباً تأخیرها را به کلی پنهان کند و دسترسی در زمانی کوتاه در حد چند نانو ثانیه صورت خواهد گرفت. در صورتی که اگر به صورت تصادفی به آن دسترسی پیدا کنم این زمان به چیزی حدود ٧٠ تا ١٠٠ نانو ثانیه افزایش پیدا می‌کند (همان کامپیوتر و همان قابلیت‌های سخت‌افزاری که با الگوی متفاوتی به حافظه دسترسی پیدا کرده است). بنابراین اجازه دهید که بگویم حافظه خیلی شبیه به یک دستگاه خطی مثل نوار عمل می‌کند تا یک دستگاه با دسترسی تصادفی. هارد دیسک‌ها هم ویژگی‌های مشابهی دارند، حتی SSD ها هم برخی از این ویژگی‌ها را دارند، اما بیشتر افراد از این مسائل آگاهی ندارند و بدون اینکه بدانند حافظه چگونه کار می‌کند به آن دسترسی پیدا می‌کنند و در نتیجه دچار مشکلات کارایی می‌شوند.

حین خواندن قسمت‌هایی از بلاگ شما یک مسئله به صورت پررنگ دیده می‌شود و آن این است که بخش عمده‌ای از کارایی برنامه تحت تأثیر رابطه‌ی میان هسته‌های CPU و لایه‌های نهان‌گاه (Cache) آن است. کمی در مورد اهمیت این موضوع توضیح دهید.

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

در تمامی سیستم‌های کامپیوتری مدرن زیر سیستم نهان‌گاه منبع اطلاعات است و هنگامی که به نقطه‌ی تخلیه (Point of Exhaustion) برسیم، محتویات نهان‌گاه در حافظه ذخیره می‌شود. نهان‌گاه‌ها از الگوریتمِ «اخیراً کمتر استفاده شده» (Least Recently Used) برای تبادل داده‌ها استفاده می‌کنند و این خوب است. داده‌هایی که اخیراً مورد استفاده قرار گرفته‌اند، احتمالاً در نهان‌گاه باقی می‌مانند. علاوه بر این نهان‌گاه حافظه را تکه تکه دریافت می‌کند و نه بایت به بایت. این تکه‌ها ممکن است با توجه به اینکه در چه سطحی از نهان‌گاه هستیم، به اندازه خط نهان‌گاه (Cache Line) یا صفحات (Page) باشند. بنابراین حافظه‌های نزدیک به هم، با هم وارد نهان‌گاه می‌شوند. مسئله‌ی دیگر پیش‌واکشی‌های سخت‌افزاری (Hardware Pre-fetcher) هستند که با بررسی الگوهای دسترسی در کد و بر اساس آن، قسمتی از حافظه را پیش از دسترسی به آن، واکشی می‌کنند.

دسترسی به نهان‌گاه سطح ١ که پردازنده‌ها مستقیماً با آن کار می‌کنند تنها ٣ تا ٤ چرخه طول می‌کشد. بعد از آن سطح ٢ است که نهان‌گاه بزرگ‌تری است و در پردازنده‌های امروزی حدود ٢٥٦ کیلوبایت حافظه دارد که به مراتب بیشتر از ٣٢ کیلوبایت در سطح قبلی است. بعد از آن سطح ٣ است که چیزی بین ٢ تا ٣ مگابایت حافظه دارد. همین‌طور که حافظه افزایش پیدا می‌کند تأخیر هم افزایش پیدا می‌کند.

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

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

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

بله، درست است. فرضیات زیادی وجود دارد، یکی از آن‌ها را گفتم. مطلب دیگر این است که تعداد ترانزیستور‌ها و هوشمندی پردازنده‌ها همواره در حال افزایش است، ولی این رشد اساساً محدود است. اگر به طور کاملاً تصادفی به حافظه دسترسی پیدا کنید، بدترین کارایی حاصل می‌شود چون سخت‌افزار نمی‌تواند به شما کمکی کند. در حقیقت مسئله فراتر از این است؛ فقط داده‌ها نیستند که بر کارایی تأثیرگذارند، بلکه جریان دستورالعمل‌ها نیز در آن مؤثر است. پردازنده‌ها اطلاعاتی آماری در مورد انشعاب‌های برنامه را نگه‌داری کرده و بر اساس آن پیش‌بینی‌هایی از مسیر انشعاب‌های بعدی انجام می‌دهند. پردازنده‌های مدرن مسیر انشعاب‌ها را پیش‌بینی می‌کنند، چون ممکن است داده‌های (لازم برای انجام محاسبات) آن هنوز آماده نباشند. بنابراین در حالی که منتظر دریافت اطلاعات هستند، فرضی را در مورد انشعاب (درست یا غلط بودن آن) در نظر گرفته و اجرای دستورالعمل‌های بعدی را ادامه می‌دهند. در صورتی که بعداً معلوم شود که فرض اشتباه بوده، می‌بایست نتایج این محاسبات دور ریخته شود. در صورتی که فرض درست باشد، کار از پیش در حال اجرا بوده و کد سریع‌تر شده است. بنابراین با توجه به این‌که این اطلاعات آماری نگه‌داری می‌شود، برنامه باید رفتاری قابل پیش‌بینی داشته باشد و از الگوهای مشابهی پیروی کند. در صورتی که رفتار تصادفی باشد، مزیت ذکر شده از دست می‌رود. مسئله‌ی جالب این است که در صورتی که در مورد الگوریتم‌ها و کاربرد آن فکر شود، بسیاری از انشعاب‌ها در برنامه می‌توانند با محاسبات ریاضی جایگزین شوند یا این‌که می‌شود مدل تمیزتر و ساده‌تری از مسئله ارائه کرد.

اطلاعات زیادی در مورد نحوه‌ی عملکرد پردازنده‌های مدرن و سیستم‌های زمان اجرا وجود دارد و اگر واقعاً می‌خواهید به کارایی بالا دست پیدا کنید، به توابع کوچک، ساده، خطی، و با قابلیت ترکیب بالا نیاز دارید. بهبود کارایی توابع بزرگ با انشعاب‌ها و حلقه‌های زیاد، در سطح پردازنده و سیستم‌های زمان اجرا (Runtime System) کار فوق‌العاده دشواری است. در مقابل، کد تمیز و قابل پیش‌بینی بسیار سریع اجرا می‌شود، علاوه بر این خواندن و درک آن برای انسان‌ها هم ساده‌تر است؛ این‌ها با یکدیگر مغایر نیستند. من زیاد می‌شنوم که افراد می‌گویند: اگر من کدم را برای کارایی بالاتر بهبود دهم، خوانایی آن از دست می‌رود. به نظر من این درست نیست. در واقع کاملاً برعکس است. مجموعه‌ای از توابع موزون و زیبا، با وظایف تفکیک شده، دارای همبستگی بالا، ساده و قابل ترکیب با هم، در بیش از ٩٠% موارد کاربرد، عملکرد فوق‌العاده چشم‌گیری دارند. شرایط خاصی نیز وجود دارد که نیاز به کارهای خاص دارد، اما آن‌ها استثناء هستند.

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

بله، کاملاً درست است. برای مثال، در محبوب‌ترین زبان برنامه‌نویسی دنیا، جاوا، اگر بخواهم چینشی به صورت ساختار آرایه‌ای داشته باشم -یک علت خوب برای این کار پیاده‌سازی بهتری از HashMap است- می‌بایست آرایه‌ای از ارجاع‌ها (Reference) داشته باشم. هر کدام از این ارجاع‌ها به گره‌هایی اشاره می‌کنند که ابتدای یک زنجیره را نشان می‌دهند. هرکدام از این زنجیره‌ها، مربوط به یک Bucket در داخل HashMap هستند. اگر می‌توانستم از مکان شروع هر کدام از Bucketها، یک ساختار آرایه‌ پیوسته داشته باشم، کارایی خیلی بهتر بود.

این‌ها چیزهایی هستند که در JDK استاندارد وجود ندارند. در واقع JDK کاملاً برای تولید مداوم خطاهای نهان‌گاه (Cache Miss) طراحی شده است. در مقابل ساختار Dictionary در #C با یک چینش (Layout) بسیار مناسب در حافظه پیاده‌سازی شده است. من کارایی را برای مجموعه داده‌هایی بیش از 2 گیگابایت در .NET و جاوا اندازه گرفته‌ام و .NET حداقل 10 برابر سریع‌تر است.

مثال‌های زیادی دیگری هم وجود دارد. ما به تغییرات زیاد دیگری هم در جاوا نیاز داریم. بیشتر اوقات ما از انواع اولیه (Primitive) به عنوان کلید استفاده می‌کنیم، مثلاً long یا integer. اگر از آدرس‌دهی باز (Open Addressing) و کاوش خطی (Linear Probing) در HashMap ها استفاده کنیم، کارایی به مراتب بیشتری خواهیم داشت، چون با hash کردن، مستقیماً به یک مکان رفته و به صورت خطی در حافظه حرکت می‌کنیم. این فقط یک مورد است، می‌توانم در مورد درخت‌ها هم بگویم.

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

مسئله از این هم فراتر می‌رود. کسانی که کتابخانه‌ی مجموعه‌ها (Collection) را در جاوا توسعه می‌دهند، در جاهایی که ثوابت و ضرایبی وجود دارد توضیحاتی در کد قرار داده‌اند که می‌توانید آن را بخوانید. این توضیحات می‌گویند که این مقادیر می‌بایست به طور مرتب بر اساس اندازه‌گیری‌ها بازبینی شوند. کدها خیلی سال‌ پیش نوشته شده‌اند و طبق اطلاعاتی که من دارم، این مقادیر هرگز به روز رسانی نشده‌اند.

یک سؤال دیگر در رابطه با کتابخانه‌ها دارم. در یکی از کنفرانس‌های سانفرانسیسکو در مورد تنظیم کارایی صحبت می‌کردید و بر هزینه‌ی بالای کپی کردن اطلاعات تأکید داشتید و اینکه کارایی برنامه‌ها عمدتاً تحت تأثیر یک کتابخانه‌ی خارجی (Third Party) قرار می‌گیرد. مثلاً یک پیاده‌سازی از JDBC یا Message Queuing که در آن نسخه‌های غیرضروری از اطلاعات نگه‌داری می‌شود. زمانی که سیستمی را توسعه می‌دهید و گلوگاه شما در یک کتابخانه‌ی خارجی است چه کار می‌کنید؟

این یک چالش جالب است. در بهترین حالت می‌توانید از کتابخانه‌ی دیگری استفاده کنید، اما همیشه نمی‌شود این کار را کرد. فکر می‌کنم اکثر کتابخانه‌ها بعداً به کتابخانه تبدیل شده‌اند (از ابتدا تصمیم به توسعه‌ی کتابخانه نبوده است). JDBC مثال خیلی خوبی است که در طول زمان بهبود پیدا کرده اما هنوز مشکلاتی دارد. مثلاً هنوز کتابخانه‌های JDBC که Non-blocking و ناهمگام (Asynchronous) باشند، وجود ندارند در حالی که باید چنین کتابخانه‌هایی وجود داشته باشد. این مسئله در طراحی‌های فعلی لحاظ نشده است. اگر به گذشته نگاه کنیم می‌بینیم که کد همیشه توسط چند تیم نوشته شده است و بافرها بین لایه‌های مختلف و متعدد جابجا می‌شوند و به همین علت کارایی پایینی دارند. گاهی اوقات این الگوها از C گرفته شده است که تبادل اطلاعات توسط اشاره‌گرها انجام می‌شود، اما در جاوا عمدتاً این‌طور نیست. چون قسمت‌های زیادی از کد برای کار با بخشی از یک آرایه طراحی نشده است، از کپی کردن استفاده شده است. خیلی زیاد به این موارد بر می‌خورید. در جاوا حتی اگر به کد برنامه‌ها دسترسی نداشته باشید می‌توانید بایت کد را به کد تبدیل کرده و کارایی آن را بررسی کنید و این مشکلات را ببینید. در برخی موارد من مجبور شدم کتابخانه‌ها را Decompile کرده، مشکلات را در برخی نقاط حل کنم و مشکل را به اطلاع تولید کننده برسانم. یا در برخی موارد دیگر پیاده‌سازی مشتری را به طور کامل تغییر دهم.

دوست دارم کمی در مورد پردازش چندهسته‌ای صحبت کنیم. یک مقاله یا نقل قول هست که می‌گوید: "دیگر از ناهار مجانی خبری نیست" (The free lunch is over). منظور گوینده از این جمله چیست؟

فکر می‌کنم شما به مقاله‌ی معروف هرب ساتر اشاره می‌کنید. من حوالی سال ٢٠٠٧-٢٠٠٨ آن را خواندم. منظور هِرب این بود که ما در فرایند کوچک‌تر کردن اندازه‌ی پردازنده‌ها به نقطه‌ای رسیده‌ایم که دما آن‌قدر زیاد شده که دیگر نمی‌توان سرعت ساعت (Clock Speed) را افزایش داد. گویی در طول سال‌های اخیر از یک وعده‌ی ناهار مجانی استفاده می‌کردیم که در آن هر ١٨ ماه، سرعت پردازنده‌هایمان در نتیجه‌ی افزایش سرعت ساعت و بهبود فرآیند تولید، دو برابر می‌شد.

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

پردازنده‌ها به خودی خود محدودیت‌های سیستم ما نیستند. اگر اکثر سیستم‌ها را بررسی و اندازه‌گیری کنید متوجه می‌شوید که پردازنده‌ها در اکثر مواقع بیکار هستند و اطلاعات با سرعت کافی به آن‌ها نمی‌رسد. مسئله‌ی اصلی اینجاست: «چطور داده‌ها و دستورالعمل‌ها را به پردازنده با سرعت کافی برسانیم؟» در اینجاست که نقش زیر سیستم‌های حافظه، شناخت ساختار سلسله مراتبی آن‌ها، غیر یکنواخت شدن (Non uniform) و نحوه‌ی کار کردن آن اهمیت پیدا می‌کند.

ممکن است به اصطلاح NUMA (یا Non-Uniform Memory Architecture) برخورد کرده باشید. من به بسیاری از افراد بر می‌خورم که یک سرور دارای چند گره خریداری می‌کنند و متوجه نیستند که حافظه به هر کدام از آن گره‌ها متصل شده است. فرض کنید یک سرور با دو گره دارید. یک سوکت وجود دارد که حافظه به آن متصل شده است. سوکت دیگری نیز در کامپیوتری که حافظه به آن متصل شده است وجود دارد. این دو سوکت توسط یک مدار اتصال با سرعت بالا به یکدیگر متصل شده‌اند. زمان پیمایش از یک طرف به طرف دیگر این مدار اتصال ٢٠ نانو ثانیه است، بنابراین رفت و برگشت حدود ٤٠ نانو ثانیه است. اگر من از سوکت یک پردازنده به حافظه‌ی پردازنده‌ی دیگر دسترسی پیدا کنم، ٤٠ نانوثانیه زمان صرف خواهم کرد و مهم نیست اطلاعات در نهان‌گاه است یا در حافظه یا جای دیگر. خیلی مواقع با در نظر گرفتن چنین مواردی می‌توان به افزایش چشم‌گیر در سرعت دست یافت. اگر برنامه‌ی من که ممکن است یک برنامه‌ی C یا جاوا یا هر چیز دیگر باشد، منابع کافی از قبیل حافظه و تعداد کافی هسته‌های پردازنده را برای اجرا در اختیار داشته باشد، می‌تواند تا سه برابر سریع‌تر عمل کند اما این در صورتی است که برنامه این منابع را روی یک گره در اختیار داشته باشد، نه اینکه روی چند سیستم پخش شده باشد (که رفتار پیش‌فرض سیستم عامل است).

در گذشته اگر می‌خواستید یک سیستم بزرگ، با حافظه یا تعداد هسته‌های زیاد بخرید به شرکت‌های بزرگ مثل IBM، Sun یا HP مراجعه می‌کردید و آن‌ها در مورد نرم‌افزارها، سخت‌افزارها و کاربردهای مورد نیاز شما از شما سوال می‌کردند تا شما محصول درست را خریداری کنید. اما امروز شما خودتان این کارها را انجام می‌دهید. با مراجعه به سایت شرکت فروشنده و چند کلیک، محصول به شما تحویل می‌شود. مشکل اینجاست که افراد فکر می‌کنند هر چه بیشتر، بهتر: سوکت‌های بیشتر، حافظه‌ی بیشتر، چیزهای بیشتر روی کامپیوتر. در صورتی که زمانی که تعداد سوکت‌های یک کامپیوتر بیشتر می‌شود به این معنی است که هزینه‌ی رفت و آمد میان گره‌ها افزایش پیدا می‌کند و تأخیر زیادی به دسترسی‌های حافظه تحمیل می‌شود. در نتیجه با اینکه شما هزینه‌ی بیشتری کردید سرعت برنامه کاهش پیدا می‌کند. علت این مسئله کمبود درک مکانیکی از سخت‌افزاری است که استفاده می‌کنیم.

آیا درست است که بگوییم سرعت برنامه با استفاده از n هسته‌ی پردازنده، n برابر نمی‌شود؟

به نظرم خیلی درست است. اگر اکثر سیستم‌ها را بررسی کنید متوجه می‌شوید که بیشتر اوقات هسته‌های پردازنده بیکار هستند. در واقع یک یا دو هسته ١٠٠% مشغول هستند و مابقی بیکارند. علت این است که طراحی برنامه دارای نقاط ازدحام (Contention Point) است. نقطه‌ی ازدحام به این معنی است که گلوگاهی وجود دارد و همه چیز می‌بایست از آن عبور کند. این مشکل ممکن است فراتر از نرم‌افزار باشد، ممکن است مربوط به سیستم زمان اجرا باشد.

مثلاً اگر در حال استفاده از سیستم زمان اجرای جاوا مقدار زیادی حافظه بگیرید و لازم شود Garbage Collector احضار شود، باید وضعیت تمامی نخ‌ها (Thread) ذخیره شده و متوقف شود تا Garbage Collector بتواند کارش را انجام دهد. ذخیره کردن وضعیت نخ‌ها به معنی عدم پیشرفت برنامه و بیکار ماندن هسته‌هاست. زمانی که می‌خواهید برای متوقف کردن تمامی نخ‌ها، وضعیت آن‌ها را ذخیره کنید، ممکن است یک نخ به سرعت ذخیره شده و ذخیره‌سازی نخ دیگر به دلایلی مثل نگه‌داری آرایه‌ها یا کپی کردن مقادیر حافظه، زمان زیادی بگیرد. در این وضعیت، تا کارها بصورت ١٠٠% به پایان نرسد، برنامه‌ی شما پیشرفتی نخواهد نداشت یعنی تا متوقف شدن همه نخ‌ها و ادامه کارهای JVM از قبیل Garbage Collection، بارگذاری کلاس‌ها، بهینه‌سازی‌ها و خیلی کارهای دیگر. این فقط در مورد جاوا نیست، هر محیط مدیریت شده‌ در زمان اجرای (Managed Runtime) دیگری مثل PHP یا Python هم چنین است. علت این است که از پیش در مورد این مشکلات فکر نشده است. در نتیجه محدودیت‌ها و گلوگاه‌هایی در سیستم زمان اجرا داریم و برنامه‌ها در آن نقاط خطی اجرا می‌شوند و کارمان پیشرفتی نخواهد داشت.

بحث JVM را مطرح کردید، دوست دارم که در مورد آن صحبت کنیم. اما می‌خواهم اول در مورد دو مفهوم اصلی در توان عملیاتی سیستم‌ها صحبت کنید: قانون Amdahl و قانون Little.

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

قانون Amdahl محدودیت مقیاس‌پذیری هر الگوریتمی را با اضافه کردن تعداد هسته‌های بیشتر به زیبایی توضیح می‌دهد. برای مثال اگر الگوریتم من ٩٥% موازی باشد، اما ٥% آن می‌بایست به صورت پشت‌سرهم اجرا شود، نمی‌توان بیش از ٢٠ هسته در اختیار آن قرار داد. هر چه تعداد بیشتری هسته در اختیار آن قرار دهید، اثر آن ٥% از کد بیشتر می‌شود، تا جایی که برنامه دیگر سریع‌تر نخواهد شد. تا زمانی که از پردازنده‌های ٢ یا ٤ هسته‌ای استفاده می‌کنید، این موارد در بیشتر مواقع از شما پنهان است، اما زمانی که تعداد هسته‌ها به ١٦، ٣٢ یا ٦٤ هسته افزایش پیدا کند، خیلی سریع این مسئله نمود پیدا می‌کند. اگر در الگوریتم‌هایتان، چنین نقاطی (کدی که قابل موازی سازی نباشد) وجود داشته باشد، از قانون Amdahl گریزی نیست و مقیاس‌پذیری الگوریتم شما اساساً محدود است، حتی اگر درصد ناچیزی از کد باشد. ممکن است افراد برخی جاها این مسئله را فراموش کنند یا متوجه آن نشوند، مثلاً اگر در نرم‌افزارتان یک نهان‌گاه (Cache) داشته باشید که دسترسی به آن، [نیاز به] قفل (Lock) داشته باشد همه‌ی دسترسی‌ها به این نها‌ن‌گاه نوبتی خواهد بود. ممکن است فکر کنید که اضافه کردن این نهان‌گاه کارایی را بهبود داده است اما در حقیقت آن را بدتر کرده است.

این توصیف قانون Amdahl و تأثیر مؤلفه‌های غیرموازی در ظرفیت بالقوه‌ی افزایش کارایی یک برنامه است. قانون Amdahl در کنار قانون Little قرار می‌گیرد. قانون Little نظریه صف‌ها را شرح می‌دهد که در نقاط غیرموازی کد رخ می‌دهد. زمانی که چنین نقاطی در کد داشته باشید، تنها یک نفر در آن واحد قادر به استفاده از آن خواهد بود و در نتیجه یک صف تشکیل می‌شود. زمانی که صف در سیستم شما تشکیل شود، تأثیر آن را در تأخیر عملیات می‌بینید: تشکیل صف برابر است با افزایش تأخیر. چون صف‌های شما محدود نیستند، این تأخیر می‌تواند به صورت نامحدود افزایش پیدا کند و حتی بدتر از آن باعث مصرف زیاد حافظه شود.

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

یکی از چیزهایی که شما درباره‌ی آن می‌نویسید، مسئله‌ی تحت تأثیر قرار گرفتن کارایی برنامه‌های چند نخی (Multithread) به علت هزینه‌ی هماهنگ‌سازی و تعویض میان نخ‌ها به جای انجام دادن کار مفید است. چرا این‌طور می‌شود؟

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

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

در یک سیستم عامل امروزی زمان این عملیات می‌تواند چیزی بین ٣ الی ١٦ میلی ثانیه باشد و اغلب این زمان بسیار بیشتر از کاری است که سعی در انجام آن داریم. بنابراین طراحی برنامه‌ها به شکل چند نخی و به این شکل استفاده از قفل‌ها و متغیرهای شرطی می‌تواند به شدت کارایی برنامه را تحت تأثیر قرار دهد و بهتر است که برنامه را به صورت تک نخی بنویسیم.

یکی از چیزهایی که من افراد را به آن تشویق می‌کنم این است که ببینند آیا می‌توان منطق کسب و کار را با یک نخ به انجام رساند؟ و اغلب اوقات این امکان‌پذیر است. تنها مشکل این است که نمی‌توانید از فراخوانی‌های مسدود کننده استفاده کنید. خیلی از افراد نرم‌افزار را به گونه‌ای طراحی می‌کنند که به صورت همگام (Synchronous) و با تکیه به فراخوانی‌های مسدود کننده کار می‌کنند. در نتیجه نمی‌توان آن را روی یک نخ اجرا کرد و مقیاس‌پذیر نیست. در اینجا لازم است الگوی برنامه را تغییر داده و از فراخوانی‌های ناهمگام استفاده شود. پس از آن می‌توان برنامه را بدون نیاز به درنظر گرفتن مسائل همزمانی بر روی یک نخ اجرا کرد.

شما یکی از کسانی هستید که در پروژه‌ی متن‌باز Disruptor که برخی از مفاهیمی که در مورد آن صحبت کردید را در بر می‌گیرد، همکاری می‌کنید. کمی در مورد طرز فکر پشت این پروژه و این که چگونه مشکلاتی که در مورد آن صحبت کردیم را حل می‌کند به ما می‌گویید؟

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

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

بعنوان مثال، فرض کنید من اطلاعات را در دیسک می‌نویسم و آن را در یک یا چند گره‌ی دیگر تکرار می‌کنم، و همزمان آن داده‌ها را برای انجام عملیات مربوط به کسب و کار نیز آماده می‌کنم. مانند سایر مسائل پردازشی، می‌خواهم که این کار را در حداقل زمان، با حداقل سربار انجام دهم. طراحی Disruptor بر اساس درک مکانیکی بوده است: چطور می‌توانم تمامی این نخ‌ها را به صورت موازی اجرا کنم؟ چطور می‌شود با یک هزینه‌ی کم، کارها را هماهنگ کرد و وقوع رخدادها را به نخ‌ها اطلاع داد؟ تمامی این‌ها به وسیله‌ی مفاهیمی به نام الگوریتم‌های بدون قفل انجام می‌شود.

علاوه بر این طراحی به گونه‌ای است که بار Garbage Collection را از دوش ماشین جاوا بر می‌دارد. در محیط‌هایی که اعوجاج پایین (Low Jitter) مد نظر است (Jitter به معنای اختلاف و واریانس مشاهده شده در تأخیرها است - مترجم) ، نمی‌خواهیم زباله‌های حافظه‌ای تولید کنیم. زباله‌های بیشتر موجب فراخوانی Garbage Collector می‌شوند. بسیاری از ماشین‌ها مجازی فعلی مشکلاتی با Concurrent Collection ها دارند و می‌بایست تمامی نخ‌های را برای انجام عملیات خود متوقف کنند. بنابراین نمی‌توانند در زمان کوتاه پاسخگو باشند. طراحی Disruptor به گونه‌ای است که حافظه‌ی مورد نیاز را از پیش تخصیص داده و چند بار از آن استفاده می‌کند. در نتیجه در صورت وجود تعداد کافی هسته برای اجرای کارهایی که می‌بایست در حال اجرا باشند، می‌توانید به توان عملیاتی بالا و تأخیر پایین دست یابید.

من گاهی اوقات می‌بینم که افراد هنگام استفاده از Disruptor بیش از تعداد هسته‌هایی که در اختیار دارند، نخ اجرا می‌کنند و این استفاده‌ی درستی از Disruptor نیست. بنابراین اگر شما سیستمی ١٦ هسته‌ای دارید، اما می‌خواهید 100 نخ را اجرا کنید، Disruptor برای این کار طراحی نشده است. اما اگر ١٦ هسته دارید و به ١٠ نخ احتیاج دارید، Disruptor بسیار خوب عمل می‌کند و توان عملیاتی بالایی خواهید داشت.

مارتین، چرا بافر حلقوی؟

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

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

بله، همان‌طور که قبلاً هم گفتم، هر وقت که وارد ناحیه‌ی بحرانی میان دو نخ می‌شویم، می‌خواهیم داده‌هایی را برداریم، یا اینکه نخ دیگری را از یک تغییر آگاه کنیم، اغلب این کارها با استفاده از قفل‌ها یا متغیرهای شرطی انجام می‌شود. متغیرهای شرطی ممکن است به صورت Notify در جاوا، یا سینگال در Java EE، یا Mutex در Posix ظاهر شود. همه‌ی این‌ها در نهایت به چیزی تبدیل می‌شوند که یک ناحیه‌ی بحرانی یا امکان سیگنال دادن را فراهم می‌کند. هر زمان که این اتفاق می‌افتد، اگر قفل شما دچار ازدحام شده باشد، لازم است با سیگنال دادن، سایرین را آگاه کنید و مجبورید هسته‌ی سیستم‌عامل را درگیر کنید. بنابراین فراخوانی‌های سیستم‌عاملی انجام می‌دهید که کاری با سربار به شدت بالاست. مثل این است که از دولت کمک بخواهید و آن‌ها نیز از وکلا برای کمک شما استفاده کنند. این رویه کار خواهد کرد، امنیت دارد، و کارآمد است اما هزینه‌ی آن به شدت بالاست. اجرای چنین دستور‌العمل‌هایی میلیون‌ها چرخه به طول می‌انجامند.

عارضه‌ی جانبی دیگری که از آن اجتناب می‌شود این است که زمانی که از الگوریتم‌های بدون قفل استفاده می‌کنید به این معنی است که شما شخص سومی (Third Party) را وارد کار نمی‌کنید. در این الگوریتم‌ها بر سر یک سری گام‌ها برای هماهنگ‌سازی به توافق می‌رسید و تغییرات را با نمایان شدن حافظه به یک ترتیب خاص به اطلاع سایرین می‌رسد. برای این کار لازم است کنترل ترتیب اعمال تغییرات در نقاط مختلف الگوریتم را به دست بگیرید.

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

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

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

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

نکته‌ی مهم بعدی این است که برخی از کارها که ممکن است دارای چند مرحله باشند باید به صورت Atomic اجرا شوند. فرض کنید چند نخ داریم که هر کدام پایان کارشان را با افزایش یک شمارنده نشان می‌دهند. اگر مراقبت نشود ممکن است برخی از شمارش‌ها گم شوند (مثلاً دو نخ شمارنده را با مقدار ١٠ دیده و همزمان آن را به ١١ افزایش می‌دهند). کاری که باید برای حل این مشکل انجام دهید استفاده از روش‌های مقداردهی و معاوضه همروند (Concurrent Set and Concurrent Swap) است. در این روش‌ها مقدار متغیر به صورت شرطی تغییر می‌کند، یعنی زمانی می‌توانم یک متغیر را به‌روز رسانی کنم که در وضعیتی باشد که من از آن آگاهم. مثلاً متغیری را می‌خوانم که مقدارش ١٠ است، آن را افزایش می‌دهم، اما زمانی می‌توانم مقدار آن را در حافظه بنویسم که مقدارش هنوز ١٠ باشد اگر این‌طور نباشد عملیات شکست خواهد خورد و باید دوباره امتحان کنم.

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

تا به حال چند بار موضوع Garbage Collection در جاوا را مطرح کرده‌اید. آیا فکر می‌کنید تکامل زبان‌ها از malloc و free به Garbage Collection باعث بهبود شده یا ترکیبی از مزایا و معایب است؟

سوال جالبی است. اگر موارد کاربرد خیلی خاصی دارید، می‌توانید تخصیص و آزادسازی حافظه را خودتان خیلی کاراتر از سایر روش‌های مدیریت حافظه‌ی عام‌منظوره (مثل Garbage Collection) انجام دهید. یک مثال از این،‌ استفاده از بافر حلقوی برای تبادل اطلاعات میان تولیدکننده و مصرف‌کننده (Producer/Consumer) است. خیلی از مسائل این‌طور نیستند، در واقع چندین تولیدکننده و چندین مصرف‌کننده وجود دارد، یا اینکه چند نخ درگیر هستند. در محیط چند نخی مدیریت حافظه دشوار است. ممکن است است بگویید از شمارش مراجع (Reference Counting) استفاده می‌کنیم اما برای شمارش مراجع می‌بایست از روش‌هایی استفاده کنیم که پیش‌تر در مورد آن‌ها صحبت کردیم (قفل‌گذاری و نواحی بحرانی) که هزینه‌ی زیادی دارند. بنابراین مدیریت حافظه با این روش احتمالاً یکی از کندترین روش‌هاست. Garbage Collector ها با جمع‌آوری همروند زباله‌ها و ردگیری حافظه‌های بدون استفاده، بسیاری از مشکلات ما را حل می‌کنند و جلوی بسیاری از خطاها را می‌گیرند.

من مقدار زیادی برنامه‌نویسی در زبان‌های سطح پایین و سطح بالا با امکان Garbage Collection انجام داده‌ام و وقتی که شما Garbage Collection دارید قطعاً نگرانی برای دسته‌ای از خطاها را ندارید. می‌توانید به راحتی به حافظه دسترسی پیدا کنید و لازم نیست در مورد دسترسی‌های غیر مجاز و بررسی شرایط مرزی نگران باشید، اما این‌ها هزینه دارد. مسئله‌ی جالب این است که از نظر تئوری Garbage Collection می‌تواند کارآمدترین روش عام‌منظوره تخصیص و آزادسازی حافظه باشد. مشکل اینجاست که اکثر Garbage Collector ها با نگاه ساده انگارانه‌ای نوشته شده‌اند. آن‌ها انتظار دارند که در برخی مراحل کارشان، همه چیز متوقف شود. این باعث می‌شود که بخشی از برنامه به صورت غیر موازی اجرا شده و قانون Amdahl بر آن حاکم شود و در نتیجه مقیاس‌پذیری محدود شود. البته Garbage Collector هایی هم وجود دارند که واقعاً به صورت همروند عمل می‌کنند و کارایی فوق‌العاده‌ای دارند. یک مثال خوب C4 محصول شرکت Azul است. اما این تنها محصول تجاری است که من می‌شناسم و می‌تواند توان عملیاتی بالایی ارائه دهد و به سادگی بدون متوقف کردن برنامه قابل استفاده باشد.

با توجه به قانون Amdahl، آیا محدودیتی روی تعداد هسته‌های قابل استفاده توسط ماشین مجازی جاوا وجود دارد؟ البته با این فرض که در سایر قسمت‌های برنامه گلوگاهی وجود نداشته باشد.

این وابسته به این است که چه مقدار تخصیص حافظه دارید و چه کارهایی در حال انجام است. بردن نخ‌ها به وضعیت امن (Safe Point)، یکی از بزرگ‌ترین مشکلات ماشین‌های مجازی امروزی است. این مسئله در برخی موارد مشکل‌ساز می‌شود. برای هرکاری که بخواهد باعث متوقف شدن کامل برنامه (Stop The World) شود لازم است ابتدا وضعیت تمامی نخ‌ها را به حالت امن ببریم. در این نقطه است که حالا می‌توانیم برنامه را متوقف کنیم و می‌توانیم چرخه Garbage Collection را اجرا کنیم، می‌توانیم کلاس‌ها را تخلیه کنیم (Class Unloading)، می‌توانیم بهینه‌سازی‌ها را انجام دهیم و خیلی کارهای دیگر. اما قبل از همه این کارها لازم است که همه نخ‌ها را به وضعیت امن برسانیم. اینها مسائلی هستند که می‌بایست بصورت ترتیبی اجرا شوند.

برای اینکه این مسأله روشن‌تر شود می‌توانیم به این موضوع اشاره کنیم که خیلی از افراد زمان Garbage Collection را در برنامه‌های خود اندازه‌گیری می‌کنند اما یکی از چیزهای دیگری که می‌بایست اندازه‌گیری شود، زمان توقف برنامه است، در بسیاری از موارد متوجه خواهید شد که زمان توقف برنامه به مراتب بیشتر از زمان Garbage Collection است. من اغلب در اندازه‌گیری‌ سیستم‌ها می‌بینم که متوقف کردن برنامه زمان بیشتری نسبت به انجام کار واقعی Garbage Collection دارد.

فکر می‌کنید تا چه حد می‌شود با تنظیم گزینه‌ها و پارامترهای Garbage Collector ها کارایی برنامه‌های سرور را افزایش داد؟

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

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

برای استفاده‌ی هوشمندانه‌تر از Garbage Collector افراد بر چه چیزهایی نظارت داشته باشند؟

فکر می‌کنم فعال کردن ثبت وقایع (Log) مهم‌ترین چیز باشد. من یک مقاله با عنوان Garbage Collection Distilled نوشته‌ام. هدف از نوشتن این مقاله این بود که انواع مختلف Garbage Collector هایی که با ماشین مجازی جاوا ارائه می‌شوند و پارامترهای اصلی تنظیمات آن‌ها معرفی شوند. یکی از تنظیماتی که باید انجام شود فعال کردن ثبت اطلاعات وقایع است. وقتی که این اطلاعات را داشته باشید، می‌توانید از ابزارهای دیگری استفاده کنید. سربار فعال کردن آن در سیستم‌های محصول (Production System) ناچیز است و حتی متوجه آن نخواهید شد. پس از آن می‌توانید اطلاعات را برداشته و با استفاده از ابزارها، ببینید در برنامه‌های شما چه اتفاقاتی رخ داده است.

پیش از این در مورد ناتوانی زبان جاوا در تعریف کردن آرایه‌ای از رکوردها (به جای آرایه‌ای از ارجاع‌ها) صحبت کردید. همچنین در مورد برخی ساختارهای خارج از Heap برای حل این مسئله صحبت کردید. ممکن است بیشتر در این مورد توضیح دهید؟

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

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

مورد دوم این‌که گاهی نیاز دارم یک شی‌ء را در دل شیء دیگری قرار دهم. مثلاً یک صف یک سر و یک ته دارد. من می‌خواهم زمانی که به سر یا ته این صف دسترسی پیدا می‌کنم اطلاعات همانجا باشد. بنابراین باید بتوانم اشیاء کوچک را درون اشیاء بزرگتر قرار دهم. یک مثال خوب از آنها، انواع atomic int و atomic long هستند. اشیاء کوچکی که پوششی (Wrapper) روی انواع داده‌ای اولیه هستند.

سومین چیزی که به آن نیاز دارم، داشتن توانایی گسترش یک چیز در زمان اجراست. این مفهوم خیلی شبیه به داشتن یک ساختار در زبان C است که آرایه‌ای با طول متغیر در انتهای خود دارد. این مورد برای چیزهایی مثل رشته‌ها (String) خیلی مفید است. اشیاء رشته مقادیری مثل hash code، طول رشته، اندیسی در آرایه و در نهایت آرایه‌ای از بایت‌ها (حروف آن رشته) را نگهداری می‌کنند. چرا باید یک شیء داشته باشیم که نماینده‌ی رشته باشد و از آنجا به نقطه‌ی دیگری ارجاع کنم که آرایه را نشان دهد؟ این آرایه باید به صورت پیوسته در انتهای شیء مورد نظر قرار گیرد تا محلی بودن (Locality) حافظه حفظ شود. ماشین جاوا از برخی ترفندها برای کار با چیزهایی مثل رشته‌ها استفاده می‌کند اما من باید بتوانم چنین کاری را برای همه‌ی ساختارها انجام دهم تا مطمئن شوم چینشی کارا دارم. باید بتوانم با علم به وجود چنین امکاناتی،‌ کتابخانه‌های خود را طراحی کنم. این تغییرات لازم نیست در سطح زبان باشد. این کار می‌تواند از طریق الگوها و نشانه‌هایی که از پیش با ماشین جاوا توافق شده است انجام شود (مثل Annotation ها). زمانی که ماشین جاوا این علامت‌ها را ببیند، کارهای لازم را انجام می‌دهد. با وجود این امکانات می‌توانیم در برخی از مجموعه‌ها و برخی الگوهای دسترسی به کارایی به مراتب بهتری دست یابیم.

اگر می‌توانستید، آموزش علم کامپیوتر را برای کسانی که فارغ‌التحصیل نشده‌اند تغییر دهید، چگونه عمل می‌کردید؟

به چیزهایی مثل روش‌های علمی خیلی بیشتر توجه می‌کردم. کار کردن بر اساس استدلال، اندازه‌گیری و علم، باید اساس باشد؛ همان‌طور که گرفتن مدرک در سایر رشته‌ها این‌گونه است مواردی از قبیل یادگرفتن نحوه‌ی استدلال، چگونگی دنبال کردن مطالبی که همواره تغییر می‌کنند، آگاهی از این مسئله که انسان خیلی راحت در دام پیروی از محبوبیت و مد (popularity and fashion) می‌افتد.

چیزی که در رشته‌ی ما می‌توانیم از آن مطمئن باشیم تغییر است. بنابراین اگر در دانشگاه جزئیات را یاد بگیرید، خیلی زود تاریخ مصرفشان خواهد گذشت. باید چیزهایی را یاد بگیرید که در دراز مدت پایدار باشد.

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

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

مارتین، اگر شنوندگان بخواهند بیشتر در مورد نظرات و کارهای شما بدانند چطور این کار را انجام دهند؟

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

آیا ارائه‌ای از کنفرانس‌ها روی اینترنت وجود دارد که بخواهید شنوندگان آن‌ها را ببینند؟

کارهای متفاوتی هست که من انجام دادم و امیدوارم در آینده هم انجام دهم. اگر نام من و ارائه‌هایم در infoq یا Yahoo را در گوگل جستجو کنید نقطه‌ی شروع خوبی است. برای معرفی و توضیح دادن یک موضوع به افراد معمولاً یک wiki در بلاگم قرار می‌دهم تا از آنجا شروع کرده و بیشتر مطالعه کنند. دوست دارم موضوعات جدید را به افراد معرفی کنم. قرار نیست آنجا همه چیزهایی که به آن نیاز دارند را پیدا کنند اما نقطه‌ی شروع خوبی است.

مارتین، از شما خیلی متشکریم که با ما صحبت کردید.

متشکرم که من را دعوت کردید.