استخدام واجهات برمجة التطبيقات غير المتزامنة للويب من WebAssembly

تكون واجهات برمجة التطبيقات للإدخال والإخراج على الويب غير متزامنة، ولكنّها متزامنة في معظم لغات النظام. عند تجميع الرمز البرمجي إلى WebAssembly، عليك ربط نوع من واجهات برمجة التطبيقات بنوع آخر، وهذا الربط هو Asyncify. في هذه المشاركة، ستتعرّف على حالات استخدام Asyncify وكيفية استخدامها وطريقة عملها.

وحدات الإدخال والإخراج بلغات النظام

سأبدأ بمثال بسيط في C. لنفترض أنّك تريد قراءة اسم المستخدم من ملف، ثم إرسال رسالة ترحيب إليه:

#include <stdio.h>

int main() {
    FILE *stream = fopen("name.txt", "r");
    char name[20+1];
    size_t len = fread(&name, 1, 20, stream);
    name[len] = '\0';
    fclose(stream);
    printf("Hello, %s!\n", name);
    return 0;
}

على الرغم من أنّ المثال لا يقدّم الكثير، إلا أنّه يوضّح بالفعل بعض العناصر التي ستجدها في أي تطبيق، مهما كان حجمه، وهي: قراءة بعض المدخلات من العالم الخارجي، ومعالجتها داخليًا، وكتابة المخرجات مرة أخرى إلى العالم الخارجي. ويتم كل هذا التفاعل مع العالم الخارجي من خلال بضع دوال تُعرف عادةً باسم دوال الإدخال والإخراج، ويتم اختصارها أيضًا إلى I/O.

لقراءة الاسم من C، تحتاج إلى استدعاءَين مهمَّين على الأقل للإدخال/الإخراج: fopen لفتح الملف، وfread لقراءة البيانات منه. بعد استرداد البيانات، يمكنك استخدام دالة إدخال/إخراج أخرى printf لطباعة النتيجة في وحدة التحكّم.

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

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

باختصار، يمكن أن تكون عمليات الإدخال والإخراج بطيئة، ولا يمكنك توقّع المدة التي ستستغرقها عملية معيّنة من خلال إلقاء نظرة سريعة على الرمز. أثناء تنفيذ هذه العملية، سيبدو تطبيقك بأكمله متوقفًا ولن يستجيب للمستخدم.

ولا يقتصر ذلك على لغتَي C أو C++‎. توفّر معظم لغات النظام جميع عمليات الإدخال والإخراج في شكل واجهات برمجة تطبيقات متزامنة. على سبيل المثال، إذا ترجمت المثال إلى Rust، قد تبدو واجهة برمجة التطبيقات أبسط، ولكن تنطبق المبادئ نفسها. ما عليك سوى إجراء مكالمة وانتظارها بشكل متزامن إلى أن تعرض النتيجة، بينما تنفّذ جميع العمليات المكلفة وتعرض النتيجة في النهاية في استدعاء واحد:

fn main() {
    let s = std::fs::read_to_string("name.txt");
    println!("Hello, {}!", s);
}

ولكن ماذا يحدث عند محاولة تجميع أي من هذه النماذج إلى WebAssembly وترجمتها إلى الويب؟ أو لتقديم مثال محدّد، ما الذي يمكن أن تعنيه عملية "قراءة الملف"؟ وسيحتاج إلى قراءة البيانات من بعض مساحات التخزين.

النموذج غير المتزامن للويب

يتضمّن الويب مجموعة متنوعة من خيارات التخزين المختلفة التي يمكنك ربطها، مثل التخزين في الذاكرة (عناصر JS) وlocalStorage وIndexedDB والتخزين من جهة الخادم وFile System Access API الجديد.

