> ## 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.

# خدمة طلبات بلغة C: libmicrohttpd مع PostgreSQL
- URL: https://arabroot.io/articles/خدمة-طلبات-بلغة-c-libmicrohttpd-مع-postgresql/
- Published: 2026-10-31T08:36:52.000Z
- Updated: 2026-10-31T09:36:56.000Z
- Description: نبني خدمة الطلبات المشتركة بلغة C مع libmicrohttpd وlibpq وcJSON، ونختبرها على PostgreSQL حقيقي وتحت Valgrind، ونقيسها، ونرى بالأسطر ما الذي جعلته C أصعب: الذاكرة وJSON وحقن SQL.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, لغات البرمجة عملياً

في كل مرحلة من مراحل مسار لغات البرمجة نبني نفس الخدمة بنفس المواصفات، لكي تقارن بين اللغات على شيء حقيقي لا على أمثلة صغيرة: واجهة طلبات API لمتجر صغير يبيع القهوة والتمور، تعرض المنتجات، وتستقبل طلب شراء فتخصم المخزون وتحسب الإجمالي مع ضريبة القيمة المضافة في معاملة واحدة، وترفض الطلب إذا لم يكف المخزون. وقد ناقشنا في مقال [كيف تختار لغة البرمجة لمشروعك](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%AE%D8%AA%D8%A7%D8%B1-%D9%84%D8%BA%D8%A9-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D9%84%D9%85%D8%B4%D8%B1%D9%88%D8%B9%D9%83/) كيف تقرر بين اللغات لخدمة من هذا النوع. وفي هذا المقال نبنيها بلغة C، وهذا اختيار لن تراه كثيراً في خدمة ويب حقيقية، ولكنه مفيد جداً للتعلم، لأن كل ما تخفيه عنك الأطر في اللغات الأخرى سوف تكتبه هنا بيدك: قراءة جسم الطلب قطعة قطعة، وتحويل JSON، وتمرير المعاملات إلى قاعدة البيانات، وتحرير كل ما حجزته في كل مسار، ومنها مسارات الخطأ.

