استفاده از APIهای وب ناهمزمان از WebAssembly

اینگوار استپانیان
Ingvar Stepanyan

رابط‌های برنامه‌نویسی ورودی/خروجی (I/O API) در وب ناهمزمان هستند، اما در اکثر زبان‌های سیستمی همگام هستند. هنگام کامپایل کد به WebAssembly، باید یک نوع API را به نوع دیگر متصل کنید - و این پل 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، حداقل به دو فراخوانی ورودی/خروجی (I/O) حیاتی نیاز دارید: fopen برای باز کردن فایل و fread برای خواندن داده‌ها از آن. پس از بازیابی داده‌ها، می‌توانید از یک تابع ورودی/خروجی دیگر به نام printf برای چاپ نتیجه در کنسول استفاده کنید.

این توابع در نگاه اول کاملاً ساده به نظر می‌رسند و لازم نیست در مورد ماشین‌آلات مربوط به خواندن یا نوشتن داده‌ها دو بار فکر کنید. با این حال، بسته به محیط، اتفاقات زیادی می‌تواند در داخل رخ دهد:

  • اگر فایل ورودی روی یک درایو محلی قرار داشته باشد، برنامه باید مجموعه‌ای از دسترسی‌ها به حافظه و دیسک را برای یافتن فایل، بررسی مجوزها، باز کردن آن برای خواندن و سپس خواندن بلوک به بلوک تا زمان بازیابی تعداد بایت‌های درخواستی انجام دهد. این فرآیند بسته به سرعت دیسک شما و حجم درخواستی می‌تواند بسیار کند باشد.
  • یا، فایل ورودی ممکن است در یک مکان شبکه‌ی نصب‌شده قرار داشته باشد، که در این صورت، پشته‌ی شبکه نیز درگیر خواهد شد و پیچیدگی، تأخیر و تعداد تلاش‌های مجدد بالقوه برای هر عملیات را افزایش می‌دهد.
  • در نهایت، حتی printf هم تضمین نمی‌کند که چیزهایی را در کنسول چاپ کند و ممکن است به یک فایل یا یک مکان شبکه هدایت شود، که در این صورت باید همان مراحل بالا را طی کند.

خلاصه کلام اینکه، عملیات ورودی/خروجی می‌تواند کند باشد و شما نمی‌توانید با یک نگاه سریع به کد، پیش‌بینی کنید که یک فراخوانی خاص چقدر طول می‌کشد. در حالی که آن عملیات در حال اجرا است، کل برنامه شما به کاربر پاسخ نمی‌دهد و هنگ می‌کند.

این موضوع فقط به C یا C++ محدود نمی‌شود. اکثر زبان‌های سیستمی تمام ورودی/خروجی‌ها را به شکلی از APIهای همزمان ارائه می‌دهند. برای مثال، اگر مثال را به Rust ترجمه کنید، API ممکن است ساده‌تر به نظر برسد، اما همان اصول اعمال می‌شود. شما فقط یک فراخوانی انجام می‌دهید و به صورت همزمان منتظر می‌مانید تا نتیجه را برگرداند، در حالی که تمام عملیات پرهزینه را انجام می‌دهد و در نهایت نتیجه را در یک فراخوانی واحد برمی‌گرداند:

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

اما چه اتفاقی می‌افتد وقتی سعی می‌کنید هر یک از آن نمونه‌ها را به WebAssembly کامپایل کنید و آنها را به وب ترجمه کنید؟ یا به عنوان یک مثال خاص، عملیات "خواندن فایل" به چه چیزی تبدیل می‌شود؟ باید داده‌ها را از یک حافظه بخواند.

مدل ناهمزمان وب

وب گزینه‌های ذخیره‌سازی متنوعی دارد که می‌توانید به آن‌ها نگاشت کنید، مانند ذخیره‌سازی درون حافظه‌ای (اشیاء JS)، localStorage ، IndexedDB ، ذخیره‌سازی سمت سرور و یک رابط برنامه‌نویسی کاربردی جدید دسترسی به سیستم فایل .