ومع ذلك، يمكن استخدام اثنتين فقط من واجهات برمجة التطبيقات هذه بشكل متزامن، وهما التخزين في الذاكرة وlocalStorage، وهما الخياران الأكثر تقييدًا من حيث البيانات التي يمكنك تخزينها ومدة التخزين. توفّر جميع الخيارات الأخرى واجهات برمجة تطبيقات غير متزامنة فقط.

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

والسبب هو أنّ الويب كان في السابق أحادي السلسلة، وأي رمز مستخدم يتفاعل مع واجهة المستخدم يجب أن يتم تشغيله على السلسلة نفسها التي يتم تشغيل واجهة المستخدم عليها. ويجب أن يتنافس مع المهام المهمة الأخرى، مثل التنسيق والعرض ومعالجة الأحداث، على وقت وحدة المعالجة المركزية. لا تريد أن يتمكّن جزء من JavaScript أو WebAssembly من بدء عملية "قراءة ملف" وحظر كل شيء آخر، أي علامة التبويب بأكملها أو المتصفّح بأكمله في السابق، وذلك لمدة تتراوح بين أجزاء من الثانية وعدة ثوانٍ، إلى أن تنتهي العملية.

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

باختصار، يُشغّل المتصفّح جميع أجزاء الرمز البرمجي في حلقة لا نهائية، وذلك من خلال أخذها من قائمة الانتظار واحدًا تلو الآخر. عندما يتم تشغيل حدث معيّن، يضع المتصفّح معالج الحدث المناسب في قائمة الانتظار، وفي تكرار الحلقة التالي، يتم إخراج المعالج من قائمة الانتظار وتنفيذه. تتيح هذه الآلية محاكاة التزامن وتنفيذ العديد من العمليات المتوازية أثناء استخدام سلسلة محادثات واحدة فقط.

الأمر المهم الذي يجب تذكّره بشأن هذه الآلية هو أنّه أثناء تنفيذ رمز JavaScript المخصّص (أو WebAssembly)، يتم حظر حلقة معالجة الأحداث، ولا يمكن التفاعل مع أي معالجات أو أحداث أو وحدات إدخال وإخراج خارجية وما إلى ذلك. الطريقة الوحيدة لاسترداد نتائج وحدات الإدخال والإخراج هي تسجيل دالة ردّ الاتصال، وإنهاء تنفيذ الرمز، وإعادة التحكّم إلى المتصفّح ليتمكّن من مواصلة معالجة أي مهام معلّقة. بعد انتهاء عملية الإدخال/الإخراج، سيصبح المعالج إحدى هذه المهام وسيتم تنفيذه.

على سبيل المثال، إذا أردت إعادة كتابة النماذج أعلاه بلغة JavaScript الحديثة وقرّرت قراءة اسم من عنوان URL بعيد، يمكنك استخدام Fetch API وتركيبة async-await:

async function main() {
  let response = await fetch("name.txt");
  let name = await response.text();
  console.log("Hello, %s!", name);
}

على الرغم من أنّها تبدو متزامنة، فإنّ كل await هي في الأساس تجميل لغوي لعمليات معاودة الاتصال:

function main() {
  return fetch("name.txt")
    .then(response => response.text())
    .then(name => console.log("Hello, %s!", name));
}

في هذا المثال الذي تم فيه إزالة السكر، وهو أكثر وضوحًا، يتم بدء طلب والاشتراك في الردود باستخدام دالة الرجوع الأولى. بعد أن يتلقّى المتصفّح الاستجابة الأولية، أي عناوين HTTP فقط، سيتم استدعاء وظيفة معاودة الاتصال هذه بشكل غير متزامن. يبدأ إجراء الاستدعاء بقراءة النص الأساسي كنص باستخدام response.text()، ويشترك في النتيجة باستخدام إجراء استدعاء آخر. أخيرًا، بعد أن يسترد fetch كل المحتوى، يستدعي وظيفة رد الاتصال الأخيرة التي تطبع "مرحبًا، (اسم المستخدم)!" في وحدة التحكم.

