في مقال PHP لمن يعرف البرمجة بدأنا العمل على متجر صغير يبيع القهوة والتمور، وكتبنا دالة الضريبة واختبارها، ورأينا أن PHPStan كشف منتجاً غير موجود كان PHP يحسب سعره صفراً بصمت. والآن في الأسبوع الثاني تصلك من فريق الدعم ثلاث شكاوى في يوم واحد: عميل دفع ثمن علبة هيل بسعر 1.50 ريال وظهرت ضريبتها في الفاتورة 22 هللة بينما يقول المحاسب إنها 23، وطلب وصل من تطبيق الجوال فارغاً تماماً دون أي رسالة خطأ في السجلات، وعملية Worker تعالج الطلبات في الخلفية يزيد استهلاكها للذاكرة كل يوم حتى يعيد أحدهم تشغيلها. وهذه الشكاوى الثلاث لها نفس الجذر: أنت تكتب PHP بعقل لغة أخرى، ولم تتعرف بعد على طريقة PHP في الأنواع Types، وفي إدارة الذاكرة Memory Management، وفي الأخطاء Errors.
وهذا المقال هو الثاني في مرحلة لغة PHP للمطورين، وسوف نأخذ فيه كل شكوى من الثلاث إلى جذرها بتجربة تعمل فعلاً على PHP 8.5.12: نحسب الضريبة لمئة ألف سعر بطريقتين ونعد الفروق، ونصنع مئتي ألف كائن في دورات ونقيس الذاكرة مع جامع الدورات وبدونه، ونشغل Worker يعالج مئتي ألف مهمة ونرى ذاكرته تكبر، ثم نشغل كل نوع من أنواع الفشل في PHP ونقرأ ما يطبعه. والمفاهيم العامة شرحناها في مقالات مستقلة: أنظمة الأنواع في أنماط البرمجة وأنظمة الأنواع، وطرق إدارة الذاكرة في إدارة الذاكرة، وأساليب الأخطاء في كيف تتعامل لغات البرمجة مع الأخطاء، لذلك سوف نركز هنا على ما يخص PHP فقط.
وسوف نناقش في هذا المقال ما يلي:
- كيف يمثل PHP القيم من الداخل، ولماذا هو ديناميكي الأنواع ويفحص الأنواع وقت التشغيل في نفس الوقت.
- الأرقام Numbers: تجاوز الحد Overflow، والأرقام العشرية float، وتجربة الضريبة على مئة ألف سعر، والتقريب Rounding.
- النصوص الرقمية Numeric Strings والتحويل Casting، ثم التصريحات Type Declarations: المتحد والتقاطع وDNF و
neverوالخصائص ذات النوع والتعداد. - الأنواع العامة Generics التي لا توجد إلا في PHPStan.
- من يحرر الذاكرة: عداد المراجع Reference Counting وجامع الدورات Cycle Collector مقيسين بالأرقام، و
memory_limit، وWeakMap. - العمليات الطويلة Long-Running Workers وتسرب الذاكرة فيها.
- الأخطاء: التحذير Warning والخطأ Error والاستثناء Exception، وكيف تحول التحذيرات إلى استثناءات، و
finallyو#[\NoDiscard]، وإعدادات الأخطاء في التطوير والإنتاج. - مشكلتان ظهرتا في التجربة، وتجربة «ماذا يحدث إذا»، وأخطاء شائعة، وقائمة تحقق.
كيف يمثل PHP القيمة Value من الداخل؟
كل قيمة في PHP، سواءً كانت رقماً أو نصاً أو مصفوفة أو كائناً، تحفظ داخل المحرك في بنية صغيرة اسمها zval، وفيها شيئان: علامة النوع Type Tag تقول هذا int أو string أو array، والقيمة نفسها أو مؤشر إليها. والأرقام الصحيحة والعشرية والقيم المنطقية تحفظ داخل الـ zval مباشرة، أما النصوص والمصفوفات والكائنات فتحفظ في مكان آخر في الذاكرة ومعها عداد مراجع Reference Count يقول كم متغيراً يشير إليها، وهذا ما يشرحه توثيق أساسيات عداد المراجع.
وهذا التصميم يفسر لك صفتين في PHP قد تبدوان متعارضتين. الأولى أن PHP ديناميكي الأنواع Dynamically Typed: المتغير نفسه ليس له نوع، وإنما القيمة التي فيه، فيمكن أن يحمل $x رقماً ثم نصاً. والثانية أن PHP يفحص الأنواع وقت التشغيل Runtime Type Checking: عندما تكتب function addHalalas(int $a) فالمحرك ينظر إلى علامة النوع في الـ zval الذي وصل، ويرفضه إذا لم يكن int. وهنا يختلف PHP عن Python التي لا تفحص التلميحات أبداً، وعن TypeScript التي تفحص قبل التشغيل ثم تمحو الأنواع، فـ PHP يقع في المنتصف: لا يوجد فحص قبل التشغيل إلا بأداة خارجية مثل PHPStan، ولكن كل دالة عليها أنواع تتحقق منها في كل استدعاء.

