لنفرض أنك المسؤول التقني في شركة ناشئة صغيرة تبيع الشاي والقهوة عبر الإنترنت، وفريقك ثلاثة مطورين: الأول يكتب Python منذ سنوات، والثاني جاء من عالم الواجهات ويعرف JavaScript وTypeScript جيداً، والثالث درس Java في الجامعة ولم يستخدمها منذ تخرجه. والآن تحتاجون خدمة صغيرة لحساب الأسعار، تأخذ رمز المنتج والكمية وترجع السعر وضريبة القيمة المضافة والإجمالي. وفي اجتماع الصباح يقول أحدهم إن Go أسرع بكثير، ويرسل آخر مقالاً عن أمان Rust، ويسأل المدير المالي سؤالاً بسيطاً: أي لغة سوف نجد لها مطورين عندما نوظف بعد ستة أشهر؟
هذا القرار يبدو تقنياً، ولكنه في الحقيقة قرار عن الفريق والتوظيف والتكلفة والصيانة لسنوات، وأغلب الفرق تتخذه بالحماس: لغة قرأ عنها أحدهم، أو لغة يحبها أكثر شخص صوتاً في الغرفة. وفي مقال قصص لغات البرمجة ومن صنعها رأينا أن كل لغة صنعت لحل مشكلة معينة، وفي مقال أنماط البرمجة وأنظمة الأنواع رأينا ما الذي يميز اللغات فعلاً. وهنا سوف نأخذ الخطوة العملية: كيف تقرر، وما الأسئلة التي يجب أن تسألها، ثم نكتب نفس خدمة الأسعار بأربع لغات، Python وJavaScript على Node.js وGo وJava، ونقيس بأنفسنا زمن البناء وحجم الـ Image والذاكرة وعدد الطلبات في الثانية، لنرى ما الذي تقوله الأرقام وما الذي لا تقوله.
وسوف نناقش في هذا المقال ما يلي:
- لماذا لا يكفي سؤال «أي لغة أسرع؟»، والأسئلة السبعة التي تحدد القرار.
- نوع المشروع Project Type: لماذا تملك بعض اللغات مجالات كاملة بسبب مكتباتها.
- الفريق والتوظيف Hiring والمنظومة Ecosystem والرخصة Licensing والأمان.
- المختبر: نفس الخدمة بأربع لغات، والبناء، والحجم، والذاكرة، والطلبات في الثانية، مع قراءة كل مخرج.
- مشكلات حقيقية ظهرت أثناء القياس: بناء Go البطيء، واختلاف الضريبة بين اللغات بهللة واحدة.
- ماذا يحدث إذا أعطينا كل خدمة 32 ميجابايت فقط من الذاكرة؟
- قصتان موثقتان لشركات غيرت لغتها، ثم جدول قرار وأخطاء شائعة وقائمة تحقق.
لماذا لا يكفي سؤال «أي لغة أسرع؟»
أغلب المقارنات التي تجدها على الإنترنت تبدأ بسؤال الأداء Performance، لأنه سؤال يمكن قياسه برقم واحد، والأرقام تعطي شعوراً بالدقة. ولكن الأداء نادراً ما يكون عنق الزجاجة في شركة ناشئة، فخدمة الأسعار في متجر صغير قد تستقبل خمسة طلبات في الثانية في أكثر الأيام ازدحاماً، وأي لغة من اللغات الأربع، كما سوف نرى في المختبر، تتحمل مئات أضعاف هذا الرقم على معالجين فقط. والذي يكلفك فعلاً هو شيء آخر: الوقت الذي يحتاجه فريقك ليكتب الخدمة ويصلحها، والوقت الذي تحتاجه لتجد مطوراً جديداً يفهم الكود، والمكتبات التي سوف تضطر لكتابتها بنفسك لأن اللغة لا تملكها.
لذلك فإن السؤال الصحيح ليس «أي لغة أفضل؟» وإنما «أي لغة أنسب لهذا المشروع وهذا الفريق في هذه المرحلة؟». والصورة التالية تجمع الأسئلة السبعة التي نستخدمها في هذا المقال، مرتبة من الأكثر تأثيراً في أغلب المشاريع إلى الأقل:

ولاحظ أن الترتيب يتغير حسب المشروع، فإذا كنت تكتب محرك قاعدة بيانات أو برنامجاً يعمل على جهاز صغير بذاكرة محدودة، فالأداء والذاكرة يصعدان إلى أعلى القائمة. ولكن في أغلب خدمات الأعمال، من المتاجر إلى أنظمة الحجز إلى لوحات التحكم، يكون الفريق والتوظيف والمنظومة أهم بكثير.
نوع المشروع Project Type: من يملك أي مجال؟
بعض القرارات لا تحتاج نقاشاً طويلاً، لأن المجال نفسه يفرض اللغة. فإذا كنت تبني تطبيقاً يعمل داخل المتصفح فلغتك JavaScript أو TypeScript، لأن المتصفح لا يشغل غيرهما مباشرة إلا عبر WebAssembly. وإذا كنت تدرب نماذج تعلم الآلة فأنت في Python، ليس لأن Python أسرع، وإنما لأن المكتبات التي يحتاجها هذا المجال، مثل PyTorch وNumPy وpandas، تعيش فيها، وكتابة بديل لها بلغة أخرى مشروع أكبر من مشروعك نفسه. وتطبيق iOS الأصلي يكتب بلغة Swift، وتطبيق Android الأصلي بلغة Kotlin، وقد ناقشنا هذا القرار بالتفصيل في مقالات المسار الخاص بتطبيقات الجوال.