وبفضل الطبيعة غير المتزامنة لهذه الخطوات، يمكن للدالة الأصلية إعادة التحكّم إلى المتصفّح بمجرد جدولة عمليات الإدخال والإخراج، وترك واجهة المستخدم بأكملها متجاوبة ومتاحة للمهام الأخرى، بما في ذلك العرض والتمرير وما إلى ذلك، أثناء تنفيذ عمليات الإدخال والإخراج في الخلفية.

كمثال أخير، حتى واجهات برمجة التطبيقات البسيطة مثل "sleep"، التي تجعل التطبيق ينتظر عددًا محددًا من الثواني، هي أيضًا شكل من أشكال عمليات الإدخال/الإخراج:

#include <stdio.h>
#include <unistd.h>
// ...
printf("A\n");
sleep(1);
printf("B\n");

بالطبع، يمكنك ترجمتها بطريقة مباشرة جدًا تحظر سلسلة المحادثات الحالية إلى أن ينتهي الوقت:

console.log("A");
for (let start = Date.now(); Date.now() - start < 1000;);
console.log("B");

في الواقع، هذا بالضبط ما يفعله Emscripten في التنفيذ التلقائي للدالة "sleep"، ولكن هذا الإجراء غير فعّال للغاية، وسيؤدي إلى حظر واجهة المستخدم بأكملها ولن يسمح بمعالجة أي أحداث أخرى في الوقت نفسه. بشكل عام، لا ننصحك بذلك في رمز الإنتاج.

بدلاً من ذلك، سيتضمّن إصدار أكثر تعبيرية من "sleep" في JavaScript استدعاء setTimeout()، والاشتراك باستخدام معالج:

console.log("A");
setTimeout(() => {
    console.log("B");
}, 1000);

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

سد الفجوة باستخدام Asyncify

وهنا يأتي دور Asyncify. ‫Asyncify هي ميزة وقت الترجمة البرمجية متوافقة مع Emscripten، وتتيح إيقاف البرنامج بأكمله مؤقتًا واستئنافه بشكل غير متزامن لاحقًا.

رسم بياني لعمليات الاستدعاء
يوضّح عملية استدعاء مهمة غير متزامنة من خلال JavaScript -> WebAssembly -> واجهة برمجة تطبيقات الويب، حيث يربط Asyncify
نتيجة المهمة غير المتزامنة مرة أخرى بـ WebAssembly

الاستخدام في C / C++‎ مع Emscripten

إذا أردت استخدام Asyncify لتنفيذ عملية إيقاف مؤقت غير متزامنة في المثال الأخير، يمكنك إجراء ذلك على النحو التالي:

#include <stdio.h>
#include <emscripten.h>

EM_JS(void, async_sleep, (int seconds), {
    Asyncify.handleSleep(wakeUp => {
        setTimeout(wakeUp, seconds * 1000);
    });
});

puts("A");
async_sleep(1);
puts("B");

EM_JS هي وحدة ماكرو تتيح تحديد مقتطفات JavaScript كما لو كانت وظائف C. في الداخل، استخدِم دالة Asyncify.handleSleep() التي تطلب من Emscripten تعليق البرنامج وتوفّر معالج wakeUp() يجب استدعاؤه بعد انتهاء العملية غير المتزامنة. في المثال أعلاه، يتم تمرير المعالج إلى setTimeout()، ولكن يمكن استخدامه في أي سياق آخر يقبل عمليات رد الاتصال. أخيرًا، يمكنك استدعاء async_sleep() في أي مكان تريده تمامًا مثل sleep() العادي أو أي واجهة برمجة تطبيقات متزامنة أخرى.

عند تجميع هذا الرمز، عليك إخبار Emscripten بتفعيل ميزة Asyncify. يمكنك إجراء ذلك من خلال تمرير -s ASYNCIFY بالإضافة إلى -s ASYNCIFY_IMPORTS=[func1, func2] مع قائمة تشبه المصفوفة من الدوال التي قد تكون غير متزامنة.