والدرس في هذا المقال ليس أن C لغة جيدة لخدمات الويب، وإنما أن ترى بالضبط أين تزيد صعوبتها، بالأسطر والأرقام. فسوف نبني الخدمة بمكتبة HTTP صغيرة معروفة هي libmicrohttpd، وبمكتبة PostgreSQL الرسمية libpq، وبمكتبة cJSON لقراءة JSON، ثم نختبرها باختبارات وحدة واختبارات تكامل على PostgreSQL 17 حقيقي، وباختبار تزامن يرسل عشرين طلباً في نفس اللحظة على منتج فيه عشر قطع، ثم نشغلها تحت Valgrind، ونبني لها Image، ونقيسها. وهي آخر خطوة قبل مقال [الأخطاء الشائعة](https://arabroot.io/start/#stage-lang-c) في مرحلة لغة C، وتعتمد على ما سبق: الأنواع والذاكرة والأخطاء، ثم الخيوط والأقفال.

وسوف نناقش في هذا المقال ما يلي:

- مواصفات الخدمة المشتركة، واختيار المكتبات في لغة لا تملك إطار ويب شائعاً.
- حساب الضريبة بالهللات، وقراءة الطلب من libmicrohttpd قطعة قطعة، ومن يحرر ماذا.
- JSON بلغة لا تعرفه: cJSON والأعداد التي تصل كلها `double`.
- قاعدة البيانات Database مع libpq: اتصال لكل خيط، ومعاملة Transaction تقفل الصفوف بترتيب، وحقن SQL Injection حقيقي بالدالة `snprintf`.
- الاختبارات Tests: وحدة وتكامل وتزامن، وValgrind الذي وجد تسرباً في الخدمة نفسها.
- الـ Dockerfile متعدد المراحل، والقياس بحد معالجين و512 MB، وجدول صريح بما جعلته C أصعب.

## المواصفات Spec: نفس الخدمة في كل اللغات

المواصفات ثابتة في كل لغات المسار، ونكتبها هنا كاملة لأن كل قرار في الكود يعود إليها. قاعدة البيانات PostgreSQL 17 بثلاثة جداول بالضبط:

```sql
CREATE TABLE products (
    id            serial PRIMARY KEY,
    name_ar       text NOT NULL,
    price_halalas int  NOT NULL CHECK (price_halalas >= 0),
    stock         int  NOT NULL CHECK (stock >= 0)
);

CREATE TABLE orders (
    id            serial PRIMARY KEY,
    created_at    timestamptz DEFAULT now(),
    total_halalas int NOT NULL
);

CREATE TABLE order_items (
    order_id      int REFERENCES orders (id),
    product_id    int REFERENCES products (id),
    qty           int CHECK (qty > 0),
    price_halalas int
);

INSERT INTO products (name_ar, price_halalas, stock) VALUES
    ('قهوة عربية', 990, 100),
    ('تمر سكري', 2500, 50),
    ('هيل مطحون', 1575, 10);
```

وثلاث نقاط نهاية Endpoints:

- `GET /products`: قائمة المنتجات بصيغة JSON.
- `POST /orders` بجسم مثل `{"items":[{"product_id":1,"qty":3}]}`: في معاملة واحدة تقفل صفوف المنتجات، وتتحقق من المخزون، وتخصمه، وتنشئ الطلب وسطوره، وتحسب الإجمالي مع ضريبة 15% بالهللات بعدد صحيح مع التقريب نصف للأعلى. ويرجع 201 عند النجاح، و409 إذا لم يكف المخزون، و400 إذا كان الطلب نفسه خاطئاً.
- `GET /orders/{id}`: الطلب وسطوره، أو 404.

والاختبارات المطلوبة: اختبار وحدة لدالة الضريبة يشمل الحالة 9.90 ريالاً × 3 = 29.70 وضريبتها 4.455 تقرب إلى 4.46 والإجمالي 34.16، واختبار تكامل على PostgreSQL حقيقي في Container، واختبار تزامن يرسل عشرين طلباً متوازياً على منتج مخزونه عشرة فينجح عشرة بالضبط. ثم Dockerfile، وقياس كل Container بحد معالجين و512 MB.

والسبب في تثبيت كل هذه التفاصيل، حتى أسماء الأعمدة وأسعار المنتجات الثلاثة، أن المقارنة بين اللغات لا معنى لها إذا اختلف العمل نفسه. فإذا قرأت مقال نفس الخدمة بلغة Go أو Rust أو Python في هذا المسار، فالفرق الذي تراه في عدد الأسطر أو في الذاكرة أو في طريقة معالجة الأخطاء هو فرق اللغة وأدواتها، لا فرق المواصفات. ولاحظ أن المواصفات تطلب حساب الضريبة بعدد صحيح، والقفل داخل المعاملة، والرد 409 لا 500 عند نقص المخزون، وهي ثلاث نقاط سوف ترى أن C تجعل كلاً منها أصعب بطريقة مختلفة.

## اختيار المكتبات Libraries: لا يوجد إطار ويب شائع في C

في Python تختار FastAPI، وفي Go مكتبة `net/http` القياسية، وفي Rust إطار Axum. أما في C فلا يوجد إطار يستخدمه أغلب الناس لخدمات الويب، لأن أغلب الناس لا يكتبون خدمات الويب بلغة C. وما يوجد مكتبات خوادم HTTP صغيرة تضمنها في برنامجك، واخترنا منها [GNU libmicrohttpd](https://www.gnu.org/software/libmicrohttpd/?ref=arabroot.io)، لأنها جزء من مشروع GNU منذ سنوات، ومتاحة في حزم كل التوزيعات، وتدير الخيوط والاتصالات وبروتوكول HTTP نيابة عنك، وتترك لك دالة واحدة تستقبل كل طلب. ولكنها لا تعرف شيئاً عن التوجيه Routing ولا JSON ولا قواعد البيانات، فكل ذلك عليك.

ولقاعدة البيانات لا يوجد اختيار حقيقي: [libpq](https://www.postgresql.org/docs/17/libpq.html?ref=arabroot.io) هي مكتبة C الرسمية من مشروع PostgreSQL نفسه، وأغلب مكتبات PostgreSQL في اللغات الأخرى، مثل psycopg في Python، مبنية عليها. ولـ JSON اخترنا [cJSON](https://github.com/DaveGamble/cJSON?ref=arabroot.io)، وهي ملف `.c` وملف `.h`، وموجودة في حزم Debian باسم `libcjson-dev`. والإصدارات في المختبر من Debian 13: libmicrohttpd 1.0.1، وlibpq 17.11، وcJSON 1.7.18، والمترجم GCC 16.2 من صورة المختبر التي بنيناها في المقال الأول.

![بنية الخدمة: wrk أو الاختبارات ترسل HTTP إلى libmicrohttpd بمجمع من أربعة خيوط، وكل خيط له اتصال PGconn خاص في متغير _Thread_local، والطلب يمر عبر route ثم parse_order بمكتبة cJSON ثم create_order بمكتبة libpq إلى PostgreSQL 17 في معاملة واحدة بـ SELECT FOR UPDATE مرتبة، والرد نص JSON محجوز تحرره libmicrohttpd بعد الإرسال، وجسم الطلب يحرر في دالة completed](https://arabroot.io/content/images/2026/10/c-real-service-01-architecture.webp)

ثلاث مكتبات، وكل حد بينها نقطة ملكية للذاكرة

والمشروع خمسة ملفات مصدر، ومعها الاختبارات وتجربة الحقن:

```text
lab/
  schema.sql          the shared schema and the three products
  Makefile            build with pkg-config, unit tests, ASan build
  Dockerfile          multi-stage: gcc:16.2.0-trixie then debian:trixie-slim
  src/money.c/.h      VAT and totals in integer halalas
  src/orders.c/.h     JSON parsing (cJSON) and database work (libpq)
  src/main.c          HTTP (libmicrohttpd), routing, one database connection per thread
  tests/test_unit.c   money and parsing, no database
  tests/itest.c       integration and concurrency tests (libcurl + libpq)
  whatif/inject.c     the SQL injection experiment
```

والملف `Makefile` يسأل `pkg-config` عن خيارات المكتبات الثلاث بدلاً من كتابتها، كما شرحنا في قسم الحزم من المقال الأول، ويبني بكل التحذيرات التي نصحنا بها في هذه المرحلة:

```makefile
CC       ?= gcc
CFLAGS   ?= -std=c23 -O2 -g -Wall -Wextra -Wpedantic -Wshadow -Wconversion -Wno-sign-conversion
PKGS      = libmicrohttpd libpq libcjson
CPPFLAGS += $(shell pkg-config --cflags $(PKGS))
LDLIBS   += $(shell pkg-config --libs $(PKGS))
```

والمترجم لم يعط أي تحذير على الخدمة كلها بهذه الخيارات، كما سترى في مخرج البناء.

## حساب الضريبة Money: عدد صحيح من البداية إلى النهاية

هذا أسهل جزء، وهو نفس الدالة التي كتبناها في المقال الأول، مع إضافة مهمة: عمود الإجمالي في قاعدة البيانات من نوع `int`، فيجب أن نرفض أي طلب يتجاوز إجماليه هذا الحد قبل أن نحاول كتابته، وإلا رفضته قاعدة البيانات بخطأ 500 أو، أسوأ من ذلك، التف العدد في C قبل أن يصل إليها:

```c
int64_t vat_halalas(int64_t subtotal) {
    // subtotal * 15 / 100 rounded half up, in integers only: +50 before the division by 100.
    return (subtotal * VAT_PERCENT + 50) / 100;
}

int order_totals(const int32_t *price, const int32_t *qty, int n, Totals *out) {
    int64_t sub = 0;
    for (int i = 0; i < n; i++) {
        sub += (int64_t)price[i] * qty[i];   // int64_t: 2,000,000,000 * 2 does not overflow
        if (sub > INT_MAX) return -1;
    }
    int64_t vat = vat_halalas(sub);
    if (sub + vat > INT_MAX) return -1;      // orders.total_halalas is an int column
    out->subtotal = sub;
    out->vat = vat;
    out->total = sub + vat;
    return 0;
}
```

لاحظ أن الحساب كله في `int64_t`، والفحص قبل أن يصل العدد إلى حده لا بعد أن يلتف، وهذا بالضبط ما تعلمناه من مقالي الأنواع والأخطاء الشائعة: فحص التجاوز بعد حدوثه في عدد بإشارة يحذفه المترجم. والتقريب نصف للأعلى بإضافة 50 قبل القسمة صحيح هنا لأن المجموع لا يكون سالباً أبداً.

## طبقة HTTP مع libmicrohttpd: الطلب يصل قطعاً

في أي إطار ويب تكتب دالة تستقبل الطلب كاملاً، وجسمه جاهز في متغير. أما libmicrohttpd فتستدعي دالتك عدة مرات لنفس الطلب: مرة عند وصول الترويسات، ثم مرة لكل قطعة من الجسم، ثم مرة أخيرة بلا بيانات تعني أن الجسم اكتمل، كما يشرح [دليل المكتبة](https://www.gnu.org/software/libmicrohttpd/manual/libmicrohttpd.html?ref=arabroot.io). وبين هذه الاستدعاءات تعطيك مؤشراً واحداً `void **state` تحفظ فيه ما تشاء، ومسؤوليتك أن تحجز وتجمع وتحرر:

```c
// Per-request state: libmicrohttpd hands us the body in pieces, we keep them here.
typedef struct {
    char *body;
    size_t len;
    int too_big;
} Request;

static enum MHD_Result handle(void *cls, struct MHD_Connection *c, const char *url, const char *method,
                              const char *version, const char *upload, size_t *upload_size, void **state) {
    Request *req = *state;
    if (!req) {                                   // first call: headers have arrived
        req = calloc(1, sizeof *req);
        if (!req) return MHD_NO;
        *state = req;
        return MHD_YES;
    }
    if (*upload_size) {                           // a piece of the body
        if (req->len + *upload_size > MAX_BODY) {
            req->too_big = 1;
        } else if (!req->too_big) {
            char *b = realloc(req->body, req->len + *upload_size + 1);
            if (!b) return MHD_NO;
            memcpy(b + req->len, upload, *upload_size);
            req->len += *upload_size;
            b[req->len] = '\0';
            req->body = b;
        }
        *upload_size = 0;                         // tell libmicrohttpd we consumed it
        return MHD_YES;
    }
    return send_reply(c, route(method, url, req)); // last call: the whole body is here
}

// Called when a request is finished, also when the client disconnects in the middle.
static void completed(void *cls, struct MHD_Connection *c, void **state, enum MHD_RequestTerminationCode t) {
    Request *req = *state;
    if (req) { free(req->body); free(req); *state = NULL; }
}
```

في الكود أعلاه لاحظ التالي:

- الجسم يجمع بـ `realloc` في متغير مؤقت `b`، وبعد كل قطعة نضع الصفر في النهاية، لأن مكتبة JSON ستقرؤه نصاً. وكل الأخطاء التي رأيناها في مقال الأنواع والذاكرة ممكنة هنا: لو نسينا `+ 1` في الحجم لكتب الصفر خارج المخزن.
- الحد `MAX_BODY` قدره 64 KB، وبدونه يستطيع أي شخص أن يرسل جسماً بحجم الذاكرة كلها. ولاحظ أننا لا نرد بخطأ 413 فوراً، وإنما نكمل قراءة القطع ونرميها ثم نرد في الاستدعاء الأخير، لأن المكتبة تنتظر أن نستهلك الجسم.
- التحرير ليس في دالة الطلب، وإنما في دالة `completed` التي نسجلها بالخيار `MHD_OPTION_NOTIFY_COMPLETED`. والسبب أن العميل قد يقطع الاتصال في منتصف الجسم، فلا يصل الاستدعاء الأخير أبداً، و`completed` تستدعى في كل الحالات. ولو حررنا في الاستدعاء الأخير فقط لصار كل اتصال مقطوع تسرباً.

والرد نفسه سلسلة ملكية صغيرة: `route` ترجع هيكلاً فيه رمز الحالة ونص JSON محجوز بـ `malloc`، و`send_reply` تسلمه للمكتبة مع الدالة التي تحرره، فتحرره المكتبة بعد أن ترسله، لا نحن:

```c
static enum MHD_Result send_reply(struct MHD_Connection *c, Reply r) {
    if (!r.body) { r.status = 500; r.body = strdup("{\"error\":\"out of memory\"}"); }
    struct MHD_Response *resp = MHD_create_response_from_buffer_with_free_callback(strlen(r.body), r.body, free);
    MHD_add_response_header(resp, "Content-Type", "application/json; charset=utf-8");
    enum MHD_Result ret = MHD_queue_response(c, r.status, resp);
    MHD_destroy_response(resp);       // the response keeps a reference until it is sent
    return ret;
}
```

والتوجيه Routing بضع مقارنات نصوص بالدالة `strcmp`، ورقم الطلب في `/orders/{id}` يقرأ بـ `strtol` مع مؤشر النهاية وحدود صريحة، كما تعلمنا من مشكلة `three` في المقال الأول، فالطلب `/orders/abc` يرجع 400 لا طلباً رقمه صفر. وأخيراً تبدأ الخدمة بمجمع خيوط حجمه من متغير البيئة `THREADS`، لا من عدد الأنوية، للسبب الذي رأيناه في مقال التزامن مع `--cpus=2`:

```c
    struct MHD_Daemon *d = MHD_start_daemon(
        MHD_USE_INTERNAL_POLLING_THREAD | MHD_USE_ERROR_LOG, (uint16_t)port, NULL, NULL, handle, NULL,
        MHD_OPTION_THREAD_POOL_SIZE, threads,
        MHD_OPTION_NOTIFY_COMPLETED, completed, NULL,
        MHD_OPTION_CONNECTION_TIMEOUT, (unsigned)30,
        MHD_OPTION_END);
```

## JSON بلغة لا تعرفه: cJSON والأعداد التي تصل double

JSON هو الصعوبة الثانية. ففي Python يصبح الجسم قاموساً بسطر واحد، وفي Go وRust تعرف هيكلاً ويملؤه المحلل ويتحقق من الأنواع. أما cJSON فتعطيك شجرة عامة من عقد، وعليك أن تمشي فيها وتتحقق من كل عقدة. وأول ما يفاجئك أن كل الأعداد فيها تحفظ `double`، فالمكتبة لا تفرق بين `3` و`3.0` و`2.5` و`1e3`، والتحقق من أن الكمية عدد صحيح موجب عليك:

```c
// cJSON keeps every number as a double: 2.5 and 1e3 are numbers too, so check for a real int.
static int get_positive_int(const cJSON *obj, const char *key, int32_t *out) {
    const cJSON *v = cJSON_GetObjectItemCaseSensitive(obj, key);
    if (!cJSON_IsNumber(v)) return -1;
    double d = v->valuedouble;
    if (d < 1 || d > 1000000 || d != (double)(int32_t)d) return -1;
    *out = (int32_t)d;
    return 0;
}
```

والترتيب في الشرط مهم: نتحقق من المدى قبل التحويل إلى `int32_t`، لأن تحويل `double` خارج مدى العدد الصحيح سلوك غير معرف في C. ثم تأخذ دالة `parse_order` سطور الطلب واحداً واحداً، وتدمج السطور المكررة لنفس المنتج، وتحفظها مرتبة برقم المنتج. والترتيب ليس للجمال، وإنما لكي تقفل المعاملة صفوف المنتجات دائماً بنفس الترتيب، وهي قاعدة ترتيب الأقفال التي منعت الجمود في مقال التزامن، وتعمل بنفس الطريقة داخل قاعدة البيانات كما شرحنا في مقال [الأقفال والجمود في قواعد البيانات](https://arabroot.io/articles/%D8%A7%D9%84%D8%A7%D9%82%D9%81%D8%A7%D9%84-locks-%D9%88%D8%A7%D9%84%D8%AC%D9%85%D9%88%D8%AF-deadlock-%D9%81%D9%8A-%D9%82%D9%88%D8%A7%D8%B9%D8%AF-%D8%A7%D9%84%D8%A8%D9%8A%D8%A7%D9%86%D8%A7%D8%AA/). وفي النهاية، وفي كل مسار خطأ، تحرر الشجرة كلها بسطر واحد للجذر:

```c
int parse_order(const char *json, size_t len, OrderRequest *out, const char **err) {
    cJSON *root = cJSON_ParseWithLength(json, len);
    if (!root) { *err = "body is not valid JSON"; return -1; }
    int rc = -1;
    const cJSON *items = cJSON_GetObjectItemCaseSensitive(root, "items");
    if (!cJSON_IsArray(items)) { *err = "items must be an array"; goto done; }
    int n = cJSON_GetArraySize(items);
    if (n < 1 || n > MAX_ITEMS) { *err = "items must have 1 to 100 entries"; goto done; }
    /* ... each item: positive integer product_id and qty, merged and sorted by product_id ... */
    rc = 0;
done:
    cJSON_Delete(root);
    return rc;
}
```

ولاحظ أن رسالة الخطأ `*err` نص ثابت لا يحتاج تحريراً، وهذا قرار مقصود: لو بنينا رسالة فيها رقم السطر الخاطئ بالدالة `snprintf` في ذاكرة محجوزة، لصار على كل من يستدعي الدالة أن يحررها. وفي C يصبح كل قرار من هذا النوع، كم أعطي من المعلومات في رسالة الخطأ، قراراً عن الذاكرة أيضاً.

## قاعدة البيانات Database مع libpq: معاملة تقفل الصفوف بترتيب

الصعوبة الثالثة في الخيوط. فـ libmicrohttpd تشغل الطلبات على مجمع من الخيوط، والاتصال بقاعدة البيانات `PGconn` لا يستخدم من خيطين في نفس الوقت، كما يقول فصل [libpq والخيوط](https://www.postgresql.org/docs/17/libpq-threading.html?ref=arabroot.io). وفي اللغات الأخرى تستخدم مجمع اتصالات Connection Pool جاهزاً، أما هنا فأبسط حل صحيح أن يكون لكل خيط اتصاله الخاص، في متغير محلي للخيط من C11 `_Thread_local`، يفتح عند أول طلب على ذلك الخيط:

```c
// libpq connections are not thread safe: each libmicrohttpd worker thread keeps its own.
static _Thread_local PGconn *tl_db;

static PGconn *db(void) {
    if (tl_db && PQstatus(tl_db) == CONNECTION_OK) return tl_db;
    if (tl_db) {
        PQreset(tl_db);
    } else {
        tl_db = PQconnectdb(db_url);
        pthread_mutex_lock(&conns_mu);
        if (n_conns < MAX_CONNS) all_conns[n_conns++] = tl_db;
        pthread_mutex_unlock(&conns_mu);
    }
    if (PQstatus(tl_db) != CONNECTION_OK) {
        fprintf(stderr, "db connect: %s", PQerrorMessage(tl_db));
        return NULL;
    }
    return tl_db;
}
```

والقائمة `all_conns` والقفل حولها لم تكن في النسخة الأولى من الكود، وسوف ترى في قسم الاختبارات كيف عرفنا أننا نحتاجها. أما قلب الخدمة فهو إنشاء الطلب، وهو معاملة واحدة بأربع خطوات، مكتوبة بنمط `goto` الذي رأيناه في مقال الأخطاء، حيث المورد الذي يجب تحريره عند كل فشل هو المعاملة نفسها، بالأمر `ROLLBACK`:

```c
    if (exec_ok(db, "BEGIN")) return error_reply(500, "database error");

    // 1) lock the product rows, in id order, until COMMIT or ROLLBACK
    const char *p1[] = {ids};
    r = PQexecParams(db, "SELECT id, price_halalas, stock FROM products WHERE id = ANY($1::int[]) "
                         "ORDER BY id FOR UPDATE", 1, NULL, p1, NULL, NULL, 0);
    if (PQresultStatus(r) != PGRES_TUPLES_OK) goto db_error;
    if (PQntuples(r) != req->n) { rep = error_reply(400, "unknown product_id"); goto rollback; }

    // 2) check the stock of every line
    for (int i = 0; i < req->n; i++) {
        price[i] = atoi(PQgetvalue(r, i, 1));
        if (atoi(PQgetvalue(r, i, 2)) < req->qty[i]) {
            rep = error_reply(409, "insufficient stock");
            goto rollback;
        }
    }
    PQclear(r);
    r = NULL;

    Totals t;
    if (order_totals(price, req->qty, req->n, &t)) { rep = error_reply(400, "order total too large"); goto rollback; }
    int_array(prices, sizeof prices, price, req->n);

    // 3) decrement the stock, one statement for all lines
    const char *p3[] = {ids, qtys};
    r = PQexecParams(db, "UPDATE products p SET stock = p.stock - t.q "
                         "FROM unnest($1::int[], $2::int[]) AS t(id, q) WHERE p.id = t.id",
                     2, NULL, p3, NULL, NULL, 0);
    if (PQresultStatus(r) != PGRES_COMMAND_OK) goto db_error;
    PQclear(r);

    /* 4) INSERT INTO orders ... RETURNING id, then INSERT INTO order_items ... FROM unnest(...) */

    if (exec_ok(db, "COMMIT")) return error_reply(500, "database error");
    /* ... build the 201 reply ... */

db_error:
    fprintf(stderr, "create_order: %s", PQerrorMessage(db));
    rep = error_reply(500, "database error");
rollback:
    PQclear(r);                      // PQclear(NULL) is allowed
    exec_ok(db, "ROLLBACK");
    return rep;
```

في الكود أعلاه لاحظ التالي:

- `SELECT ... FOR UPDATE` يقفل صفوف المنتجات حتى نهاية المعاملة، كما في [فصل الأقفال في توثيق PostgreSQL](https://www.postgresql.org/docs/17/explicit-locking.html?ref=arabroot.io). فإذا جاء طلبان على نفس المنتج في نفس اللحظة، ينتظر الثاني حتى ينهي الأول، ثم يقرأ المخزون بعد الخصم. وهذا ما يجعل اختبار التزامن ينجح، وبدونه يقرأ الطلبان نفس المخزون ويخصمان معاً، وهو نفس سباق البيانات الذي رأيناه في مقال التزامن، ولكن بين معاملات لا بين خيوط.
- `ORDER BY id` مع القفل يضمن ترتيب الأقفال، فطلب فيه المنتجان 1 و3 وطلب فيه 3 و1 يقفلان بنفس الترتيب، ولا يحدث جمود.
- القوائم ترسل مصفوفات PostgreSQL نصية مثل `{1,3}`، والدالة `unnest` من [دوال المصفوفات](https://www.postgresql.org/docs/17/functions-array.html?ref=arabroot.io) تحولها إلى صفوف، فيخصم المخزون لكل السطور بأمر واحد، وتكتب السطور بأمر واحد، بدلاً من طلب منفصل إلى قاعدة البيانات لكل سطر.
- كل نتيجة `PGresult` تحرر بـ `PQclear` قبل أن نعيد استخدام المتغير، والمتغير يصبح `NULL` بعد آخر تحرير لكي لا يحرر مرتين في مسار `rollback`. وفي ملف `orders.c` وحده 16 سطراً تحرر شيئاً، وهي أسطر لا وجود لها في نسخ اللغات الأخرى.
- كل قيمة تمر إلى SQL معاملاً Parameter عبر `PQexecParams`، لا نصاً داخل الاستعلام، وهذه النقطة تستحق قسماً كاملاً.

### ماذا يحدث إذا حذفت FOR UPDATE؟ What If

القفل هو الذي يجعل اختبار التزامن ينجح، ولكن هل هو ضروري فعلاً، أم أن قاعدة البيانات تحمي نفسها؟ نسخنا الكود، وحذفنا `FOR UPDATE` من استعلام القراءة فقط، وشغلنا اختبار التكامل ثلاث مرات على هذه النسخة، والمخرج في `lab/run-nolock.txt`:

```text
FAIL tests/itest.c:118: created == 10 && conflict == 10 && other == 0
concurrency: 10 created, 7 conflict, 3 other, stock now 0
integration: 16 checks, 1 failures
FAIL tests/itest.c:118: created == 10 && conflict == 10 && other == 0
concurrency: 10 created, 7 conflict, 3 other, stock now 0
integration: 16 checks, 1 failures
FAIL tests/itest.c:118: created == 10 && conflict == 10 && other == 0
concurrency: 10 created, 7 conflict, 3 other, stock now 0
integration: 16 checks, 1 failures
      9 create_order: ERROR:  new row for relation "products" violates check constraint "products_stock_check"
```

النتيجة أدق مما توقعنا. فالمخزون بقي صفراً، ولم يبع أحد قطعة غير موجودة، وعشرة طلبات فقط نجحت، فالبيانات سليمة. ولكن في كل تشغيل حصل ثلاثة عملاء على خطأ 500 بدلاً من 409، والسطر الأخير من سجل الخدمة يبين السبب: تسع مرات في التشغيلات الثلاثة رفضت قاعدة البيانات خصم المخزون لأن القيد `CHECK (stock >= 0)` كان سيكسر. والذي حدث أن بعض الطلبات قرأت المخزون قبل أن تخصمه الطلبات الأخرى، فرأت أنه يكفي، ثم وصلت إلى `UPDATE` الذي ينتظر قفل الصف ويعيد قراءة القيمة الحالية، فوجد أن الخصم سيجعل المخزون سالباً. فالقيد في الجدول هو الذي أنقذ البيانات، وهذا سبب أن المواصفات تضعه، ولكن الخدمة أعطت رمزاً خاطئاً لأن منطقها بني على قراءة قديمة. والدرس مزدوج: القيود في قاعدة البيانات خط الدفاع الأخير ولا تحذفها، والقفل في المعاملة هو ما يجعل منطق الخدمة نفسه صحيحاً ورسائلها صادقة.

### ماذا يحدث إذا بنيت SQL بالدالة snprintf؟ SQL Injection

في C لا يوجد ORM ولا منشئ استعلامات، وأقرب أداة لبناء نص هي `snprintf`، وهي مغرية جداً: الاستعلام يصبح سطراً واحداً، بدلاً من مصفوفة معاملات وأرقام. ولكي نرى الثمن كتبنا في `whatif/inject.c` بحثاً عن منتج بالاسم بطريقتين، الأولى تضع الاسم في النص، والثانية ترسله معاملاً:

```c
    char sql[512];   // the wrong way: the input becomes part of the SQL text
    snprintf(sql, sizeof sql, "SELECT id, name_ar FROM products WHERE name_ar = '%s'", name);
    show(db, PQexec(db, sql), "snprintf + PQexec");

    const char *params[] = {name};   // the right way: the input travels separately as a parameter
    show(db, PQexecParams(db, "SELECT id, name_ar FROM products WHERE name_ar = $1", 1, NULL, params, NULL, NULL, 0),
         "PQexecParams    ");
```

وشغلناه بثلاثة أسماء: اسم عادي، ثم `x' OR '1'='1`، ثم `x'; UPDATE products SET price_halalas = 1; --`، وبعد كل تشغيل يطبع البرنامج أسعار المنتجات:

```text
snprintf + PQexec: 1 rows | 2 تمر سكري
PQexecParams    : 1 rows | 2 تمر سكري
prices now: 1=990 2=2500 3=1575
snprintf + PQexec: 3 rows | 2 تمر سكري | 1 قهوة عربية | 3 هيل مطحون
PQexecParams    : 0 rows
prices now: 1=990 2=2500 3=1575
snprintf + PQexec: PQexecParams    : 0 rows
prices now: 1=1 2=1 3=1
```

مع الاسم العادي أعطت الطريقتان نفس النتيجة، وهذا سبب أن هذا الخطأ يمر في الاختبار. ومع الاسم الثاني صار الشرط `name_ar = 'x' OR '1'='1'`، صحيحاً لكل صف، فرجعت المنتجات الثلاثة، بينما بحثت الطريقة الثانية عن منتج اسمه حرفياً `x' OR '1'='1` فلم تجد شيئاً. ومع الاسم الثالث حدث الأسوأ: الدالة `PQexec` تقبل عدة أوامر مفصولة بفاصلة منقوطة، فنفذت البحث ثم نفذت `UPDATE`، وصار سعر كل منتج هللة واحدة. ولم يطبع البرنامج نتيجة للسطر الأول لأن آخر أمر نفذ كان `UPDATE` لا يرجع صفوفاً. أما `PQexecParams` فلا تقبل إلا أمراً واحداً، والمعامل لا يمكن أن يغير شكل الأمر أبداً، لأنه يصل إلى قاعدة البيانات منفصلاً عن نص الاستعلام، كما يشرح [فصل تنفيذ الأوامر في libpq](https://www.postgresql.org/docs/17/libpq-exec.html?ref=arabroot.io). وقد شرحنا هذا النوع من الثغرات في مقال [OWASP Top 10](https://arabroot.io/articles/owasp-top-10-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)، والجديد في C أن الطريقة الخاطئة هي الأسهل كتابة، ولا توجد طبقة تمنعك منها، فالقاعدة يجب أن تكون مطلقة: لا `snprintf` لأي نص يدخل في SQL، حتى الأرقام التي تبدو آمنة.

![ثلاث تجارب بحث عن منتج بالاسم: الاسم العادي أعطى صفاً واحداً بالطريقتين، والاسم x' OR '1'='1 أعطى ثلاثة صفوف مع snprintf وصفر مع PQexecParams، والاسم الذي فيه UPDATE products SET price_halalas = 1 جعل كل الأسعار هللة واحدة مع snprintf وPQexec لأن PQexec تقبل عدة أوامر، بينما PQexecParams ترسل الاسم معاملاً منفصلاً](https://arabroot.io/content/images/2026/10/c-real-service-03-injection.webp)

الطريقة الخاطئة سطر واحد، والصحيحة ثلاثة أسطر، والفرق كل أسعار المتجر

## الاختبارات Tests: وحدة وتكامل وتزامن

الاختبارات في ثلاث طبقات، كما شرحناها في مقال [أنواع الاختبارات](https://arabroot.io/articles/%D8%A7%D9%86%D9%88%D8%A7%D8%B9-%D8%A7%D9%84%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1%D8%A7%D8%AA-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86-unit-%D9%88-integration-%D9%88-e2e/)، وكلها برامج C عادية تعد الفحوص والإخفاقات بماكرو صغير، بدلاً من `assert` الذي رأينا في المقال الأول كيف يختفي في بناء `Release`. والسكربت `run-lab.sh` يشغل كل شيء بالترتيب على PostgreSQL 17 حقيقي من الصورة `postgres:17-alpine`:

```bash
bash run-lab.sh > run-output.txt 2>&1
```

### اختبار الوحدة Unit Test: الضريبة وقراءة الطلب

```c
static void test_vat(void) {
    // 9.90 SAR x 3 = 29.70, VAT 4.455 -> 4.46, total 34.16
    int32_t price[] = {990}, qty[] = {3};
    Totals t;
    CHECK(order_totals(price, qty, 1, &t) == 0);
    CHECK(t.subtotal == 2970);
    CHECK(t.vat == 446);
    CHECK(t.total == 3416);
    CHECK(vat_halalas(0) == 0);
    CHECK(vat_halalas(10) == 2);     // 1.5 -> 2 (half up)
    CHECK(vat_halalas(9) == 1);      // 1.35 -> 1
    int32_t big[] = {2000000000}, two[] = {2};
    CHECK(order_totals(big, two, 1, &t) == -1);   // does not fit the int column
}
```

ومعه اختبار لقراءة الطلب: طلب صحيح فيه منتج مكرر يجب أن يدمج ويرتب، وسبعة أجسام خاطئة يجب أن ترفض، منها نص ليس JSON، وقائمة فارغة، وكمية صفر، وكمية `2.5`، ورقم منتج نصي `"1"`. والمخرج، مع البناء نفسه:

```text
cc -I/usr/include/p11-kit-1 -I/usr/include/postgresql -I/usr/include/cjson  -std=c23 -O2 -g -Wall -Wextra -Wpedantic -Wshadow -Wconversion -Wno-sign-conversion src/main.c src/orders.c src/money.c -o orders -lmicrohttpd -lpq -lcjson  -lpthread
unit: 19 checks, 0 failures
### unit tests under ASan + UBSan
unit: 19 checks, 0 failures
```

تسعة عشر فحصاً، صفر إخفاقات، ولا تحذير واحد من المترجم بكل الخيارات، ثم نفس الاختبارات في بناء ثان بـ AddressSanitizer وUndefinedBehaviorSanitizer دون أي تقرير.

### اختبار التكامل Integration Test على PostgreSQL حقيقي

البرنامج `tests/itest.c` يتكلم مع الخدمة عبر HTTP بمكتبة libcurl، ومع قاعدة البيانات مباشرة بمكتبة libpq لكي يجهز البيانات ويتحقق من المخزون. فيطلب المنتجات ويتأكد من وجود «قهوة عربية» بالعربي في JSON، ثم ينشئ طلب 3 قطع قهوة ويتحقق من الضريبة 446 والإجمالي 3416 ومن أن المخزون صار 97، ثم يقرأ الطلب برقمه، ثم يجرب الحالات الخاطئة: طلب غير موجود 404، ورقم `abc` 400، وكمية صفر 400، وجسم ليس JSON 400، ومنتج غير موجود 400، و51 قطعة تمر من مخزون 50 فيرجع 409 ويبقى المخزون 50، أي أن المعاملة تراجعت كاملة. وعند بنائه ظهرت مشكلة حقيقية:

```text
tests/itest.c:92:8: error: unknown type name 'pthread_barrier_t'; did you mean 'pthread_attr_t'?
tests/itest.c:98:5: error: implicit declaration of function 'pthread_barrier_wait'; did you mean 'pthread_cond_wait'? [-Wimplicit-function-declaration]
```

النوع `pthread_barrier_t` موجود في `pthread.h`، ولكن البناء بالمعيار الصارم `-std=c23` يجعل ترويسات glibc تخفي كل ما ليس في معيار C، وحواجز الخيوط من POSIX لا من C. وهذا هو الفرق بين `c23` و`gnu23` الذي ذكرناه في المقال الأول. والحل سطر في أول الملف يطلب امتدادات POSIX صراحة، كما تشرح صفحة [feature\_test\_macros(7)](https://man7.org/linux/man-pages/man7/feature%5Ftest%5Fmacros.7.html?ref=arabroot.io):

```c
#define _POSIX_C_SOURCE 200809L   // -std=c23 hides POSIX extras such as pthread_barrier_t
```

### اختبار التزامن Concurrency Test: عشرون طلباً على عشر قطع

الاختبار الأهم: نضبط مخزون الهيل على 10، ثم ننشئ عشرين خيطاً، وكل خيط ينتظر عند حاجز `pthread_barrier_wait` حتى يصل الجميع، ثم ترسل كلها في نفس اللحظة طلب قطعة واحدة:

```c
static void *client(void *arg) {
    long i = (long)arg;
    Buf b;
    pthread_barrier_wait(&start);
    codes[i] = request("POST", "/orders", "{\"items\":[{\"product_id\":3,\"qty\":1}]}", &b);
    return NULL;
}
```

```text
### integration + concurrency tests (service built in the dev image)
concurrency: 10 created, 10 conflict, 0 other, stock now 0
integration: 16 checks, 0 failures
```

عشرة طلبات نجحت برمز 201، وعشرة رفضت برمز 409، ولا شيء غير ذلك، والمخزون صفر بالضبط، لا سالب ولا واحد. والحاجز مهم هنا: بدونه يبدأ أول خيط ويكاد ينهي طلبه قبل أن يبدأ الأخير، فيصبح الاختبار تسلسلياً دون أن تعرف، ولا يختبر القفل شيئاً.

### مشكلة وجدناها: Valgrind والاتصالات المفقودة Memory Leak

بعد أن نجحت كل الاختبارات شغلنا نفس الاختبارات والخدمة تعمل تحت Valgrind، ثم أوقفناها بالإشارة `SIGTERM` لكي تطبع الأداة تقريرها. والنسخة الأولى من الخدمة أعطت هذا:

```text
==1==     in use at exit: 145,408 bytes in 212 blocks
==1==   total heap usage: 45,406 allocs, 45,194 frees, 6,176,685 bytes allocated
==1== 129,024 (4,864 direct, 124,160 indirect) bytes in 4 blocks are definitely lost in loss record 27 of 27
==1==    at 0x484BBA3: calloc (vg_replace_malloc.c:1675)
==1==    by 0x48ADD0B: ??? (in /usr/lib/x86_64-linux-gnu/libpq.so.5.17)
==1==    by 0x48B37F2: PQconnectStart (in /usr/lib/x86_64-linux-gnu/libpq.so.5.17)
==1==    by 0x48B3C61: PQconnectdb (in /usr/lib/x86_64-linux-gnu/libpq.so.5.17)
==1==    by 0x4027A3: db (main.c:23)
==1==    definitely lost: 4,864 bytes in 4 blocks
==1==    indirectly lost: 124,160 bytes in 207 blocks
==1==      possibly lost: 16,384 bytes in 1 blocks
==1== ERROR SUMMARY: 2 errors from 2 contexts (suppressed: 0 from 0)
```

أربع قطع ضائعة، والرقم أربعة ليس صدفة: هو عدد خيوط المجمع. والمسار يقول إنها من `PQconnectdb` في دالتنا `db`، أي أن كل خيط فتح اتصاله، ثم انتهى الخيط عند إيقاف الخدمة، وضاع المتغير `_Thread_local` معه، ولم يبق أي مؤشر إلى الاتصال، فلم يغلقه أحد. والأسطر `indirectly lost` هي ما يملكه كل اتصال من مخازن. وهذا تسرب لا يكبر مع الطلبات، فهو أربع مرات فقط في عمر الخدمة، ولكنه يعني أن الخدمة تقطع اتصالاتها بقاعدة البيانات دون أن تغلقها، فيكتب PostgreSQL في سجله أن العميل اختفى، وأن أي أداة مثل Valgrind سوف تصرخ كل مرة فتخفي تسرباً حقيقياً لو ظهر. والإصلاح هو القائمة `all_conns` التي رأيتها في قسم قاعدة البيانات: كل اتصال جديد يسجل فيها تحت قفل، وبعد أن يوقف `MHD_stop_daemon` الخيوط كلها يغلقها الخيط الرئيسي:

```c
    MHD_stop_daemon(d);                 // waits for the worker threads to finish
    for (int i = 0; i < n_conns; i++) PQfinish(all_conns[i]);
    printf("stopped, closed %d database connections\n", n_conns);
```

```text
### the same tests with the service under Valgrind
concurrency: 10 created, 10 conflict, 0 other, stock now 0
integration: 16 checks, 0 failures
orders listening on :8080 with 4 threads
stopped, closed 4 database connections
==1==     in use at exit: 0 bytes in 0 blocks
==1==   total heap usage: 45,407 allocs, 45,407 frees, 6,209,501 bytes allocated
==1== All heap blocks were freed -- no leaks are possible
==1== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)
```

كل حجز من 45407 حرر، ولا خطأ قراءة أو كتابة في أي من مسارات الاختبار. ولاحظ أن الاختبارات نفسها نجحت تحت Valgrind أيضاً، رغم أن الأداة تبطئ الخدمة عشرات المرات، فاختبار التزامن لم يكن يعتمد على السرعة. وفي أي لغة فيها جامع قمامة أو ملكية لم يكن هذا الخطأ ليوجد، وفي C لم يكن ليظهر لولا أداة خارج اللغة.

![نتائج الاختبارات: 19 فحص وحدة بلا إخفاق ومثلها تحت ASan وUBSan، و16 فحص تكامل بلا إخفاق، واختبار التزامن 20 طلباً على 10 قطع أعطى 10 نجاحات و10 رفضاً والمخزون صفر، وValgrind في النسخة الأولى وجد 4 اتصالات ضائعة بمجموع 129,024 بايت من PQconnectdb في الدالة db، وبعد تسجيل الاتصالات وإغلاقها بعد MHD_stop_daemon لم يبق أي تسرب](https://arabroot.io/content/images/2026/10/c-real-service-02-tests.webp)

كل الاختبارات نجحت، والتسرب لم يظهر إلا تحت Valgrind

### الإيقاف الآمن Graceful Shutdown: الإشارة ثم الخيوط ثم الاتصالات

عندما يوقف Docker الـ Container بالأمر `docker stop` يرسل الإشارة `SIGTERM` إلى العملية الأولى، وينتظر عشر ثوان افتراضياً، ثم يقتلها بـ `SIGKILL` إذا لم تخرج. والخدمة التي لا تتعامل مع `SIGTERM` تموت في منتصف معاملة، أو تنتظر الثواني العشر في كل نشر جديد. وفي C لا يجوز أن تفعل شيئاً معقداً داخل معالج الإشارة، فلا `printf` ولا `free` ولا استدعاء لـ libpq، لأن الإشارة قد تصل والخيط في منتصف `malloc` نفسها. ولذلك يرفع المعالج علامة من النوع `volatile sig_atomic_t` فقط، وهو النوع الوحيد الذي يضمن المعيار أن الكتابة فيه من معالج الإشارة آمنة، ويستيقظ الخيط الرئيسي من `pause` فيرى العلامة ويبدأ الإيقاف بالترتيب: `MHD_stop_daemon` تتوقف عن قبول اتصالات جديدة وتنتظر الخيوط حتى تنهي طلباتها الحالية، ثم يغلق الخيط الرئيسي اتصالات قاعدة البيانات واحداً واحداً، ثم يخرج برمز 0:

```c
static volatile sig_atomic_t stop;
static void on_signal(int s) { (void)s; stop = 1; }

    signal(SIGTERM, on_signal);
    signal(SIGINT, on_signal);
    while (!stop) pause();
    MHD_stop_daemon(d);                 // waits for the worker threads to finish
    for (int i = 0; i < n_conns; i++) PQfinish(all_conns[i]);
```

وهذا الترتيب هو الذي جعل تقرير Valgrind ممكناً أصلاً، فالأداة تطبع تقريرها عندما يخرج البرنامج خروجاً طبيعياً، ولو قتل بـ `SIGKILL` لما طبعت شيئاً. ورأيت في المخرج السطر `stopped, closed 4 database connections` قبل التقرير. وتذكر ما قلناه في مقال التزامن عن الإشارات والخيوط: الإشارة قد تصل إلى أي خيط لم يحجبها، ولكن هذا لا يضر هنا، لأن المعالج لا يفعل إلا رفع العلامة، والخيط الرئيسي يرى العلامة عندما يستيقظ، أو في أسوأ حالة عندما تصله إشارة أخرى. وفي خدمة أكبر يحجب البرنامج الإشارات في كل الخيوط ويخصص خيطاً واحداً ينتظرها بالدالة `sigwait`، وهو الأسلوب الأنظف.

## الـ Dockerfile متعدد المراحل Multi-stage Build

الخدمة ملف تنفيذي صغير يحتاج ثلاث مكتبات وقت التشغيل، أما المترجم والترويسات فلا يحتاجها إلا البناء. ولهذا نبني على مرحلتين، كما في [توثيق Docker](https://docs.docker.com/build/building/multi-stage/?ref=arabroot.io): الأولى صورة GCC الرسمية مع حزم التطوير، وفيها نشغل اختبارات الوحدة أيضاً فيفشل البناء إذا فشلت، والثانية Debian النحيلة مع حزم التشغيل فقط:

```dockerfile
# Stage 1: build with the current GCC and the -dev packages (headers, pkg-config files).
FROM gcc:16.2.0-trixie AS build
RUN apt-get update && apt-get install -y --no-install-recommends \
      libmicrohttpd-dev libpq-dev libcjson-dev pkg-config \
 && rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY Makefile ./
COPY src ./src
COPY tests ./tests
RUN make orders test_unit && ./test_unit

# Stage 2: only the binary and the shared libraries it needs at run time.
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
      libmicrohttpd12t64 libpq5 libcjson1 \
 && rm -rf /var/lib/apt/lists/* \
 && useradd --system --no-create-home orders
COPY --from=build /src/orders /usr/local/bin/orders
USER orders
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/orders"]
```

ولاحظ أن مرحلة البناء والتشغيل كلتيهما على Debian 13، وهذا ليس تفصيلاً: الملف التنفيذي مربوط بـ glibc من مرحلة البناء، فلو بنيناه على نظام أحدث من نظام التشغيل لرأينا خطأ `GLIBC_... not found` الذي ذكرناه في المقال الأول. وأسماء الحزم في المرحلة الثانية مثل `libmicrohttpd12t64` هي أسماء مكتبات التشغيل التي تجدها في صفحة الحزمة على [موقع Debian](https://packages.debian.org/trixie/libmicrohttpd-dev?ref=arabroot.io). والخدمة تعمل بمستخدم ليس `root`، فإذا وجد أحد ثغرة ذاكرة فيها لم يحصل على صلاحيات الجذر داخل الـ Container على الأقل.

## القياس Benchmark: صورة 132 MB وذاكرة 2.4 MiB

القياس بنفس الشروط في كل لغات المسار: الـ Container محدود بمعالجين و512 MB بالخيارين `--cpus=2 --memory=512m`، والخدمة بأربعة خيوط `THREADS=4`، وقاعدة البيانات في Container مستقل على نفس الشبكة، والضغط Load بالأداة [wrk](https://github.com/wg/wrk?ref=arabroot.io) من Container ثالث بخيطين وخمسين اتصالاً لمدة 15 ثانية على `GET /products`، مرتين. والسكربت `run-measure.sh` والمخرج كاملاً في `run-measure.txt`. وقبل الأرقام تحذير مهم: الجهاز مشترك فيه ثماني أنوية منطقية، وكانت تعمل عليه في نفس الساعة تجارب لغات أخرى، فكل رقم وقت هنا يتأثر بما كان يحدث على الجهاز، ولذلك نعرض كل تشغيل كما هو.

```text
image size 132MB
### idle memory
slr-c-orders 2.363MiB / 512MiB 0.00% PIDs 5
```

حجم الصورة 132 MB، وأغلبه طبقة `debian:trixie-slim` والمكتبات المشتركة، أما الملف التنفيذي نفسه فبضع عشرات من الكيلوبايتات. والذاكرة في وضع الخمول 2.4 MiB فقط، وخمس مهام في الـ Container: الخيط الرئيسي وخيوط المجمع الأربعة. وبعد جولة الضغط الأولى صارت 4.1 MiB، وبعد الثانية 5.3 MiB، وهي الاتصالات الأربعة بقاعدة البيانات ومخازن libpq، لا تسرب، كما أكدت Valgrind.

وزمن البدء البارد Cold Start قسناه بطريقتين. الأولى من لحظة تشغيل العملية إلى أول رد 200، داخل Container محدود بنفس الحدود، بسكربت يشغل الملف التنفيذي نفسه المنسوخ من الصورة ثم يسأل `localhost` حتى يرد. والثانية من الأمر `docker run` على الجهاز إلى أول رد 200 عبر المنفذ المنشور:

```text
process start to first 200: 433902 us
process start to first 200: 930597 us
process start to first 200: 560838 us
process start to first 200 (5 ms poll): 307301 us
second request: 50981 us
process start to first 200 (5 ms poll): 671334 us
second request: 18134 us
process start to first 200 (5 ms poll): 1159021 us
second request: 73558 us
docker run to first 200: 4182 ms
docker run to first 200: 6547 ms
docker run to first 200: 8703 ms
```

هذه الأرقام تحتاج قراءة متأنية لأنها لا تقيس ما تبدو أنها تقيسه. فمن تشغيل العملية إلى أول رد بين 0.31 و1.16 ثانية، وهذا كثير لبرنامج C لا يفعل عند البدء إلا فتح منفذ. ولكن انظر إلى السطر `second request`: طلب ثان واحد بعد أن صارت الخدمة جاهزة أخذ بين 18 و74 ملي ثانية، وهذا وقت تشغيل عملية `curl` جديدة على جهاز مزدحم، لا وقت الخدمة. فالجزء الأكبر من الرقم هو تكلفة السؤال نفسه، ومعه أول اتصال بقاعدة البيانات الذي يفتح عند أول طلب. وطريقة `docker run` أعطت بين 4 و9 ثوان، وأغلبها إنشاء الـ Container وإعداد الشبكة وتمرير المنفذ في Docker Desktop على Windows، وهي نفس التكلفة لأي لغة. فالاستنتاج الصادق أن الخدمة نفسها تبدأ أسرع مما تستطيع هذه الطريقة قياسه على هذا الجهاز، وأن أي فرق بين اللغات في البدء البارد أقل من عشرات الملي ثانية لن يظهر في هذه الأرقام.

أما الضغط فهذه أهم أسطر الجولتين بالصورة النهائية:

```text
### wrk run 1
    Latency    26.82ms   29.07ms 265.23ms   87.02%
     50%   17.55ms
     99%  135.10ms
  36520 requests in 15.08s, 11.70MB read
Requests/sec:   2421.29
### wrk run 2
    Latency    44.23ms   53.06ms 526.75ms   86.90%
     50%   27.65ms
     99%  246.19ms
  25089 requests in 15.03s, 8.04MB read
Requests/sec:   1669.74
```

وقبل إصلاح تسرب الاتصالات كنا قد شغلنا نفس القياس على النسخة الأولى من الصورة، ومسار `GET /products` فيها مطابق حرفياً، لأن الإصلاح لا يعمل إلا عند الإيقاف، في ساعة كان فيها الجهاز أهدأ:

```text
### wrk run 1
    Latency    14.19ms   13.30ms 157.70ms   84.55%
     50%   11.83ms
     99%   58.49ms
  62113 requests in 15.12s, 19.90MB read
Requests/sec:   4108.92
### wrk run 2
    Latency    14.85ms   13.78ms 130.75ms   82.95%
     50%   12.22ms
     99%   61.30ms
  59904 requests in 15.07s, 19.20MB read
  Socket errors: connect 0, read 0, write 0, timeout 12
Requests/sec:   3973.79
```

نفس الكود أعطى 4109 و3974 طلباً في الثانية في الساعة الأولى، و2421 و1670 في الساعة الثانية، وتضاعف المئين 99 من 60 إلى 135 ثم 246 ملي ثانية. وهذا الفرق كله من الجهاز لا من الخدمة، وهو أكبر من أي فرق ستراه بين أغلب اللغات في هذا القياس، فلا تبن مقارنة بين اللغات من رقم واحد في جدول. ولاحظ أيضاً ما يقيده هذا القياس فعلاً: كل طلب `GET /products` يذهب إلى PostgreSQL، والخدمة لها أربعة اتصالات فقط بأربعة خيوط، فالحد الأعلى هنا هو الذهاب إلى قاعدة البيانات والعودة في كل طلب على معالجين، لا سرعة كود C. وفي ذلك درس عام شرحناه في مقال [PostgreSQL لكل شيء](https://arabroot.io/articles/postgresql-%D9%84%D9%83%D9%84-%D8%B4%D9%8A%D8%A1-%D9%85%D8%AA%D9%89-%D8%AA%D9%83%D9%81%D9%8A%D9%83-%D9%82%D8%A7%D8%B9%D8%AF%D8%A9-%D8%A8%D9%8A%D8%A7%D9%86%D8%A7%D8%AA-%D9%88%D8%A7%D8%AD%D8%AF%D8%A9/): في خدمة تقرأ من قاعدة البيانات في كل طلب، اللغة نادراً ما تكون عنق الزجاجة، والذاكرة القليلة هي الميزة الحقيقية التي تبقى ثابتة في كل الجولات.

![قياسات الخدمة بحد معالجين و512 MB: الصورة 132 MB، والذاكرة في الخمول 2.363 MiB بخمس مهام وبعد الضغط 4.1 و5.3 MiB، والبدء من تشغيل العملية إلى أول رد بين 0.31 و1.16 ثانية بينما طلب curl الثاني وحده بين 18 و74 ملي ثانية، وdocker run إلى أول رد بين 4.2 و8.7 ثانية، وwrk بخيطين وخمسين اتصالاً: 4109 و3974 طلباً في الثانية بمئين 99 حوالي 60 ملي ثانية في ساعة أهدأ، و2421 و1670 بمئين 99 بين 135 و246 في ساعة مزدحمة لنفس الكود](https://arabroot.io/content/images/2026/10/c-real-service-04-measure.webp)

الذاكرة ثابتة في كل جولة، والوقت يتبع ازدحام الجهاز

## أين تكتب الخدمات بلغة C فعلاً؟ C in Production

بعد كل هذا قد تسأل: إذا كانت C بهذه الصعوبة في خدمة ويب، فلماذا يعمل الإنترنت كله فوق برامج مكتوبة بها؟ والإجابة في نوع البرنامج. فالبرامج التي تستقبل أغلب طلبات الويب في العالم مكتوبة بلغة C فعلاً، مثل nginx وHAProxy وRedis وPostgreSQL نفسه، ولكنها برامج بنية تحتية: تكتب مرة بعناية شديدة، ويعمل عليها فريق صغير متخصص لسنوات، وتتغير ببطء، وكل سطر فيها يمر بمراجعة واختبار وتغذية عشوائية مستمرة، والأداء والذاكرة فيها هما المنتج نفسه. أما خدمة الطلبات في شركة ناشئة فهي العكس تماماً: منطق أعمال يتغير كل أسبوع، ويعمل عليه مطورون يدخلون ويخرجون، والأداء فيها تحدده قاعدة البيانات لا اللغة، كما رأيت في القياس. ولهذا ترى في الواقع C في الطبقة التي تحت الخدمة، أو في مكتبة صغيرة تستدعيها الخدمة، كمكتبة الطابعة في المقال الأول، أو في الأجهزة المدمجة التي لا يتسع فيها شيء غير برنامج C صغير، وهذا بالضبط المكان الذي صممت له مكتبات مثل libmicrohttpd: لوحة إعدادات راوتر، أو واجهة مراقبة داخل جهاز، أو نقطة نهاية صغيرة داخل برنامج أكبر.

وإذا اضطررت يوماً لكتابة خدمة شبكة بلغة C، فهناك ثلاثة أشياء تجعل الثمن معقولاً، وكلها رأيتها في هذا المقال: أن تقلل الكود الذي يتعامل مع المدخلات الخارجية إلى أصغر حجم ممكن وتختبره بالتغذية العشوائية، وأن تجعل كل ملكية للذاكرة واضحة في مكان واحد لكل طلب، وأن تجعل Sanitizers وValgrind جزءاً من كل تشغيل للاختبارات، لا أداة تتذكرها عند الانهيار.

## ما الذي جعلته C أصعب؟ جدول صريح Trade-offs

الخدمة تعمل، والاختبارات تمر، والأرقام جيدة. ولكن هذا هو القسم الأهم في المقال، لأنه الدرس الذي جئنا من أجله: ما الذي كتبناه بأيدينا هنا وتعطيك إياه اللغات الأخرى؟ الكود كله 441 سطراً في `src` مع ملفات الترويسة، منها 218 في `orders.c` وحده، وهذا ما وجدناه:

| المجال             | ما فعلناه في C                                                                                     | ما يحدث في لغة بإطار ويب                         |
| ------------------ | -------------------------------------------------------------------------------------------------- | ------------------------------------------------ |
| الذاكرة            | جسم الطلب يجمع بـ realloc ويحرر في completed، و16 سطر تحرير في orders.c، وكل مسار خطأ يحرر ما قبله | الجامع أو الملكية، ولا يوجد مسار تنسى فيه شيئاً  |
| JSON               | شجرة عامة، وكل عدد double، والتحقق من كل حقل بيدك                                                  | هيكل أو نموذج يملؤه المحلل ويرفض الأنواع الخاطئة |
| SQL                | snprintf هي الأسهل وتسمح بالحقن، والمعاملات مصفوفات نصوص                                           | المعاملات افتراضية في المكتبات والـ ORM          |
| الخيوط والاتصالات  | اتصال لكل خيط، وقائمة وقفل لإغلاقها، وتسرب وجده Valgrind                                           | مجمع اتصالات جاهز                                |
| التوجيه والأخطاء   | strcmp وstrtol بيدك، ورموز الحالة تبنى يدوياً                                                      | إطار يحول المسار والمعاملات والأخطاء             |
| الأمان وقت التشغيل | كل خطأ ذاكرة ثغرة محتملة، والحماية من ASan وValgrind والتغذية العشوائية                            | أغلب هذه الفئة غير ممكن أصلاً                    |
| الثمن مقابل ذلك    | ذاكرة واستهلاك أقل في التشغيل وصورة صغيرة، كما في قسم القياس، وتحكم كامل في كل بايت                |                                                  |

![ما جعلته C أصعب في هذه الخدمة: الذاكرة بجمع الجسم بـ realloc و16 سطر تحرير في orders.c، وJSON بشجرة عامة كل أعدادها double، وSQL حيث snprintf هي الأسهل وتسمح بالحقن، والاتصالات حيث اتصال لكل خيط وتسرب وجده Valgrind، والتوجيه بـ strcmp وstrtok، ومقابل ذلك صورة 132 MB وذاكرة 2.4 MiB](https://arabroot.io/content/images/2026/10/c-real-service-05-costs.webp)

ما تعطيك إياه الأطر في اللغات الأخرى، كتبناه هنا بأيدينا

والخلاصة العملية لمن يختار: C مناسبة لمكتبة صغيرة تضمن في شيء آخر، أو لبرنامج يجب أن يعمل بأقل موارد ممكنة على جهاز مدمج، أو لامتداد لبرنامج مكتوب بها مثل PostgreSQL نفسه. أما خدمة ويب يكتبها فريق ويغيرها كل أسبوع فثمنها في C هو كل سطر في الجدول أعلاه، مضروباً في كل مطور جديد ينضم إلى الفريق. وقد ناقشنا هذا القرار من زاوية الفريق والمشروع في مقال [البرمجة بعدة لغات Polyglot](https://arabroot.io/articles/%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D8%A8%D8%B9%D8%AF%D8%A9-%D9%84%D8%BA%D8%A7%D8%AA-polyglot-%D9%85%D8%AA%D9%89-%D8%AA%D9%81%D9%8A%D8%AF-%D9%88%D9%85%D8%AA%D9%89-%D8%AA%D8%B9%D9%82%D8%AF-%D9%85%D8%B4%D8%B1%D9%88%D8%B9%D9%83/).

## أخطاء شائعة Common Mistakes

- تحرير جسم الطلب في الاستدعاء الأخير فقط، فكل عميل يقطع الاتصال في المنتصف يصبح تسرباً، والصحيح دالة `MHD_OPTION_NOTIFY_COMPLETED`.
- قبول جسم بلا حد للحجم، فيستطيع أي شخص أن يملأ الذاكرة بطلب واحد.
- الثقة في أن العدد من cJSON صحيح: `2.5` و`1e3` أعداد أيضاً.
- مشاركة `PGconn` واحد بين خيوط الخدمة.
- بناء SQL بالدالة `snprintf`: الاسم `x' OR '1'='1` أرجع كل المنتجات، والاسم مع `UPDATE` جعل كل الأسعار هللة.
- قراءة المخزون ثم خصمه دون `FOR UPDATE`، أو قفل المنتجات بترتيب الطلب لا بترتيب الرقم.
- متغيرات `_Thread_local` تملك موارد دون طريقة لإغلاقها بعد انتهاء الخيوط.
- اختبار تزامن دون حاجز يجعل الطلبات تبدأ معاً، فيصبح تسلسلياً دون أن تعرف.
- البناء على نظام أحدث من نظام التشغيل في الـ Dockerfile، فيفشل الملف بخطأ glibc.

## قائمة تحقق Checklist لخدمة بلغة C

- كل حجز لكل طلب له مكان تحرير واحد يستدعى في كل الحالات، وحد صريح لحجم الجسم.
- كل حقل JSON يتحقق من نوعه ومداه قبل التحويل، والرسائل نصوص ثابتة أو موثقة الملكية.
- كل قيمة في SQL معامل في `PQexecParams`، و`PQexec` للأوامر الثابتة فقط.
- اتصال قاعدة بيانات لكل خيط أو مجمع بقفل، مع إغلاق كل الاتصالات عند الإيقاف.
- المعاملة تقفل الصفوف بـ `FOR UPDATE` مرتبة بالرقم، وكل مسار خطأ ينتهي بـ `ROLLBACK`.
- اختبارات وحدة وتكامل وتزامن، وبناء ASan وUBSan، وتشغيل تحت Valgrind، وتغذية عشوائية لقارئ الطلبات كما في المقال التالي.
- عدد الخيوط من متغير بيئة مضبوط على حد الـ Container، والـ Image متعددة المراحل بمستخدم ليس `root`.

## تدريب عملي Practice

- احذف القيد `CHECK (stock >= 0)` و`FOR UPDATE` معاً في قاعدة اختبار، وشغل اختبار التزامن، ولاحظ ماذا يحدث للمخزون هذه المرة.
- أضف `GET /products?name=` بالمعاملات الصحيحة، واكتب له اختبار تكامل يرسل `x' OR '1'='1` ويتوقع قائمة فارغة.
- غير `THREADS` إلى 2 ثم 8 وأعد قياس wrk، وقارن بما رأيناه في مقال التزامن عن الحد `--cpus=2`.

## الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

- لا يوجد إطار ويب شائع في C، فبنينا الخدمة من ثلاث مكتبات: libmicrohttpd للـ HTTP، وlibpq الرسمية لـ PostgreSQL، وcJSON، في 441 سطراً.
- الطلب يصل قطعاً، فجمعناه بـ `realloc` بحد 64 KB، وحررناه في دالة تستدعى حتى عند انقطاع الاتصال.
- JSON شجرة عامة كل أعدادها `double`، والتحقق من الأنواع والمدى بيدك، والترتيب بالرقم يضمن ترتيب أقفال الصفوف.
- المعاملة تقفل الصفوف بـ `FOR UPDATE` مرتبة، فنجح عشرة من عشرين طلباً متزامناً بالضبط وبقي المخزون صفراً، وبدون القفل حمى القيد البيانات ولكن ثلاثة عملاء أخذوا 500 بدلاً من 409.
- `snprintf` في SQL أرجعت كل المنتجات مرة، وجعلت كل الأسعار هللة مرة، و`PQexecParams` منعت الاثنين.
- كل الاختبارات نجحت، وValgrind وحدها وجدت أربعة اتصالات لا تغلق عند الإيقاف، وأصلحناها بقائمة تغلق بعد توقف الخيوط.
- الإيقاف الآمن: معالج الإشارة يرفع علامة فقط، ثم يتوقف المجمع، ثم تغلق الاتصالات، ولهذا استطاعت Valgrind أن تطبع تقريرها.
- الدرس: C تعطيك صورة صغيرة وذاكرة قليلة، وتأخذ منك كل ما في جدول الصعوبات، وهذا ما يجب أن يدخل في قرار اختيار اللغة.

وفي المقال الأخير من مرحلة [لغة C للمطورين](https://arabroot.io/start/#stage-lang-c) نجمع الأخطاء الشائعة التي رأيناها في هذه المرحلة وأدوات كشفها، ونجرب التغذية العشوائية على قارئ الطلبات من هذه الخدمة:

[![](https://arabroot.io/content/images/2026/10/c-idioms-cover-cover.webp)دليلأخطاء شائعة في C وكشفها بالأدوات Sanitizersأربعة عشر خطأً يقع فيها من يأتي إلى C من لغة أخرى، شغلنا كلاً منها ورأينا أي أداة كشفته وأيها فاتها، ثم جربنا التغذية العشوائية على قارئ الطلبات، وختمنا بخطة قراءة من المصادر الرسمية.](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-c-%D9%88%D9%83%D8%B4%D9%81%D9%87%D8%A7-%D8%A8%D8%A7%D9%84%D8%A7%D8%AF%D9%88%D8%A7%D8%AA-sanitizers/)[![](https://arabroot.io/content/images/2026/10/c-concurrency-cover-cover.webp)دليلالتزامن في C: الخيوط pthreads والأقفال Locksنقيس بلغة C مئة طلب شبكة وألفي ختم SHA-256 بالتسلسل وبالخيوط وبحلقة أحداث وOpenMP، ثم نصنع سباق بيانات وجموداً حقيقيين ونكشفهما بـ ThreadSanitizer ونصلحهما.](https://arabroot.io/articles/%D8%A7%D9%84%D8%AA%D8%B2%D8%A7%D9%85%D9%86-%D9%81%D9%8A-c-%D8%A7%D9%84%D8%AE%D9%8A%D9%88%D8%B7-pthreads-%D9%88%D8%A7%D9%84%D8%A7%D9%82%D9%81%D8%A7%D9%84-locks/)[![](https://arabroot.io/content/images/2026/10/c-types-memory-cover-cover.webp)دليلالمؤشرات Pointers والذاكرة والأخطاء في لغة Cنقرأ ملف طلبات نقطة البيع بلغة C ونقف عند كل ما تسمح به اللغة دون أن تخبرك: تحويلات الأنواع، والحشو، وتجاوز المخزن، ومن يحرر الذاكرة، وكيف تصل الأخطاء بقيمة الرجوع وerrno.](https://arabroot.io/articles/%D8%A7%D9%84%D9%85%D8%A4%D8%B4%D8%B1%D8%A7%D8%AA-pointers-%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-%D9%84%D8%BA%D8%A9-c/)[![](https://arabroot.io/content/images/2026/10/choose-language-cover-cover.webp)دليلكيف تختار لغة البرمجة Programming Language لمشروعك؟نختار لغة خدمة حقيقية بأسئلة الفريق والتوظيف والمكتبات والرخصة، ثم نكتبها بأربع لغات ونقيس البناء والحجم والذاكرة والطلبات في الثانية بأنفسنا.](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%AE%D8%AA%D8%A7%D8%B1-%D9%84%D8%BA%D8%A9-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D9%84%D9%85%D8%B4%D8%B1%D9%88%D8%B9%D9%83/)[![](https://arabroot.io/content/images/2026/10/postgres-for-everything-cover-cover.webp)دليلPostgreSQL لكل شيء: متى يكفيك ومتى تحتاج غيره؟كم عملاً يستطيع PostgreSQL واحد أن يقوم به في شركتك الناشئة قبل أن تحتاج Redis أو Elasticsearch، وما الإشارة التي تقول لك إن الوقت قد حان.](https://arabroot.io/articles/postgresql-%D9%84%D9%83%D9%84-%D8%B4%D9%8A%D8%A1-%D9%85%D8%AA%D9%89-%D8%AA%D9%83%D9%81%D9%8A%D9%83-%D9%82%D8%A7%D8%B9%D8%AF%D8%A9-%D8%A8%D9%8A%D8%A7%D9%86%D8%A7%D8%AA-%D9%88%D8%A7%D8%AD%D8%AF%D8%A9/)[![](https://arabroot.io/content/images/2026/10/owasp-top10-cover-cover.webp)دليلOWASP Top 10 للمبتدئين: أخطر عشر مشكلات في تطبيقات الويبأخطر عشر مشكلات أمنية في تطبيقات الويب، كل واحدة بمثال ضعيف ثم مصلح، وكيف تمنعها في الكود وأين تكتشفها.](https://arabroot.io/articles/owasp-top-10-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)

## سجل التحديثات

- أكتوبر 2026: كتابة المقال، وبناء الخدمة واختبارها بالمترجم GCC 16.2.0 مع libmicrohttpd 1.0.1 وlibpq 17.11 وcJSON 1.7.18 من Debian 13، على PostgreSQL 17.11 من الصورة postgres:17-alpine، والقياس بالأداة wrk من Debian 13 على Docker Engine 29.8.0.