با این حال، تنها دو مورد از این APIها - ذخیره‌سازی درون حافظه‌ای و localStorage - می‌توانند به صورت همزمان استفاده شوند و هر دو، محدودترین گزینه‌ها در مورد آنچه می‌توانید ذخیره کنید و مدت زمان آن هستند. سایر گزینه‌ها فقط APIهای ناهمزمان ارائه می‌دهند.

این یکی از ویژگی‌های اصلی اجرای کد در وب است: هر عملیات زمان‌بر، که شامل هرگونه ورودی/خروجی می‌شود، باید غیرهمزمان باشد.

دلیلش این است که وب از نظر تاریخی تک‌رشته‌ای بوده است و هر کد کاربری که با رابط کاربری (UI) در ارتباط باشد، باید روی همان رشته رابط کاربری اجرا شود. این کد باید با سایر وظایف مهم مانند طرح‌بندی، رندرینگ و مدیریت رویدادها برای زمان پردازنده رقابت کند. شما نمی‌خواهید که یک قطعه جاوا اسکریپت یا WebAssembly بتواند یک عملیات "خواندن فایل" را شروع کند و همه چیزهای دیگر - کل تب یا در گذشته، کل مرورگر - را برای مدتی از میلی‌ثانیه تا چند ثانیه مسدود کند تا زمانی که تمام شود.

در عوض، کد فقط مجاز است یک عملیات ورودی/خروجی را به همراه یک فراخوانی برگشتی که پس از اتمام آن اجرا می‌شود، زمان‌بندی کند. چنین فراخوانی‌های برگشتی به عنوان بخشی از حلقه رویداد مرورگر اجرا می‌شوند. من در اینجا وارد جزئیات نمی‌شوم، اما اگر علاقه‌مند به یادگیری نحوه عملکرد حلقه رویداد در زیر کاپوت هستید، به Tasks، microtasks، queues و schedules مراجعه کنید که این موضوع را به طور عمیق توضیح می‌دهد.

خلاصه کلام این است که مرورگر تمام قطعات کد را به صورت یک حلقه بی‌نهایت اجرا می‌کند، به این صورت که آنها را یکی یکی از صف خارج می‌کند. وقتی رویدادی رخ می‌دهد، مرورگر هندلر مربوطه را در صف قرار می‌دهد و در تکرار بعدی حلقه، از صف خارج شده و اجرا می‌شود. این مکانیزم امکان شبیه‌سازی همزمانی و اجرای تعداد زیادی عملیات موازی را در حالی که فقط از یک رشته استفاده می‌شود، فراهم می‌کند.

نکته‌ی مهمی که باید در مورد این مکانیزم به خاطر داشته باشید این است که در حالی که کد جاوا اسکریپت سفارشی (یا WebAssembly) شما اجرا می‌شود، حلقه‌ی رویداد مسدود می‌شود و در این مدت، هیچ راهی برای واکنش به هیچ کنترل‌کننده‌ی خارجی، رویداد، ورودی/خروجی و غیره وجود ندارد. تنها راه برای بازگرداندن نتایج ورودی/خروجی، ثبت یک فراخوانی مجدد، پایان اجرای کد و بازگرداندن کنترل به مرورگر است تا بتواند به پردازش هرگونه وظیفه‌ی در حال انتظار ادامه دهد. پس از پایان ورودی/خروجی، کنترل‌کننده‌ی شما به یکی از آن وظایف تبدیل شده و اجرا خواهد شد.

برای مثال، اگر می‌خواستید نمونه‌های بالا را با جاوا اسکریپت مدرن بازنویسی کنید و تصمیم بگیرید نامی را از یک 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 تمام محتوا را بازیابی کرد، آخرین فراخوانی را فراخوانی می‌کند که عبارت "Hello, (username)!" را در کنسول چاپ می‌کند.