emcc -O2 \
    -s ASYNCIFY \
    -s ASYNCIFY_IMPORTS=[async_sleep] \
    ...

يتيح ذلك لـ Emscripten معرفة أنّ أي طلبات يتم إرسالها إلى هذه الدوال قد تتطلّب حفظ الحالة واستعادتها، وبالتالي سيُدرج المترجم البرمجي رمزًا برمجيًا داعمًا حول هذه الطلبات.

الآن، عند تنفيذ هذا الرمز في المتصفّح، سيظهر لك سجلّ ناتج سلس كما تتوقّع، مع ظهور B بعد تأخير قصير بعد A.

A
B

يمكنك أيضًا عرض قيم من دوال Asyncify. ما عليك فعله هو عرض نتيجة handleSleep()، وتمرير النتيجة إلى دالة الاستدعاء wakeUp(). على سبيل المثال، إذا أردت جلب رقم من مورد بعيد بدلاً من قراءته من ملف، يمكنك استخدام مقتطف مثل المقتطف أدناه لإرسال طلب وتعليق رمز C واستئنافه بعد استرداد نص الرد، ويتم كل ذلك بسلاسة كما لو كان الطلب متزامنًا.

EM_JS(int, get_answer, (), {
     return Asyncify.handleSleep(wakeUp => {
        fetch("answer.txt")
            .then(response => response.text())
            .then(text => wakeUp(Number(text)));
    });
});
puts("Getting answer...");
int answer = get_answer();
printf("Answer is %d\n", answer);

في الواقع، بالنسبة إلى واجهات برمجة التطبيقات المستندة إلى Promise، مثل fetch()، يمكنك حتى الجمع بين Asyncify وميزة async-await في JavaScript بدلاً من استخدام واجهة برمجة التطبيقات المستندة إلى معاودة الاتصال. لإجراء ذلك، اتّصِل بالرقم Asyncify.handleAsync() بدلاً من Asyncify.handleSleep(). بعد ذلك، بدلاً من الحاجة إلى جدولة wakeUp() دالة ردّ الاتصال، يمكنك تمرير دالة async JavaScript واستخدام await وreturn في الداخل، ما يجعل الرمز البرمجي يبدو أكثر طبيعية وتزامنًا، مع الحفاظ على جميع مزايا عمليات الإدخال والإخراج غير المتزامنة.

EM_JS(int, get_answer, (), {
     return Asyncify.handleAsync(async () => {
        let response = await fetch("answer.txt");
        let text = await response.text();
        return Number(text);
    });
});

int answer = get_answer();

بانتظار قيم معقّدة

ومع ذلك، يقتصر هذا المثال على الأرقام فقط. ماذا لو أردت تنفيذ المثال الأصلي، حيث حاولت الحصول على اسم مستخدم من ملف كسلسلة؟ يمكنك فعل ذلك أيضًا.

توفّر Emscripten ميزة تُعرف باسم Embind تتيح لك التعامل مع عمليات التحويل بين قيم JavaScript وC++. يتوافق هذا الإصدار أيضًا مع Asyncify، لذا يمكنك استدعاء await() على Promise خارجي وسيتصرف تمامًا مثل await في رمز JavaScript غير المتزامن:

val fetch = val::global("fetch");
val response = fetch(std::string("answer.txt")).await();
val text = response.call<val>("text").await();
auto answer = text.as<std::string>();

عند استخدام هذه الطريقة، لن تحتاج حتى إلى تمرير ASYNCIFY_IMPORTS كعلامة تجميع، لأنّها مضمّنة تلقائيًا.

حسنًا، كل هذا يعمل بشكل رائع في Emscripten. ماذا عن سلاسل الأدوات واللغات الأخرى؟

الاستخدام من لغات أخرى