الأرقام Numbers: int وfloat وما بينهما
في PHP نوعان من الأرقام فقط: int وهو رقم صحيح بحجم 64 بت على كل الأنظمة الحديثة، وfloat وهو رقم عشري بمعيار IEEE 754 بدقة مضاعفة Double Precision، ولا يوجد decimal مدمج كما في C# أو BigInteger كما في Java. وأول ما شغلناه في الملف types.php كان حدود هذين النوعين:
var_dump(PHP_INT_MAX + 1);
var_dump(0.1 + 0.2 === 0.3, 9.90 * 3);
printf("%.20f\n", 9.90 * 3);
var_dump(7 / 2, intdiv(7, 2), -7 % 3, fmod(-7.5, 2));
var_dump(round(2.5), round(-2.5), round(4.455, 2));والمخرج سوف يكون كما يلي:
float(9.223372036854776E+18)
bool(false)
float(29.700000000000003)
29.70000000000000284217
float(3.5)
int(3)
int(-1)
float(-1.5)
float(3)
float(-3)
float(4.46)في المخرج أعلاه لاحظ التالي:
PHP_INT_MAX + 1لم يرم خطأ تجاوز Overflow كما في C# معchecked، ولم يلتف إلى رقم سالب كما في C وJava، وإنما تحول بصمت إلىfloat، وفقد دقته لأن الـ float يحفظ 53 بت فقط للأرقام المعنوية. وهذا السلوك موثق في صفحة الأعداد الصحيحة، ونتيجته أن معرفاً رقمياً كبيراً جاء من نظام آخر قد يتغير آخر رقمين فيه دون أي تنبيه.9.90 * 3أعطت29.700000000000003، وهذه ليست مشكلة في PHP، وإنما في تمثيل الأرقام العشرية بالنظام الثنائي في كل اللغات، وقد شرحنا السبب بالتفصيل في مقال الأرقام العشرية Floating Point من الداخل.- القسمة
/تعطيfloatدائماً عندما لا تكون القسمة تامة، والقسمة الصحيحة لها دالة مستقلةintdiv، أما Python فلها عامل//. - باقي القسمة
-7 % 3يساوي-1في PHP، أي أنه يأخذ إشارة المقسوم كما في C وJava وJavaScript، أما Python فتعطيك2. وهذا الفرق يظهر عندما تحسب يوم الأسبوع أو رقم الدفعة من رقم سالب. round(2.5)أعطت 3 وround(-2.5)أعطت -3، أي أن التقريب الافتراضي في PHP نصف بعيداً عن الصفر Half Away From Zero، بينماround(2.5)في Python تعطيك 2 لأنها تقرب إلى الزوجي Banker's Rounding. فإذا نقلت حساباً من Python إلى PHP ستختلف النتائج في كل حالة نصف بالضبط، والتفاصيل وخيارات التقريب الأخرى في توثيق round.
تجربة الضريبة VAT على مئة ألف سعر
والآن نعود إلى الشكوى الأولى: علبة الهيل بسعر 1.50 ريال وضريبتها 22 هللة. كتبنا الملف money.php الذي يحسب الضريبة بطريقتين لكل سعر من هللة واحدة حتى ألف ريال، أي مئة ألف سعر: الطريقة الأولى كما يكتبها أغلب من يبدأ، بالريال كرقم عشري ثم round لخانتين، والطريقة الثانية بالهللة كرقم صحيح مع التقريب نصف للأعلى بأرقام صحيحة فقط، وهي نفس دالة المقال الأول:
function vatFloat(float $riyals): float
{
return round($riyals * 0.15, 2);
}
function vatInt(int $halalas): int
{
return intdiv($halalas * 150 + 500, 1000);
}
$diff = 0;
$examples = [];
$total = 0;
for ($h = 1; $h <= 100_000; $h++) { // every price from 0.01 to 1000.00 SAR
$total++;
$f = (int) round(vatFloat($h / 100) * 100);
$i = vatInt($h);
if ($f !== $i) {
$diff++;
if (count($examples) < 5) {
$examples[] = sprintf('%.2f SAR: float %d, int %d (exact %s)', $h / 100, $f, $i, bcmul((string) $h, '0.15', 3));
}
}
}والمخرج سوف يكون كما يلي:
prices checked: 100000, disagreements: 1276
1.50 SAR: float 22, int 23 (exact 22.500)
3.10 SAR: float 46, int 47 (exact 46.500)
3.30 SAR: float 49, int 50 (exact 49.500)
4.10 SAR: float 61, int 62 (exact 61.500)
4.30 SAR: float 64, int 65 (exact 64.500)
9.90 x 3 = 29.700000000000003, vat float = 4.46, vat int = 446 halalas
0.1 + 0.2 = 0.30000000000000004
في المخرج أعلاه لاحظ التالي:
- اختلفت الطريقتان في 1,276 سعراً من مئة ألف، وتحققنا بسطر إضافي في التجربة أن كل هذه الحالات بلا استثناء هي الحالات التي تكون فيها الضريبة الدقيقة نصف هللة بالضبط، مثل 22.5. وفي المدى الذي جربناه يوجد 5,000 سعر من هذا النوع، أي أن الحساب بالريال أخطأ في ربعها تقريباً.
- السبب أن
1.50 * 0.15في الـ float ليست 0.225 بالضبط، وإنما رقم أصغر منها بقليل، فتقربهroundإلى الأسفل، ولو كان أكبر بقليل لقربته إلى الأعلى، ولذلك لا تستطيع أن تتوقع أي الأسعار سوف تخطئ دون أن تجربها كلها. - العمود
exactحسبناه بالدالةbcmulمن إضافة BCMath التي تحسب بالنصوص العشرية بدقة كاملة، وهي الأداة الصحيحة عندما تحتاج كسوراً دقيقة مثل نسبة عمولة بثلاث خانات، ومنذ PHP 8.4 يوجد كلاس BcMath\Number يسمح بكتابة$a * $bعليها مباشرة. - مثال المسار كله، ثلاث عبوات قهوة بـ 9.90، أعطى نفس النتيجة بالطريقتين: 4.46 و446 هللة. أي أن اختبار حالة واحدة لم يكن ليكشف المشكلة، وهذا ما يجعلها تصل إلى الإنتاج.
والقاعدة التي تخرج بها من هذه التجربة: كل مبلغ مالي في PHP رقم صحيح int بأصغر وحدة، والتحويل إلى ريالات يكون فقط عند العرض، وإذا احتجت كسوراً لا تنتهي بالهللة فاستخدم BCMath ولا تستخدم float إطلاقاً. وحتى مع int بحجم 64 بت عندك حد أعلى يقارب 92 مليون مليار هللة، وهو أكبر من أي فاتورة سوف تراها.
ونفس حد الـ 64 بت يظهر بشكل أخطر عندما يأتي الرقم من نظام آخر داخل JSON، مثل رقم طلب من بوابة دفع أو معرف من نظام ERP، لأن json_decode تحوله إلى float إذا تجاوز PHP_INT_MAX دون أي تحذير. وجربنا ذلك في الملف bigint.php برقم من عشرين خانة:
$json = '{"external_id": 92233720368547758070, "qty": 3}';
var_dump(json_decode($json, true)['external_id']);
var_dump(json_decode($json, true, flags: JSON_BIGINT_AS_STRING)['external_id']);والمخرج:
float(9.223372036854776E+19)
string(20) "92233720368547758070"في السطر الأول ضاعت آخر أربع خانات من الرقم، ولو حفظته في قاعدة البيانات ثم بحثت به عن الطلب لما وجدته. والخيار JSON_BIGINT_AS_STRING يبقيه نصاً كما هو، وهذه هي القاعدة لكل معرف يأتي من خارج نظامك: المعرف نص وليس رقماً، حتى لو كان كل ما فيه أرقاماً، لأنك لا تجري عليه أي عملية حسابية.
النصوص الرقمية Numeric Strings والتحويل Casting
لأن PHP ولد ليعالج النماذج Forms، وكل ما يأتي من النموذج نص، فقد بنيت اللغة على فكرة النص الرقمي Numeric String: نص يشبه الرقم يعامل كرقم في العمليات الحسابية والمقارنات. وهذه الفكرة مريحة في صفحة بسيطة ومربكة في نظام مالي، والقسم الثاني من types.php يبين حدودها:
var_dump((int) '12abc', (int) 'abc', (float) '1e3', is_numeric('1e3'), is_numeric(' 5'), is_numeric('5 '));
var_dump('abc' <=> 'abd', [1, 2] == [2 => 1], '1e3' == '1000');والمخرج:
int(12)
int(0)
float(1000)
bool(true)
bool(true)
bool(true)
int(-1)
bool(false)
bool(true)في المخرج أعلاه لاحظ التالي:
- التحويل الصريح
(int)لا يفشل أبداً:'12abc'تصبح 12، و'abc'تصبح صفراً. لذلك لا تستخدم(int) $_POST['qty']للتحقق من المدخلات، لأن كمية مكتوبة خطأ سوف تصبح صفراً أو رقماً آخر بصمت، واستخدمfilter_var($v, FILTER_VALIDATE_INT)التي تعيدfalseعند أي نص غير صحيح. is_numericتقبل'1e3'والمسافة قبل الرقم وبعده، وهذا موثق في صفحة النصوص الرقمية، ولكنه يعني أن «رقمي» في PHP أوسع مما تتوقع.- العامل
<=>Spaceship يعيد -1 أو 0 أو 1، وهو ما تحتاجه دوال الترتيبusort. '1e3' == '1000'صحيحة، لأن النصين رقميان فيقارنان كرقمين، وهذا سبب آخر لـ===الذي ذكرناه في المقال الأول، وتغيير قواعد هذه المقارنات في PHP 8 موثق في مقترح Saner String to Number Comparisons.
التصريحات Type Declarations: من Union إلى never
منذ PHP 7.0 صارت الأنواع على المعاملات وقيم الإرجاع، ثم أضيفت كل سنة قطعة جديدة، والقسم الثالث من التجربة يجمع أهمها في دوال صغيرة من عالم المتجر:
interface HasPrice { public function price(): int; }
interface HasStock { public function stock(): int; }
function parseQty(int|string $raw): int
{
return is_int($raw) ? $raw : (int) $raw;
}
function describe((HasPrice&HasStock)|null $item): string // DNF type (PHP 8.2)
{
return $item === null ? 'none' : "{$item->price()}/{$item->stock()}";
}
function fail(string $msg): never
{
throw new RuntimeException($msg);
}
var_dump(parseQty(3), parseQty('4'), describe(new Item(990, 5)), describe(null));
try {
parseQty(2.5);
} catch (TypeError $e) {
echo 'TypeError: ', $e->getMessage(), "\n";
}والمخرج سوف يكون كما يلي:
int(3)
int(4)
string(5) "990/5"
string(4) "none"
TypeError: parseQty(): Argument #1 ($raw) must be of type string|int, float given, called in /app/types.php on line 43في الكود أعلاه لاحظ التالي:
int|stringنوع متحد Union Type يقبل أحد النوعين، وهو مناسب للقيم التي تأتي من مصادر مختلفة، ولكن لاحظ أن الرسالة كتبتهstring|intبترتيب مختلف، لأن PHP يرتب الأنواع داخلياً.(HasPrice&HasStock)|nullصيغة DNF أي Disjunctive Normal Form، وهي تقاطع Intersection بين واجهتين أوnull، أي «أي كائن له سعر ومخزون معاً، أو لا شيء». وهي من مقترح DNF Types في PHP 8.2.neverنوع إرجاع لدالة لا تعود إطلاقاً، فهي إما ترمي استثناء أو تنهي البرنامج، وفائدته أن PHPStan يعرف أن الكود بعد استدعائها لن ينفذ.parseQty(2.5)فشلت رغم أن الدالة تقبل نوعين، لأن الوضع الصارم لا يحولfloatإلىintأوstring.
الخصائص ذات النوع Typed Properties: غير مهيأة وليست null
والقسم الرابع يوضح نقطة تفاجئ القادم من Java أو C#، حيث كل حقل له قيمة افتراضية، أو من JavaScript حيث الحقل غير الموجود undefined:
final class Order
{
public int $id;
public ?string $note = null;
}
$o = new Order();
var_dump($o->note);
try {
echo $o->id;
} catch (Error $e) {
echo get_class($e), ': ', $e->getMessage(), "\n";
}والمخرج:
NULL
Error: Typed property Order::$id must not be accessed before initializationوالخاصية $id لها نوع ولا قيمة افتراضية لها، فهي في حالة خاصة اسمها غير مهيأة Uninitialized، وهي ليست null لأن نوعها لا يقبل null أصلاً. وقراءتها قبل أن تسند لها قيمة ترمي Error، وهذا أفضل من أن تعطيك صفراً أو null يتسرب إلى قاعدة البيانات. وفي المقابل، الخاصية بدون نوع تبدأ بـ null تلقائياً كما كانت في PHP القديمة، وهذا من الأسباب التي تجعلك تكتب النوع على كل خاصية، والتفاصيل في توثيق الخصائص.
التعداد Enum: from أم tryFrom؟
والحالة التي يعيش فيها الطلب في المتجر مثال مناسب للتعداد المدعوم بقيمة Backed Enum، حيث تحفظ القيمة النصية في قاعدة البيانات ويتعامل الكود مع نوع حقيقي:
enum Status: string
{
case New = 'new';
case Paid = 'paid';
}
var_dump(Status::from('paid'), Status::tryFrom('refunded'));
try {
Status::from('refunded');
} catch (ValueError $e) {
echo 'ValueError: ', $e->getMessage(), "\n";
}والمخرج:
enum(Status::Paid)
NULL
ValueError: "refunded" is not a valid backing value for enum Statusوالفرق بين الدالتين هو نفس الفرق بين الخطأ والحالة المتوقعة: from ترمي ValueError عندما تكون القيمة غير معروفة، فاستخدمها عندما تقرأ من قاعدة بياناتك وتعرف أن القيمة يجب أن تكون صحيحة، وtryFrom تعيد null، فاستخدمها عندما تقرأ مدخلات من مستخدم أو من نظام خارجي وتريد أن ترد بخطأ 400 بنفسك.
الأنواع العامة Generics: موجودة في PHPStan فقط
إذا جئت من Java أو C# أو TypeScript فأول ما تبحث عنه هو List<int>، وPHP لا يدعم الأنواع العامة وقت التشغيل، ونوقشت الفكرة في عدة مقترحات دون أن تصل، والسبب أن فحصها في كل استدعاء مكلف في محرك يفحص الأنواع وقت التشغيل. والحل الذي اتفق عليه المجتمع هو أن تكتب الأنواع العامة في تعليقات PHPDoc، ويفهمها PHPStan وPsalm، كما يشرح دليل Generics في PHPStan:
/**
* @template T
*/
final class TypedList
{
/** @var list<T> */
private array $items = [];
/** @param T $item */
public function add(mixed $item): void
{
$this->items[] = $item;
}
/** @return T|null */
public function first(): mixed
{
return $this->items[0] ?? null;
}
}
/** @var TypedList<int> $prices */
$prices = new TypedList();
$prices->add(990);
$prices->add('4500'); // runs fine: no check at runtime
echo cheapest($prices), "\n";وعند التشغيل طبع الملف 990 دون أي خطأ، رغم أننا أضفنا نصاً إلى قائمة أرقام. أما PHPStan بالمستوى max فأعطى:
------ -----------------------------------------------------------------------
Line generics.php
------ -----------------------------------------------------------------------
37 Parameter #1 $item of method TypedList<int>::add() expects int, strin
g given.
------ -----------------------------------------------------------------------
[ERROR] Found 1 errorوهنا ترى حدود نظام الأنواع في PHP بوضوح: ما تكتبه في الكود نفسه يفحص في كل استدعاء، وما تكتبه في التعليقات لا يراه PHP أبداً، وإنما يراه PHPStan فقط. لذلك يصبح تشغيل PHPStan في CI جزءاً من نظام الأنواع، وليس أداة إضافية تختارها حسب ذوقك، وهذا يشبه وضع Python مع mypy أكثر مما يشبه Java.
من يحرر الذاكرة Memory Management في PHP؟
الآن ننتقل إلى الشكوى الثالثة، الـ Worker الذي تكبر ذاكرته، ولكن قبلها نحتاج أن نفهم كيف يحرر PHP الذاكرة أصلاً. وPHP يستخدم آليتين معاً: عداد المراجع Reference Counting كآلية رئيسية، وجامع الدورات Cycle Collector كآلية مساعدة للحالة الوحيدة التي يفشل فيها العداد. وهذا يختلف عن Java وGo وNode.js التي تعتمد على جامع قمامة Garbage Collector يتتبع الكائنات الحية Tracing GC، ويقترب من Python التي تستخدم نفس الفكرة تقريباً، ومن ARC في Swift مع فرق أن Swift لا تملك جامعاً للدورات أصلاً.
عداد المراجع Reference Counting: التحرير الفوري
كل نص أو مصفوفة أو كائن له عداد، يزيد عندما يشير إليه متغير جديد، وينقص عندما يحذف المتغير أو يخرج من نطاقه، وعندما يصل إلى صفر تحرر الذاكرة فوراً ويستدعى __destruct إذا كان كائناً. والقسم الأول من الملف memory.php يبين ذلك بكائن يطبع رسالة عند إنشائه وعند تحريره:
$a = new Node('demo-A', true);
$b = $a;
echo " unset(\$a)\n";
unset($a);
echo " unset(\$b)\n";
unset($b);
echo " after unset\n";والمخرج سوف يكون كما يلي:
== 1. refcount: freed the moment the last reference goes
construct demo-A
unset($a)
unset($b)
destruct demo-A
after unsetلاحظ أن unset($a) لم يحرر شيئاً، لأن $b ما زال يشير إلى الكائن، وأن destruct demo-A ظهر مباشرة بعد unset($b) وقبل السطر التالي. وهذا التحرير المحدد التوقيت Deterministic مفيد جداً: الملف الذي فتحه كائن يغلق في __destruct لحظة انتهاء استخدامه، وليس عندما يقرر جامع القمامة في Java أن يعمل بعد ثوان.
الدورات Cycles وجامعها
والمشكلة في عداد المراجع أنه لا يرى الدورات: كائنان يشير كل منهما إلى الآخر، مثل طلب يحمل قائمة أسطره وكل سطر يحمل مرجعاً إلى طلبه. وعندما تحذف آخر متغير يشير إليهما يبقى عداد كل واحد منهما 1، لأن الآخر يشير إليه، فلا يحرران أبداً بالعداد وحده:
$x = new Node('demo-X', true);
$y = new Node('demo-Y', true);
$x->other = $y;
$y->other = $x;
unset($x, $y);
echo " after unset (no destruct yet)\n";
$freed = gc_collect_cycles();
echo " gc_collect_cycles() freed {$freed} objects\n";والمخرج:
== 2. a cycle is not freed by refcount
construct demo-X
construct demo-Y
after unset (no destruct yet)
destruct demo-X
destruct demo-Y
gc_collect_cycles() freed 2 objects
وجامع الدورات لا يعمل باستمرار، وإنما يحفظ في مخزن الجذور Root Buffer كل قيمة نقص عدادها دون أن يصل إلى صفر، لأنها قد تكون جزءاً من دورة، وعندما يمتلئ المخزن بـ 10,000 جذر محتمل يبدأ الخوارزمية التي تبحث عن الدورات وتحررها، كما يشرح توثيق جمع الدورات. ويمكنك أن تستدعيه بنفسك بـ gc_collect_cycles()، وأن ترى إحصاءاته بـ gc_status.
القياس: مئتا ألف كائن مع الجامع وبدونه
حتى نرى ثمن الدورات بالأرقام كتبنا الملف cycles.php، والذي ينشئ مئة ألف زوج من الكائنات، كل كائن يحمل نصاً بحجم كيلوبايت، وكل زوج يشير أحدهما إلى الآخر، ثم يطبع الزمن والذاكرة وإحصاءات الجامع، ويقبل معاملاً on أو off لتشغيل الجامع أو إيقافه بـ gc_disable():
$enabled = ($argv[1] ?? 'on') === 'on';
$enabled ? gc_enable() : gc_disable();
$start = memory_get_usage();
$t = hrtime(true);
for ($i = 0; $i < 100_000; $i++) {
$p = new Node("p{$i}");
$q = new Node("q{$i}");
$p->other = $q;
$q->other = $p;
}
unset($p, $q);وشغلناه ثلاث مرات بكل وضع، مع رفع حد الذاكرة في وضع الإيقاف إلى 512 ميجابايت لسبب سوف تراه بعد قليل:
for i in 1 2 3; do php cycles.php on; php -d memory_limit=512M cycles.php off; doneوالمخرج سوف يكون كما يلي:
gc on memory_limit 128M time 393.1 ms memory now +0.2 MB peak 14.0 MB gc runs 20 collected 199960
gc off memory_limit 512M time 1054.9 ms memory now +270.5 MB peak 271.0 MB gc runs 0 collected 0
gc on memory_limit 128M time 450.0 ms memory now +0.2 MB peak 14.0 MB gc runs 20 collected 199960
gc off memory_limit 512M time 626.2 ms memory now +270.5 MB peak 271.0 MB gc runs 0 collected 0
gc on memory_limit 128M time 498.5 ms memory now +0.2 MB peak 14.0 MB gc runs 20 collected 199960
gc off memory_limit 512M time 253.6 ms memory now +270.5 MB peak 271.0 MB gc runs 0 collected 0
في المخرج أعلاه لاحظ التالي:
- مع الجامع لم تتجاوز الذروة 14 ميجابايت، وعمل الجامع 20 مرة بالضبط، وهذا يطابق ما في التوثيق: كل كائن من المئتي ألف يدخل المخزن كجذر محتمل عندما ينقص عداده دون أن يصل إلى صفر، ومئتا ألف مقسومة على 10,000 تعطي 20.
- جمع 199,960 كائناً وليس 200,000، لأن آخر الأزواج ما زالت في المخزن ولم يمتلئ بعد، وتحرر عند نهاية البرنامج.
- بدون الجامع بقيت 270.5 ميجابايت في الذاكرة بعد انتهاء الحلقة، أي كل الكائنات تقريباً، رغم أن الكود لم يعد يشير إلى أي واحد منها.
- الزمن في هذه التشغيلات الثلاث ومعها تسع تشغيلات سابقة محفوظة في الملف
cycles-earlier-runs.txtكان بين 273 و631 مللي ثانية مع الجامع، وبين 228 و1055 بدونه، والجهاز كان يشغل أعمالاً أخرى في نفس الوقت، لذلك لا نستطيع أن نقول من هذه الأرقام إن أحد الوضعين أسرع، والنتيجة الواضحة الوحيدة هي الذاكرة.
وقد يتساءل البعض: إذا كان الجامع يعمل تلقائياً، فلماذا أهتم؟ والإجابة أن الطلب العادي في PHP يحذف كل شيء في نهايته كما رأينا في نموذج Share-Nothing، فالدورات فيه لا تضر غالباً، أما سكربت يستورد ملف منتجات فيه مليون سطر، أو Worker يعمل أياماً، فالدورات فيه تتراكم بين مرات عمل الجامع، وكل مرة يعمل فيها يمر على عشرة آلاف جذر. والحل أن تتجنب الدورات في التصميم من البداية، مثلاً بأن يحفظ السطر رقم الطلب بدل الطلب نفسه، أو أن تستخدم WeakReference للمرجع العكسي.
الكاش الضعيف WeakMap: لا يبقي الكائن حياً
ومن الأخطاء الشائعة أن تحفظ معلومات إضافية عن كائنات في مصفوفة كاش، فتبقى الكائنات حية لأن الكاش يشير إليها حتى بعد أن ينتهي استخدامها. والكلاس WeakMap منذ PHP 8.0 يحل ذلك، فهو يستخدم الكائن مفتاحاً دون أن يزيد عداده، وعندما يحرر الكائن يختفي مدخله من الكاش تلقائياً:
== 3. WeakMap: a cache that does not keep objects alive
WeakMap entries while referenced: 3
WeakMap entries after dropping references: 0وهذا النمط مفيد عندما تحتاج أن تربط بيانات محسوبة بكائنات لا تملكها، مثل كيانات Entities من مكتبة ORM.
حد الذاكرة memory_limit
في أول تشغيل لتجربة الدورات لم نرفع حد الذاكرة، فتوقف وضع الإيقاف بهذه الرسالة:
Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 2097152 bytes) in /app/cycles.php on line 13
Stack trace:
#0 /app/cycles.php(13): str_repeat('x', 1024)
#1 {main}والرقم 134217728 بايت هو 128 ميجابايت، وهو القيمة الافتراضية للإعداد memory_limit، وهو حد لكل عملية PHP وليس للسيرفر كله. وهذا الحد هو خط الدفاع الذي يمنع طلباً واحداً سيئاً من أن يأخذ ذاكرة السيرفر كلها، لذلك لا ترفعه إلى -1 (بدون حد) في إعدادات الويب لأن سكربتاً واحداً احتاج أكثر، وإنما ارفعه لذلك السكربت فقط بـ php -d memory_limit=512M أو ini_set في أوله. ولاحظ أن هذا الخطأ من نوع Fatal Error الذي لا يمكن التقاطه بـ try، وسوف نعود لذلك في قسم الأخطاء.
ولاحظ الرقم في الرسالة: tried to allocate 2097152 bytes، أي 2 ميجابايت بالضبط، رغم أن الكائن الذي نصنعه كيلوبايت واحد فقط. والسبب أن مدير الذاكرة في PHP لا يطلب الذاكرة من نظام التشغيل كائناً كائناً، وإنما يحجزها بقطع Chunks حجم كل منها 2 ميجابايت، ثم يقسمها داخلياً على القيم الصغيرة، فالرسالة تذكر القطعة التي فشل حجزها وليس الكائن. ولهذا أيضاً تعطيك memory_get_usage(true) رقماً أكبر من memory_get_usage(): الأولى تعد القطع المحجوزة من النظام، والثانية تعد ما تستخدمه القيم داخلها فعلاً، وهي التي استخدمناها في كل القياسات.
ولهذا إذا رأيت في سجل الإنتاج رسالة Allowed memory size فلا تبدأ برفع الحد، وإنما ابحث أولاً عن الحلقة أو الاستعلام الذي حمل بيانات أكثر مما يحتاج.
العمليات الطويلة Long-Running Workers: لماذا تكبر الذاكرة؟
نعود الآن إلى الشكوى الثالثة. في المتجر عملية تعمل في الخلفية بالأمر php artisan queue:work تعالج الطلبات الجديدة وترسل رسائل التأكيد، وهي عملية واحدة تعمل أياماً، أي أنها لا تحصل على «البداية النظيفة» التي يحصل عليها كل طلب ويب. وكتبنا الملف worker.php الذي يحاكي ذلك بكلاس يحفظ سعر كل طلب في كاش ثابت static حتى لا يحسبه مرتين:
final class PriceLookup
{
/** @var array<string, int> */
private static array $cache = [];
public static function priceFor(string $orderRef): int
{
return self::$cache[$orderRef] ??= random_int(100, 10_000); // cached per order: grows forever
}
}
$limitMb = (int) ($argv[1] ?? 0);
for ($job = 1; $job <= 200_000; $job++) {
PriceLookup::priceFor("order-{$job}");
if ($job % 50_000 === 0) {
printf("job %6d memory %5.1f MB\n", $job, memory_get_usage() / 1048576);
}
if ($limitMb > 0 && memory_get_usage() > $limitMb * 1048576) {
printf("job %6d memory over %d MB: exit so the supervisor starts a fresh process\n", $job, $limitMb);
exit(0);
}
}والمخرج بدون حد ثم مع حد 8 ميجابايت:
job 50000 memory 4.9 MB
job 100000 memory 9.3 MB
job 150000 memory 16.2 MB
job 200000 memory 18.1 MB
job 50000 memory 4.9 MB
job 66985 memory over 8 MB: exit so the supervisor starts a fresh process
وهذه ليست دورة يحلها الجامع، وإنما كاش يحتفظ بكل شيء عن قصد، فلا يوجد أي آلية في PHP سوف تحرره. والعلاج له طبقتان: الأولى في الكود، بأن يكون للكاش حجم أقصى أو مدة، أو أن يفرغ بعد كل مهمة، والثانية في التشغيل، بأن يخرج الـ Worker بشكل نظيف عندما يتجاوز حداً معيناً من الذاكرة أو عدداً من المهام، ويعيد تشغيله مشرف Supervisor مثل systemd أو supervisord. وهذه بالضبط فكرة الخيارين --memory و--max-jobs في طوابير Laravel. وتذكر أن نفس المشكلة تظهر في أي وضع يبقي التطبيق في الذاكرة، مثل FrankenPHP في وضع Worker وLaravel Octane، فهناك كل static وكل Singleton يعيش بين الطلبات.
الأخطاء Errors في PHP: ثلاثة أنواع من الفشل
بقيت الشكوى الثانية: الطلب الذي وصل فارغاً دون أي خطأ في السجلات. وجذرها أن PHP فيه ثلاثة أنواع من الفشل، وليس نوعاً واحداً كما في Java أو Python، والنوع الأول منها لا يوقف الكود أصلاً:
- التحذير Warning والتنبيه Notice والإهمال Deprecated: رسالة تكتب في السجل أو على الشاشة، ثم يكمل الكود بقيمة مثل
falseأوnull. وهذا هو الإرث الأقدم في اللغة، وما زالت دوال كثيرة في المكتبة القياسية تعمل به. - الخطأ
Error: يرمى عند خلل في الكود نفسه، مثل نوع خاطئ أو استدعاء دالة علىnullأو قسمة على صفر، وهو قابل للالتقاط منذ PHP 7 ولكنه ليس مخصصاً لذلك. - الاستثناء
Exception: يرمى عند فشل متوقع يمكن أن يتعامل معه البرنامج، مثل JSON غير صالح أو منتج نفد من المخزون.
والاثنان الأخيران يطبقان الواجهة Throwable، أما الأول فليس كائناً أصلاً.

