في المقالين السابقين من هذه المرحلة كتبنا كود المتجر الصغير الذي يبيع القهوة والتمور، وصححنا أنواعه وأخطاءه، والآن يقترب موسم التخفيضات، ويطلب منك الفريق ثلاثة أشياء قبله: صفحة إتمام الطلب Checkout تستدعي بوابة الدفع وخدمة الرسائل القصيرة وشركة الشحن، وكل واحدة منها تأخذ عشرات المللي ثواني، فهل نستطيع أن نستدعيها في نفس الوقت بدل أن ننتظرها واحدة بعد الأخرى؟ ومهمة ليلية تحسب بصمة هاش لكل فاتورة تأخذ ساعات، فهل نستطيع أن نوزعها على كل أنوية السيرفر؟ وفي الموسم الماضي باع المتجر علب هيل أكثر مما في المخزون، ولم يعرف أحد السبب. وإذا كنت قادماً من Go أو Node.js أو Java فأول سؤال سوف تسأله: أين الخيوط Threads في PHP؟ وأين async وawait؟
والإجابة المختصرة أن PHP يحقق التزامن Concurrency بطريقة مختلفة عن أغلب اللغات، فهو يخدم طلبات الويب الكثيرة بعمليات Processes مستقلة لا تتشارك شيئاً، وليس بخيوط داخل عملية واحدة، وأضاف في PHP 8.1 الألياف Fibers التي تبني عليها مكتبات مثل AMPHP حلقة أحداث Event Loop تشبه ما في Node.js، وعنده للحساب الثقيل التفرع pcntl_fork والخيوط عبر إضافة parallel. وهذا المقال الثالث في مرحلة لغة PHP للمطورين، وسوف نقيس فيه كل نموذج بنفس المهمتين التي نقيس بها كل لغات المسار: مئة طلب HTTP إلى خدمة بطيئة يرد كل طلب منها بعد 50 مللي ثانية، وأربعة ملايين عملية SHA-256، ثم نصنع حالة سباق Race Condition حقيقية بين عمليات PHP-FPM ونصلحها. والمفاهيم العامة للخيوط والأقفال شرحناها في مقال Threads وasync/await، وحلقة الأحداث في حلقة الأحداث Event Loop، لذلك سوف نركز هنا على PHP.
وسوف نناقش في هذا المقال ما يلي:
- لماذا لا يستخدم PHP الخيوط في الويب، وما معنى NTS وZTS.
- بيئة التجربة: الخدمة البطيئة والقياس على جهاز مشغول.
- نموذج PHP-FPM: العملية الرئيسية والأبناء وطابور الانتظار، وقياس 100 طلب مع 5 و20 و50 عملية، وكيف تحسب عدد الأبناء.
- الألياف Fibers: ما الذي تفعله وما الذي لا تفعله.
- المهمة المقيدة بالشبكة I/O-bound بأربع طرق: متتابعة، و
curl_multi، وFibers وحدها، وAMPHP مع حلقة الأحداث. - المهمة المقيدة بالمعالج CPU-bound: Fibers و
pcntl_forkوparallel. - حالة سباق على المخزون بين عمليات FPM، وإصلاحها بالقفل.
- الخوادم التي تبقي التطبيق في الذاكرة: FrankenPHP وSwoole وRoadRunner وOctane.
- مشكلات ظهرت في التجربة، وجدول قرار، وأخطاء شائعة.
لماذا يستخدم PHP العمليات Processes وليس الخيوط Threads؟
عندما ولد PHP كان يعمل كبرنامج CGI يبدأ مع كل طلب وينتهي معه، ثم صار وحدة داخل Apache، وكان Apache في وضعه الأشهر prefork يخدم كل اتصال بعملية مستقلة. وفي هذا الوضع لم يكن PHP يحتاج أن يكون آمناً مع الخيوط Thread Safe، فكتبت الإضافات Extensions بلغة C دون أقفال، وكثير من المكتبات التي تعتمد عليها مثل مكتبات الصور وقواعد البيانات القديمة لم تكن آمنة مع الخيوط أصلاً. ولذلك يوجد حتى اليوم بناءان من PHP: NTS أي Non Thread Safe وهو الافتراضي، وZTS أي Zend Thread Safety الذي يحمي حالة المحرك لكل خيط على حدة، ويستخدم عندما يعمل PHP داخل سيرفر بالخيوط مثل FrankenPHP، أو عندما تحتاج إضافة parallel.
والنتيجة العملية أن التزامن في تطبيق PHP التقليدي ليس من مسؤولية الكود الذي تكتبه، وإنما من مسؤولية السيرفر: PHP-FPM يشغل عشرين عملية مثلاً، وكل عملية تخدم طلباً واحداً في كل لحظة، فيخدم السيرفر عشرين طلباً في نفس الوقت. وكما رأينا في نموذج Share-Nothing في المقال الأول، لا تتشارك هذه العمليات أي متغير، فلا توجد مشاكل خيوط داخل الكود، ولكن المشاكل تنتقل إلى الموارد المشتركة خارج PHP: الملفات وقاعدة البيانات والكاش، وهذا ما سوف نراه في حالة السباق.

بيئة التجربة Lab Setup: خدمة بطيئة وجهاز مشغول
حتى تكون المقارنة عادلة بين اللغات نحتاج خدمة ترد بعد 50 مللي ثانية بالضبط، ولا تكون هي عنق الزجاجة. وكتبناها بـ Node.js في ملف واحد، لأن حلقة الأحداث فيه تخدم مئات الاتصالات المتزامنة دون جهد، وتكتب الترويسات Headers والجسم Body في عملية كتابة واحدة على الـ Socket. وهذه النقطة الأخيرة مهمة أكثر مما تبدو: الخدمة التي تكتب الترويسات ثم الجسم في كتابتين منفصلتين قد تتأخر 40 مللي ثانية إضافية بسبب تفاعل خوارزمية Nagle مع الإقرار المتأخر Delayed ACK في TCP، فيصبح كل طلب 90 مللي ثانية بدل 50 وتفسد كل القياسات. لذلك قبل أي قياس، قس طلباً واحداً:
const http = require('node:http');
http.createServer((req, res) => {
setTimeout(() => {
const body = JSON.stringify({ ok: true, path: req.url });
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) });
res.end(body);
}, 50);
}).listen(8080, () => console.log('slow endpoint on :8080'));== slow endpoint: three single calls
0.093764s
0.059230s
0.090833sوالطلب الواحد أخذ بين 59 و94 مللي ثانية، أي 50 مللي ثانية من الخدمة، والباقي إنشاء اتصال TCP جديد في كل مرة لأن curl من سطر الأوامر لا يعيد استخدام الاتصال، وتأخير شبكة Docker. وكل الخدمات تعمل في Containers على شبكة Docker واحدة، والخدمة البطيئة وPHP-FPM محدودة بمعالجين و512 ميجابايت. والجهاز الذي قسنا عليه كان يشغل في نفس الوقت أعمالاً أخرى في Containers غيرها، وهذا ظاهر في المخرجات: نفس الأمر يعطي أرقاماً تختلف بين جولة وأخرى، لذلك شغلنا كل قياس عدة مرات، وسوف نذكر المدى كاملاً بدل رقم واحد، وننظر إلى الفروق الكبيرة فقط. وكل المخرجات في ملف run-output.txt في مجلد التجربة، مع جولة سابقة كاملة في run-output-earlier.txt.
نموذج PHP-FPM: عملية رئيسية Master وأبناء Workers
PHP-FPM أي FastCGI Process Manager هو الطريقة القياسية لتشغيل PHP في الإنتاج خلف Nginx أو Caddy أو Apache. وهو يتكون من عملية رئيسية Master تقرأ الإعدادات وتراقب، وعدد من العمليات الأبناء Children، وكل ابن يستقبل طلباً عبر بروتوكول FastCGI، وينفذ السكربت، ويرسل الرد، ثم ينتظر الطلب التالي. والطلب الذي يصل والأبناء كلهم مشغولون ينتظر في طابور الاتصالات Listen Queue الخاص بالـ Socket، حتى يتحرر أحدهم.