لنفترض أنّ لديك طلبًا متزامنًا مشابهًا في مكان ما في رمز Rust الذي تريد ربطه بواجهة برمجة تطبيقات غير متزامنة على الويب. اتّضح أنّه يمكنك إجراء ذلك أيضًا.

أولاً، عليك تحديد هذه الدالة كعملية استيراد عادية من خلال كتلة extern (أو بنية اللغة التي اخترتها للدوال الخارجية).

extern {
    fn get_answer() -> i32;
}

println!("Getting answer...");
let answer = get_answer();
println!("Answer is {}", answer);

ويمكنك تجميع الرمز البرمجي إلى WebAssembly:

cargo build --target wasm32-unknown-unknown

عليك الآن إضافة رمز إلى ملف WebAssembly لتخزين/استعادة الحزمة. في لغتَي C وC++‎، يمكن أن تنفّذ Emscripten هذه العملية نيابةً عنّا، ولكنّها غير مستخدَمة هنا، لذا تكون العملية أكثر تعقيدًا.

لحسن الحظ، إنّ عملية تحويل Asyncify نفسها لا تتطلّب أي سلسلة أدوات. ويمكنه تحويل ملفات WebAssembly عشوائية، بغض النظر عن برنامج الترجمة البرمجية الذي تم إنشاؤها من خلاله. يتم توفير عملية التحويل بشكل منفصل كجزء من أداة التحسين wasm-opt من مجموعة أدوات Binaryen ويمكن استدعاؤها على النحو التالي:

wasm-opt -O2 --asyncify \
      --pass-arg=asyncify-imports@env.get_answer \
      [...]

مرِّر القيمة --asyncify لتفعيل عملية التحويل، ثم استخدِم --pass-arg=… لتوفير قائمة مفصولة بفواصل تتضمّن دوال غير متزامنة، حيث يجب تعليق حالة البرنامج ثم استئنافها لاحقًا.

كل ما تبقى هو توفير رمز وقت التشغيل الداعم الذي سينفّذ ذلك فعليًا، أي تعليق رمز WebAssembly واستئنافه. مرة أخرى، في حالة C / C++‎، سيتم تضمين ذلك بواسطة Emscripten، ولكن عليك الآن استخدام رمز JavaScript مخصّص للربط يمكنه التعامل مع ملفات WebAssembly عشوائية. أنشأنا مكتبة لهذا الغرض.

يمكنك العثور عليه على GitHub من خلال الرابط https://github.com/GoogleChromeLabs/asyncify أو على npm بالاسم asyncify-wasm.

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

const { instance } = await Asyncify.instantiateStreaming(fetch('app.wasm'), {
    env: {
        async get_answer() {
            let response = await fetch("answer.txt");
            let text = await response.text();
            return Number(text);
        }
    }
});

await instance.exports.main();

عند محاولة استدعاء دالة غير متزامنة، مثل get_answer() في المثال أعلاه، من جهة WebAssembly، سيرصد البرنامج Promise الذي تم عرضه، ويعلّق تطبيق WebAssembly ويحفظ حالته، ويشترك في إكمال العملية غير المكتملة، وبعد ذلك، عند حلّها، يعيد تلقائيًا حزمة التنفيذ والحالة ويستأنف التنفيذ كما لو لم يحدث شيء.

بما أنّ أي دالة في الوحدة قد تجري عملية استدعاء غير متزامن، تصبح جميع عمليات التصدير غير متزامنة أيضًا، لذا يتم تضمينها أيضًا. ربما لاحظت في المثال أعلاه أنّه عليك await نتيجة instance.exports.main() لمعرفة الوقت الذي ينتهي فيه التنفيذ فعلاً.

كيف يتم كل ذلك في الخلفية؟

