> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# التزامن Concurrency في PHP: عمليات FPM والألياف Fibers
- URL: https://arabroot.io/articles/التزامن-في-php-fpm-و-fibers-و-parallel/
- Published: 2026-10-31T08:39:01.000Z
- Updated: 2026-10-31T11:08:58.000Z
- Description: قسنا مئة طلب HTTP وأربعة ملايين عملية SHA-256 بعمليات PHP-FPM وFibers وAMPHP والتفرع والخيوط، ثم صنعنا حالة سباق على المخزون بين عمليات FPM وأصلحناها بالقفل.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, لغات البرمجة عملياً

في المقالين السابقين من هذه المرحلة كتبنا كود المتجر الصغير الذي يبيع القهوة والتمور، وصححنا أنواعه وأخطاءه، والآن يقترب موسم التخفيضات، ويطلب منك الفريق ثلاثة أشياء قبله: صفحة إتمام الطلب 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 للمطورين](https://arabroot.io/start/#stage-lang-php)، وسوف نقيس فيه كل نموذج بنفس المهمتين التي نقيس بها كل لغات المسار: مئة طلب HTTP إلى خدمة بطيئة يرد كل طلب منها بعد 50 مللي ثانية، وأربعة ملايين عملية SHA-256، ثم نصنع حالة سباق Race Condition حقيقية بين عمليات PHP-FPM ونصلحها. والمفاهيم العامة للخيوط والأقفال شرحناها في مقال [Threads وasync/await](https://arabroot.io/articles/%D8%A7%D9%84%D8%AE%D9%8A%D9%88%D8%B7-threads-%D9%88%D8%A7%D9%84%D8%A3%D9%82%D9%81%D8%A7%D9%84-%D9%88-async-%D9%84%D9%84%D9%85%D8%A8%D8%B1%D9%85%D8%AC%D9%8A%D9%86/)، وحلقة الأحداث في [حلقة الأحداث Event Loop](https://arabroot.io/articles/%D8%AD%D9%84%D9%82%D8%A9-%D8%A7%D9%84%D8%A7%D8%AD%D8%AF%D8%A7%D8%AB-event-loop-%D9%81%D9%8A-javascript/)، لذلك سوف نركز هنا على 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: الملفات وقاعدة البيانات والكاش، وهذا ما سوف نراه في حالة السباق.

![أربعة صناديق للنماذج: عمليات PHP-FPM بعدد ثابت كل عملية تخدم طلباً واحداً وتناسب الويب العادي، وFibers مع حلقة أحداث في عملية وخيط واحد تناسب مئات الطلبات الخارجية، والتفرع pcntl_fork لنسخ العملية إلى أبناء تناسب سكربتات سطر الأوامر، والخيوط parallel في نسخة ZTS تناسب الحساب الثقيل داخل عملية طويلة](https://arabroot.io/content/images/2026/10/php-concurrency-01-models.webp)

أربع طرق للتزامن في PHP، ولكل واحدة مكانها

## بيئة التجربة Lab Setup: خدمة بطيئة وجهاز مشغول

حتى تكون المقارنة عادلة بين اللغات نحتاج خدمة ترد بعد 50 مللي ثانية بالضبط، ولا تكون هي عنق الزجاجة. وكتبناها بـ Node.js في ملف واحد، لأن حلقة الأحداث فيه تخدم مئات الاتصالات المتزامنة دون جهد، وتكتب الترويسات Headers والجسم Body في عملية كتابة واحدة على الـ Socket. وهذه النقطة الأخيرة مهمة أكثر مما تبدو: الخدمة التي تكتب الترويسات ثم الجسم في كتابتين منفصلتين قد تتأخر 40 مللي ثانية إضافية بسبب تفاعل خوارزمية Nagle مع الإقرار المتأخر Delayed ACK في TCP، فيصبح كل طلب 90 مللي ثانية بدل 50 وتفسد كل القياسات. لذلك قبل أي قياس، قس طلباً واحداً:

```javascript
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'));
```

```text
== 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، حتى يتحرر أحدهم.

![مئة طلب تصل إلى Nginx الذي يمررها عبر FastCGI إلى PHP-FPM فيه عملية رئيسية وخمسة أبناء كل واحد يخدم طلباً مدته 50 مللي ثانية، وتحتهم طابور الانتظار فيه طلبات صفراء كثيرة، وتحتها القاعدة: الزمن الأدنى لدفعة الطلبات يساوي عدد الطلبات مقسوماً على عدد الأبناء مضروباً في زمن الطلب، والثمن أن كل ابن عملية كاملة بذاكرتها](https://arabroot.io/content/images/2026/10/php-concurrency-02-fpm.webp)

كل ابن يخدم طلباً واحداً في كل لحظة، والباقي ينتظر في الطابور

والإعدادات الأساسية في ملف الـ Pool، وهي موثقة في [صفحة إعدادات FPM](https://www.php.net/manual/en/install.fpm.configuration.php?ref=arabroot.io). وفي التجربة استخدمنا ملفاً صغيراً يقرأ عدد الأبناء من متغير بيئة، لأن FPM يستبدل `${VAR}` في ملفاته بقيمة المتغير:

```ini
[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
<?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`، ثلاث مرات لكل إعداد، ثم قرأنا صفحة الحالة. والمخرج سوف يكون كما يلي:

```text
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 أبناء الزمن المقيس بين 1231 و1358 مللي ثانية والحد النظري 1000، ومع 20 ابناً بين 594 و671 والحد النظري 250، ومع 50 ابناً بين 408 و578 والحد النظري 100، وبجانب كل شريط طول الطابور الأقصى 90 و64 و44 والذاكرة 12.8 و18.7 و31.1 ميجابايت](https://arabroot.io/content/images/2026/10/php-concurrency-04-fpm-results.webp)

عدد الأبناء يحدد الزمن عندما يكون قليلاً، وبعد ذلك تظهر تكاليف أخرى

في المخرج أعلاه لاحظ التالي:

- مع 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](https://www.php.net/manual/en/install.fpm.configuration.php?ref=arabroot.io). وهذان المصدران معاً يجيبان على السؤال الذي يأتي قبل أي تغيير في عدد الأبناء: هل المشكلة أن الطلبات تنتظر دورها، أم أن كل طلب بطيء بنفسه؟ فالحل في الحالة الأولى أبناء أكثر أو سيرفر أكبر، وفي الثانية زيادة الأبناء تجعل الأمر أسوأ لأنها تضيف ضغطاً على الشيء البطيء نفسه.

## الألياف Fibers: دالة تتوقف وتكمل

أضاف PHP 8.1 الكلاس [Fiber](https://www.php.net/manual/en/class.fiber.php?ref=arabroot.io) حسب [مقترح Fibers](https://wiki.php.net/rfc/fibers?ref=arabroot.io)، وهو أقرب شيء في PHP إلى الـ Coroutines. والـ Fiber دالة تبدأ بـ `start`، وتستطيع في أي سطر داخلها أن تستدعي `Fiber::suspend()` فتعيد التحكم إلى من شغلها مع قيمة، ثم تكمل من نفس السطر عندما يستدعي أحد `resume`. والفرق المهم عن `async` في JavaScript أن الدالة التي تتوقف لا تحتاج أن تعلن ذلك في تعريفها، فلا توجد مشكلة «لون الدالة» التي تجبرك أن تجعل كل من يستدعي دالة `async` مثلها. وحتى نرى ترتيب التنفيذ كتبنا الملف `fiber.php`، فيه طلبان A وB، كل واحد يتوقف عند انتظار بوابة الدفع، ثم ينتظر 20 مللي ثانية بـ `usleep`:

```php
$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());
```

والمخرج سوف يكون كما يلي:

```text
   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
```

![خط زمني بثلاثة مسارات: main يبدأ وينتهي، وFiber A يبدأ ثم يتوقف ثم يعمل من 0.3 حتى 20.5 مللي ثانية، وFiber B يبدأ ثم يتوقف ثم يعمل من 20.5 حتى 45.8، وتحته شرح: suspend يعيد التحكم ومعه قيمة، والانتظار بـ usleep أو file_get_contents داخل Fiber يوقف العملية كلها فانتهى B بعد A وليس معه](https://arabroot.io/content/images/2026/10/php-concurrency-03-fiber.webp)

الـ Fibers تتناوب، ولا تعمل في نفس اللحظة

في المخرج أعلاه لاحظ التالي:

- الرسالتان `A waits` و`B waits` ظهرتا في `main`، أي أن `suspend` أعاد القيمة إلى من استدعى `start`، وهكذا تتواصل المكتبة التي تجدول الألياف مع الألياف نفسها.
- B انتهى عند 45.8 مللي ثانية وليس عند 20.5 مع A، لأن `usleep` يوقف العملية كلها، والألياف تعمل في خيط واحد، فلا يعمل B إلا بعد أن يتوقف A أو ينتهي.
- لا يوجد أي شيء في PHP نفسه يقرر متى يكمل كل Fiber، فهذا عمل المجدول Scheduler الذي تكتبه أنت أو مكتبة مثل [Revolt](https://revolt.run/?ref=arabroot.io)، ولهذا تسمى الجدولة تعاونية 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](https://www.php.net/manual/en/function.curl-multi-exec.php?ref=arabroot.io) وهي أقدم طريقة للتزامن في PHP، حيث تضيف مئة مقبض إلى مقبض واحد وتدير نقلها كلها من حلقة واحدة:

```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](https://amphp.org/?ref=arabroot.io) بإصدارها الثالث، وهو مكتبة مبنية على Fibers وعلى حلقة الأحداث Revolt، ومعها [عميل HTTP غير حاجز](https://amphp.org/http-client?ref=arabroot.io):

```php
$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 آخر، وتكمله عندما يصل رده. وشغلنا الطرق الأربع ثلاث جولات:

```text
== 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 مللي ثانية، والـ Fibers وحدها بين 5939 و7017، وAMPHP بين 884 و2483، وcurl_multi بين 160 و672، وتحتها شرح أن الـ Fibers وحدها لم تغير شيئاً لأن كل استدعاء يوقف العملية، وأن curl_multi وAMPHP ينتظران الطلبات معاً](https://arabroot.io/content/images/2026/10/php-concurrency-05-io-results.webp)

الـ Fibers وحدها لا تجعل الشبكة متزامنة، والذي يفعل ذلك هو الانتظار غير الحاجز

في المخرج أعلاه لاحظ التالي:

- المتتابع بين 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](https://reactphp.org/?ref=arabroot.io) بنى نفس الفكرة قبل الألياف على 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` تحتاج أن تحمله بنفسها كما سوف نرى:

```php
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](https://www.php.net/manual/en/function.pcntl-fork.php?ref=arabroot.io) تكتب كل واحدة نتيجتها في ملف مؤقت، وأربعة خيوط بإضافة [parallel](https://www.php.net/manual/en/book.parallel.php?ref=arabroot.io) التي تحتاج بناء ZTS:

```php
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 العادي:

```text
== 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
```

![أربعة أشرطة للزمن: المتتابع بين 7069 و9115 مللي ثانية، وأربع Fibers بين 6757 و6985، وأربعة خيوط parallel بين 3296 و4114، وأربع عمليات pcntl_fork بين 2598 و3661، مع ملاحظة أن النتيجة check واحدة في كل الطرق والتسريع ضعفان إلى ثلاثة](https://arabroot.io/content/images/2026/10/php-concurrency-06-cpu-results.webp)

للحساب الثقيل تحتاج عمليات أو خيوطاً على أنوية مختلفة، والـ Fibers لا تساعد

في المخرج أعلاه لاحظ التالي:

- القيمة `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](https://www.php.net/manual/en/opcache.configuration.php?ref=arabroot.io). وشغلنا المتتابع ثلاث جولات، في كل جولة مرة بدون OPcache ومرة مع JIT بوضع `tracing`:

```bash
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
```

```text
-- 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](https://arabroot.io/articles/%D8%A7%D9%84%D9%85%D8%AA%D8%B1%D8%AC%D9%85-%D9%88%D8%A7%D9%84%D9%85%D9%81%D8%B3%D8%B1-%D9%88-jit-%D9%88-webassembly-%D8%A8%D8%A7%D9%84%D8%AA%D9%81%D8%B5%D9%8A%D9%84/).

## حالة السباق Race Condition بين عمليات PHP-FPM

الآن نعود إلى علب الهيل التي بيعت أكثر من المخزون. قد يظن القادم من Go أو Java أن PHP محمي من حالات السباق لأنه لا يملك خيوطاً، ولكن عشرين عملية FPM تقرأ وتكتب نفس المورد المشترك هي بالضبط عشرون خيطاً من وجهة نظر ذلك المورد. وحتى نراها كتبنا أبسط نسخة من «شراء علبة»: نقرأ المخزون من ملف، ثم ننتظر 2 مللي ثانية تمثل التحقق من المخزون في قاعدة البيانات أو خدمة أخرى، ثم نكتب القيمة الجديدة:

```php
<?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 ومخزون نهائي صفر:

```text
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](https://www.php.net/manual/en/function.flock.php?ref=arabroot.io):

```php
<?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);
```

```text
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 وتنتظران ثم تكتب كل منهما 49، فبيعت 18 و42 و26 علبة في ثلاث جولات والمخزون بعدها 47 و45 و45 مع ردود 409 خاطئة، ومع flock تأخذ عملية واحدة القفل وتقرأ وتكتب ثم تحرر، فبيعت 50 في كل جولة والمخزون صفر](https://arabroot.io/content/images/2026/10/php-concurrency-07-race.webp)

بدون قفل تضيع التحديثات ويرفض عملاء بالخطأ، ومع القفل النتيجة صحيحة في كل جولة

والنتيجة صحيحة في الجولات الثلاث: 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](https://frankenphp.dev/docs/worker/?ref=arabroot.io): سيرفر مكتوب بـ Go مبني على Caddy، يشغل PHP بخيوط داخل عملية واحدة ببناء ZTS، ويستطيع أن يعمل بالنموذج التقليدي أو بوضع Worker الذي يبقي التطبيق محملاً.
- [Swoole](https://swoole.com/?ref=arabroot.io): إضافة بلغة C تضيف إلى PHP سيرفراً وحلقة أحداث وCoroutines على مستوى المحرك، وتجعل حتى بعض الدوال الحاجزة غير حاجزة.
- [RoadRunner](https://roadrunner.dev/?ref=arabroot.io): سيرفر بـ Go يدير مجموعة من عمليات PHP الطويلة ويمرر لها الطلبات.
- [Laravel Octane](https://laravel.com/docs/13.x/octane?ref=arabroot.io): طبقة من Laravel تشغل التطبيق فوق FrankenPHP أو Swoole أو RoadRunner.

وهذه السيرفرات تعطيك زمن استجابة أقل بوضوح للطلبات الصغيرة، ولكنها تعيد إليك كل مشاكل الحالة المشتركة التي يعرفها مطورو Node.js وJava: المتغير `static` والـ Singleton يعيشان بين الطلبات، فبيانات مستخدم قد تتسرب إلى طلب مستخدم آخر، والكاش الذي يكبر يتراكم كما رأينا في Worker المقال الثاني، واتصال قاعدة البيانات الذي انقطع يبقى مقطوعاً حتى تعيد فتحه. لذلك لا تنقل تطبيقاً قائماً إليها قبل أن تراجع كل حالة مشتركة فيه، وقبل أن تقيس أن زمن تحميل الإطار هو فعلاً المشكلة.

## ثلاث مشكلات ظهرت في التجربة Lab Problems

المشكلة الأولى كانت في أول تشغيل لتجربة السباق: أرسلنا 100 طلب إلى النسخة بدون قفل، فنجحت كلها برد 200، وبقي المخزون 50 كما هو. أي أن المتجر «باع» مئة علبة ولم ينقص شيء. والسبب أن الـ Volume المشترك أنشأه Docker بمالك `root`، وعمليات FPM تعمل بالمستخدم `www-data`:

```text
-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 هذا التحذير في كل تشغيل، حتى في الطرق التي لا تستخدم الألياف:

```text
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 في إحدى الجولات الأولى، فتوقف البرنامج بهذا الخطأ:

```text
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، وقياس للأداء والذاكرة.

[![](https://arabroot.io/content/images/2026/10/php-for-devs-cover-cover.webp)دليلPHP لمن يعرف البرمجة: الصيغة والأدوات Toolchain في يومدليل عملي للمطور القادم من Python أو JavaScript يثبت PHP 8.5 ويقارن صيغتها بما يعرفه ويشغل Composer وPint وPHPStan وPHPUnit على مشروع صغير بمخرجات حقيقية.](https://arabroot.io/articles/php-%D9%84%D9%85%D9%86-%D9%8A%D8%B9%D8%B1%D9%81-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D8%A7%D9%84%D8%B5%D9%8A%D8%BA%D8%A9-%D9%88%D8%A7%D9%84%D8%A7%D8%AF%D9%88%D8%A7%D8%AA/)[![](https://arabroot.io/content/images/2026/10/php-types-memory-cover-cover.webp)دليلالأنواع Types والذاكرة والأخطاء في PHP 8 بتجارب مقيسةنحسب ضريبة مئة ألف سعر بطريقتين ونقيس جامع الدورات وذاكرة Worker طويل ونشغل كل نوع من أنواع الفشل في PHP 8.5، لترى أين يخطئ من يكتب PHP بعقل لغة أخرى.](https://arabroot.io/articles/%D8%A7%D9%84%D8%A7%D9%86%D9%88%D8%A7%D8%B9-%D9%88%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-%D9%88%D8%A7%D9%84%D8%A7%D8%AE%D8%B7%D8%A7%D8%A1-%D9%81%D9%8A-php-8/)[![](https://arabroot.io/content/images/2026/10/php-real-service-cover-cover.webp)دليلخدمة حقيقية بـ PHP: Laravel مع PostgreSQL والاختباراتبنينا خدمة طلبات لمتجر صغير بـ Laravel 13 وPostgreSQL 17 بمعاملة تقفل المخزون، مع اختبار يرسل 20 طلباً متزامناً على 10 علب، ثم Docker Image بمرحلتين وقياس للأداء كشف ثمن الاتصال بقاعدة البيانات.](https://arabroot.io/articles/%D8%AE%D8%AF%D9%85%D8%A9-%D8%AD%D9%82%D9%8A%D9%82%D9%8A%D8%A9-%D8%A8-php-laravel-%D9%88-postgresql/)[![](https://arabroot.io/content/images/2026/10/php-idioms-cover-cover.webp)دليلأخطاء شائعة وأسلوب PHP الحديث Modern PHPثلاثة عشر خطأ يقع فيها المطور القادم إلى PHP من لغة أخرى، كل خطأ بتشغيل حقيقي على PHP 8.5 ثم الإصلاح، مع ما كشفه PHPStan منها وخطة قراءة من المصادر الرسمية.](https://arabroot.io/articles/%D8%A7%D8%AE%D8%B7%D8%A7%D8%A1-%D8%B4%D8%A7%D8%A6%D8%B9%D8%A9-%D9%81%D9%8A-php-%D9%88%D8%A7%D8%B3%D9%84%D9%88%D8%A8-php-%D8%A7%D9%84%D8%AD%D8%AF%D9%8A%D8%AB/)[![](https://arabroot.io/content/images/2026/10/threading-cover-cover.webp)دليلThreads وasync/await: كود لا يتجمد ولا يضيع بياناتهكيف تكتب كوداً يعمل في أكثر من خيط Thread دون أن تضيع بياناته أو يتجمد، ولماذا تظهر هذه الأخطاء على السيرفر ولا تظهر على جهازك.](https://arabroot.io/articles/%D8%A7%D9%84%D8%AE%D9%8A%D9%88%D8%B7-threads-%D9%88%D8%A7%D9%84%D8%A3%D9%82%D9%81%D8%A7%D9%84-%D9%88-async-%D9%84%D9%84%D9%85%D8%A8%D8%B1%D9%85%D8%AC%D9%8A%D9%86/)[![](https://arabroot.io/content/images/2026/10/js-event-loop-cover-cover.webp)دليلحلقة الأحداث Event Loop: لماذا تتأخر النقرة في صفحتك؟كيف ترتب JavaScript المهام والوعود والرسم على خيط واحد، ولماذا تنتظر النقرة خلف مهمة طويلة، وكيف تقسم العمل لتبقى الصفحة سريعة.](https://arabroot.io/articles/%D8%AD%D9%84%D9%82%D8%A9-%D8%A7%D9%84%D8%A7%D8%AD%D8%AF%D8%A7%D8%AB-event-loop-%D9%81%D9%8A-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.