به لطف ماهیت ناهمزمان این مراحل، تابع اصلی می‌تواند به محض زمان‌بندی ورودی/خروجی، کنترل را به مرورگر بازگرداند و کل رابط کاربری را پاسخگو و در دسترس برای سایر وظایف، از جمله رندر کردن، پیمایش و غیره، در حالی که ورودی/خروجی در پس‌زمینه اجرا می‌شود، قرار دهد.

به عنوان مثال آخر، حتی API های ساده ای مانند "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" در جاوا اسکریپت شامل فراخوانی setTimeout() و اشتراک با یک handler است:

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

وجه مشترک همه این مثال‌ها و APIها چیست؟ در هر مورد، کد اصطلاحی در زبان سیستم اصلی از یک API مسدودکننده برای ورودی/خروجی استفاده می‌کند، در حالی که یک مثال معادل برای وب به جای آن از یک API ناهمزمان استفاده می‌کند. هنگام کامپایل به وب، باید به نحوی بین این دو مدل اجرا تبدیل شوید و WebAssembly هنوز هیچ توانایی داخلی برای انجام این کار ندارد.

پر کردن شکاف با Asyncify

اینجاست که Asyncify وارد عمل می‌شود. Asyncify یک ویژگی زمان کامپایل است که توسط Emscripten پشتیبانی می‌شود و امکان توقف کل برنامه و از سرگیری ناهمگام آن را در زمان دیگری فراهم می‌کند.

یک گراف فراخوانی توصیف‌کننده‌ی فراخوانی وظیفه‌ی ناهمگام جاوااسکریپت -> وب‌اسمبلی -> وب‌ای‌پی‌آی که در آن Asyncify نتیجه‌ی وظیفه‌ی ناهمگام را به وب‌اسمبلی متصل می‌کند

کاربرد در C/C++ با Emscripten

اگر می‌خواستید از Asyncify برای پیاده‌سازی یک sleep ناهمزمان برای مثال آخر استفاده کنید، می‌توانستید این کار را به این صورت انجام دهید:

#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 یک ماکرو است که امکان تعریف قطعه کدهای جاوا اسکریپت را مانند توابع C فراهم می‌کند. درون آن، از تابعی به نام Asyncify.handleSleep() استفاده کنید که به Emscripten می‌گوید برنامه را به حالت تعلیق درآورد و یک هندلر wakeUp() ارائه می‌دهد که باید پس از پایان عملیات ناهمزمان فراخوانی شود. در مثال بالا، هندلر به setTimeout() ارسال می‌شود، اما می‌تواند در هر زمینه دیگری که فراخوانی‌های برگشتی را می‌پذیرد، استفاده شود. در نهایت، می‌توانید async_sleep() را در هر جایی که می‌خواهید فراخوانی کنید، درست مانند sleep() معمولی یا هر API همگام دیگر.

هنگام کامپایل چنین کدی، باید به 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);

در واقع، برای APIهای مبتنی بر Promise مانند fetch() ، حتی می‌توانید Asyncify را با ویژگی async-await جاوااسکریپت به جای استفاده از API مبتنی بر callback ترکیب کنید. برای این کار، به جای Asyncify.handleSleep() ، Asyncify.handleAsync() را فراخوانی کنید. سپس، به جای اینکه مجبور باشید یک callback wakeUp() را زمان‌بندی کنید، می‌توانید یک تابع جاوااسکریپت async ارسال کنید و 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 ارائه می‌دهد که به شما امکان می‌دهد تبدیل بین مقادیر جاوا اسکریپت و C++ را مدیریت کنید. این قابلیت از Asyncify نیز پشتیبانی می‌کند، بنابراین می‌توانید await() را روی Promise های خارجی فراخوانی کنید و درست مانند await در کد جاوا اسکریپت async-await عمل خواهد کرد:

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 خود دارید که می‌خواهید آن را به یک API ناهمزمان در وب نگاشت کنید. معلوم می‌شود که می‌توانید این کار را هم انجام دهید!