عندما يرصد Asyncify طلبًا لإحدى دوال ASYNCIFY_IMPORTS، يبدأ عملية غير متزامنة، ويحفظ حالة التطبيق بالكامل، بما في ذلك مكدس الاستدعاء وأي متغيرات محلية مؤقتة، ثم يعيد لاحقًا، عند انتهاء هذه العملية، جميع الذاكرة ومكدس الاستدعاء ويستأنف من المكان نفسه وبالحالة نفسها كما لو أنّ البرنامج لم يتوقف أبدًا.

وهي تشبه إلى حد كبير ميزة async-await في JavaScript التي عرضتها سابقًا، ولكن على عكس ميزة JavaScript، لا تتطلّب أي بنية خاصة أو دعم وقت التشغيل من اللغة، بل تعمل عن طريق تحويل الدوال المتزامنة العادية في وقت الترجمة.

عند تجميع مثال السكون غير المتزامن المعروض سابقًا:

puts("A");
async_sleep(1);
puts("B");

تأخذ Asyncify هذا الرمز وتحوّله إلى رمز مشابه للرمز التالي (رمز زائف، عملية التحويل الحقيقية أكثر تعقيدًا من ذلك):

if (mode == NORMAL_EXECUTION) {
    puts("A");
    async_sleep(1);
    saveLocals();
    mode = UNWINDING;
    return;
}
if (mode == REWINDING) {
    restoreLocals();
    mode = NORMAL_EXECUTION;
}
puts("B");

في البداية، يتم ضبط قيمة mode على NORMAL_EXECUTION. وبالتالي، عند تنفيذ هذا الرمز المحوّل للمرة الأولى، سيتم تقييم الجزء الذي يسبق async_sleep() فقط. فور تحديد موعد لتنفيذ العملية غير المتزامنة، يحفظ Asyncify جميع المتغيرات المحلية، ثم يرجع من كل دالة وصولاً إلى الأعلى، وبالتالي يعيد التحكّم إلى حلقة معالجة الأحداث في المتصفح.

بعد ذلك، عند حلّ async_sleep()، سيغيّر رمز Asyncify المتوافق mode إلى REWINDING، ثم يعيد استدعاء الدالة. في هذه المرة، يتم تخطّي فرع "التنفيذ العادي" لأنّه نفّذ المهمة في المرة السابقة وأريد تجنُّب طباعة "A" مرتين، وبدلاً من ذلك، يتم الانتقال مباشرةً إلى فرع "الترجيع". وبعد الوصول إلى هذا الحد، يتم استعادة جميع المتغيرات المحلية المخزَّنة، وتتم إعادة الوضع إلى "عادي"، ويستمر التنفيذ كما لو لم يتم إيقاف الرمز في المقام الأول.

تكاليف التحويل

مع الأسف، لا يمكن استخدام أداة Asyncify transform مجانًا بالكامل، لأنّها تتطلّب إدخال قدر كبير من الرموز البرمجية الداعمة لتخزين جميع المتغيرات المحلية واستعادتها، والتنقّل في حزمة التنفيذ في أوضاع مختلفة وما إلى ذلك. يحاول هذا الخيار تعديل الدوال التي تم وضع علامة "غير متزامن" عليها فقط في سطر الأوامر، بالإضافة إلى أي من الدوال التي يمكن أن تستدعيها، ولكن قد يظل حجم الرمز البرمجي الإضافي يصل إلى% 50 تقريبًا قبل الضغط.

رسم بياني يعرض الحمل الزائد لحجم الرمز البرمجي لمختلف مقاييس الأداء، بدءًا من نسبة تقارب% 0 في الظروف المضبوطة إلى أكثر من% 100 في أسوأ الحالات

هذا ليس مثاليًا، ولكنّه مقبول في كثير من الحالات عندما لا يكون هناك بديل سوى عدم توفّر الوظيفة تمامًا أو الاضطرار إلى إعادة كتابة الرمز الأصلي بشكل كبير.