أما المجال الذي يحتاج قراراً حقيقياً فهو خدمات الويب الخلفية Backend Services، مثل خدمة الأسعار في قصتنا، لأن كل اللغات الشائعة تملك فيه مكتبات ناضجة لخوادم HTTP وقواعد البيانات والطوابير. وهنا تنتقل الأولوية إلى الأسئلة التالية.
وفي أنظمة المؤسسات الكبيرة، كالبنوك وشركات الاتصالات والجهات الحكومية، ستجد Java وC# بكثرة، والسبب ليس تقنياً فقط، فهذه المؤسسات تملك فرقاً كبيرة تعرف هذه اللغات، وأنظمة عمرها عشرون سنة تحتاج من يصونها، وعقود دعم مع موردين كبار. لذلك إذا كانت شركتك تبيع لهذه المؤسسات وتحتاج أن يتسلم فريقهم الكود لاحقاً، فلغتهم تصبح عاملاً في قرارك.
الفريق Team والتوظيف Hiring: من سيكتب الكود بعد سنة؟
في قصتنا يعرف الفريق Python وJavaScript، ويعرف أحدهم Java بشكل قديم. فإذا اخترتم Go، فالفريق كله سوف يتعلم لغة جديدة أثناء بناء منتج حقيقي، وهذا ممكن، ولكنه يعني أن أول ستة أشهر من الكود سوف تكون مكتوبة بأسلوب مبتدئين في اللغة، وسوف تعيشون مع هذا الكود سنوات. وقد يتساءل البعض: أليست Go لغة بسيطة يمكن تعلمها في أسبوعين؟ والإجابة أن تعلم قواعد اللغة سريع فعلاً، ولكن معرفة طريقتها في معالجة الأخطاء والتزامن وتنظيم المشروع واختيار المكتبات هي التي تأخذ وقتاً، وهي التي تفرق بين كود يعمل وكود يسهل صيانته.
والتوظيف سؤال مختلف عن معرفة الفريق الحالي. ففي استبيان Stack Overflow للمطورين لسنة 2025، الذي أجاب فيه قرابة 24,800 مطور محترف عن سؤال اللغات، قال 68.8% منهم إنهم استخدموا JavaScript في السنة الماضية، و54.8% استخدموا Python، و48.8% TypeScript، ثم Java بنسبة 29.6% وC# بنسبة 29.9%، بينما كانت Go عند 17.4% وRust عند 14.5% وKotlin عند 11.5%. وهذا استبيان عالمي وليس محلياً، ونسب المجيبين لا تساوي سوق التوظيف عندك، ولكنه يعطيك فكرة عن حجم المجتمع: عندما تبحث عن مطور Python فأنت تبحث في بحر، وعندما تبحث عن مطور Rust خبير فأنت تبحث في بحيرة.
ولاحظ أن اللغة النادرة ليست سيئة للتوظيف دائماً، فبعض المطورين الجيدين ينجذبون إلى الشركات التي تستخدم لغة يحبونها، والشركة الصغيرة التي تستخدم Rust أو Elixir قد تجذب مطوراً متمكناً لا تجذبه شركة Java عادية. ولكن هذا يعمل عندما توظف شخصين، ولا يعمل عندما تحتاج عشرين مطوراً في سنة.
وهناك سؤال يغفل عنه أغلب الفرق الصغيرة: عامل الحافلة Bus Factor، أي كم شخصاً في الفريق يجب أن يغيب حتى يتوقف المشروع؟ فإذا اختار الفريق لغة لا يعرفها إلا شخص واحد، فالمشروع كله مرتبط بهذا الشخص، وإذا استقال فأنت لا تبحث عن مطور فقط، وإنما عن مطور يستطيع أن يفهم كوداً كتبه شخص وحده بأسلوبه الخاص.
المنظومة Ecosystem: المكتبات والأدوات قبل اللغة
اللغة نفسها جزء صغير مما ستستخدمه كل يوم، والجزء الأكبر هو ما حولها: مدير الحزم Package Manager، والمكتبات المتوفرة لكل شيء تحتاجه، وأدوات الاختبار والتنسيق والتحليل، وبيئة التطوير، والتوثيق، وسرعة إصلاح الثغرات في المكتبات المشهورة. وقبل أن تقرر اكتب قائمة بما تحتاجه الخدمة فعلاً، ثم ابحث عن المكتبة المناسبة في كل لغة وانظر إلى حالتها: متى آخر إصدار، وكم مساهماً نشطاً فيها، وهل يدعمها مورد أو مؤسسة.
ففي خدمة الأسعار مثلاً سوف تحتاج قريباً: الاتصال بقاعدة بيانات PostgreSQL، ورسائل الطوابير، وإصدار فواتير إلكترونية بصيغة معينة وتوقيعها، والتكامل مع بوابة دفع محلية. فإذا كانت بوابة الدفع توفر مكتبة رسمية لـ Node.js وJava فقط، فهذا وزن حقيقي في القرار، لأنك إن اخترت Go سوف تكتب التكامل بنفسك وتصونه بنفسك، وأي تغيير في واجهة البوابة يصبح مسؤوليتك.
وهناك جانب آخر للمنظومة يظهر بعد سنوات: سياسة الإصدارات والدعم الطويل LTS. فبعض اللغات تصدر نسخة جديدة كل ستة أشهر وتدعم نسخاً محددة لسنوات، وبعضها يغير أشياء أساسية بين الإصدارات الكبيرة، ورأينا في مقال من يقرر مستقبل لغات البرمجة كيف تدار كل لغة ومن يقرر ما يدخل فيها. والانتقال من Python 2 إلى Python 3 مثال حي على أن هذا السؤال ليس نظرياً، كما سوف نرى في قصة Khan Academy بعد قليل.
الرخصة Licensing والأمان Security
أغلب اللغات الشائعة اليوم مفتوحة المصدر برخص متساهلة، فرخصة Python هي PSF License، وGo تحت رخصة من نوع BSD، وNode.js تحت رخصة MIT، وهذه الرخص لا تفرض عليك شيئاً في كودك أنت. ولكن Java تحتاج انتباهاً خاصاً، لأن OpenJDK نفسه تحت رخصة GPLv2 مع استثناء Classpath وهي آمنة لتشغيل برامجك، أما Oracle JDK فرخصته تغيرت أكثر من مرة وقد يتطلب اشتراكاً مدفوعاً حسب عدد موظفيك، وقد شرحنا ذلك بالتفصيل في مقال هل Java مجانية؟ أي JDK تستخدم في شركتك. وفي مختبرنا استخدمنا OpenJDK 25 من حزم Debian، وهو بناء مفتوح لا يحتاج اشتراكاً.
ولاحظ أن رخصة اللغة شيء ورخص المكتبات شيء آخر، فقد تكون اللغة MIT وتعتمد خدمتك على مكتبة برخصة AGPL تفرض عليك نشر كودك إذا قدمت الخدمة عبر الشبكة، وقد شرحنا الفروق في مقال البرمجيات مفتوحة المصدر وتراخيصها.
أما الأمان فقد صار جزءاً من قرار اللغة في السنوات الأخيرة، حيث تدفع جهات حكومية مثل وكالة CISA الأمريكية نحو اللغات الآمنة في الذاكرة Memory-Safe Languages، وشرحنا الأسباب والأرقام في مقال أمان الذاكرة Memory Safety. والخبر الجيد لخدمات الويب أن اللغات الأربع في مختبرنا كلها آمنة في الذاكرة لأنها تدير الذاكرة بنفسها، كما رأينا في مقال إدارة الذاكرة، والقرار بين C وC++ وRust يصبح حقيقياً عندما تكتب برنامجاً قريباً من النظام أو يحتاج كل بايت من الذاكرة.
المختبر Lab: نفس الخدمة بأربع لغات
الآن نعود إلى خدمة الأسعار. كتبناها بأربع لغات، وكل نسخة تستخدم مكتبة HTTP الموجودة في اللغة نفسها دون أي إطار عمل Framework، حتى تكون المقارنة بين اللغات وبيئات تشغيلها وليس بين الأطر. ولكل نسخة طريقان:
/price?qty=3: يحسب السعر الصافي لثلاث عبوات سعر الواحدة 49.5 ريالاً، والضريبة 15%، والإجمالي، ويرجعها بصيغة JSON. هذا يمثل أغلب خدمات الأعمال: عمل حسابي قليل وكثير من معالجة الطلبات والنصوص./work: يحسب الهاش SHA-256 ألفي مرة متتالية، وهذا يمثل طلباً يحتاج المعالج فعلاً، مثل توليد تقرير أو التحقق من توقيع.
والجهاز الذي قسنا عليه فيه 8 معالجات منطقية و32 جيجابايت من الذاكرة ويعمل بنظام Docker Desktop فوق WSL2، وأعطينا كل خدمة معالجين اثنين فقط و512 ميجابايت من الذاكرة، وكل الأرقام التالية من مخرجات حقيقية محفوظة في مجلد lab مع المقال.

