كيف تبدأ سريعًا مع VUE3.0: البدء في التعلم
عندما يتعلق الأمر بـ Node.js، أعتقد أن معظم مهندسي الواجهة الأمامية سيفكرون في تطوير خوادم تعتمد عليها، ما عليك سوى إتقان JavaScript لتصبح حزمة كاملة مهندس، ولكن في الواقع، معنى Node.js وهذا ليس كل شيء.
بالنسبة للعديد من اللغات عالية المستوى، يمكن أن تصل أذونات التنفيذ إلى نظام التشغيل، ولكن تشغيل جافا سكريبت على جانب المتصفح يعد استثناءً، حيث تغلق بيئة الحماية التي أنشأها المتصفح مهندسي الواجهة الأمامية في برج عاجي في عالم البرمجة. ومع ذلك، فإن ظهور Node.js عوض هذا النقص، ويمكن لمهندسي الواجهة الأمامية أيضًا الوصول إلى قاع عالم الكمبيوتر.
لذلك، فإن أهمية Nodejs لمهندسي الواجهة الأمامية لا تقتصر فقط على توفير إمكانات التطوير الكاملة، ولكن الأهم من ذلك، فتح الباب أمام عالم أجهزة الكمبيوتر الأساسي لمهندسي الواجهة الأمامية. تفتح هذه المقالة هذا الباب من خلال تحليل مبادئ تنفيذ Node.js.
هناك أكثر من اثنتي عشرة تبعيات في الدليل /deps الخاص بمستودع كود مصدر Node.js، بما في ذلك الوحدات المكتوبة بلغة C (مثل libuv وV8) والوحدات المكتوبة بلغة JavaScript (مثل acorn) ، بلوط الإضافات).
وأهمها الوحدات المقابلة لمجلدي v8 وuv. لا يتمتع V8 نفسه بالقدرة على التشغيل بشكل غير متزامن، ولكن يتم تنفيذه بمساعدة سلاسل رسائل أخرى في المتصفح، ولهذا السبب نقول غالبًا أن Js أحادي الخيط، لأن محرك التحليل الخاص به يدعم فقط كود التحليل المتزامن. لكن في Node.js، يعتمد التنفيذ غير المتزامن بشكل أساسي على libuv. دعونا نركز على تحليل مبدأ تنفيذ libuv.
مكتبة الإدخال/الإخراج غير المتزامنة مكتوبة بلغة C وتدعم منصات متعددة، وهي تحل بشكل أساسي مشكلة عمليات الإدخال/الإخراج التي تسبب الحظر بسهولة. تم تطويره في الأصل خصيصًا للاستخدام مع Node.js، ولكن منذ ذلك الحين تم استخدامه بواسطة وحدات أخرى مثل Luvit، وJulia، وpyuv. الشكل أدناه هو مخطط هيكل libuv.
لدى libuv طريقتين للتنفيذ غير المتزامنة، وهما الجزأين المحددين بواسطة المربع الأصفر الموجود على يسار ويمين الصورة أعلاه.
الجزء الأيسر هو وحدة الإدخال / الإخراج للشبكة، والتي لها آليات تنفيذ مختلفة ضمن منصات مختلفة. تستخدم أنظمة Linux epoll لتنفيذها، وتستخدم أنظمة OSX وأنظمة BSD الأخرى KQueue، وتستخدم أنظمة SunOS منافذ الأحداث، وتستخدم أنظمة Windows IOCP. وبما أنها تتضمن واجهة برمجة التطبيقات (API) الأساسية لنظام التشغيل، فإن فهمها أكثر تعقيدًا، لذا لن أقدمها هنا.
يتضمن الجزء الأيمن وحدة الإدخال/الإخراج للملف، ووحدة DNS، ورمز المستخدم، الذي ينفذ العمليات غير المتزامنة من خلال تجمع مؤشرات الترابط. يختلف الإدخال/الإخراج للملف عن الإدخال/الإخراج للشبكة. لا يعتمد libuv على واجهة برمجة التطبيقات الأساسية للنظام، ولكنه ينفذ عمليات الإدخال/الإخراج للملفات المحظورة في تجمع الخيوط العام.
الشكل التالي هو مخطط سير عمل استقصاء الأحداث المقدم من موقع libuv الرسمي، دعنا نحلله مع الكود.
يتم تنفيذ الكود الأساسي لحلقة حدث libuv في وظيفة uv_run(). ما يلي هو جزء من الكود الأساسي في نظام Unix. على الرغم من أنها مكتوبة بلغة C، إلا أنها لغة عالية المستوى مثل JavaScript، لذلك ليس من الصعب فهمها. قد يكون الاختلاف الأكبر هو العلامات النجمية والأسهم، يمكننا ببساطة تجاهل العلامات النجمية. على سبيل المثال، يمكن فهم حلقة uv_loop_t* في معلمة الوظيفة على أنها حلقة متغيرة من النوع uv_loop_t. يمكن فهم السهم "→" على أنه النقطة "."، على سبيل المثال، يمكن فهم حلقة → stop_flag على أنها حلقة.stop_flag.
int uv_run(uv_loop_t* حلقة، وضع uv_run_mode) { ... r = uv__loop_alive(loop); إذا (!r) uv__update_time(loop); بينما (r != 0 && حلقة ->stop_flag == 0) { uv__update_time(loop); uv__run_timers(loop); ran_pending = uv__run_pending(loop); uv__run_idle(loop); uv__run_prepare(loop);...uv__io_poll(loop, timeout); uv__run_check(loop); uv__run_closing_handles(حلقة);... }... }
uv__loop_alive
يتم استخدام هذه الوظيفة لتحديد ما إذا كان يجب استمرار استقصاء الحدث إذا لم تكن هناك مهمة نشطة في كائن الحلقة، فسوف يُرجع 0 ويخرج من الحلقة.
في لغة C، هذه "المهمة" لها اسم احترافي، أي "المقبض"، والذي يمكن فهمه على أنه متغير يشير إلى المهمة. يمكن تقسيم المقابض إلى فئتين: الطلب والمقبض، والتي تمثل مقابض دورة قصيرة العمر ومقابض دورة طويلة العمر على التوالي. الكود المحدد هو كما يلي:
static int uv__loop_alive(const uv_loop_t *loop) { إرجاع uv__has_active_handles(loop) || uv__has_active_reqs(loop) ||. }
uv__update_time
من أجل تقليل عدد مكالمات النظام المرتبطة بالوقت، يتم استخدام هذه الوظيفة لتخزين وقت النظام الحالي مؤقتًا. الدقة عالية جدًا ويمكن أن تصل إلى مستوى النانو ثانية، لكن الوحدة لا تزال بالمللي ثانية.
كود المصدر المحدد هو كما يلي:
UV_UNUSED(static void uv__update_time(uv_loop_t *loop)) { حلقة ->time = uv__hrtime(UV_CLOCK_FAST) / 1000000; }ينفذ
uv__run_timers
وظائف رد الاتصال التي تصل إلى الحد الزمني في setTimeout() وsetInterval(). يتم تنفيذ عملية التنفيذ هذه من خلال اجتياز الحلقة، كما ترون من الكود أدناه، يتم تخزين رد الاتصال المؤقت في بيانات الحد الأدنى من بنية الكومة، ويتم الخروج عندما يكون الحد الأدنى من الكومة فارغًا أو لم يصل إلى الحد الأقصى للدورة الزمنية .
قم بإزالة المؤقت قبل تنفيذ وظيفة رد اتصال المؤقت. إذا تم تعيين التكرار، فيجب إضافته إلى الحد الأدنى من الكومة مرة أخرى، ثم يتم تنفيذ رد اتصال المؤقت.
الكود المحدد هو كما يلي:
void uv__run_timers(uv_loop_t *loop) { بناء heap_node * heap_node؛ uv_timer_t * مقبض؛ ل (؛؛) { heap_node = heap_min(timer_heap(loop)); إذا (heap_node == NULL) استراحة؛ Handle = Container_of(heap_node, uv_timer_t, heap_node); إذا (مقبض - >مهلة > حلقة - >وقت) استراحة؛ uv_timer_stop(handle); uv_timer_again(handle); مقبض ->timer_cb(مقبض); } }يجتاز
uv__run_pending
جميع وظائف رد اتصال الإدخال/الإخراج المخزنة في انتظار_الانتظار، ويعيد 0 عندما يكون انتظار_الانتظار فارغًا؛ وإلا، يُرجع 1 بعد تنفيذ وظيفة رد الاتصال في انتظار_الانتظار.
الكود كما يلي:
static int uv__run_pending(uv_loop_t *loop) { قائمة الانتظار * س؛ QUEUE pq; uv__io_t * w; إذا كان (QUEUE_EMPTY(& حلقة ->pending_queue)) يُرجع 0؛ QUEUE_MOVE( & حلقة ->pending_queue, &pq); بينما (!QUEUE_EMPTY( & pq)) { q = QUEUE_HEAD( & pq); QUEUE_REMOVE(ف); QUEUE_INIT(ف); w = QUEUE_DATA(q, uv__io_t, hanging_queue); w ->cb(loop, w, POLLOUT); } العودة 1؛ }يتم تعريف الوظائف الثلاث
uvrun_idle / uvrun_prepare / uv__run_check
من خلال وظيفة ماكرو UV_LOOP_WATCHER_DEFINE. يمكن فهم وظيفة الماكرو كقالب رمز، أو وظيفة تستخدم لتحديد الوظائف. يتم استدعاء وظيفة الماكرو ثلاث مرات ويتم تمرير قيم معلمات الاسم إعداد وفحص وخمول على التوالي. وفي الوقت نفسه، تم تحديد ثلاث وظائف، uvrun_idle، وuvrun_prepare، وuv__run_check.
لذلك، فإن منطق التنفيذ الخاص بهم متسق. جميعهم ينفذون ويخرجون الكائنات الموجودة في حلقة قائمة الانتظار -> الاسم ##_ يعالج وفقًا لمبدأ الوارد أولاً يخرج أولاً، ثم ينفذون وظيفة رد الاتصال المقابلة.
#define UV_LOOP_WATCHER_DEFINE(الاسم، النوع) باطلة uv__run_##name(uv_loop_t* حلقة) { uv_##name##_t* h; قائمة الانتظار؛ قائمة الانتظار * س؛ QUEUE_MOVE(&loop->name##_handles, &queue); بينما (!QUEUE_EMPTY(&queue)) { س = QUEUE_HEAD(&queue); h = QUEUE_DATA(q, uv_##name##_t, queue); QUEUE_REMOVE(ف); QUEUE_INSERT_TAIL(&loop->name##_handles, q); h->name##_cb(h); } } UV_LOOP_WATCHER_DEFINE(التحضير، التحضير) UV_LOOP_WATCHER_DEFINE(تحقق، تحقق) UV_LOOP_WATCHER_DEFINE(خامل، IDLE)
uv__io_poll
uv__io_poll يستخدم بشكل أساسي لاستقصاء عمليات الإدخال/الإخراج. سيختلف التنفيذ المحدد وفقًا لنظام التشغيل. نحن نأخذ نظام Linux كمثال للتحليل.
تحتوي وظيفة uv__io_poll على الكثير من التعليمات البرمجية المصدر. يتكون الجزء الأساسي من رمز الحلقة من جزأين:
void uv__io_poll(uv_loop_t *loop, int timeout) {. بينما (!QUEUE_EMPTY(& حلقة ->watcher_queue)) { q = QUEUE_HEAD( & حلقة ->watcher_queue); QUEUE_REMOVE(ف); QUEUE_INIT(ف); w = QUEUE_DATA(q, uv__io_t, watcher_queue); e.events = w ->pevents; e.data.fd = w ->fd; if (w ->events == 0) op = EPOLL_CTL_ADD; else op = EPOLL_CTL_MOD; إذا (epoll_ctl(loop - >backend_fd, op, w - >fd, &e)) { if (errno != EEXIST) abort(); if (epoll_ctl(loop - >backend_fd, EPOLL_CTL_MOD, w - >fd, &e)) abort(); } w - >events = w - >pevents; } ل (؛؛) { لـ (i = 0; i < nfds; i++) { pe = الأحداث + i; fd = pe ->data.fd; w = حلقة ->watchers[fd]; pe - >events &= w - >pevents |. إذا (pe ->events == POLLERR || pe ->events == POLLHUP) pe ->events |= w ->pevents & (POLLIN | POLLOUT | UV__POLLRDHUP | UV__POLLPRI); إذا (pe ->الأحداث != 0) { if (w == &loop ->signal_io_watcher) has_signals = 1; آخر w - >cb(loop, w, pe - >events); نيفينتس++; } } إذا (have_signals! = 0) حلقة ->signal_io_watcher.cb(loop, &loop ->signal_io_watcher, POLLIN); }... }
في الحلقة while، قم باجتياز قائمة انتظار المراقب watcher_queue، وأخرج واصف الحدث والملف وقم بتعيينهما لكائن الحدث e، ثم قم باستدعاء وظيفة epoll_ctl لتسجيل حدث epoll أو تعديله.
في حلقة for، سيتم إخراج واصف الملف المنتظر في epoll وتعيينه إلى nfds، ثم سيتم اجتياز nfds لتنفيذ وظيفة رد الاتصال.
يجتازuv__run_closing_handles
قائمة الانتظار التي تنتظر الإغلاق، ويغلق المقابض مثل الدفق، وtcp، وudp، وما إلى ذلك، ثم يستدعي المقبض Close_cb المطابق. الكود كما يلي:
static void uv__run_closing_handles(uv_loop_t *loop) { uv_handle_t * p; uv_handle_t * س؛ ع = حلقة ->إغلاق_المقابض؛ حلقة ->closing_handles = NULL; بينما (ع) { q = p ->next_closing; uv__finish_ Close(p); ع = ف؛ } }
على الرغم من أن كلاً من Process.nextTick وPromise عبارة عن واجهات برمجة تطبيقات غير متزامنة، إلا أنهما ليسا جزءًا من استقصاء الأحداث، ولديهما قوائم انتظار المهام الخاصة بهما، والتي يتم تنفيذها بعد اكتمال كل خطوة من استقصاء الأحداث. لذلك، عندما نستخدم هاتين الواجهتين غير المتزامنتين لواجهات برمجة التطبيقات، نحتاج إلى الانتباه إلى أنه إذا تم تنفيذ مهام طويلة أو تكرارات في وظيفة رد الاتصال الواردة، فسيتم حظر استقصاء الأحداث، وبالتالي "تجويع" عمليات الإدخال/الإخراج.
التعليمة البرمجية التالية هي مثال حيث لا يمكن تنفيذ وظيفة رد الاتصال لـ fs.readFile عن طريق استدعاء prcoess.nextTick بشكل متكرر.
fs.readFile('config.json', (err, data) = >{... }) اجتياز ثابت = () = >{ عملية.التالي علامة (اجتياز) }
لحل هذه المشكلة، يمكنك استخدام setImmediate بدلاً من ذلك، لأن setImmediate سيقوم بتنفيذ قائمة انتظار وظيفة رد الاتصال في حلقة الحدث. تتمتع قائمة انتظار المهامprocess.nextTick بأولوية أعلى من قائمة انتظار المهام Promise، ولأسباب محددة، يرجى الرجوع إلى الكود التالي:
functionprocessTicksAndRejections() {. دع توك؛ يفعل { بينما (tock = queue.shift()) { const asyncId = tock[async_id_symbol]; emitBefore(asyncId, tock[trigger_async_id_symbol], tock); يحاول { رد اتصال const = tock.callback; إذا (tock.args === غير محدد) { أتصل مرة أخرى()؛ } آخر { const args = tock.args; التبديل (args. length) { الحالة 1: رد الاتصال(args[0]); استراحة؛ الحالة 2: رد الاتصال(args[0], args[1]); استراحة؛ الحالة 3: رد الاتصال(args[0], args[1], args[2]); استراحة؛ الحالة 4: رد الاتصال(args[0], args[1], args[2], args[3]); استراحة؛ تقصير: رد الاتصال(...args); } } } أخيراً { if (destroyHooksExist()) emitDestroy(asyncId); } emitAfter(asyncId); } runMicrotasks(); } while (! queue . isEmpty () ||processPromiseRejections()); setHasTickScheduled(false); setHasRejectionToWarn(false); }
كما يمكن رؤيته من وظيفة ProcessTicksAndRejections()، يتم إخراج وظيفة رد الاتصال لقائمة الانتظار أولاً من خلال حلقة while، وتتم إضافة وظيفة رد الاتصال في قائمة الانتظار من خلال Process.nextTick. عندما تنتهي حلقة while، يتم استدعاء الدالة runMicrotasks() لتنفيذ وظيفة رد الاتصال Promise.
يمكن تقسيم البنية الأساسية لـ Node.js التي تعتمد على libuv إلى قسمين. الجزء الأول هو إدخال/إخراج الشبكة. سيعتمد التنفيذ الأساسي على واجهات برمجة تطبيقات النظام المختلفة وفقًا لأنظمة التشغيل المختلفة /O وDNS ورمز المستخدم يستخدم هذا الجزء تجمع مؤشرات الترابط للمعالجة.
الآلية الأساسية لـ libuv للتعامل مع العمليات غير المتزامنة هي استقصاء الأحداث. وتتمثل العملية العامة في اجتياز وظيفة رد الاتصال في قائمة الانتظار وتنفيذها.
أخيرًا، يُذكر أن عملية API غير المتزامنة.nextTick وPromise لا ينتميان إلى استقصاء الأحداث، وسيؤدي الاستخدام غير الصحيح إلى حظر استقصاء الأحداث. أحد الحلول هو استخدام setImmediate بدلاً من ذلك.