والإعدادات الأساسية في ملف الـ Pool، وهي موثقة في صفحة إعدادات FPM. وفي التجربة استخدمنا ملفاً صغيراً يقرأ عدد الأبناء من متغير بيئة، لأن FPM يستبدل ${VAR} في ملفاته بقيمة المتغير:
[www]
pm = static
pm.max_children = ${FPM_CHILDREN}
pm.status_path = /fpm-status
request_terminate_timeout = 30sفي الإعداد أعلاه لاحظ التالي:
pmله ثلاث قيم:staticعدد ثابت من الأبناء يبدأ مع السيرفر، وdynamicعدد يتغير بين حد أدنى وحد أعلى حسب الضغط، وondemandيبدأ الابن عند وصول الطلب وينهيه بعد فترة خمول. واخترناstaticفي التجربة حتى يكون العدد معروفاً في كل قياس، وهو أيضاً الخيار المناسب لسيرفر مخصص للتطبيق.pm.max_childrenهو الإعداد الأهم في أي سيرفر PHP، لأنه يحدد أقصى عدد من الطلبات التي تنفذ في نفس الوقت.pm.status_pathيفتح صفحة حالة فيها عدد الطلبات المقبولة وطول الطابور وأقصى عدد من الأبناء النشطين، وسوف نقرأ منها بعد كل قياس.request_terminate_timeoutينهي الابن الذي تجاوز طلبه 30 ثانية، حتى لا يعلق ابن إلى الأبد على استدعاء خارجي لا يرد.
والسكربت الذي نقيسه هو أبسط ما يمكن: ينتظر 50 مللي ثانية كأنه ينتظر قاعدة بيانات أو API، ثم يرد:
<?php
// One "request" that waits 50 ms on I/O (like a database or an API call), then answers.
usleep(50_000);
header('Content-Type: text/plain');
echo getmypid(), "\n";القياس: 100 طلب مع 5 و20 و50 ابناً
أرسلنا 100 طلب في نفس اللحظة من عميل يستخدم curl_multi، ثلاث مرات لكل إعداد، ثم قرأنا صفحة الحالة. والمخرج سوف يكون كما يلي:
fpm children=5 ready
idle memory 12.76MiB / 512MiB
multi n=100 1357.8 ms 200x100
multi n=100 1250.2 ms 200x100
multi n=100 1231.3 ms 200x100
max listen queue: 90
max active processes: 5
fpm children=20 ready
idle memory 18.72MiB / 512MiB
multi n=100 605.8 ms 200x100
multi n=100 594.0 ms 200x100
multi n=100 671.2 ms 200x100
max listen queue: 64
max active processes: 20
fpm children=50 ready
idle memory 31.11MiB / 512MiB
multi n=100 578.4 ms 200x100
multi n=100 407.8 ms 200x100
multi n=100 569.5 ms 200x100
max listen queue: 44
max active processes: 50
في المخرج أعلاه لاحظ التالي:
- مع 5 أبناء الحد النظري هو 100 طلب مقسومة على 5 أبناء، أي 20 دفعة، مضروبة في 50 مللي ثانية، أي 1000 مللي ثانية، والمقيس كان بين 1231 و1358، أي قريباً منه مع تكلفة الاتصالات. وصفحة الحالة تقول إن الطابور وصل إلى 90 طلباً تنتظر، وهذا يعني أن كل الطلبات تقريباً انتظرت قبل أن تبدأ.
- مع 20 ابناً الحد النظري 250 مللي ثانية، والمقيس بين 594 و671، ومع 50 الحد 100 والمقيس بين 408 و578. أي أن مضاعفة الأبناء لم تقسم الزمن بنفس النسبة، لأن تكلفة فتح مئة اتصال عبر Nginx إلى FPM على جهاز مشغول ومعالجين فقط صارت هي الجزء الأكبر.
- عمود
200x100يعني أن كل الطلبات المئة نجحت برد 200، فلم يرفض FPM أي طلب، وإنما أخرها في الطابور. وهذا السلوك هو ما تراه في الإنتاج عندما يكونmax_childrenصغيراً: الموقع لا يتوقف، ولكن زمن الاستجابة يرتفع فجأة مع كل زيادة في الضغط. - الذاكرة في حالة الخمول ارتفعت من 12.8 إلى 31.1 ميجابايت فقط مع 50 ابناً، لأن السكربت صغير جداً والأبناء يتشاركون الكود في ذاكرة OPcache المشتركة. أما مع تطبيق Laravel حقيقي فكل ابن يستهلك عشرات الميجابايتات أثناء الطلب، وسوف نقيسه في المقال التالي.
كيف تحسب pm.max_children؟
والسؤال الذي يأتي بعد هذا القياس: لماذا لا نضع 500 ابن ونرتاح؟ والإجابة أن كل ابن عملية كاملة تحجز ذاكرتها أثناء الطلب، وإذا تجاوز مجموع الأبناء ذاكرة السيرفر بدأ النظام يستخدم الـ Swap أو قتل عمليات، وعندها يصبح السيرفر كله أبطأ من سيرفر بخمسة أبناء. والطريقة العملية لحساب الرقم هي أن تقيس ذاكرة الطلب الواحد في تطبيقك الحقيقي من صفحة الحالة أو من ps، ثم تقسم الذاكرة المتاحة لـ PHP عليها. فإذا كان عندك 2 جيجابايت لـ PHP وكل طلب يستهلك 60 ميجابايت في أسوأ الحالات، فالحد الأعلى الآمن حوالي 33 ابناً. وبعد ذلك يأتي سؤال المعالج: إذا كانت طلباتك تقضي أغلب وقتها في انتظار قاعدة البيانات، فالأبناء الكثيرون مفيدون لأن أغلبهم ينتظر، أما إذا كانت تحسب طوال الوقت فلا فائدة من أبناء أكثر من أنوية المعالج.
كيف تعرف أن الأبناء لا يكفون؟ صفحة الحالة Status Page والسجل البطيء Slowlog
في الإنتاج لن ترسل مئة طلب بنفسك لترى الطابور، وإنما تحتاج أن يخبرك FPM. وصفحة الحالة التي فتحناها بـ pm.status_path هي المصدر الأول، والحقول التي تهمك فيها ثلاثة: listen queue وهو عدد الطلبات التي تنتظر الآن، وmax listen queue وهو أكبر طابور منذ بدأ السيرفر، وmax children reached وهو عدد المرات التي أراد فيها FPM أن ينشئ ابناً جديداً ووجد أنه وصل إلى الحد، وهذا الحقل الأخير لا يزيد في وضع static لأن العدد ثابت أصلاً، ولذلك كان صفراً في تجربتنا رغم أن الطابور وصل إلى 90. وإذا كان max listen queue يزيد عن الصفر بانتظام في ساعات الذروة، فطلباتك تنتظر قبل أن تبدأ، وهذا الانتظار لا يظهر في أي قياس داخل الكود نفسه، لأن الكود لم يبدأ بعد.
والأداة الثانية هي السجل البطيء، وتفعله بإعدادين: request_slowlog_timeout = 2s وslowlog = /proc/self/fd/2، فيكتب FPM الـ Stack Trace الكامل لأي طلب تجاوز ثانيتين في اللحظة التي تجاوزهما فيها، أي أنك ترى السطر الذي كان الطلب ينتظر عنده بالضبط، وغالباً ستجده استدعاء لخدمة خارجية بلا مهلة Timeout، أو استعلاماً بلا فهرس. وتفاصيل الإعدادين في نفس صفحة إعدادات FPM. وهذان المصدران معاً يجيبان على السؤال الذي يأتي قبل أي تغيير في عدد الأبناء: هل المشكلة أن الطلبات تنتظر دورها، أم أن كل طلب بطيء بنفسه؟ فالحل في الحالة الأولى أبناء أكثر أو سيرفر أكبر، وفي الثانية زيادة الأبناء تجعل الأمر أسوأ لأنها تضيف ضغطاً على الشيء البطيء نفسه.
الألياف Fibers: دالة تتوقف وتكمل
أضاف PHP 8.1 الكلاس Fiber حسب مقترح Fibers، وهو أقرب شيء في PHP إلى الـ Coroutines. والـ Fiber دالة تبدأ بـ start، وتستطيع في أي سطر داخلها أن تستدعي Fiber::suspend() فتعيد التحكم إلى من شغلها مع قيمة، ثم تكمل من نفس السطر عندما يستدعي أحد resume. والفرق المهم عن async في JavaScript أن الدالة التي تتوقف لا تحتاج أن تعلن ذلك في تعريفها، فلا توجد مشكلة «لون الدالة» التي تجبرك أن تجعل كل من يستدعي دالة async مثلها. وحتى نرى ترتيب التنفيذ كتبنا الملف fiber.php، فيه طلبان A وB، كل واحد يتوقف عند انتظار بوابة الدفع، ثم ينتظر 20 مللي ثانية بـ usleep:
$make = function (string $name) use ($log): Fiber {
return new Fiber(function (int $orderId) use ($name, $log): string {
$log("{$name}: start order {$orderId}");
$reply = Fiber::suspend("{$name} waits for the payment API"); // give control back
$log("{$name}: resumed with '{$reply}'");
usleep(20_000); // blocking: nobody else runs
$log("{$name}: done");
return "{$name}-ok";
});
};
$a = $make('A');
$b = $make('B');
$log('main: ' . $a->start(101));
$log('main: ' . $b->start(102));
$a->resume('paid');
$b->resume('paid');
$log('main: results ' . $a->getReturn() . ', ' . $b->getReturn());والمخرج سوف يكون كما يلي:
0.1 ms A: start order 101
0.3 ms main: A waits for the payment API
0.3 ms B: start order 102
0.3 ms main: B waits for the payment API
0.3 ms A: resumed with 'paid'
20.5 ms A: done
20.5 ms B: resumed with 'paid'
45.8 ms B: done
45.9 ms main: results A-ok, B-ok
في المخرج أعلاه لاحظ التالي:
- الرسالتان
A waitsوB waitsظهرتا فيmain، أي أنsuspendأعاد القيمة إلى من استدعىstart، وهكذا تتواصل المكتبة التي تجدول الألياف مع الألياف نفسها. - B انتهى عند 45.8 مللي ثانية وليس عند 20.5 مع A، لأن
usleepيوقف العملية كلها، والألياف تعمل في خيط واحد، فلا يعمل B إلا بعد أن يتوقف A أو ينتهي. - لا يوجد أي شيء في PHP نفسه يقرر متى يكمل كل Fiber، فهذا عمل المجدول Scheduler الذي تكتبه أنت أو مكتبة مثل Revolt، ولهذا تسمى الجدولة تعاونية Cooperative.
وقد يتساءل البعض: إذا كانت الألياف لا تشغل شيئين في نفس اللحظة، فما فائدتها؟ والإجابة أن فائدتها تظهر فقط عندما يكون الانتظار نفسه غير حاجز Non-blocking، أي عندما تفتح اتصالات الشبكة في وضع لا يوقف العملية، وتسأل نظام التشغيل «أي هذه الاتصالات صار جاهزاً؟»، ثم تكمل الـ Fiber الذي ينتظر ذلك الاتصال بالضبط. وهذا ما تفعله حلقة الأحداث، وهو موضوع القسم التالي.
وما الفرق بين الألياف Fibers والمولدات Generators؟
قبل الألياف كان في PHP منذ الإصدار 5.5 شيء قريب منها، وهو المولدات Generators بالكلمة yield: دالة تعيد قيمة وتتوقف، ثم تكمل من نفس المكان عندما يطلب أحد القيمة التالية. واستخدمتها المكتبات غير المتزامنة القديمة، مثل الإصدار الثاني من AMPHP، لتكتب كوداً يبدو متتابعاً. ولكن للمولد قيداً كبيراً: لا يستطيع أن يتوقف إلا في جسمه هو، فإذا استدعى دالة أخرى تحتاج أن تنتظر الشبكة، فيجب أن تكون تلك الدالة مولداً أيضاً، ويجب أن يمرر كل من في سلسلة الاستدعاء yield إلى الأعلى. وهذه هي مشكلة «لون الدالة» نفسها التي تعرفها من async في JavaScript. أما الـ Fiber فيستطيع أن يتوقف من أي عمق في سلسلة الاستدعاءات، فدالة عميقة داخل مكتبة HTTP تستدعي Fiber::suspend()، وكل الدوال التي فوقها تبقى عادية لا تعرف شيئاً عن التوقف، وهذا ما جعل كود AMPHP 3 في تجربتنا يبدو كوداً متتابعاً عادياً. والمولدات ما زالت مفيدة في مكانها الأصلي: قراءة ملف كبير سطراً سطراً، أو المرور على نتائج استعلام كبير دون تحميلها كلها في الذاكرة.
والخلاصة العملية من هذا القسم: إذا رأيت في كود PHP كلمة Fiber مباشرة فغالباً أنت داخل مكتبة، لأن الكود العادي يستخدم ما تبنيه المكتبة فوقها، مثل async وawait في AMPHP.
المهمة المقيدة بالشبكة I/O-bound: 100 طلب بأربع طرق
المهمة الأولى هي ما تحتاجه صفحة Checkout في المتجر بشكل مكبر: مئة طلب HTTP إلى الخدمة البطيئة. وكتبنا الملف io.php الذي ينفذها بأربع طرق ويطبع الزمن ورموز الردود. والطريقة الأولى متتابعة بمقبض curl واحد يعيد استخدام الاتصال Keep-Alive، والثانية curl_multi وهي أقدم طريقة للتزامن في PHP، حيث تضيف مئة مقبض إلى مقبض واحد وتدير نقلها كلها من حلقة واحدة:
$mh = curl_multi_init();
$handles = [];
for ($i = 0; $i < $n; $i++) {
$ch = curl_init("{$url}?i={$i}");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_multi_add_handle($mh, $ch);
$handles[] = $ch;
}
do {
$status = curl_multi_exec($mh, $running);
if ($running) {
curl_multi_select($mh);
}
} while ($running && $status === CURLM_OK);والطريقة الثالثة مئة Fiber، كل واحد يستدعي file_get_contents، وهي ما يكتبه كثيرون بعد أن يقرأوا أن PHP صار عنده Fibers. والرابعة AMPHP بإصدارها الثالث، وهو مكتبة مبنية على Fibers وعلى حلقة الأحداث Revolt، ومعها عميل HTTP غير حاجز:
$client = HttpClientBuilder::buildDefault();
$futures = [];
for ($i = 0; $i < $n; $i++) {
$futures[] = async(fn () => $client->request(new Request("{$url}?i={$i}"))->getStatus());
}
$codes = await($futures);لاحظ أن الكود يبدو متتابعاً تماماً: request ثم getStatus، دون await داخل الدالة ودون Callbacks، والسبب أن request عندما تحتاج أن تنتظر الشبكة تستدعي Fiber::suspend من داخل المكتبة، فتعود الحلقة لتشغل Fiber آخر، وتكمله عندما يصل رده. وشغلنا الطرق الأربع ثلاث جولات:
== I/O: 100 calls, 3 rounds
seq n=100 5664.2 ms 200x100
multi n=100 335.7 ms 200x100
fibers n=100 7017.2 ms 200x100
amp n=100 2483.3 ms 200x100
seq n=100 5929.6 ms 200x100
multi n=100 672.4 ms 200x100
fibers n=100 5939.3 ms 200x100
amp n=100 883.5 ms 200x100
seq n=100 5212.7 ms 200x100
multi n=100 159.7 ms 200x100
fibers n=100 6195.3 ms 200x100
amp n=100 1200.6 ms 200x100
في المخرج أعلاه لاحظ التالي:
- المتتابع بين 5213 و5930 مللي ثانية، أي 52 إلى 59 مللي ثانية لكل طلب، وهذا يطابق 50 مللي ثانية من الخدمة مع تكلفة صغيرة لكل طلب، ويؤكد أن الخدمة البطيئة تعمل كما يجب.
- مئة Fiber مع
file_get_contentsأخذت بين 5939 و7017، أي أنها ليست أسرع من المتتابع، بل أبطأ قليلاً بسبب تكلفة إنشاء الألياف وفتح اتصال جديد لكل طلب. وهذا أهم درس في القسم كله: الـ Fibers لا تجعل الكود متزامناً بنفسها. curl_multiكان الأسرع، بين 160 و672 مللي ثانية، والحد النظري 50 فقط لأن كل الطلبات تنتظر معاً، والفرق كله فتح مئة اتصال TCP في نفس الوقت على جهاز مشغول. ولاحظ المدى الواسع بين الجولات، وهو أثر الأعمال الأخرى على الجهاز.- AMPHP بين 884 و2483، أي أبطأ من
curl_multiبوضوح، والسبب أنcurl_multiيترك كل العمل لمكتبة libcurl المكتوبة بـ C، بينما AMPHP يحلل بروتوكول HTTP ويدير الاتصالات بكود PHP. وفي المقابل AMPHP يعطيك نموذجاً كاملاً لكتابة خدمة متزامنة: قواعد بيانات وملفات ومؤقتات وقنوات، وليس فقط طلبات HTTP.
والخلاصة العملية لصفحة Checkout: إذا كنت تحتاج أن تستدعي ثلاث خدمات خارجية في طلب واحد من PHP-FPM فلا تحتاج حلقة أحداث، فـ curl_multi أو ما فوقه مثل Http::pool في Laravel وGuzzle مع Promises يكفي، ويقسم زمن الانتظار على ثلاثة. أما AMPHP وReactPHP فمكانهما عملية طويلة تخدم آلاف الاتصالات بنفسها، مثل سيرفر WebSocket أو Worker يستهلك طابوراً ويستدعي خدمات كثيرة.
كيف تعمل حلقة الأحداث Event Loop في PHP؟
حلقة الأحداث في PHP ليست جزءاً من اللغة كما في Node.js، وإنما مكتبة. والمكتبة الأشهر اليوم هي Revolt، وهي حلقة مشتركة اتفق عليها فريقا AMPHP وReactPHP حتى تعمل مكتباتهما معاً. والفكرة نفسها الموجودة في كل حلقات الأحداث: كل اتصال شبكة يفتح في وضع غير حاجز، والحلقة تحفظ قائمة بالاتصالات التي ينتظرها أحد، وفي كل دورة تسأل نظام التشغيل عبر stream_select أو عبر إضافة مثل ev أو uv «أي هذه الاتصالات صار جاهزاً للقراءة أو الكتابة؟»، ثم تكمل الـ Fiber المرتبط بكل اتصال جاهز. وReactPHP بنى نفس الفكرة قبل الألياف على Promises وCallbacks، ويستطيع اليوم أن يعمل مع Fibers أيضاً عبر مكتبة async الخاصة به.
وهذا النموذج له شرط لا يتسامح فيه: لا يجوز أن يستدعي أي كود داخل الحلقة دالة حاجزة مثل file_get_contents على رابط، أو PDO العادي، أو sleep، لأن هذه الدوال توقف الحلقة كلها، وكل الاتصالات الأخرى تنتظر معها، وهذا بالضبط ما رأيته في تجربة Fibers وحدها. ولذلك تحتاج نسخة غير حاجزة من كل مكتبة: amphp/postgres بدل PDO، وamphp/http-client بدل Guzzle، وهكذا. وهذا هو السبب الرئيسي في أن أغلب تطبيقات PHP لا تستخدم حلقة أحداث: مكتبات النظام البيئي كلها تقريباً حاجزة، وإطار مثل Laravel مبني عليها.
المهمة المقيدة بالمعالج CPU-bound: من يستخدم الأنوية الأربع؟
المهمة الثانية تمثل المهمة الليلية في المتجر: ألفا نص، وكل نص يمر على SHA-256 ألفي مرة، أي أربعة ملايين عملية هاش. وكتبنا الدالة في ملف مستقل work.php لأن خيوط parallel تحتاج أن تحمله بنفسها كما سوف نرى:
function work(int $from, int $to): string
{
$acc = '';
for ($s = $from; $s < $to; $s++) {
$h = "order-{$s}";
for ($r = 0; $r < ROUNDS; $r++) {
$h = hash('sha256', $h);
}
$acc .= $h;
}
return $acc;
}ثم قسمنا العمل على أربعة أجزاء بثلاث طرق في الملف cpu.php: أربع Fibers، وأربع عمليات بـ pcntl_fork تكتب كل واحدة نتيجتها في ملف مؤقت، وأربعة خيوط بإضافة parallel التي تحتاج بناء ZTS:
case 'fork': // pcntl_fork: separate processes, results through temp files
$pids = [];
for ($w = 0; $w < $workers; $w++) {
$pid = pcntl_fork();
if ($pid === 0) {
file_put_contents("/tmp/part-{$w}", work($w * $chunk, ($w + 1) * $chunk));
exit(0);
}
$pids[$w] = $pid;
}
foreach ($pids as $w => $pid) {
pcntl_waitpid($pid, $status);
$parts[] = file_get_contents("/tmp/part-{$w}");
unlink("/tmp/part-{$w}");
}
break;
case 'parallel': // ext-parallel (ZTS build): threads, each with its own PHP runtime
$futures = [];
for ($w = 0; $w < $workers; $w++) {
$rt = new parallel\Runtime(__DIR__ . '/work.php');
$futures[] = $rt->run(fn (int $a, int $b) => work($a, $b), [$w * $chunk, ($w + 1) * $chunk]);
}
foreach ($futures as $f) {
$parts[] = $f->value();
}
break;وشغلنا الطرق كلها في Container محدود بأربعة معالجات، جولتين على بناء ZTS، ثم المتتابع والتفرع على بناء NTS العادي:
== CPU: 2,000 strings x 2,000 SHA-256 rounds, container limited to 4 CPUs, 2 rounds
seq workers=1 7688.5 ms check=36f3f555ace451fd
fibers workers=4 6984.6 ms check=36f3f555ace451fd
fork workers=4 3660.9 ms check=36f3f555ace451fd
parallel workers=4 4114.4 ms check=36f3f555ace451fd
seq workers=1 7068.9 ms check=36f3f555ace451fd
fibers workers=4 6757.1 ms check=36f3f555ace451fd
fork workers=4 2597.6 ms check=36f3f555ace451fd
parallel workers=4 3295.5 ms check=36f3f555ace451fd
seq workers=1 9115.0 ms check=36f3f555ace451fd
fork workers=4 2989.9 ms check=36f3f555ace451fd
في المخرج أعلاه لاحظ التالي:
- القيمة
checkواحدة في كل السطور، وهي هاش لكل النتائج مجتمعة، فكل الطرق أعطت نفس الجواب، والفرق في الزمن فقط. - الـ Fibers الأربع أخذت تقريباً نفس زمن المتتابع، لأنها تعمل في خيط واحد وعلى نواة واحدة، ولا يوجد أي انتظار يمكن أن تستفيد منه.
- التفرع كان الأسرع، بين 2598 و3661 مللي ثانية، أي أسرع من المتتابع في نفس الجولة بضعفين إلى ثلاثة أضعاف تقريباً، وليس أربعة، لأن الجهاز كان مشغولاً، ولأن الأجزاء الأربعة لا تنتهي في نفس اللحظة.
- الخيوط في
parallelقريبة من التفرع وأبطأ قليلاً في الجولتين، وكل خيط فيها يشغل نسخة PHP مستقلة Runtime ولا يرى دوال الملف الرئيسي، ولهذا مررنا مسارwork.phpإلىparallel\Runtime، والبيانات بين الخيوط تنسخ ولا تشترك، والتواصل بينها عبر قنوات Channels. - بناء NTS في السطرين الأخيرين أعطى أرقاماً من نفس المدى، وفي مقارنتنا لا يظهر فرق يمكن قياسه بين البنائين مع هذا التذبذب.
ومتى تستخدم أياً منهما؟ التفرع متاح في أي بناء على Linux، ولا يعمل على Windows ولا داخل PHP-FPM بشكل آمن، لأن الابن يرث اتصالات قاعدة البيانات المفتوحة من الأب، وأي استعلام منه يخلط البيانات على نفس الاتصال، لذلك افتح الاتصالات بعد التفرع في كل ابن. أما parallel فيحتاج بناء ZTS، وهذا يعني أن تبني PHP أو تختار صورته الخاصة، ولذلك تجده نادراً في الإنتاج. والطريقة الأكثر شيوعاً في التطبيقات الحقيقية لتوزيع عمل ثقيل ليست أياً منهما، وإنما طابور Queue مثل Redis أو قاعدة البيانات مع عدة Workers مستقلين، كل واحد عملية PHP عادية تأخذ جزءاً من العمل، وهذا ما يفعله php artisan queue:work عندما تشغل منه أربع نسخ.
وماذا عن مترجم JIT؟
قبل أن توزع حساباً ثقيلاً على عدة عمليات، قد يخطر لك سؤال أبسط: PHP 8 فيه مترجم JIT يحول الكود الساخن إلى تعليمات المعالج مباشرة، فهل يكفي أن نفعله؟ والـ JIT جزء من OPcache، ولا يعمل في سطر الأوامر إلا إذا فعلت opcache.enable_cli، وحجزت له ذاكرة بـ opcache.jit_buffer_size، كما في إعدادات OPcache. وشغلنا المتتابع ثلاث جولات، في كل جولة مرة بدون OPcache ومرة مع JIT بوضع tracing:
php -d opcache.enable_cli=0 cpu.php seq
php -d opcache.enable_cli=1 -d opcache.jit=tracing -d opcache.jit_buffer_size=64M cpu.php seq-- round 1
no-opcache seq workers=1 9573.7 ms check=36f3f555ace451fd
jit-tracing seq workers=1 8564.1 ms check=36f3f555ace451fd
-- round 2
no-opcache seq workers=1 8190.0 ms check=36f3f555ace451fd
jit-tracing seq workers=1 7099.7 ms check=36f3f555ace451fd
-- round 3
no-opcache seq workers=1 9200.2 ms check=36f3f555ace451fd
jit-tracing seq workers=1 6689.7 ms check=36f3f555ace451fd
bool(true)
bool(true)والسطران الأخيران من opcache_get_status() يؤكدان أن الـ JIT كان مفعلاً ويعمل فعلاً، وليس مطلوباً فقط في الإعدادات، وهذا أول ما يجب أن تتحقق منه قبل أن تنسب أي فرق إليه. وفي الجولات الثلاث كان JIT أسرع بين ثانية وثانيتين ونصف تقريباً، أي أقل بكثير من التحسن الذي أعطاه التفرع على أربع أنوية، ومع تذبذب الجهاز لا نستطيع أن نحدد النسبة بدقة. والسبب أن أغلب زمن هذه المهمة داخل الدالة hash نفسها، وهي مكتوبة بلغة C أصلاً، فلا يجد الـ JIT ما يحوله إلا الحلقة وبناء النصوص حولها. والـ JIT يفيد أكثر في الكود الحسابي المكتوب بـ PHP نفسه، مثل معالجة الصور بالحلقات أو المحاكاة، أما تطبيقات الويب التي تقضي وقتها في انتظار قاعدة البيانات فلا يكاد يغير زمنها، ولهذا هو غير مفعل افتراضياً. وقد شرحنا الفكرة العامة للمترجم والـ JIT في مقال المترجم والمفسر وJIT.
حالة السباق Race Condition بين عمليات PHP-FPM
الآن نعود إلى علب الهيل التي بيعت أكثر من المخزون. قد يظن القادم من Go أو Java أن PHP محمي من حالات السباق لأنه لا يملك خيوطاً، ولكن عشرين عملية FPM تقرأ وتكتب نفس المورد المشترك هي بالضبط عشرون خيطاً من وجهة نظر ذلك المورد. وحتى نراها كتبنا أبسط نسخة من «شراء علبة»: نقرأ المخزون من ملف، ثم ننتظر 2 مللي ثانية تمثل التحقق من المخزون في قاعدة البيانات أو خدمة أخرى، ثم نكتب القيمة الجديدة:
<?php
// Read stock, "check" it, then write it back: two workers can read the same value.
$file = '/shared/stock.txt';
$stock = (int) file_get_contents($file);
usleep(2_000); // the time a real stock check takes (a query, a call)
if ($stock > 0) {
file_put_contents($file, (string) ($stock - 1));
echo "sold\n";
} else {
http_response_code(409);
echo "out of stock\n";
}ووضعنا في الملف 50 علبة، وأرسلنا 100 طلب شراء في نفس اللحظة إلى FPM بعشرين ابناً، ثلاث جولات، والمتوقع في كل جولة 50 عملية بيع و50 رد 409 ومخزون نهائي صفر:
stock-race multi n=100 960.2 ms 200x18 409x82
final stock: 47
stock-race multi n=100 606.0 ms 200x42 409x58
final stock: 45
stock-race multi n=100 586.0 ms 200x26 409x74
final stock: 45والنتيجة خاطئة في كل جولة وبشكل مختلف كل مرة، وهذه علامة حالة السباق المميزة. ففي الجولة الثانية مثلاً بيعت 42 علبة ولكن المخزون نقص 5 فقط، أي أن 37 عملية بيع ضاعت تحديثاتها Lost Updates: عمليتان قرأتا 50 في نفس الوقت، وكتبت كل واحدة 49. وفي نفس الوقت رفض المتجر 58 طلباً بحجة نفاد المخزون رغم أن فيه 45 علبة، والسبب أن file_put_contents تفرغ الملف أولاً ثم تكتب، فالعملية التي تقرأ في تلك اللحظة تجد ملفاً فارغاً، و(int) '' تساوي صفراً كما رأينا في المقال الثاني. أي أن نفس الخطأ باع أكثر مما يجب ورفض عملاء كان يجب أن يبيعهم.
والإصلاح هو أن تجعل القراءة والتحقق والكتابة عملية واحدة لا يدخلها إلا ابن واحد في كل لحظة، وأبسط طريقة لذلك مع الملف قفل حصري بـ flock:
<?php
// The same steps inside an exclusive lock: one worker at a time between read and write.
$fp = fopen('/shared/stock.txt', 'c+');
flock($fp, LOCK_EX);
$stock = (int) stream_get_contents($fp);
usleep(2_000);
if ($stock > 0) {
ftruncate($fp, 0);
rewind($fp);
fwrite($fp, (string) ($stock - 1));
fflush($fp);
echo "sold\n";
} else {
http_response_code(409);
echo "out of stock\n";
}
flock($fp, LOCK_UN);
fclose($fp);stock-lock multi n=100 709.3 ms 200x50 409x50
final stock: 0
stock-lock multi n=100 1255.6 ms 200x50 409x50
final stock: 0
stock-lock multi n=100 1004.2 ms 200x50 409x50
final stock: 0
والنتيجة صحيحة في الجولات الثلاث: 50 بيعاً و50 رفضاً ومخزون صفر. والثمن ظاهر في الزمن، فالطلبات صارت تمر على هذا الجزء واحداً بعد الآخر، ومئة طلب مع انتظار 2 مللي ثانية لكل واحد تعني 200 مللي ثانية على الأقل مهما كان عدد الأبناء. ولاحظ أن fopen بالوضع c+ لا يفرغ الملف عند الفتح، بعكس w، وإلا لعادت مشكلة الملف الفارغ قبل أن نأخذ القفل.
والقفل على ملف يعمل فقط عندما يكون كل الأبناء على نفس السيرفر ونفس نظام الملفات، أما في متجر حقيقي بسيرفرين أو أكثر فالمخزون في قاعدة البيانات، والقفل يكون هناك: إما تحديث ذري واحد مثل UPDATE products SET stock = stock - 1 WHERE id = ? AND stock >= 1 ثم التحقق من عدد الصفوف التي تغيرت، أو قفل الصف داخل معاملة Transaction بـ SELECT ... FOR UPDATE. وهذا بالضبط ما سوف نبنيه في المقال التالي في خدمة الطلبات بـ Laravel وPostgreSQL، مع اختبار يرسل 20 طلباً متزامناً على منتج مخزونه 10 ويتحقق أن 10 فقط نجحت.
الخوادم التي تبقي التطبيق في الذاكرة: FrankenPHP وSwoole وRoadRunner
كل ما سبق مبني على نموذج FPM حيث يبدأ كل طلب من الصفر، وهذا النموذج يدفع في كل طلب ثمن تحميل الإطار: قراءة الإعدادات، وتسجيل مئات الخدمات في الحاوية Service Container، وبناء المسارات. ومع Laravel قد يكون هذا الجزء أطول من منطق الطلب نفسه. لذلك ظهرت سيرفرات تحمل التطبيق مرة واحدة وتبقيه في الذاكرة، ثم تمرر إليه الطلبات واحداً بعد الآخر:
- FrankenPHP في وضع Worker: سيرفر مكتوب بـ Go مبني على Caddy، يشغل PHP بخيوط داخل عملية واحدة ببناء ZTS، ويستطيع أن يعمل بالنموذج التقليدي أو بوضع Worker الذي يبقي التطبيق محملاً.
- Swoole: إضافة بلغة C تضيف إلى PHP سيرفراً وحلقة أحداث وCoroutines على مستوى المحرك، وتجعل حتى بعض الدوال الحاجزة غير حاجزة.
- RoadRunner: سيرفر بـ Go يدير مجموعة من عمليات PHP الطويلة ويمرر لها الطلبات.
- Laravel Octane: طبقة من Laravel تشغل التطبيق فوق FrankenPHP أو Swoole أو RoadRunner.
وهذه السيرفرات تعطيك زمن استجابة أقل بوضوح للطلبات الصغيرة، ولكنها تعيد إليك كل مشاكل الحالة المشتركة التي يعرفها مطورو Node.js وJava: المتغير static والـ Singleton يعيشان بين الطلبات، فبيانات مستخدم قد تتسرب إلى طلب مستخدم آخر، والكاش الذي يكبر يتراكم كما رأينا في Worker المقال الثاني، واتصال قاعدة البيانات الذي انقطع يبقى مقطوعاً حتى تعيد فتحه. لذلك لا تنقل تطبيقاً قائماً إليها قبل أن تراجع كل حالة مشتركة فيه، وقبل أن تقيس أن زمن تحميل الإطار هو فعلاً المشكلة.
ثلاث مشكلات ظهرت في التجربة Lab Problems
المشكلة الأولى كانت في أول تشغيل لتجربة السباق: أرسلنا 100 طلب إلى النسخة بدون قفل، فنجحت كلها برد 200، وبقي المخزون 50 كما هو. أي أن المتجر «باع» مئة علبة ولم ينقص شيء. والسبب أن الـ Volume المشترك أنشأه Docker بمالك root، وعمليات FPM تعمل بالمستخدم www-data:
-rw-r--r-- 1 root root 3 Oct 31 09:14 /shared/stock.txt
multi n=100 715.0 ms 200x100
final stock: 50
-- fix: chown the volume to www-dataفكانت file_put_contents تفشل في كل طلب، وتطبع تحذيراً يذهب إلى سجل FPM، وتعيد false، والكود لا يتحقق منها فيطبع sold. وهذا تطبيق حي لما شرحناه في المقال الثاني عن التحذيرات التي لا توقف الكود: لو كان معالج الأخطاء يحول التحذيرات إلى استثناءات لكان كل طلب فشل برد 500 واضح. والحل كان chown للمجلد، وفي الإنتاج اجعل المالك وصلاحيات الكتابة جزءاً من الـ Dockerfile أو إعداد النشر، وتحقق دائماً من قيمة file_put_contents.
المشكلة الثانية ظهرت في أول تشغيل لـ io.php، حيث كتبنا نسخة الألياف بالمتغير القديم $http_response_header لنقرأ رمز الرد، فطبع PHP 8.5 هذا التحذير في كل تشغيل، حتى في الطرق التي لا تستخدم الألياف:
Deprecated: The predefined locally scoped $http_response_header variable is deprecated, call http_get_last_response_headers() instead in /app/io.php on line 60وهذا المتغير كان يظهر سحرياً في النطاق المحلي بعد كل استدعاء لـ file_get_contents على رابط، وأهمل في PHP 8.5 لصالح الدالة http_get_last_response_headers() التي أضيفت في 8.4، والتحذير يظهر عند ترجمة الملف وليس عند تنفيذ السطر، ولهذا ظهر في كل الطرق. وهو مثال على ما يعنيه E_DEPRECATED: الكود يعمل اليوم، وسوف يتوقف في إصدار قادم، والإنتاج لن يريك هذا التحذير لأنه يستثنيه من السجلات.
والمشكلة الثالثة كانت مع AMPHP عندما رفعنا عدد الطلبات إلى 200 في إحدى الجولات الأولى، فتوقف البرنامج بهذا الخطأ:
Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 2097152 bytes) in /app/vendor/amphp/amp/src/TimeoutCancellation.php on line 27
Stack trace:
#0 /app/vendor/amphp/amp/src/TimeoutCancellation.php(27): debug_backtrace(2)
#1 {main}والسطر 27 في المكتبة هو \assert((bool) ($trace = \debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS)));، أي أن المكتبة تحفظ مسار الاستدعاء لكل مهلة Timeout لتساعدك في التصحيح، ولكن فقط عندما تكون التأكيدات Assertions مفعلة. والـ Container لا يملك ملف php.ini، فقيمة zend.assertions هي 1، أي وضع التطوير، وتأكدنا من ذلك بـ ini_get. وعندما شغلنا نفس الأمر بـ -d zend.assertions=-1 انتهى دون مشكلة، وفي الجولات اللاحقة لم يتكرر التوقف بنفس العدد لأن الاستهلاك كان قريباً من الحد. والدرس: مكتبات PHP الجادة تستخدم assert للتصحيح بثمن في الذاكرة والزمن، فلا تقس أي أداء دون ملف php.ini-production أو ما يعادله، وقد رأينا في المقال الثاني أنه يضع zend.assertions = -1.
ماذا يحدث إذا زدت الأبناء إلى 50؟ What If
رأيت في قياس FPM أن رفع الأبناء من 5 إلى 20 خفض الزمن من حوالي 1300 إلى حوالي 600 مللي ثانية، فماذا لو رفعناه إلى 50؟ النتيجة في نفس القياس كانت بين 408 و578، أي تحسن أقل بكثير من رفع العدد مرتين ونصف، وطابور الانتظار لم يختف بل نزل إلى 44. والسبب أن الأبناء ليسوا المورد الوحيد: Nginx يفتح اتصال FastCGI لكل طلب، والـ Container محدود بمعالجين، والجهاز كله مشغول، و100 طلب تصل في نفس اللحظة. وفي الإنتاج يأتي فوق ذلك ثمن الذاكرة الذي ذكرناه، وثمن قاعدة البيانات: 50 ابناً يعني حتى 50 اتصالاً متزامناً بقاعدة البيانات من سيرفر واحد، وقد يكون هذا أكثر مما تتحمله قاعدة بيانات صغيرة. لذلك الرقم الصحيح لـ max_children يأتي من قياس تطبيقك وليس من رفع الرقم حتى يختفي الطابور.
جدول القرار Decision Table: أي نموذج لأي مهمة؟
| المهمة | الحل المناسب في PHP | ما لا يفيد |
|---|---|---|
| طلبات ويب كثيرة متزامنة | PHP-FPM مع max_children محسوب من الذاكرة | خيوط داخل الكود |
| ثلاث خدمات خارجية في طلب واحد | curl_multi أو Http::pool أو Guzzle Promises | Fibers مع دوال حاجزة |
| آلاف الاتصالات الطويلة مثل WebSocket | AMPHP أو ReactPHP أو Swoole في عملية طويلة | PHP-FPM |
| حساب ثقيل في سكربت ليلي | طابور مع عدة Workers، أو pcntl_fork في سطر الأوامر | Fibers |
| حساب ثقيل داخل عملية طويلة واحدة | parallel مع بناء ZTS | التفرع مع اتصالات مفتوحة |
| تحديث مورد مشترك مثل المخزون | تحديث ذري أو قفل صف في قاعدة البيانات | قراءة ثم كتابة بدون قفل |
| زمن تحميل الإطار في كل طلب | OPcache أولاً، ثم FrankenPHP Worker أو Octane بعد مراجعة الحالة المشتركة | زيادة الأبناء |
أخطاء شائعة Common Mistakes
- الاعتقاد أن PHP آمن من حالات السباق لأنه لا يستخدم خيوطاً: عمليات FPM تتسابق على قاعدة البيانات والملفات والكاش.
- كتابة كود بـ Fibers واستدعاء دوال حاجزة داخله، ثم توقع أنه صار متزامناً.
- رفع
pm.max_childrenدون حساب الذاكرة واتصالات قاعدة البيانات. - القياس مع
zend.assertions = 1أو مع Xdebug مفعل، ثم الحكم على أداء مكتبة. - التفرع بعد فتح اتصال قاعدة البيانات، فيتشارك الأبناء نفس الاتصال.
- تجاهل قيمة
file_put_contentsوfwrite، فيمر فشل الكتابة كأنه نجاح. - نقل تطبيق إلى Octane أو FrankenPHP Worker دون مراجعة المتغيرات
staticوالـ Singletons. - قياس الأداء على جهاز مشغول برقم واحد دون تكرار، ثم بناء قرار على فرق صغير.
تدريب عملي Practice
- غير
pmفيwww.confإلىdynamicمعpm.start_servers = 2وpm.max_spare_servers = 5، وأعد قياس 100 طلب، واقرأmax children reachedفي صفحة الحالة. - استبدل الملف في تجربة السباق بجدول PostgreSQL، وجرب التحديث الذري بشرط
stock >= 1بدلflock. - أضف إلى
io.phpطريقة خامسة بـ ReactPHP، وقارن زمنها بـ AMPHP وcurl_multi.
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- PHP يخدم الويب المتزامن بعمليات PHP-FPM مستقلة، وكل ابن يخدم طلباً واحداً، والباقي ينتظر في طابور. ومع 5 أبناء أخذت 100 طلب من 50 مللي ثانية بين 1231 و1358 مللي ثانية، قريباً من الحد النظري 1000.
- الـ Fibers تتوقف وتكمل في خيط واحد، ووحدها لم تغير زمن 100 طلب حاجز، فبقي بين 5.9 و7.0 ثوان مثل المتتابع.
- التزامن الحقيقي للشبكة يأتي من الانتظار غير الحاجز:
curl_multiبين 160 و672 مللي ثانية، وAMPHP مع Revolt بين 884 و2483. - للحساب الثقيل تحتاج عمليات أو خيوطاً: التفرع والخيوط على 4 أنوية كانت أسرع من المتتابع بضعفين إلى ثلاثة على جهاز مشغول، والـ Fibers لم تساعد.
- عشرون عملية FPM تتسابق على المخزون مثل عشرين خيطاً، فضاعت تحديثات ورفض عملاء بالخطأ، والقفل أعاد 50 بيعاً بالضبط في كل جولة.
- السيرفرات التي تبقي التطبيق في الذاكرة تقلل الزمن، وتعيد مشاكل الحالة المشتركة.
وفي المقال التالي من المرحلة نبني خدمة الطلبات الكاملة بـ Laravel وPostgreSQL 17: معاملة واحدة تقفل الصفوف وتتحقق من المخزون وتحسب الضريبة بالهللة، مع اختبارات وحدة وتكامل واختبار تزامن، وDockerfile، وقياس للأداء والذاكرة.
دليلPHP لمن يعرف البرمجة: الصيغة والأدوات Toolchain في يومدليل عملي للمطور القادم من Python أو JavaScript يثبت PHP 8.5 ويقارن صيغتها بما يعرفه ويشغل Composer وPint وPHPStan وPHPUnit على مشروع صغير بمخرجات حقيقية.
دليلالأنواع Types والذاكرة والأخطاء في PHP 8 بتجارب مقيسةنحسب ضريبة مئة ألف سعر بطريقتين ونقيس جامع الدورات وذاكرة Worker طويل ونشغل كل نوع من أنواع الفشل في PHP 8.5، لترى أين يخطئ من يكتب PHP بعقل لغة أخرى.
دليلخدمة حقيقية بـ PHP: Laravel مع PostgreSQL والاختباراتبنينا خدمة طلبات لمتجر صغير بـ Laravel 13 وPostgreSQL 17 بمعاملة تقفل المخزون، مع اختبار يرسل 20 طلباً متزامناً على 10 علب، ثم Docker Image بمرحلتين وقياس للأداء كشف ثمن الاتصال بقاعدة البيانات.
دليلأخطاء شائعة وأسلوب PHP الحديث Modern PHPثلاثة عشر خطأ يقع فيها المطور القادم إلى PHP من لغة أخرى، كل خطأ بتشغيل حقيقي على PHP 8.5 ثم الإصلاح، مع ما كشفه PHPStan منها وخطة قراءة من المصادر الرسمية.
دليلThreads وasync/await: كود لا يتجمد ولا يضيع بياناتهكيف تكتب كوداً يعمل في أكثر من خيط Thread دون أن تضيع بياناته أو يتجمد، ولماذا تظهر هذه الأخطاء على السيرفر ولا تظهر على جهازك.
دليلحلقة الأحداث Event Loop: لماذا تتأخر النقرة في صفحتك؟كيف ترتب JavaScript المهام والوعود والرسم على خيط واحد، ولماذا تنتظر النقرة خلف مهمة طويلة، وكيف تقسم العمل لتبقى الصفحة سريعة.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتنفيذ التجارب بـ PHP 8.5.12 (NTS وZTS) وparallel 1.2.15 وAMPHP 3.1.3 مع http-client 5.3.6 وRevolt 1.0.9، وNginx مع PHP-FPM داخل Docker.