الكود في اللغات الأربع
نسخة Python تستخدم ThreadingHTTPServer من المكتبة القياسية، ولاحظ السطر protocol_version = "HTTP/1.1" الذي يبقي الاتصال مفتوحاً بين الطلبات Keep-Alive مثل بقية الخوادم الثلاثة:
import hashlib, json
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
UNIT, VAT = 49.5, 0.15
class H(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1" # keep-alive, like the other three servers
def do_GET(self):
u = urlparse(self.path)
if u.path == "/price":
qty = int(parse_qs(u.query).get("qty", ["1"])[0])
net = UNIT * qty
body = {"sku": "TEA-250", "qty": qty, "net": net, "vat": round(net * VAT, 2), "total": round(net * (1 + VAT), 2)}
elif u.path == "/work":
h = b"order-42"
for _ in range(2000):
h = hashlib.sha256(h).digest()
body = {"hash": h.hex()[:16]}
else:
self.send_response(404); self.send_header("Content-Length", "0"); self.end_headers(); return
data = json.dumps(body).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def log_message(self, *a):
pass
ThreadingHTTPServer(("0.0.0.0", 8080), H).serve_forever()ونسخة Go تستخدم الحزمة net/http، وهي الحزمة التي تستخدمها أغلب خدمات Go في الإنتاج أصلاً، فلا فرق كبير هنا بين «المكتبة القياسية» و«الإنتاج»:
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"math"
"net/http"
"strconv"
)
const unit, vat = 49.5, 0.15
func r2(x float64) float64 { return math.Round(x*100) / 100 }
func main() {
http.HandleFunc("/price", func(w http.ResponseWriter, r *http.Request) {
qty, err := strconv.Atoi(r.URL.Query().Get("qty"))
if err != nil {
qty = 1
}
net := unit * float64(qty)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"sku": "TEA-250", "qty": qty, "net": net, "vat": r2(net * vat), "total": r2(net * (1 + vat))})
})
http.HandleFunc("/work", func(w http.ResponseWriter, r *http.Request) {
h := []byte("order-42")
for i := 0; i < 2000; i++ {
s := sha256.Sum256(h)
h = s[:]
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"hash": hex.EncodeToString(h)[:16]})
})
http.ListenAndServe(":8080", nil)
}ونسخة Node.js تستخدم الوحدة node:http، ونسخة Java تستخدم com.sun.net.httpserver.HttpServer الموجود في JDK مع خيوط افتراضية Virtual Threads من JEP 444، بحيث يحصل كل طلب على خيط خفيف خاص به. والملفات الأربعة كاملة في المجلد lab، وأهم سطرين في نسخة Java هما:
HttpServer s = HttpServer.create(new InetSocketAddress(8080), 1024);
s.setExecutor(Executors.newVirtualThreadPerTaskExecutor());الـ Dockerfile لكل لغة: هنا يبدأ الفرق
قبل أن نقيس أي شيء، لاحظ أن طريقة التغليف نفسها تختلف بين اللغات. فلغات Python وNode.js تحتاج بيئة التشغيل كاملة في الـ Image، لأن الكود يقرأ ويترجم وقت التشغيل، بينما Go تنتج ملفاً تنفيذياً واحداً لا يحتاج شيئاً، وJava تحتاج JDK كاملاً للبناء ثم JRE للتشغيل. والـ Dockerfile الخاص بـ Go يبين ذلك بوضوح:
FROM debian:trixie-slim AS build
RUN apt-get update && apt-get install -y --no-install-recommends golang-go && rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY go.mod main.go ./
RUN --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /price .
FROM scratch
COPY --from=build /price /price
USER 65534
ENTRYPOINT ["/price"]في الإعداد أعلاه لاحظ التالي:
- البناء على مرحلتين Multi-Stage Build: المرحلة الأولى فيها مترجم Go كاملاً، والمرحلة الثانية تبدأ من
scratch، أي Image فارغ تماماً، ولا ينسخ إليها إلا الملف التنفيذي. CGO_ENABLED=0يجعل الملف مستقلاً عن مكتبة C في النظام، ولولاه لما عمل علىscratch.-ldflags="-s -w"يحذف معلومات التنقيح Debug Information من الملف فيصغر حجمه.- السطر
--mount=type=cacheلم يكن في النسخة الأولى، وسوف نرى بعد قليل لماذا أضفناه.
زمن البناء Build Time وحجم الـ Image
بنينا الـ Images الأربع من الصفر باستخدام docker build --no-cache، بعد تنزيل الـ Base Images مسبقاً، والمخرج سوف يكون كما يلي:
python build 205.0 s image 167.7 MB
node build 16.3 s image 326.2 MB
go build 227.5 s image 8.1 MBوعند تشغيل الأمر سوف تجد أن السطر الخاص بـ Java غير موجود، والسبب أن البناء فشل في المرة الأولى بهذا الخطأ:
ERROR: failed to build: failed to solve: process "/bin/sh -c apt-get update && apt-get install -y --no-install-recommends openjdk-25-jdk-headless && rm -rf /var/lib/apt/lists/*" did not complete successfully: exit code: 100والرمز 100 من apt-get يعني أن التنزيل أو التثبيت فشل، وفي حالتنا كان مستودع الحزم بطيئاً جداً وقت التجربة، ونجح البناء في المحاولة الثانية، ولكن مرحلة تثبيت JDK وحدها أخذت 786 ثانية بينما أخذت الترجمة بالأمر javac وتجميع الملف jar قرابة 6 ثوان فقط. وهنا أول درس من المختبر: لاحظ أن رقم Python، وهي لغة لا تحتاج ترجمة إطلاقاً، كان 205 ثوان، وأغلب هذا الوقت كان تنزيل حزمة python3 من المستودع. أي أن زمن البناء من الصفر في هذا المختبر يقيس سرعة الشبكة أكثر مما يقيس اللغة، ولهذا فإن المقارنة بين هذه الأرقام مضللة إذا قرأتها كأنها سرعة المترجم. والرقم الوحيد الذي يقول شيئاً عن اللغة هنا هو Node.js، حيث تأتي بيئة التشغيل جاهزة داخل الـ Base Image الرسمي node:22-slim فلا يوجد ما ينزل.
أما حجم الـ Image فهو رقم ثابت لا يتأثر بالشبكة، وفيه الفرق الأوضح في المختبر كله، فالخدمة نفسها تحتاج 8.1 ميجابايت في Go و167.7 في Python و326.2 في Node.js و460 في Java. والصورة التالية تجمع الأرقام:

وقد يتساءل البعض: هل يهم حجم الـ Image فعلاً إذا كان القرص رخيصاً؟ والإجابة أن الحجم يظهر في أماكن أخرى غير القرص: في زمن سحب الـ Image عند كل نشر Deploy على سيرفر جديد أو عقدة Kubernetes جديدة، وفي سعة سجل الـ Images Registry الذي تدفع عليه، وفي عدد الحزم الموجودة داخل الـ Image التي يجب أن تفحصها من الثغرات، فالـ Image المبني من scratch لا يحتوي إلا ملفك، بينما الـ Image المبني على Debian يحتوي مئات الحزم التي قد تظهر فيها ثغرة لا علاقة لها بكودك، وقد ناقشنا ذلك في مقال فحص الحاويات مع Trivy.
إعادة البناء بعد تعديل سطر: المشكلة التي ظهرت في Go
البناء من الصفر يحدث نادراً، أما ما يحدث عشرات المرات في اليوم فهو أن يعدل المطور سطراً ويعيد البناء. لذلك كتبنا السكربت rebuild.sh الذي يضيف تعليقاً إلى ملف الكود ويعيد البناء مع الـ Cache، ويقيس الوقت الكلي ووقت خطوة الترجمة وحدها:
python rebuild after a code change 18.1 s compile step none
node rebuild after a code change 13.5 s compile step none
go rebuild after a code change 72.2 s compile step 47.2s
java rebuild after a code change 23.4 s compile step 4.7sفي المخرج أعلاه لاحظ التالي:
- Python وNode.js لا تملكان خطوة ترجمة، وما زالت إعادة البناء تأخذ 13 إلى 18 ثانية، وهذا وقت Docker Desktop نفسه في تصدير الطبقات Layers وفك الـ Image، وليس وقت اللغة.
- Java ترجمت الكود في 4.7 ثانية، وهو رقم معقول لملف واحد.
- Go أخذت 47.2 ثانية لترجمة ملف من 40 سطراً، وهذا غريب للغة معروفة بسرعة الترجمة.
والسبب أن Go تحفظ نتائج الترجمة السابقة في مجلد اسمه Build Cache، ولكن هذا المجلد داخل خطوة RUN في Docker يبدأ فارغاً في كل مرة يتغير فيها الكود، فيعيد المترجم ترجمة المكتبة القياسية كاملة، بما فيها net/http وcrypto، مع كل تعديل. والحل هو ما يسمى Cache Mount، أي مجلد يحفظه Docker بين عمليات البناء ويركبه داخل الخطوة، وهو السطر --mount=type=cache,target=/root/.cache/go-build الذي رأيته في الـ Dockerfile. وبعد إضافته أعدنا القياس مرتين:
go rebuild after a code change 28.4 s (cache mount, first build: empty cache)
go rebuild after a code change 7.8 s (cache mount, second build)المرة الأولى بطيئة لأن الـ Cache فارغ، ومن المرة الثانية نزلت إعادة البناء من 72.2 إلى 7.8 ثانية، أي أسرع من كل اللغات الأخرى. وهذا مثال واضح على أن أرقام المقارنات التي تقرأها على الإنترنت قد تقيس طريقة الإعداد أكثر مما تقيس اللغة، ولو توقفنا عند القياس الأول لقلنا إن Go أبطأ لغة في دورة التطوير، وهذا غير صحيح. وفي خط CI حقيقي سوف تحتاج نفس الفكرة، أي حفظ الـ Cache بين التشغيلات، في كل لغة: ~/.m2 في Maven، وnode_modules أو مجلد npm في Node.js، ومجلد pip في Python.
الطلبات في الثانية Requests/s والذاكرة Memory
الآن نشغل كل خدمة وحدها، ونرسل إليها الطلبات بأداة wrk من Container آخر على نفس الشبكة، بخيطين وخمسين اتصالاً مفتوحاً لمدة 15 ثانية لكل طريق، ونقرأ الذاكرة من docker stats قبل الضغط Load وبعده. والسكربت كاملاً في load.sh، وأهم سطر فيه هو:
docker run --rm --network scp6-net --cpus=2 scp6-wrk -t2 -c50 -d15s "http://scp6-$l:8080$path"ومخرج خدمة Python في التشغيل الأول سوف يكون كما يلي:
== python first answer after 995 ms {"sku": "TEA-250", "qty": 3, "net": 148.5, "vat": 22.27, "total": 170.77}
idle memory: 9.266MiB / 512MiB
Latency 68.33ms 124.00ms 1.77s 97.88%
12785 requests in 15.03s, 2.65MB read
Socket errors: connect 0, read 0, write 0, timeout 3
Requests/sec: 850.49
memory after /price?qty=3: 13.96MiB / 512MiB
Latency 198.95ms 204.91ms 1.67s 87.50%
2518 requests in 15.07s, 425.48KB read
Socket errors: connect 0, read 0, write 0, timeout 3
Requests/sec: 167.10
memory after /work: 16.56MiB / 512MiBولنقرأ المخرج سطراً سطراً:
- السطر الأول فيه أول رد من الخدمة بعد التشغيل، وهو رد صحيح بصيغة JSON. ولاحظ قيمة الضريبة
22.27، فسوف نعود إليها لأنها مختلفة في اللغات الأخرى. أما زمن «first answer» فيشمل إنشاء الـ Container في Docker Desktop وليس بدء اللغة وحده، لذلك لن نعتمد عليه في المقارنة. idle memory: الذاكرة التي يستهلكها الـ Container وهو ينتظر، 9.3 ميجابايت.- سطر
Latencyفيه ثلاثة أرقام: المتوسط 68.33 ms، والانحراف المعياري 124 ms، وأقصى زمن 1.77 ثانية، ثم نسبة الطلبات التي وقعت ضمن انحراف معياري واحد من المتوسط. والفرق الكبير بين المتوسط والأقصى يعني أن بعض الطلبات انتظرت طويلاً، وقد شرحنا لماذا لا يكفي المتوسط في مقال الإحصاء للمهندسين. 12785 requests in 15.03s: عدد الطلبات التي اكتملت، وRequests/sec: 850.49هو ناتج القسمة.timeout 3: ثلاثة طلبات لم يأت ردها خلال المهلة الافتراضية لأداة wrk وهي ثانيتان، وهذا أول مؤشر على أن الخادم يقترب من حده.- الكتلة الثانية للطريق
/work، وفيها نزل العدد إلى 167 طلباً في الثانية، لأن كل طلب يحتاج ألفي عملية هاش.
وكررنا القياس كاملاً مرتين، والجدول التالي يجمع النتيجتين:
| اللغة | الذاكرة وهي تنتظر | /price طلب/ثانية (التشغيل 1 · 2) | /work طلب/ثانية (التشغيل 1 · 2) | الذاكرة بعد الضغط |
|---|---|---|---|---|
| Python 3.13 | 9.3 MiB | 850 · 865 | 167 · 326 | 13 إلى 17 MiB |
| Node.js 22 | 9.6 · 11.5 MiB | 4,837 · 10,663 | 113 · 141 | 14 إلى 18 MiB |
| Go 1.24 | 2.7 MiB | 10,228 · 28,505 | 742 · 1,648 | 14 إلى 21 MiB |
| Java 25 | 33.7 MiB | 5,223 · 13,996 | 878 · 2,283 | 58 إلى 82 MiB |