التحذير Warning: الكود يكمل بقيمة خاطئة
والقسم الأول من الملف errors.php يعيد إنتاج شكوى الطلب الفارغ بالضبط: نقرأ ملفاً غير موجود، ثم نحلل JSON من تطبيق الجوال فيه قوس ناقص:
$body = file_get_contents('/app/missing-order.json');
var_dump($body);
$order = json_decode('{"items": [{"product_id": 1, "qty": 3}', true); // missing closing brace
var_dump($order, json_last_error_msg());والمخرج سوف يكون كما يلي:
Warning: file_get_contents(/app/missing-order.json): Failed to open stream: No such file or directory in /app/errors.php on line 8
bool(false)
NULL
string(12) "Syntax error"في المخرج أعلاه لاحظ التالي:
file_get_contentsطبعت تحذيراً وأعادتfalse، والكود أكمل إلى السطر التالي، ولو لم نتحقق منfalseلمررناها إلى دالة أخرى تتوقع نصاً.json_decodeأسوأ من ذلك: أعادتNULLدون أن تطبع أي تحذير، والطريقة الوحيدة لمعرفة السبب هي أن تسألjson_last_error_msg()بعدها. وهذا بالضبط ما حدث في المتجر: الكود قرأ$order['items'] ?? []فأصبحت قائمة الأسطر فارغة، وحفظ طلباً فارغاً.
والإصلاح له طريقتان، وكلاهما في القسم الثاني من التجربة. الأولى للدوال التي تدعم ذلك، وهي أن تطلب منها أن ترمي استثناء، مثل الخيار JSON_THROW_ON_ERROR الموجود منذ PHP 7.3 كما في توثيق json_decode. والثانية لكل الدوال الأخرى، وهي معالج أخطاء عام يحول كل تحذير إلى ErrorException باستخدام set_error_handler:
try {
json_decode('{"items": [{"product_id": 1, "qty": 3}', true, flags: JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
echo get_class($e), ': ', $e->getMessage(), ' (code ', $e->getCode(), ")\n";
}
set_error_handler(function (int $no, string $msg, string $file, int $line): bool {
throw new ErrorException($msg, 0, $no, $file, $line);
});
try {
file_get_contents('/app/missing-order.json');
} catch (ErrorException $e) {
echo get_class($e), ': ', $e->getMessage(), "\n";
}
restore_error_handler();والمخرج:
JsonException: Syntax error (code 4)
ErrorException: file_get_contents(/app/missing-order.json): Failed to open stream: No such file or directoryوأطر العمل الحديثة مثل Laravel وSymfony تسجل معالجاً مثل هذا تلقائياً، فتتحول التحذيرات إلى استثناءات في بيئة التطوير، لذلك قد لا تلاحظ المشكلة إلا عندما تكتب سكربتاً مستقلاً بدون إطار. والقاعدة: في أي كود جديد خارج إطار عمل، سجل معالج الأخطاء في أول سطر، واستخدم JSON_THROW_ON_ERROR في كل استدعاء لـ json_decode وjson_encode.
الخطأ Error: أخطاء البرمجة ترمى أيضاً
والقسم الثالث يشغل ست حالات من أخطاء البرمجة ويطبع نوع كل واحدة ورسالتها:
== 3. Error: programming mistakes are thrown too
intdiv by zero DivisionByZeroError Division by zero
modulo by zero DivisionByZeroError Modulo by zero
division by zero DivisionByZeroError Division by zero
negative repeat ValueError str_repeat(): Argument #2 ($times) must be greater than or equal to 0
undefined method Error Call to undefined method ArrayObject::total()
null method Error Call to a member function total() on nullوقبل PHP 8 كانت القسمة 10 / 0 تعطي تحذيراً وتعيد INF، وstr_repeat('x', -1) تعطي تحذيراً وتعيد null، أما الآن فكل منها يرمي خطأ يوقف الكود إذا لم يلتقط. وValueError جديد في PHP 8.0، وهو للحالة التي يكون فيها النوع صحيحاً والقيمة غير مقبولة. وهذه الأخطاء لا تلتقطها لتكمل كأن شيئاً لم يحدث، وإنما يلتقطها معالج عام واحد في أعلى التطبيق ليكتبها في السجل ويرد بخطأ 500، ثم تصلح الكود.
الاستثناءات Exceptions الخاصة بالمتجر: previous وfinally
أما الفشل المتوقع في منطق المتجر، مثل نفاد المخزون، فله استثناء خاص يحمل البيانات التي يحتاجها من يلتقطه، والقسم الخامس من التجربة يبين ثلاث أدوات معاً: الاستثناء الخاص، والاستثناء السابق Previous الذي يحفظ السبب الأصلي، وfinally الذي ينفذ في كل الحالات:
final class OutOfStock extends DomainException
{
public function __construct(public readonly int $productId, public readonly int $available, ?Throwable $previous = null)
{
parent::__construct("product {$productId}: only {$available} left", 409, $previous);
}
}
function reserve(int $productId, int $qty): void
{
try {
echo " open transaction\n";
if ($qty > 2) {
throw new OutOfStock($productId, 2, new RuntimeException('stock check failed'));
}
echo " commit\n";
} finally {
echo " finally: release lock\n";
}
}والمخرج:
== 5. domain exceptions, previous, finally
open transaction
finally: release lock
caught OutOfStock code=409 available=2: product 1: only 2 left
previous: stock check failedفي الكود والمخرج أعلاه لاحظ التالي:
finallyنفذ قبل أن يصل الاستثناء إلىcatchفي الدالة التي استدعتreserve، ولذلك هو المكان الصحيح لتحرير القفل أو إغلاق الملف.- الاستثناء يحمل
available=2كخاصيةreadonly، فيستطيع من يلتقطه أن يكتب رسالة واضحة للعميل دون أن يحلل نص الرسالة. - رمز 409 في
codeمجرد رقم نختاره، ولكنه سوف يصبح رد HTTP 409 Conflict في خدمة الطلبات في المقال الرابع. - الاستثناء السابق
getPrevious()يحفظ سلسلة الأسباب، وتطبعه أطر العمل كاملاً في السجل، فلا تفقد السبب الأصلي عندما تحول استثناء منخفض المستوى إلى استثناء من منطق المتجر.
السمة NoDiscard في PHP 8.5: لا تتجاهل النتيجة
بعض الدوال لا ترمي استثناء عند الفشل، وإنما تعيد false، ومشكلتها أن من يستدعيها قد يتجاهل النتيجة. والسمة #[\NoDiscard] الجديدة في PHP 8.5 تجعل PHP يطبع تحذيراً إذا استدعيت الدالة وتجاهلت ما أعادته:
#[\NoDiscard('the result says whether the stock was reserved')]
function tryReserve(int $qty): bool
{
return $qty <= 2;
}
tryReserve(5);
$ok = tryReserve(1);
(void) tryReserve(1);والمخرج:
Warning: The return value of function tryReserve() should either be used or intentionally ignored by casting it as (void), the result says whether the stock was reserved in /app/errors.php on line 88
ok=trueوالاستدعاء الأول وحده هو الذي أعطى التحذير، أما الثاني فاستخدم النتيجة، والثالث تجاهلها صراحة بالتحويل (void) الجديد أيضاً في 8.5، وهو طريقتك لتقول للقارئ «أعرف أن هناك نتيجة ولا أحتاجها».
ما لا يلتقط Uncaught: رمز الخروج 255
وفي آخر الملف رمينا OutOfStock دون أن نلتقطه:
Fatal error: Uncaught OutOfStock: product 2: only 0 left in /app/errors.php:94
Stack trace:
#0 {main}
thrown in /app/errors.php on line 94
exit=255ورمز الخروج 255 هو ما تراه أنظمة التشغيل وأدوات CI، وهو يختلف عن الصفر الذي رأيناه في المقال الأول عندما أعطى PHP تحذيراً وأكمل. وهناك أخطاء قاتلة لا تمر عبر Throwable إطلاقاً، مثل تجاوز memory_limit الذي رأيناه، وتجاوز max_execution_time، والطريقة الوحيدة لتسجيلها هي دالة تسجل بـ register_shutdown_function وتقرأ error_get_last()، وهذا ما تفعله أطر العمل لتسجل هذه الحالات في السجل.
إعدادات الأخطاء Error Configuration بين التطوير والإنتاج
بقي سؤال مهم يفسر لماذا لم يظهر الطلب الفارغ في سجلات المتجر: أين تذهب التحذيرات أصلاً؟ والجواب في أربعة إعدادات في php.ini، والصورة الرسمية تأتي بنموذجين لهما قيم مختلفة، فقارناهما مباشرة:
for f in development production; do echo "-- $f"; grep -E "^(display_errors|display_startup_errors|error_reporting|log_errors|zend.exception_ignore_args|zend.assertions|memory_limit)" /usr/local/etc/php/php.ini-$f; doneوالمخرج سوف يكون كما يلي:
-- development
zend.exception_ignore_args = Off
memory_limit = 128M
error_reporting = E_ALL
display_errors = On
display_startup_errors = On
log_errors = On
zend.assertions = 1
-- production
zend.exception_ignore_args = On
memory_limit = 128M
error_reporting = E_ALL & ~E_DEPRECATED
display_errors = Off
display_startup_errors = Off
log_errors = On
zend.assertions = -1في المخرج أعلاه لاحظ التالي:
display_errorsيعرض الأخطاء في الصفحة نفسها، وهو مفعل في التطوير ومغلق في الإنتاج، والسبب أن رسالة الخطأ تكشف مسارات الملفات وأحياناً أجزاء من الاستعلامات للمستخدم وللمخترق معاً. ولا تفعله في الإنتاج إطلاقاً حتى لدقيقة واحدة لتتبع مشكلة.log_errorsمفعل في الاثنين، وهو الذي يكتب الأخطاء في السجل، ومكانه يحددهerror_log، وإذا لم تحدده يكتب PHP-FPM في سجل الـ Worker أو سجل السيرفر. وفي الـ Container ضعه على/dev/stderrحتى يظهر فيdocker logs، وقد جربنا ذلك فظهر التحذير بهذا الشكل:PHP Warning: file_get_contents(/nope): Failed to open streamمع الوقت بتوقيت UTC.error_reportingفي الإنتاج يستثنيE_DEPRECATED، أي أن تحذيرات الإهمال مثلImplicit conversion from float 5.5 to int loses precisionالتي رأيناها في المقال الأول لن تصل إلى سجل الإنتاج أبداً. وهذه التحذيرات هي إنذارك المبكر بأن الإصدار القادم سوف يغير سلوكاً تعتمد عليه، لذلك يجب أن تراها في التطوير وفي CI، وأن يفشل CI عندما تظهر.zend.exception_ignore_argsمفعل في الإنتاج، فلا يحفظ قيم المعاملات في الـ Stack Trace، والسبب أن هذه القيم قد تكون باسوردات أو توكنات تصل إلى السجل. ولهذا ترى في سجلات الإنتاج أسماء الدوال دون قيمها.zend.assertions = -1في الإنتاج يحذف استدعاءاتassert()من الكود عند الترجمة، فلا تكلف شيئاً، لذلك لا تضع فيassertأي تحقق يجب أن يعمل في الإنتاج مثل التحقق من صلاحيات المستخدم، وإنما فقط افتراضات تريد أن تراها تفشل أثناء التطوير.
وقد يتساءل البعض: لماذا لم تحذرنا PHP عندما أعادت json_decode القيمة null؟ والإجابة أن هذه الدالة لا تطبع تحذيراً أصلاً، فلو كان المتجر يكتب كل التحذيرات في السجل لما ظهر شيء أيضاً. ولهذا الحل الصحيح ليس رفع مستوى السجلات، وإنما أن يتحول كل فشل إلى استثناء يمر عبر معالج واحد يسجله ويرد على العميل بخطأ واضح، والتفاصيل الكاملة لكل إعداد في توثيق إعدادات الأخطاء.
كيف تغير هذا كله من PHP 7 إلى 8.5؟
كل ما رأيته في هذا المقال جاء على مراحل، ومعرفة الإصدار الذي أضاف كل شيء تساعدك عندما تقرأ كوداً قديماً، أو عندما يكون مشروعك على إصدار أقدم من 8.5:

والنتيجة العملية أن كوداً كتب لـ PHP 7.2 قد يكون صحيحاً تماماً ولكنه لا يستفيد من أي شيء في هذا المقال: خصائص بلا أنواع، وثوابت نصية بدل enum، وswitch بدل match، وتحذيرات بدل استثناءات. والأداة التي تساعدك في الترقية هي Rector، والتي تعيد كتابة الكود تلقائياً لصيغ الإصدار الأحدث، وأدلة الترقية الرسمية مثل دليل الترقية إلى 8.5 تذكر كل تغيير قد يغير سلوك كودك.
مشكلتان ظهرتا في التجربة Lab Problems
المشكلة الأولى هي رسالة Allowed memory size exhausted التي رأيتها في قسم حد الذاكرة. ففي الكتابة الأولى للتجربة وضعنا الوضعين، مع الجامع وبدونه، في نفس الملف ونفس العملية، فتوقف البرنامج كله عند الوضع الثاني ولم نحصل على أي رقم منه. والحل كان فصل كل وضع في عملية مستقلة cycles.php on وcycles.php off، ورفع الحد لوضع الإيقاف فقط من سطر الأوامر، وبقيت رسالة الحد الافتراضي في المخرجات كتجربة مستقلة. والدرس العام: عندما تقيس الذاكرة في PHP فشغل كل قياس في عملية جديدة، لأن العملية لا تعيد الذاكرة التي حجزتها إلى النظام بسهولة، والقياس الثاني يتأثر بالأول.
والمشكلة الثانية أطرف. كتبنا سطر جمع الدورات أول مرة كما يلي:
echo ' gc_collect_cycles() = ', gc_collect_cycles(), "\n";فظهر المخرج مختلطاً بهذا الشكل:
gc_collect_cycles() = destruct demo-X
destruct demo-Y
2والسبب أن echo بعدة قيم مفصولة بفواصل يطبع كل قيمة بعد حسابها بالترتيب، فطبع النص الأول، ثم استدعى gc_collect_cycles() التي حررت الكائنين فطبع __destruct رسائله، ثم طبع الرقم 2. وهذا يذكرك بأن __destruct يعمل في لحظة التحرير مهما كان ما يحدث حوله، فلا تضع فيه شيئاً يعتمد على ترتيب المخرجات أو على حالة خارجية، وفصلنا السطر إلى متغير ثم طباعة.
ماذا يحدث إذا أوقفت جامع الدورات بالحد الافتراضي؟ What If
بعض المقالات القديمة تنصح بإيقاف جامع الدورات بـ gc_disable() أو zend.enable_gc=0 لتسريع السكربتات الطويلة، وكان لهذا النصح سبب في PHP 5.3 حيث كان الجامع أبطأ بكثير. فماذا يحدث إذا طبقته على نفس التجربة دون أن ترفع حد الذاكرة؟
php cycles.php off; echo "exit=$?"Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 2097152 bytes) in /app/cycles.php on line 13
Stack trace:
#0 /app/cycles.php(13): str_repeat('x', 1024)
#1 {main}
exit=255والنتيجة أن السكربت الذي كان يكفيه 14 ميجابايت أصبح يحتاج أكثر من 128، ولم يكمل. ولم تظهر في قياسنا أي فائدة واضحة في الزمن تبرر ذلك، لذلك لا توقف الجامع إلا إذا قست بنفسك أن كودك لا يصنع دورات، وأن الزمن تحسن فعلاً على بياناتك.
أخطاء شائعة Common Mistakes
- حساب المبالغ بـ
floatثمround: رأيت أنه يخطئ في ربع حالات النصف هللة. احسب بالهللةint، أو بـ BCMath للنسب الدقيقة. - التحقق من الأرقام بالتحويل
(int): لا يفشل أبداً، فاستخدمfilter_varمعFILTER_VALIDATE_INT. json_decodeبدونJSON_THROW_ON_ERROR: تعيدnullبصمت ويمر الطلب فارغاً.- تجاهل قيمة
falseمن دوال الملفات والشبكة: سجل معالج أخطاء يحول التحذيرات إلى استثناءات. - التقاط
ThrowableأوErrorفي منتصف الكود للاستمرار: هذا يخفي أخطاء برمجة يجب إصلاحها، والتقط الاستثناءات المحددة فقط. - كتابة
@قبل استدعاء دالة لإسكات التحذير: يخفي السبب ولا يصلحه. - كاش
staticأو Singleton يكبر دون حد في Worker أو في Octane. - الاعتماد على Generics في PHPDoc دون تشغيل PHPStan في CI: لا أحد يفحصها غيره.
- رفع
memory_limitإلى-1في إعدادات الويب لأن سكربتاً واحداً احتاج أكثر.
جدول القرار Decision Table: أي أداة لأي حالة؟
| الحالة | استخدم | ولا تستخدم |
|---|---|---|
| مبلغ مالي بالريال والهللة | int بالهللة | float مع round |
| نسبة أو سعر صرف بكسور طويلة | BCMath أو BcMath\Number | float |
| رقم من مدخلات المستخدم | filter_var(..., FILTER_VALIDATE_INT) | (int) $value |
| قيمة من مجموعة ثابتة | enum مع tryFrom للمدخلات | ثوابت نصية |
| قائمة من نوع واحد | list<T> في PHPDoc مع PHPStan | array بلا وصف |
| فشل متوقع في منطق العمل | استثناء خاص يرث من DomainException أو RuntimeException | إعادة false |
| دالة تعيد نتيجة مهمة | #[\NoDiscard] | تعليق يطلب من القارئ أن ينتبه |
| كاش لبيانات عن كائنات | WeakMap | مصفوفة عادية |
| Worker طويل | حد للذاكرة وعدد المهام مع مشرف يعيد التشغيل | إعادة تشغيل يدوية حين يشتكي أحد |
تدريب عملي Practice
- عدل
money.phpليستخدمround($riyals * 0.15, 2, PHP_ROUND_HALF_EVEN)، وعد الفروق مرة أخرى، ثم فكر: أي القاعدتين تطلبها الجهة الضريبية التي تتعامل معها؟ - غير
Nodeفيcycles.phpليحفظ المرجع العكسي فيWeakReferenceبدل الكائن نفسه، وشغله مع إيقاف الجامع، وقارن الذاكرة. - أضف إلى
worker.phpحداً لحجم الكاش يحذف أقدم مدخل عندما يتجاوز عشرة آلاف، وارسم الذاكرة كل خمسين ألف مهمة.
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- PHP ديناميكي الأنواع ويفحص الأنواع المكتوبة على الدوال والخصائص في كل استدعاء، أما Generics وأشكال المصفوفات فيفحصها PHPStan فقط.
- الحساب بالريال العشري أخطأ في 1,276 ضريبة من مئة ألف سعر، كلها حالات نصف هللة، والحساب بالهللة الصحيحة لا يخطئ.
- الذاكرة يحررها عداد المراجع فوراً، والدورات يحررها جامع يعمل كل 10,000 جذر، وبدونه احتاجت التجربة 271 ميجابايت بدل 14 وتوقفت عند حد 128.
- الـ Worker لا يبدأ من الصفر مثل طلب الويب، وكل كاش ثابت فيه يكبر حتى يوقفه حد الذاكرة والمشرف.
- التحذير ليس استثناء، و
json_decodeتعيدnullبصمت، فاستخدمJSON_THROW_ON_ERRORومعالج أخطاء يحول التحذيرات إلىErrorException. Errorلأخطاء الكود وExceptionللفشل المتوقع، وfinallyلتحرير الموارد، و#[\NoDiscard]في 8.5 للنتائج التي لا يجب تجاهلها.
وفي المقال التالي من المرحلة ننتقل إلى التزامن: كيف يخدم PHP طلبات كثيرة في نفس الوقت عبر عمليات PHP-FPM، وماذا تضيف Fibers والمكتبات غير المتزامنة، مع قياس لنفس المهمة بعدة طرق.
دليلPHP لمن يعرف البرمجة: الصيغة والأدوات Toolchain في يومدليل عملي للمطور القادم من Python أو JavaScript يثبت PHP 8.5 ويقارن صيغتها بما يعرفه ويشغل Composer وPint وPHPStan وPHPUnit على مشروع صغير بمخرجات حقيقية.
دليلالتزامن Concurrency في PHP: عمليات FPM والألياف Fibersقسنا مئة طلب HTTP وأربعة ملايين عملية SHA-256 بعمليات PHP-FPM وFibers وAMPHP والتفرع والخيوط، ثم صنعنا حالة سباق على المخزون بين عمليات FPM وأصلحناها بالقفل.
دليلخدمة حقيقية بـ PHP: Laravel مع PostgreSQL والاختباراتبنينا خدمة طلبات لمتجر صغير بـ Laravel 13 وPostgreSQL 17 بمعاملة تقفل المخزون، مع اختبار يرسل 20 طلباً متزامناً على 10 علب، ثم Docker Image بمرحلتين وقياس للأداء كشف ثمن الاتصال بقاعدة البيانات.
دليلأخطاء شائعة وأسلوب PHP الحديث Modern PHPثلاثة عشر خطأ يقع فيها المطور القادم إلى PHP من لغة أخرى، كل خطأ بتشغيل حقيقي على PHP 8.5 ثم الإصلاح، مع ما كشفه PHPStan منها وخطة قراءة من المصادر الرسمية.
دليلإدارة الذاكرة: يدوياً أم Garbage Collector أم Ownership؟نقيس بأيدينا في C وGo وPython وRust من يحرر الذاكرة ومتى، وكم يكلف ذلك من الذاكرة والوقت، وما الأخطاء التي تبقى ممكنة في كل طريقة.
دليلكيف تتعامل لغات البرمجة مع الأخطاء؟لماذا ترمي Java وPython الاستثناءات بينما ترجع Go وRust الخطأ كقيمة، وكيف تمنع الخطأ من أن يختفي بصمت في كودك.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتنفيذ التجارب بـ PHP 8.5.12 وPHPStan 2.3.4 داخل Docker.