ابتدا، باید چنین تابعی را به عنوان یک تابع import معمولی از طریق بلوک 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 انجام می‌شود، اما اکنون به کد چسبنده جاوا اسکریپت سفارشی نیاز دارید که فایل‌های WebAssembly دلخواه را مدیریت کند. ما یک کتابخانه دقیقاً برای این کار ایجاد کرده‌ایم.

می‌توانید آن را در گیت‌هاب به آدرس https://github.com/GoogleChromeLabs/asyncify یا در npm با نام asyncify-wasm پیدا کنید.

این یک API نمونه‌سازی استاندارد WebAssembly را شبیه‌سازی می‌کند، اما تحت فضای نام خاص خود. تنها تفاوت این است که تحت یک API WebAssembly معمولی، شما فقط می‌توانید توابع همگام را به عنوان import ارائه دهید، در حالی که تحت پوشش Asyncify، می‌توانید importهای ناهمگام را نیز ارائه دهید:

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 را به حالت تعلیق در می‌آورد و ذخیره می‌کند، در تکمیل promise مشترک می‌شود و بعداً، پس از حل شدن، پشته فراخوانی و وضعیت را به طور یکپارچه بازیابی می‌کند و اجرا را طوری ادامه می‌دهد که انگار هیچ اتفاقی نیفتاده است.

از آنجایی که هر تابعی در ماژول ممکن است فراخوانی غیرهمزمان انجام دهد، تمام export ها نیز به طور بالقوه غیرهمزمان می‌شوند، بنابراین آنها نیز بسته‌بندی می‌شوند. ممکن است در مثال بالا متوجه شده باشید که برای اطلاع از پایان واقعی اجرا، باید await نتیجه instance.exports.main() باشید.

چطور همه اینها زیر کاپوت کار می‌کنند؟

وقتی Asyncify فراخوانی یکی از توابع ASYNCIFY_IMPORTS را تشخیص می‌دهد، یک عملیات ناهمزمان را شروع می‌کند، کل وضعیت برنامه، شامل پشته فراخوانی و هرگونه محلی موقت را ذخیره می‌کند، و بعداً، وقتی آن عملیات تمام شد، تمام حافظه و پشته فراخوانی را بازیابی می‌کند و از همان مکان و با همان وضعیت از سر می‌گیرد، گویی برنامه هرگز متوقف نشده است.

این کاملاً شبیه به ویژگی async-await در جاوااسکریپت است که قبلاً نشان دادم، اما برخلاف جاوااسکریپت، به هیچ سینتکس یا پشتیبانی زمان اجرا خاصی از زبان نیاز ندارد و در عوض با تبدیل توابع همگام ساده در زمان کامپایل کار می‌کند.

هنگام کامپایل مثال خواب ناهمزمان که قبلاً نشان داده شده است:

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 کاملاً رایگان نیست، زیرا باید مقدار زیادی کد پشتیبانی برای ذخیره و بازیابی همه آن فایل‌های محلی، پیمایش پشته فراخوانی در حالت‌های مختلف و غیره تزریق کند. این تبدیل سعی می‌کند فقط توابعی را که در خط فرمان به عنوان ناهمزمان علامت‌گذاری شده‌اند، و همچنین هر یک از فراخوانی‌کننده‌های احتمالی آنها را تغییر دهد، اما حجم کد سربار ممکن است قبل از فشرده‌سازی تقریباً به 50٪ برسد.

نموداری که سربار حجم کد را برای معیارهای مختلف نشان می‌دهد، از نزدیک به ۰٪ در شرایط تنظیم دقیق تا بیش از ۱۰۰٪ در بدترین حالت‌ها

این ایده‌آل نیست، اما در بسیاری از موارد، زمانی که گزینه جایگزین، کلاً آن عملکرد را نداشته باشد یا مجبور به بازنویسی‌های قابل توجهی در کد اصلی باشد، قابل قبول است.