وأول ما يجب أن تلاحظه هو الفرق بين التشغيلين، فخدمة Go أعطت 10,228 طلباً في الثانية في المرة الأولى و28,505 في المرة الثانية، على نفس الجهاز ونفس الكود. والسبب أن الجهاز كان يشغل أعمالاً أخرى في نفس الوقت، وأي مقارنة أداء على جهاز تطوير مشترك أو سيرفر سحابي مشترك سوف تتأثر بمثل هذا. لذلك لا تثق برقم من تشغيل واحد، وانظر إلى الترتيب والنسب بين اللغات أكثر من الأرقام المطلقة.
والآن ماذا تقول الأرقام؟
- في الطريق البسيط
/priceكانت Go الأسرع بفارق واضح، ثم Java وNode.js متقاربتين، ثم Python بفارق كبير، حيث كانت Go أسرع من Python بين 12 و33 مرة. - في طريق المعالج
/workتغير الترتيب، فتقدمت Java على Go في التشغيلين، وتأخرت Node.js خلف Python. والسبب في Node.js أن الكود ينشئ كائنcreateHashجديداً في كل دورة من الألفين، وكل ذلك في خيط واحد يدير كل الطلبات، فيتوقف الخادم كله أثناء الحساب. وهذا درس مهم: اللغة ليست القصة كلها، وطريقة استخدام المكتبة ونموذج التزامن في بيئة التشغيل يغيران النتيجة، وقد شرحنا ذلك في مقال الخيوط Threads وasync. - في الذاكرة كانت Go الأصغر وهي تنتظر، وJava الأكبر، حيث بدأت عند 33.7 ميجابايت ووصلت إلى 82 بعد الضغط، لأن الـ JVM تحجز ذاكرة لجامع القمامة Garbage Collector والمترجم الفوري JIT.
ولكن تذكر قصتنا: المتجر يحتاج خمسة طلبات في الثانية في أسوأ يوم. وحتى Python، أبطأ اللغات هنا، أعطت 850 طلباً في الثانية على معالجين، أي أكثر من حاجة المتجر بمئة وسبعين ضعفاً، وهذا يكفي لأكثر من 70 مليون طلب في اليوم. لذلك فإن الفرق في الأداء هنا حقيقي، ولكنه لا يغير القرار لهذا المتجر في هذه المرحلة. ويبدأ الأداء في التأثير عندما يصبح عدد السيرفرات كبيراً بحيث يكون الفرق بين سيرفرين وعشرين سيرفراً مبلغاً يظهر في الميزانية.
ولنحسب ذلك بأرقامنا نحن: لنفرض أن المتجر نجح نجاحاً كبيراً وصار يحتاج 20,000 طلب في الثانية على هذا الطريق البسيط. فإذا أخذنا أرقام التشغيل الأول، وهي الأقل، فإن نسخة Python بمعالجين تعطي 850 طلباً، أي أنك تحتاج قرابة 24 نسخة، أي 48 معالجاً، بينما تكفي نسخة Go بمعالجين قرابة نسختين، أي 4 معالجات، وتقع Java وNode.js بينهما بقرابة 4 إلى 5 نسخ. وهنا يصبح الفرق اثني عشر ضعفاً في عدد المعالجات، وهو رقم تراه في فاتورة السحابة كل شهر. ولكن لاحظ أمرين قبل أن تقفز إلى النتيجة: الأول أن هذا الحساب على طريق لا يلمس قاعدة البيانات، وفي الخدمة الحقيقية يقضي أغلب الطلبات وقته في انتظار قاعدة البيانات والشبكة، فيتقلص الفرق بين اللغات كثيراً. والثاني أن الوصول إلى 20,000 طلب في الثانية يأخذ سنوات، وفي ذلك الوقت سوف يكون عندك فريق أكبر وبيانات حقيقية عن الطرق الساخنة، فتعيد كتابة الطريق الذي يستحق وحده.
هللة واحدة بين اللغات: مشكلة التقريب Rounding التي لم نتوقعها
عندما وضعنا الردود الأولى من الخدمات الأربع جنباً إلى جنب ظهر شيء لا علاقة له بالأداء:
== python ... {"sku": "TEA-250", "qty": 3, "net": 148.5, "vat": 22.27, "total": 170.77}
== node ... {"sku":"TEA-250","qty":3,"net":148.5,"vat":22.28,"total":170.77}
== go ... {"net":148.5,"qty":3,"sku":"TEA-250","total":170.77,"vat":22.28}
== java ... {"sku":"TEA-250","qty":3,"net":148.5,"vat":22.28,"total":170.77}نفس المعادلة، ونفس الأرقام، وضريبة Python أقل بهللة واحدة. والأسوأ أن الإجمالي في اللغات الثلاث الأخرى هو 170.77 بينما السعر الصافي مع الضريبة يساوي 148.5 + 22.28 = 170.78، أي أن الفاتورة لا تجمع نفسها. فأين المشكلة؟

