تلهی بنچمارک
منتشرکنندگان بنچمارک هرگز تصویر کامل را به شما نشان نمیدهند. آنها هندلری را اندازهگیری میکنند که تقریباً هیچ کاری انجام نمیدهد. یک JSON Hello World ساده. یک رشتهی استاتیک. در آن خلاء، Fiber بدون شک برنده است. این فریمورک در هر ثانیه درخواستهای بیشتری را پاسخ میدهد و حافظه کمتری نسبت به Gin مصرف میکند. این مهندسی واقعی است. اما اپلیکیشن شما یک خلاء نیست.
من در حال ساخت یک اپلیکیشن امنیتی بلادرنگ (real-time) در کنیا هستم. این اپلیکیشن مختصات GPS کاربران را در سراسر نایروبی و مومباسا دریافت کرده و هشدارهایی درباره نقاط جرمخیز یا تصادفات جادهای ارسال میکند. این یعنی عملیات نوشتن در PostgreSQL، فراخوانیهای Firebase Cloud Messaging و جستجوهای reverse geocoding. وقتی پشتهی (stack) شما شامل گامهای شبکه (network hops) و رفتوبرگشتهای پایگاه داده است، فریمورکی که در مسیریابی (routing) نانوثانیهها صرفهجویی میکند، عملاً نامرئی است. گلوگاه (bottleneck) هرگز روتر نیست؛ بلکه تایماوت شدن درگاه SMS است، یا کوئری PostGIS که در حال اسکن کردن جدولی از گزارشهای حوادث است. توان عملیاتی خام (Raw throughput) تا زمانی که با محیط عملیاتی (production) روبرو نشود، وسوسهانگیز به نظر میرسد.
وفاداری به کتابخانه استاندارد
Gin مستقیماً بر پایه net/http ساخته شده است. این موضوع بیش از آنچه به نظر میرسد اهمیت دارد.
هر توسعهدهندهی Go با net/http آشناست. دیباگ کردن از مسیرهای پشتهای (stack traces) آشنا پیروی میکند. میانافزارهایی (Middleware) که پنج سال پیش نوشته شدهاند، همچنان بدون هیچ دردسری قابل استفاده هستند. اشیاء درخواست (request) و پاسخ (response) دقیقاً همانطور که در مستندات Go آمده، رفتار میکنند. Gin قابل پیشبینی باقی میماند زیرا به خودِ زبان نزدیک است.
Fiber آن بنیاد را با fasthttp جایگزین میکند؛ یک موتور HTTP سفارشی که برای عملکرد بدون تخصیص حافظه (zero-allocation) بهینهسازی شده است. برای دستیابی به این هدف، از Request/Response Pooling استفاده میکند. به جای اینکه اجازه دهد garbage collector پس از هر درخواست حافظه را آزاد کند، Fiber بلوکهای حافظه را بازیافت (recycle) میکند. آنها را پاکسازی کرده و به اتصال ورودی بعدی تحویل میدهد. این ترفند منبع سرعت است، اما در عین حال منبع خطر نیز هست.
خطر پنهان حافظه بازیافتی
استفاده از Pooling در تئوری ایمن به نظر میرسد، اما در عمل، یک قرارداد ظریف ایجاد میکند: شما هرگز نباید پس از بازگشت handler، مرجعی (reference) به دادههای درخواست نگه دارید. اگر یک goroutine طولانیتر از طول عمر درخواست باشد، یا اگر بخشی از بدنه درخواست (request body) را برای پردازشهای بعدی ذخیره کنید، Fiber آن حافظه را بازپس میگیرد و به کاربر بعدی تحویل میدهد.
نتیجه، آلودگی است. این باگ در تست واحد (unit test) ظاهر نمیشود؛ بلکه به صورت مختصات GPS یک راننده در نایروبی ظاهر میشود که ناگهان در نقشهای در کیسومو دیده میشود. یا به صورت نشت یک توکن JWT از یک کاربر به بافت (context) کاربر دیگر. اینها شکستهای زمانی (temporal failures) هستند. وقتی لاگگذاری (logging) را اضافه میکنید، آنها ناپدید میشوند؛ زیرا خودِ عملیات لاگگذاری حافظه جدیدی تخصیص میدهد و زمانبندی را تغییر میدهد. در نهایت، شما در حال تعقیب روح هستید.
در Gin، کتابخانه استاندارد یک شیء درخواست تازه تخصیص میدهد. هیچ استخری برای مسموم شدن وجود ندارد. این ایمنی زمانی اهمیت دارد که اپلیکیشن شما با مکانهای واقعی که با امنیت واقعی در ارتباط هستند، سروکار دارد.
جاذبهی اکوسیستم
همچنین یک هزینهی پنهان برای سازگاری وجود دارد.
بیشتر میانافزارهای Go فرض را بر net/http میگذارند. اعتبارسنجهای JWT، صادرکنندههای OpenTelemetry، هندلرهای CORS و محدودکنندههای نرخ (rate limiters) همگی از رابط استاندارد استفاده میکنند. Gin آن رابط را به صورت بومی (natively) درک میکند. یک میانافزار را اضافه کنید و کار میکند.
Fiber نوع context و امضای handler مخصوص به خود را دارد. شما به آداپتور نیاز دارید. گاهی اوقات آداپتور رسمی است، گاهی یک سال از کتابخانه اصلی عقب میماند و گاهی اوقات تحت فشار (load) کمی متفاوت رفتار میکند. وقتی یک تیم کوچک را مدیریت میکنید، ساعتهای اضافهای برای دیباگ کردن این موضوع ندارید که چرا یک میانافزار احراز هویت در لپتاپ شما کار میکند اما توکنهای معتبر را در سرور رد میکند. شما میخواهید go get خستهکننده و ساده باشد. Gin آن را ساده نگه میدارد، و این دقیقاً همان چیزی است که ساعت ۳ صبح نیاز دارید.
آنچه اپلیکیشن واقعاً نیاز دارد
سرویس امنیتی من هر چند ثانیه یک بار پینگهای موقعیت مکانی را از کاربران همزمان دریافت میکند. برای جادههای پرخطر محدوده جغرافیایی (geofence) تعیین میکند و اعلانها را فعال میکند. یک هشدار کند، کلافهکننده است؛ یک هشدار اشتباه، خطرناک است. فرستادن کسی به سمت صحنه یک تصادف در حال وقوع، آن هم به دلیل فساد در حافظه بازیافتی (pooled memory)، غیرقابل قبول است.
در اینجا قابلیت اطمینان (Reliability) بر توان عملیاتی (throughput) برتری دارد. من به stack traceهایی نیاز دارم که وقتی pprof را متصل میکنم، منطقی باشند. من به پروفایلهای حافظهای نیاز دارم که نیازی به درک چرخه حیات داخلی یک استخر حافظه سفارشی نداشته باشم. من نیاز دارم توسعهدهنده بعدی که این پروژه را به ارث میبرد، بتواند در یک بعدازظهر بدون یادگیری مدل شیء fasthttp با پروژه آشنا شود. Gin این را به من میدهد. سلامت عملیاتی (Operational sanity) در بنچمارکها منعکس نمیشود، اما همان چیزی است که یک سرویس را