مطمئن شوید که همیشه بهینه‌سازی‌ها را برای ساخت‌های نهایی فعال می‌کنید تا از افزایش بیشتر آن جلوگیری شود. همچنین می‌توانید گزینه‌های بهینه‌سازی خاص Asyncify را بررسی کنید تا با محدود کردن تبدیل‌ها فقط به توابع مشخص شده و/یا فقط فراخوانی‌های مستقیم تابع، سربار را کاهش دهید. همچنین هزینه کمی برای عملکرد زمان اجرا وجود دارد، اما محدود به خود فراخوانی‌های async است. با این حال، در مقایسه با هزینه کار واقعی، معمولاً ناچیز است.

نسخه‌های نمایشی دنیای واقعی

حالا که مثال‌های ساده را بررسی کردید، به سراغ سناریوهای پیچیده‌تر می‌روم.

همانطور که در ابتدای مقاله ذکر شد، یکی از گزینه‌های ذخیره‌سازی در وب، API دسترسی به سیستم فایل ناهمزمان است. این API دسترسی به یک سیستم فایل میزبان واقعی را از یک برنامه وب فراهم می‌کند.

از سوی دیگر، یک استاندارد بالفعل به نام WASI برای WebAssembly I/O در کنسول و سمت سرور وجود دارد. این استاندارد به عنوان یک هدف کامپایل برای زبان‌های سیستمی طراحی شده است و انواع سیستم فایل و سایر عملیات را به شکل همگام سنتی ارائه می‌دهد.

چه می‌شود اگر بتوانید یکی را به دیگری نگاشت کنید؟ آنگاه می‌توانید هر برنامه‌ای را به هر زبان منبعی با هر زنجیره ابزاری که از هدف WASI پشتیبانی می‌کند، کامپایل کنید و آن را در یک جعبه شنی (sandbox) در وب اجرا کنید، در حالی که همچنان به آن اجازه می‌دهید روی فایل‌های کاربر واقعی کار کند! با Asyncify، می‌توانید دقیقاً همین کار را انجام دهید.

در این نسخه آزمایشی، من جعبه Rust coreutils را با چند وصله کوچک به WASI کامپایل کرده‌ام، از طریق تبدیل Asyncify منتقل کرده‌ام و اتصالات ناهمزمان را از WASI به API دسترسی به سیستم فایل در سمت جاوا اسکریپت پیاده‌سازی کرده‌ام. پس از ترکیب با مؤلفه ترمینال Xterm.js ، یک پوسته واقع‌گرایانه ایجاد می‌شود که در تب مرورگر اجرا می‌شود و روی فایل‌های کاربر واقعی کار می‌کند - درست مانند یک ترمینال واقعی.

آن را به صورت زنده در https://wasi.rreverser.com/ تماشا کنید.

موارد استفاده‌ی Asyncify فقط به تایمرها و سیستم فایل‌ها محدود نمی‌شود. می‌توانید پا را فراتر گذاشته و از APIهای تخصصی‌تری در وب استفاده کنید.

برای مثال، همچنین با کمک Asyncify، می‌توان libusb - که احتمالاً محبوب‌ترین کتابخانه بومی برای کار با دستگاه‌های USB است - را به یک WebUSB API نگاشت کرد که دسترسی ناهمزمان به چنین دستگاه‌هایی را در وب فراهم می‌کند. پس از نگاشت و کامپایل، تست‌ها و مثال‌های استاندارد libusb را برای اجرا روی دستگاه‌های انتخاب شده، درست در sandbox یک صفحه وب دریافت کردم.

تصویری از خروجی اشکال‌زدایی libusb در یک صفحه وب، که اطلاعاتی در مورد دوربین متصل کانن را نشان می‌دهد

احتمالاً این داستانی برای یک پست وبلاگ دیگر است.

این مثال‌ها نشان می‌دهند که Asyncify چقدر می‌تواند برای پر کردن شکاف و انتقال انواع برنامه‌ها به وب قدرتمند باشد، و به شما امکان دسترسی بین پلتفرمی، sandboxing و امنیت بهتر را بدون از دست دادن عملکرد بدهد.