المشكلة أن الرقم 0.15 لا يمكن تمثيله بدقة في الأرقام العشرية الثنائية Floating Point، كما شرحنا في مقال الأرقام العشرية Floating Point من الداخل. فناتج 148.5 * 0.15 ليس 22.275 بالضبط، وإنما 22.27499999999999857891452847979962825775146484375. ودالة round في Python، كما يذكر توثيقها، تقرب هذه القيمة الحقيقية، فتصبح 22.27. أما الطريقة التي استخدمناها في اللغات الثلاث الأخرى، أي الضرب في 100 ثم التقريب ثم القسمة، فتضرب أولاً، وناتج الضرب يقرب في الأرقام الثنائية إلى 2227.5 بالضبط، ثم تقربه Math.round إلى 2228. والإجمالي حسب من معادلة منفصلة، فقرب وحده إلى 170.77.
ولاحظ أن هذا ليس خطأ في Python ولا في JavaScript، فكل لغة فعلت ما يقوله توثيقها بالضبط. وإنما هو خطأ في الكود الذي كتبناه نحن، وهو نفس الخطأ في الأربع: حساب المال بأرقام Float. والحل المعروف أن تحسب المبالغ بالهللات كأعداد صحيحة Integers، أو أن تستخدم نوعاً عشرياً حقيقياً مثل Decimal في Python وBigDecimal في Java، وأن تحسب الضريبة مرة واحدة ثم تجمع الإجمالي من الأجزاء المقربة، لا أن تقرب كل رقم وحده. وتجربة سريعة في Python تبين الفرق:
from decimal import Decimal, ROUND_HALF_UP
(Decimal("148.5") * Decimal("0.15")).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)والنتيجة 22.28، وهي الضريبة الصحيحة بالتقريب التجاري المعتاد. وهذا هو الدرس الأهم في هذا المختبر لمن يكتب خدمات متعددة اللغات: إذا كانت لديك خدمتان بلغتين مختلفتين تحسبان نفس المبلغ، فسوف تختلفان يوماً ما بهللة، وسوف يأتيك محاسب يسأل لماذا لا تتطابق الفواتير مع التقرير. وسوف نعود إلى هذه النقطة في المقال التالي عن البرمجة بعدة لغات Polyglot.
ماذا يحدث إذا أعطينا كل خدمة 32 ميجابايت فقط؟
في الإنتاج لا تملك كل خدمة 512 ميجابايت دائماً، فإذا كنت تشغل عشرين خدمة صغيرة على سيرفر واحد، أو تدفع لمنصة تحسب السعر حسب الذاكرة، فسوف تسأل: ما أقل ذاكرة تكفي؟ لذلك أعدنا التجربة بحد 32 ميجابايت دون Swap، والسكربت lowmem.sh، والمخرج كما يلي:
== python with 32 MB: running=true oomkilled=false exit=0 9.238MiB / 32MiB
12851 requests in 15.03s, 2.66MB read
Requests/sec: 855.21
after: running=true oomkilled=false exit=0
== node with 32 MB: running=true oomkilled=false exit=0 9.141MiB / 32MiB
166026 requests in 15.03s, 34.68MB read
Requests/sec: 11049.04
after: running=true oomkilled=false exit=0
== go with 32 MB: running=true oomkilled=false exit=0 2.691MiB / 32MiB
441022 requests in 15.06s, 72.76MB read
Socket errors: connect 0, read 0, write 0, timeout 31
Requests/sec: 29276.98
after: running=true oomkilled=false exit=0
== java with 32 MB: running=true oomkilled=false exit=0 23.03MiB / 32MiB
0 requests in 15.04s, 0.00B read
Socket errors: connect 0, read 53, write 1769, timeout 0
Requests/sec: 0.00
after: running=false oomkilled=true exit=137
في المخرج أعلاه لاحظ التالي:
- Python وNode.js وGo عملت بنفس السرعة تقريباً، لأنها أصلاً لم تستهلك أكثر من 21 ميجابايت في التجربة الأولى.
- Java بدأت وهي تستهلك 23 من 32 ميجابايت قبل أي طلب، ثم مع أول دفعة من الطلبات تجاوزت الحد، فقتل النظام العملية، وهذا معنى
oomkilled=trueورمز الخروج137، أي أن العملية أنهيت بالإشارة SIGKILL. - أخطاء
write 1769في أداة wrk تعني أن الاتصالات قطعت من الطرف الآخر لأن الخادم مات.
وهل يعني هذا أن Java لا تصلح للحاويات الصغيرة؟ لا، والتفصيل كما يلي: الـ JVM تحدد حجم الـ Heap تلقائياً كنسبة من ذاكرة الـ Container، وتحتاج فوقها ذاكرة للخيوط والمترجم الفوري والكود المحمل، والإعدادات -XX:MaxRAMPercentage و-Xss وغيرها الموثقة في دليل الأمر java تسمح لك بضبط ذلك. ولكن المعنى العملي واضح: Java تحتاج حداً أدنى من الذاكرة أعلى بكثير من Go، وإذا كانت الذاكرة هي ما تدفع عليه، فهذا جزء من القرار. وفي الجهة الأخرى، تذكر أن Java كانت الأسرع في طريق المعالج، فالذاكرة الإضافية تشتري لك مترجماً فورياً يحسن الكود الساخن أثناء التشغيل.
وهناك ملاحظة أخرى تخص Go في هذه التجربة: نسخة Go التي استخدمناها هي 1.24 من مستودع Debian، وهي لا تعرف أن الـ Container محدود بمعالجين، فتشغل خيوطاً بعدد معالجات الجهاز الثمانية. وقد أضافت Go في الإصدار 1.25 قراءة حدود المعالج من الـ Container تلقائياً، وهذا مثال على أن إصدار بيئة التشغيل يغير السلوك في الإنتاج بقدر ما تغيره اللغة.
قصتان حقيقيتان: من Go إلى Rust، ومن Python إلى Go
القياس في مختبر صغير يعطيك إحساساً، ولكن قرارات الشركات الحقيقية تبين لك متى يصبح الأداء سبباً كافياً لتغيير اللغة. والقصتان التاليتان موثقتان من أصحابها.
القصة الأولى من Discord، التي نشرت في فبراير 2020 مقالاً بعنوان Why Discord is switching from Go to Rust. كانت لديها خدمة اسمها Read States تتابع الرسائل التي قرأها كل مستخدم في كل قناة، وهي في طريق كل اتصال وكل رسالة، وفيها ذاكرة مؤقتة Cache كبيرة. وكانت الخدمة المكتوبة بلغة Go تعاني ارتفاعاً مفاجئاً في زمن الاستجابة واستهلاك المعالج كل دقيقتين تقريباً، والسبب أن جامع القمامة في Go يعمل إجبارياً مرة كل دقيقتين على الأقل، ويفحص الذاكرة المؤقتة كلها في كل مرة. وجرب الفريق ضبط الجامع ولم ينفع، وجرب تصغير الذاكرة المؤقتة فارتفع زمن الاستجابة لأن طلبات أكثر ذهبت إلى قاعدة البيانات. وفي النهاية أعادوا كتابة الخدمة بلغة Rust التي لا تملك جامع قمامة، فاختفت تلك الارتفاعات، وتفوقت النسخة الجديدة بعد التحسين في زمن الاستجابة والمعالج والذاكرة.
ولاحظ تفاصيل هذه القصة: لم تغير Discord لغتها كلها، وإنما خدمة واحدة في الطريق الساخن، بعد أن قاست المشكلة وجربت الحلول الأرخص أولاً، وعندها خبرة كافية في Rust لتصون الخدمة الجديدة. وهذا هو الشكل الصحيح لقرار الأداء.
والقصة الثانية من Khan Academy، التي نشرت في ديسمبر 2019 مقال Go + Services = One Goliath Project. كان لديها نظام خلفي كبير بلغة Python 2 عمره عشر سنوات تقريباً، ومع اقتراب نهاية دعم Python 2 في أول يناير 2020 كان لا بد من الانتقال. ودرس الفريق ثلاثة خيارات: الانتقال إلى Python 3، وتوقعوا منه تحسناً في الأداء بين 10 و15% فقط، وKotlin التي كانوا يستخدمونها في بعض المهام، وGo. واختاروا Go، والأسباب في مقالهم: أداء قريب من Kotlin على الـ JVM مع استهلاك ذاكرة أقل بكثير يسمح بأجهزة أصغر، وترجمة سريعة جداً تسرع دورة التطوير، ودعم أفضل في المحررات التي يستخدمها فريقهم.
والقصتان معاً تعلمانك شيئين: الأول أن تغيير اللغة له سبب محدد يمكن قياسه، مثل توقف جامع القمامة أو نهاية دعم الإصدار، وليس «اللغة الجديدة أفضل». والثاني أن عوامل مثل الذاكرة وسرعة الترجمة ودعم المحررات، وهي نفس الأشياء التي قسناها في المختبر، كانت جزءاً من القرار الحقيقي بجانب السرعة.
جدول القرار Decision Table
إذا جمعنا ما سبق في جدول، فهذه نقطة بداية معقولة لخدمة ويب خلفية، وعدلها حسب فريقك:
| حالتك | الاختيار المعقول | السبب |
|---|---|---|
| فريق صغير يعرف Python، والخدمة فيها بيانات أو تعلم آلة | Python | المكتبات في Python، والأداء كاف لأغلب الخدمات |
| فريق يكتب الواجهات بـ TypeScript ويريد لغة واحدة | Node.js مع TypeScript | نفس اللغة في الواجهة والخادم، وانتبه للعمل الثقيل على المعالج |
| خدمات بنية تحتية، أو حاجة لحجم صغير وذاكرة قليلة | Go | ملف واحد صغير، وذاكرة قليلة، وترجمة سريعة |
| نظام مؤسسي كبير، أو فريق Java أو C# موجود، أو عميل يتسلم الكود | Java أو C# | منظومة ناضجة وأدوات ومطورون، وانتبه لرخصة الـ JDK |
| برنامج قريب من النظام، أو يحتاج زمناً ثابتاً دون توقفات | Rust (أو C++ بخبرة) | لا جامع قمامة، وأمان ذاكرة في Rust |
| تطبيق جوال أصلي | Swift وKotlin | المنصة تفرضهما |
وفي قصتنا، الفريق يعرف Python وJavaScript، والخدمة بسيطة، والضغط صغير، والتوظيف القادم سوف يكون من سوق كبير. لذلك فالقرار المعقول أن تكتب الخدمة بلغة يعرفها الفريق، Python أو TypeScript، مع حساب المبالغ بالهللات، وأن تؤجل قرار Go إلى اليوم الذي تظهر فيه مشكلة أداء حقيقية تقاس، وعندها يمكن إعادة كتابة الخدمة الساخنة وحدها كما فعلت Discord.
أخطاء شائعة Common Mistakes
- اختيار اللغة من مقارنة أداء قرأتها على الإنترنت: رأيت في المختبر أن رقم Go نفسه تغير ثلاثة أضعاف بين تشغيلين، وأن إعداد الـ Cache غير زمن البناء عشرة أضعاف. قس عملك أنت على جهازك أنت.
- تجاهل تكلفة التعلم: كل لغة جديدة على الفريق تعني أشهراً من الكود المكتوب بأسلوب المبتدئين، وهذا الكود يبقى سنوات.
- اختيار لغة لا يعرفها إلا شخص واحد: هذا يجعل المشروع رهينة لهذا الشخص.
- نسيان المكتبات الخاصة بمجالك: بوابة الدفع، والفوترة الإلكترونية، ومزود الرسائل، قد لا تملك مكتبة رسمية في اللغة التي تحبها.
- حساب المال بأرقام Float: رأيت النتيجة بعينك، هللة مختلفة بين اللغات وفاتورة لا تجمع نفسها.
- مقارنة زمن البناء دون فصل التنزيل عن الترجمة: 205 ثوان لبناء خدمة Python لا تترجم كانت كلها تقريباً تنزيل حزم.
- تجاهل الذاكرة الدنيا لبيئة التشغيل: الـ JVM قتلت عند 32 ميجابايت بينما عملت الثلاث الأخرى، فإذا كنت تخطط لعشرات الخدمات الصغيرة فاحسب الذاكرة قبل أن تختار.
- إعادة كتابة النظام كله بلغة جديدة بسبب مشكلة في خدمة واحدة: Discord غيرت خدمة واحدة.
قائمة تحقق Checklist قبل أن تقرر
- هل يفرض نوع المشروع لغة معينة بسبب المنصة أو المكتبات؟
- ما اللغات التي يعرفها الفريق الحالي جيداً، وكم شخصاً يعرف كل لغة؟
- كم مطوراً سوف توظف في السنة القادمة، وما حجم سوق كل لغة عندك؟
- هل توجد مكتبات ناضجة ونشطة لكل ما تحتاجه، خصوصاً التكاملات المحلية؟
- ما رخصة بيئة التشغيل والمكتبات الأساسية، ومن أي مورد سوف تأخذ الـ Runtime؟
- ما الضغط المتوقع فعلاً بعد سنة، وهل قسته أم خمنته؟
- كم ذاكرة وحجم Image تسمح به بيئة التشغيل وطريقة الدفع؟
- هل جربت نموذجاً صغيراً Prototype بلغتين وقست ما يهمك على جهازك؟
- هل كتبت القرار وأسبابه في وثيقة قرار معماري ADR حتى يعرف من يأتي بعدك لماذا اخترت؟
حذف المختبر Cleanup
بعد انتهاء القياس احذف الـ Containers والـ Images والشبكة التي أنشأها المختبر:
docker rm -f scp6-python scp6-node scp6-go scp6-java 2>/dev/null
docker network rm scp6-net 2>/dev/null
docker rmi scp6-price-python scp6-price-node scp6-price-go scp6-price-java scp6-wrk scp6-java-build scp6-go-buildولاحظ أن الـ Build Cache، بما فيه الـ Cache Mount الذي أضفناه لـ Go، يبقى بعد حذف الـ Images، ويمكنك حذفه بالأمر docker builder prune، ولكن هذا الأمر يحذف Cache كل المشاريع على الجهاز وليس هذا المختبر فقط، فاستخدمه وأنت تعرف ذلك.
الخلاصة
- اختيار اللغة قرار عن الفريق والتوظيف والمكتبات والتشغيل قبل أن يكون عن الأداء، وفي بعض المجالات تفرض المكتبات أو المنصة اللغة.
- في مختبرنا كانت Go الأصغر حجماً (8.1 ميجابايت) والأسرع في الطلبات البسيطة، وJava الأسرع في طريق المعالج والأكبر في الذاكرة، وPython الأبطأ ولكنها تحملت أكثر من 850 طلباً في الثانية على معالجين.
- الأرقام تغيرت حتى ثلاثة أضعاف بين تشغيلين، وإعداد الـ Cache غير زمن بناء Go من 72 إلى 8 ثوان، فلا تحكم من قياس واحد أو إعداد واحد.
- حساب المال بأرقام Float أعطى ضريبة مختلفة بهللة بين Python والبقية، والحل أعداد صحيحة بالهللات أو Decimal.
- بحد 32 ميجابايت عملت Python وNode.js وGo، وقتلت Java بسبب نفاد الذاكرة.
- تغيير اللغة في الشركات الحقيقية كان لسبب محدد مقاس وفي خدمة محددة، كما فعلت Discord وKhan Academy.
ولتكمل الصورة اقرأ قصص لغات البرمجة ومن صنعها لتعرف لماذا صنعت كل لغة، وهل Java مجانية؟ قبل أن تختار Java في شركتك، وأمان الذاكرة Memory Safety إذا كان مشروعك قريباً من النظام، ثم المقال التالي في هذه المرحلة عن البرمجة بعدة لغات Polyglot، والذي يجيب عن سؤال: ماذا لو احتجت أكثر من لغة في نفس النظام؟
سجل التحديثات
- أكتوبر 2026: كتابة المقال واختبار الخدمة بأربع لغات على Python 3.13.5 وNode.js 22.23.4 وGo 1.24.4 وOpenJDK 25.0.5 مع أداة wrk 4.1.0.