احرص على تفعيل التحسينات دائمًا في الإصدارات النهائية لتجنُّب ارتفاعها أكثر. يمكنك أيضًا الاطّلاع على خيارات التحسين الخاصة بـ Asyncify لتقليل الحمل الزائد عن طريق حصر عمليات التحويل على الدوال المحددة و/أو استدعاءات الدوال المباشرة فقط. هناك أيضًا تكلفة بسيطة لأداء وقت التشغيل، ولكنها تقتصر على عمليات الاستدعاء غير المتزامنة نفسها. ومع ذلك، تكون هذه التكلفة عادةً ضئيلة مقارنةً بتكلفة العمل الفعلي.

عروض توضيحية واقعية

بعد أن اطّلعت على الأمثلة البسيطة، سأنتقل إلى سيناريوهات أكثر تعقيدًا.

كما ذكرنا في بداية المقالة، أحد خيارات التخزين على الويب هو File System Access API غير المتزامن. ويتيح الوصول إلى نظام ملفات المضيف الحقيقي من تطبيق ويب.

من ناحية أخرى، هناك معيار فعلي يُعرف باسم WASI لعمليات الإدخال والإخراج في WebAssembly في وحدة التحكّم وفي جهة الخادم. تم تصميمها لتكون هدفًا للتجميع للغات الأنظمة، وتتيح جميع أنواع عمليات نظام الملفات وغيرها من العمليات في شكل متزامن تقليدي.

ماذا لو كان بإمكانك ربط أحدهما بالآخر؟ بعد ذلك، يمكنك تجميع أي تطبيق بأي لغة مصدر باستخدام أي مجموعة أدوات متوافقة مع هدف WASI، وتشغيله في بيئة آمنة على الويب، مع السماح له بالعمل على ملفات المستخدمين الحقيقية. باستخدام Asyncify، يمكنك إجراء ذلك.

في هذا العرض التوضيحي، جمعتُ حزمة coreutils من Rust مع بعض التصحيحات الطفيفة على WASI، وتم تمريرها من خلال عملية تحويل Asyncify، كما تم تنفيذ عمليات ربط غير متزامنة من WASI إلى واجهة برمجة التطبيقات File System Access API على جهة JavaScript. بعد الدمج مع مكوّن الطرفية Xterm.js، يوفّر ذلك غلافًا واقعيًا يعمل في علامة تبويب المتصفّح ويتعامل مع ملفات المستخدمين الحقيقية، تمامًا مثل الطرفية الفعلية.

يمكنك الاطّلاع عليه مباشرةً على https://wasi.rreverser.com/.

لا تقتصر حالات استخدام Asyncify على المؤقتات وأنظمة الملفات فقط. يمكنك أيضًا استخدام المزيد من واجهات برمجة التطبيقات المتخصّصة على الويب.

على سبيل المثال، يمكن أيضًا، بمساعدة Asyncify، ربط libusb، وهي على الأرجح المكتبة المجمّعة من رموز برمجية أصلية الأكثر شيوعًا للتعامل مع أجهزة USB، بواجهة برمجة تطبيقات WebUSB، ما يتيح الوصول غير المتزامن إلى هذه الأجهزة على الويب. بعد الربط والتجميع، حصلت على اختبارات وأمثلة libusb عادية لتشغيلها على الأجهزة المحدّدة مباشرةً في وضع الحماية لصفحة الويب.

لقطة شاشة لناتج تصحيح الأخطاء في libusb
على صفحة ويب، تعرض معلومات حول كاميرا Canon المتصلة

ربما سأكتب عن ذلك في منشور آخر على المدونة.

توضّح هذه الأمثلة مدى فعالية Asyncify في سد الفجوة ونقل جميع أنواع التطبيقات إلى الويب، ما يتيح لك الاستفادة من إمكانية الوصول من عدّة منصات، والحماية من خلال وضع الحماية، والأمان الأفضل، وكل ذلك بدون فقدان أي وظائف.