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

APIهای I/O در وب ناهمزمان هستند، اما در اکثر زبان‌های سیستم همزمان هستند. هنگام کامپایل کد به WebAssembly، باید یک نوع API را به دیگری متصل کنید - و این پل Asyncify است. در این پست، زمان و نحوه استفاده از Asyncify و نحوه عملکرد آن در زیر کاپوت را خواهید آموخت.

I/O در زبان های سیستم

من با یک مثال ساده در 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 برای خواندن داده‌ها از آن. هنگامی که داده ها را بازیابی کردید، می توانید از تابع I/O دیگری printf برای چاپ نتیجه در کنسول استفاده کنید.

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

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

به طور خلاصه، I/O می تواند کند باشد و نمی توانید با یک نگاه سریع به کد، مدت زمان یک تماس خاص را پیش بینی کنید. در حالی که این عملیات در حال اجرا است، کل برنامه شما ثابت و بدون پاسخ به کاربر ظاهر می شود.

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

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

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

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

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

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

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

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

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

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

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

برای مثال، اگر می‌خواهید نمونه‌های بالا را در جاوا اسکریپت مدرن بازنویسی کنید و تصمیم به خواندن یک نام از یک URL راه دور دارید، از Fetch API و syntax 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, (نام کاربری)" را چاپ می کند! به کنسول

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

به عنوان مثال پایانی، حتی APIهای ساده مانند "خواب"، که باعث می شود برنامه برای تعداد مشخصی از ثانیه منتظر بماند، نیز نوعی عملیات I/O هستند:

#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 در اجرای پیش‌فرض «خواب» انجام می‌دهد، اما بسیار ناکارآمد است، کل رابط کاربری را مسدود می‌کند و اجازه نمی‌دهد تا در این میان هیچ رویداد دیگری مدیریت شود. به طور کلی، این کار را در کد تولید انجام ندهید.

در عوض، یک نسخه اصطلاحی تر از "خواب" در جاوا اسکریپت شامل فراخوانی setTimeout() و اشتراک با یک handler است:

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

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

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

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

یک نمودار فراخوانی که یک جاوا اسکریپت -> WebAssembly -> Web API -> فراخوانی کار async را توصیف می کند، جایی که Asyncify نتیجه کار async را دوباره به 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 یک ماکرو است که به شما اجازه می دهد تا قطعات جاوا اسکریپت را طوری تعریف کنید که انگار توابع C هستند. در داخل، از یک تابع Asyncify.handleSleep() استفاده کنید که به Emscripten می‌گوید برنامه را به حالت تعلیق درآورد و یک کنترلر wakeUp() ارائه می‌کند که باید پس از پایان عملیات ناهمزمان فراخوانی شود. در مثال بالا، handler به 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() ، حتی می توانید به جای استفاده از API مبتنی بر callback، Asyncify را با ویژگی async-await جاوا اسکریپت ترکیب کنید. برای آن، به جای Asyncify.handleSleep() ، Asyncify.handleAsync() را فراخوانی کنید. سپس، به جای برنامه‌ریزی یک فراخوان wakeUp() ، می‌توانید یک تابع جاوا اسکریپت async را ارسال کنید و از await و return در داخل استفاده کنید، و باعث می‌شود کد حتی طبیعی‌تر و همزمان‌تر به نظر برسد، در حالی که هیچ یک از مزایای I/O ناهمزمان را از دست ندهید.

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 s خارجی فراخوانی کنید و دقیقاً مانند 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 غیر همگام در وب نگاشت کنید. معلوم است، شما هم می توانید این کار را انجام دهید!

ابتدا، باید چنین تابعی را به عنوان یک وارد کردن معمولی از طریق بلوک 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 دلخواه را مدیریت کند. ما یک کتابخانه فقط برای آن ایجاد کرده ایم.

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

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

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

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

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

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

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

دموهای دنیای واقعی

اکنون که به نمونه های ساده نگاه کردید، به سراغ سناریوهای پیچیده تر می روم.

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

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

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

در این نسخه ی نمایشی، من Rust coreutils crate را با چند وصله کوچک برای WASI کامپایل کرده ام، از طریق Asyncify transform عبور کرده و اتصالات ناهمزمان را از WASI به File System Access API در سمت جاوا اسکریپت پیاده سازی کرده ام. هنگامی که با کامپوننت ترمینال Xterm.js ترکیب می شود، پوسته ای واقع گرایانه را ارائه می دهد که در تب مرورگر اجرا می شود و بر روی فایل های کاربر واقعی کار می کند - درست مانند یک ترمینال واقعی.

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

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

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

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

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

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

،

APIهای I/O در وب ناهمزمان هستند، اما در اکثر زبان‌های سیستم همزمان هستند. هنگام کامپایل کد به WebAssembly، باید یک نوع API را به دیگری متصل کنید - و این پل Asyncify است. در این پست، زمان و نحوه استفاده از Asyncify و نحوه عملکرد آن در زیر کاپوت را خواهید آموخت.

I/O در زبان های سیستم

من با یک مثال ساده در 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 برای خواندن داده‌ها از آن. هنگامی که داده ها را بازیابی کردید، می توانید از تابع I/O دیگری printf برای چاپ نتیجه در کنسول استفاده کنید.

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

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

به طور خلاصه، I/O می تواند کند باشد و نمی توانید با یک نگاه سریع به کد، مدت زمان یک تماس خاص را پیش بینی کنید. در حالی که این عملیات در حال اجرا است، کل برنامه شما ثابت و بدون پاسخ به کاربر ظاهر می شود.

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

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

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

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

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

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

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

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

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

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

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

برای مثال، اگر می‌خواهید نمونه‌های بالا را در جاوا اسکریپت مدرن بازنویسی کنید و تصمیم به خواندن یک نام از یک URL راه دور دارید، از Fetch API و syntax 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, (نام کاربری)" را چاپ می کند! به کنسول

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

به عنوان مثال پایانی، حتی APIهای ساده مانند "خواب"، که باعث می شود برنامه برای تعداد مشخصی از ثانیه منتظر بماند، نیز نوعی عملیات I/O هستند:

#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 در اجرای پیش‌فرض «خواب» انجام می‌دهد، اما بسیار ناکارآمد است، کل رابط کاربری را مسدود می‌کند و اجازه نمی‌دهد تا در این میان هیچ رویداد دیگری مدیریت شود. به طور کلی، این کار را در کد تولید انجام ندهید.

در عوض، یک نسخه اصطلاحی تر از "خواب" در جاوا اسکریپت شامل فراخوانی setTimeout() و اشتراک با یک handler است:

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

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

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

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

یک نمودار فراخوانی که یک جاوا اسکریپت -> WebAssembly -> Web API -> فراخوانی کار async را توصیف می کند، جایی که Asyncify نتیجه کار async را دوباره به 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 یک ماکرو است که به شما اجازه می دهد تا قطعات جاوا اسکریپت را طوری تعریف کنید که انگار توابع C هستند. در داخل، از یک تابع Asyncify.handleSleep() استفاده کنید که به Emscripten می‌گوید برنامه را به حالت تعلیق درآورد و یک کنترلر wakeUp() ارائه می‌کند که باید پس از پایان عملیات ناهمزمان فراخوانی شود. در مثال بالا، handler به 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() ، حتی می توانید به جای استفاده از API مبتنی بر callback، Asyncify را با ویژگی async-await جاوا اسکریپت ترکیب کنید. برای آن، به جای Asyncify.handleSleep() ، Asyncify.handleAsync() را فراخوانی کنید. سپس، به جای برنامه‌ریزی یک فراخوان wakeUp() ، می‌توانید یک تابع جاوا اسکریپت async را ارسال کنید و از await و return در داخل استفاده کنید، و باعث می‌شود کد حتی طبیعی‌تر و همزمان‌تر به نظر برسد، در حالی که هیچ یک از مزایای I/O ناهمزمان را از دست ندهید.

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 s خارجی فراخوانی کنید و دقیقاً مانند 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 غیر همگام در وب نگاشت کنید. معلوم است، شما هم می توانید این کار را انجام دهید!

ابتدا، باید چنین تابعی را به عنوان یک وارد کردن معمولی از طریق بلوک 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 دلخواه را مدیریت کند. ما فقط برای آن یک کتابخانه ایجاد کرده ایم.

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

این یک API نمونه استاندارد WebAssembly را شبیه سازی می کند، اما تحت فضای نام خودش. تنها تفاوت این است که، تحت یک WebAssembly API معمولی، شما فقط می توانید توابع همزمان را به عنوان واردات ارائه کنید، در حالی که تحت پوشش 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-Wait در JavaScript است که من قبلاً نشان دادم ، اما برخلاف JavaScript One ، به هیچ نحوی ویژه یا پشتیبانی از زمان اجرا از زبان احتیاج ندارد و در عوض با تبدیل توابع همزمان ساده در زمان کامپایل کار می کند.

هنگام تهیه نمونه خواب ناهمزمان قبلی نشان داده شده:

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

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

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

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

از طرف دیگر ، یک استاندارد de-facto به نام WASI برای WebAssembly I/O در کنسول و سمت سرور وجود دارد. این برنامه به عنوان یک هدف تلفیقی برای زبانهای سیستم طراحی شده است و انواع سیستم فایل و سایر عملیات را به صورت همزمان سنتی در معرض نمایش قرار می دهد.

اگر بتوانید یکی به دیگری نقشه بکشید چه می کنید؟ سپس می توانید هر برنامه کاربردی را به هر زبان منبع با هرگونه ابزار ابزار پشتیبانی از هدف WASI کامپایل کنید و آن را در یک جعبه ماسه ای در وب اجرا کنید ، در حالی که هنوز هم به آن اجازه می دهد تا روی پرونده های کاربر واقعی کار کند! با asyncify ، شما می توانید همین کار را انجام دهید.

در این نسخه ی نمایشی ، من جعبه Rust Coreutils را با چند تکه جزئی به WASI گردآوری کرده ام ، که از طریق Asyncify Transform منتقل شده و اتصالات ناهمزمان را از WASI به پرونده دسترسی به سیستم API در سمت جاوا اسکریپت اجرا کرده ام. پس از ترکیب با مؤلفه ترمینال XTERM.JS ، این یک پوسته واقع بینانه را در برگه مرورگر اجرا می کند و بر روی پرونده های کاربر واقعی کار می کند - دقیقاً مانند یک ترمینال واقعی.

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

موارد استفاده Asyncify فقط به تایمرها و سیستم های فایل محدود نمی شوند. می توانید فراتر بروید و از API های طاقچه بیشتری در وب استفاده کنید.

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

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

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

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

،

API های I/O در وب ناهمزمان هستند ، اما در اکثر زبان های سیستم همزمان هستند. هنگام تهیه کد به WebAssembly ، باید یک نوع API را به دیگری بپیوندید - و این پل به هم ریخته است. در این پست ، شما می آموزید که چه موقع و چگونه می توانید از Asyncify و نحوه عملکرد آن در زیر کاپوت استفاده کنید.

I/O به زبان های سیستم

من با یک مثال ساده در 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 برای خواندن داده های آن. پس از بازیابی داده ها ، می توانید از یک عملکرد I/O printf دیگر برای چاپ نتیجه در کنسول استفاده کنید.

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

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

داستان کوتاه ، I/O می تواند کند باشد و شما نمی توانید پیش بینی کنید که یک تماس خاص با یک نگاه سریع به کد طول می کشد. در حالی که این عملیات در حال اجرا است ، کل برنامه شما یخ زده و پاسخگو به کاربر خواهد بود.

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

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

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

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

وب دارای گزینه های مختلف ذخیره سازی مختلف است که می توانید از آنها نقشه برداری کنید ، مانند ذخیره سازی حافظه (JS Objects) ، localStorage ، IndexedDB ، ذخیره سازی سمت سرور و API دسترسی به سیستم پرونده جدید.

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

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

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

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

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

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

به عنوان مثال ، اگر می خواستید نمونه های فوق را در JavaScript مدرن بازنویسی کنید و تصمیم به خواندن نامی از URL از راه دور گرفتید ، از API Fetch و نحو Async-Wait استفاده می کنید:

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 تمام محتویات را بازیابی کرد ، آخرین پاسخ به تماس را فراخوانی می کند ، که چاپ "سلام ، (نام کاربری)) را چاپ می کند! به کنسول

با تشکر از ماهیت ناهمزمان آن مراحل ، عملکرد اصلی می تواند به محض برنامه ریزی I/O ، کنترل را به مرورگر برگرداند و کل UI را پاسخگو و برای سایر کارهای دیگر از جمله ارائه ، پیمایش و غیره در دسترس قرار دهد. I/O در پس زمینه اجرا می شود.

به عنوان مثال نهایی ، حتی API های ساده مانند "خواب" ، که باعث می شود یک برنامه منتظر تعداد مشخصی از ثانیه باشد ، همچنین نوعی عملیات I/O است:

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

در عوض ، یک نسخه ایدیوماتیک تر از "خواب" در JavaScript شامل فراخوانی setTimeout() و مشترک شدن با یک کنترل کننده است:

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

چه چیزی برای همه این مثالها و API ها مشترک است؟ در هر حالت ، کد ایدیوماتیک در زبان سیستم های اصلی از یک API مسدود کننده برای I/O استفاده می کند ، در حالی که یک نمونه معادل برای وب به جای آن از API ناهمزمان استفاده می کند. هنگام تدوین در وب ، باید به نوعی بین آن دو مدل اجرا تغییر شکل دهید ، و WebAnsembly هنوز توانایی داخلی برای انجام این کار را ندارد.

پل زدن شکاف با asyncify

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

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

هنگام تهیه چنین کدی ، برای فعال کردن ویژگی Asyncify باید به Emscripten بگویید. این کار را با عبور -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 های مبتنی بر وعده مانند fetch() ، حتی می توانید به جای استفاده از API مبتنی بر تماس ، Asyncify را با ویژگی Async-Wait JavaScript ترکیب کنید. برای این کار ، به جای Asyncify.handleSleep() ، با Asyncify.handleAsync() تماس بگیرید. سپس ، به جای اینکه مجبور به برنامه ریزی برای پاسخ wakeUp() باشید ، می توانید یک عملکرد async JavaScript را پشت سر بگذارید و await و return به داخل استفاده کنید ، باعث می شود کد حتی طبیعی تر و همزمان به نظر برسد ، در حالی که هیچ یک از مزایای I/O ناهمزمان را از دست نمی دهید.

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 نیز دارد ، بنابراین می توانید در مورد Promise های خارجی await() تماس بگیرید و دقیقاً مانند await در کد جاوا اسکریپت Async-Wait عمل می کند:

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 بسیار عالی است. در مورد سایرچین و زبانهای دیگر چیست؟

استفاده از زبانهای دیگر

بگویید که شما یک تماس همزمان مشابه در جایی در کد زنگ زدگی خود دارید که می خواهید به یک API ASYNC در وب نقشه بکشید. معلوم است ، شما هم می توانید این کار را انجام دهید!

ابتدا باید چنین عملکردی را به عنوان یک واردات منظم از طریق بلوک 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 پیدا کنید.

این یک API Instantiation WebAssembly استاندارد را شبیه سازی می کند ، اما در فضای نام خود قرار دارد. تنها تفاوت این است که ، تحت یک API ASSEMBLE معمولی فقط می توانید عملکردهای همزمان را به عنوان واردات ارائه دهید ، در حالی که تحت بسته بندی 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-Wait در JavaScript است که من قبلاً نشان دادم ، اما برخلاف JavaScript One ، به هیچ نحوی ویژه یا پشتیبانی از زمان اجرا از زبان احتیاج ندارد و در عوض با تبدیل توابع همزمان ساده در زمان کامپایل کار می کند.

هنگام تهیه نمونه خواب ناهمزمان قبلی نشان داده شده:

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

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

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

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

از طرف دیگر ، یک استاندارد de-facto به نام WASI برای WebAssembly I/O در کنسول و سمت سرور وجود دارد. این برنامه به عنوان یک هدف تلفیقی برای زبانهای سیستم طراحی شده است و انواع سیستم فایل و سایر عملیات را به صورت همزمان سنتی در معرض نمایش قرار می دهد.

اگر بتوانید یکی به دیگری نقشه بکشید چه می کنید؟ سپس می توانید هر برنامه کاربردی را به هر زبان منبع با هرگونه ابزار ابزار پشتیبانی از هدف WASI کامپایل کنید و آن را در یک جعبه ماسه ای در وب اجرا کنید ، در حالی که هنوز هم به آن اجازه می دهد تا روی پرونده های کاربر واقعی کار کند! با asyncify ، شما می توانید همین کار را انجام دهید.

در این نسخه ی نمایشی ، من جعبه Rust Coreutils را با چند تکه جزئی به WASI گردآوری کرده ام ، که از طریق Asyncify Transform منتقل شده و اتصالات ناهمزمان را از WASI به پرونده دسترسی به سیستم API در سمت جاوا اسکریپت اجرا کرده ام. پس از ترکیب با مؤلفه ترمینال XTERM.JS ، این یک پوسته واقع بینانه را در برگه مرورگر اجرا می کند و بر روی پرونده های کاربر واقعی کار می کند - دقیقاً مانند یک ترمینال واقعی.

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

موارد استفاده Asyncify فقط به تایمرها و سیستم های فایل محدود نمی شوند. می توانید فراتر بروید و از API های طاقچه بیشتری در وب استفاده کنید.

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

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

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

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

،

API های I/O در وب ناهمزمان هستند ، اما در اکثر زبان های سیستم همزمان هستند. هنگام تهیه کد به WebAssembly ، باید یک نوع API را به دیگری بپیوندید - و این پل به هم ریخته است. در این پست ، شما می آموزید که چه موقع و چگونه می توانید از Asyncify و نحوه عملکرد آن در زیر کاپوت استفاده کنید.

I/O به زبان های سیستم

من با یک مثال ساده در 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 برای خواندن داده های آن. پس از بازیابی داده ها ، می توانید از یک عملکرد I/O printf دیگر برای چاپ نتیجه در کنسول استفاده کنید.

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

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

داستان کوتاه ، I/O می تواند کند باشد و شما نمی توانید پیش بینی کنید که یک تماس خاص با یک نگاه سریع به کد طول می کشد. در حالی که این عملیات در حال اجرا است ، کل برنامه شما یخ زده و پاسخگو به کاربر خواهد بود.

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

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

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

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

وب دارای گزینه های مختلف ذخیره سازی مختلف است که می توانید از آنها نقشه برداری کنید ، مانند ذخیره سازی حافظه (JS Objects) ، localStorage ، IndexedDB ، ذخیره سازی سمت سرور و API دسترسی به سیستم پرونده جدید.

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

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

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

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

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

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

به عنوان مثال ، اگر می خواستید نمونه های فوق را در JavaScript مدرن بازنویسی کنید و تصمیم به خواندن نامی از URL از راه دور گرفتید ، از API Fetch و نحو Async-Wait استفاده می کنید:

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 تمام محتویات را بازیابی کرد ، آخرین پاسخ به تماس را فراخوانی می کند ، که چاپ "سلام ، (نام کاربری)) را چاپ می کند! به کنسول

با تشکر از ماهیت ناهمزمان آن مراحل ، عملکرد اصلی می تواند به محض برنامه ریزی I/O ، کنترل را به مرورگر برگرداند و کل UI را پاسخگو و برای سایر کارهای دیگر از جمله ارائه ، پیمایش و غیره در دسترس قرار دهد. I/O در پس زمینه اجرا می شود.

به عنوان مثال نهایی ، حتی API های ساده مانند "خواب" ، که باعث می شود یک برنامه منتظر تعداد مشخصی از ثانیه باشد ، همچنین نوعی عملیات I/O است:

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

در عوض ، یک نسخه ایدیوماتیک تر از "خواب" در JavaScript شامل فراخوانی setTimeout() و مشترک شدن با یک کنترل کننده است:

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

چه چیزی برای همه این مثالها و API ها مشترک است؟ در هر حالت ، کد ایدیوماتیک در زبان سیستم های اصلی از یک API مسدود کننده برای I/O استفاده می کند ، در حالی که یک نمونه معادل برای وب به جای آن از API ناهمزمان استفاده می کند. هنگام تدوین در وب ، باید به نوعی بین آن دو مدل اجرا تغییر شکل دهید ، و WebAnsembly هنوز توانایی داخلی برای انجام این کار را ندارد.

پل زدن شکاف با asyncify

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

A call graph
describing a JavaScript -> WebAssembly -> web API -> async task invocation, where Asyncify connects
the result of the async task back into WebAssembly

Usage in C / C++ with Emscripten

If you wanted to use Asyncify to implement an asynchronous sleep for the last example, you could do it like this:

#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 is a macro that allows defining JavaScript snippets as if they were C functions. Inside, use a function Asyncify.handleSleep() which tells Emscripten to suspend the program and provides a wakeUp() handler that should be called once the asynchronous operation has finished. In the example above, the handler is passed to setTimeout() , but it could be used in any other context that accepts callbacks. Finally, you can call async_sleep() anywhere you want just like regular sleep() or any other synchronous API.

When compiling such code, you need to tell Emscripten to activate the Asyncify feature. Do that by passing -s ASYNCIFY as well as -s ASYNCIFY_IMPORTS=[func1, func2] with an array-like list of functions that might be asynchronous.

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

This lets Emscripten know that any calls to those functions might require saving and restoring the state, so the compiler will inject supporting code around such calls.

Now, when you execute this code in the browser you'll see a seamless output log like you'd expect, with B coming after a short delay after A.

A
B

You can return values from Asyncify functions too. What you need to do is return the result of handleSleep() , and pass the result to the wakeUp() callback. For example, if, instead of reading from a file, you want to fetch a number from a remote resource, you can use a snippet like the one below to issue a request, suspend the C code, and resume once the response body is retrieved—all done seamlessly as if the call were synchronous.

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);

In fact, for Promise-based APIs like fetch() , you can even combine Asyncify with JavaScript's async-await feature instead of using the callback-based API. For that, instead of Asyncify.handleSleep() , call Asyncify.handleAsync() . Then, instead of having to schedule a wakeUp() callback, you can pass an async JavaScript function and use await and return inside, making code look even more natural and synchronous, while not losing any of the benefits of the asynchronous I/O.

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();

Awaiting complex values

But this example still limits you only to numbers. What if you want to implement the original example, where I tried to get a user's name from a file as a string? Well, you can do that too!

Emscripten provides a feature called Embind that allows you to handle conversions between JavaScript and C++ values. It has support for Asyncify as well, so you can call await() on external Promise s and it will act just like await in async-await JavaScript code:

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>();

When using this method, you don't even need to pass ASYNCIFY_IMPORTS as a compile flag, as it's already included by default.

Okay, so this all works great in Emscripten. What about other toolchains and languages?

Usage from other languages

Say that you have a similar synchronous call somewhere in your Rust code that you want to map to an async API on the web. Turns out, you can do that too!

First, you need to define such a function as a regular import via extern block (or your chosen language's syntax for foreign functions).

extern {
    fn get_answer() -> i32;
}

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

And compile your code to WebAssembly:

cargo build --target wasm32-unknown-unknown

Now you need to instrument the WebAssembly file with code for storing/restoring the stack. For C / C++, Emscripten would do this for us, but it's not used here, so the process is a bit more manual.

Luckily, the Asyncify transform itself is completely toolchain-agnostic. It can transform arbitrary WebAssembly files, no matter which compiler it's produced by. The transform is provided separately as part of the wasm-opt optimiser from the Binaryen toolchain and can be invoked like this:

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

Pass --asyncify to enable the transform, and then use --pass-arg=… to provide a comma-separated list of asynchronous functions, where the program state should be suspended and later resumed.

All that's left is to provide supporting runtime code that will actually do that—suspend and resume WebAssembly code. Again, in the C / C++ case this would be included by Emscripten, but now you need custom JavaScript glue code that would handle arbitrary WebAssembly files. We've created a library just for that.

You can find it on GitHub at https://github.com/GoogleChromeLabs/asyncify or npm under the name asyncify-wasm .

It simulates a standard WebAssembly instantiation API , but under its own namespace. The only difference is that, under a regular WebAssembly API you can only provide synchronous functions as imports, while under the Asyncify wrapper, you can provide asynchronous imports as well:

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();

Once you try to call such an asynchronous function - like get_answer() in the example above - from the WebAssembly side, the library will detect the returned Promise , suspend and save the state of the WebAssembly application, subscribe to the promise completion, and later, once it's resolved, seamlessly restore the call stack and state and continue execution as if nothing has happened.

Since any function in the module might make an asynchronous call, all the exports become potentially asynchronous too, so they get wrapped as well. You might have noticed in the example above that you need to await the result of instance.exports.main() to know when the execution is truly finished.

How does this all work under the hood?

When Asyncify detects a call to one of the ASYNCIFY_IMPORTS functions, it starts an asynchronous operation, saves the entire state of the application, including the call stack and any temporary locals, and later, when that operation is finished, restores all the memory and call stack and resumes from the same place and with the same state as if the program has never stopped.

This is quite similar to async-await feature in JavaScript that I showed earlier, but, unlike the JavaScript one, doesn't require any special syntax or runtime support from the language, and instead works by transforming plain synchronous functions at compile-time.

When compiling the earlier shown asynchronous sleep example:

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

Asyncify takes this code and transforms it to roughly like the following one (pseudo-code, real transformation is more involved than this):

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

Initially mode is set to NORMAL_EXECUTION . Correspondingly, the first time such transformed code is executed, only the part leading up to async_sleep() will get evaluated. As soon as the asynchronous operation is scheduled, Asyncify saves all the locals, and unwinds the stack by returning from each function all the way to the top, this way giving control back to the browser event loop.

Then, once async_sleep() resolves, Asyncify support code will change mode to REWINDING , and call the function again. This time, the "normal execution" branch is skipped - since it already did the job last time and I want to avoid printing "A" twice - and instead it comes straight to the "rewinding" branch. Once it's reached, it restores all the stored locals, changes mode back to "normal" and continues the execution as if the code were never stopped in the first place.

Transformation costs

Unfortunately, Asyncify transform isn't completely free, since it has to inject quite a bit of supporting code for storing and restoring all those locals, navigating the call stack under different modes and so on. It tries to modify only functions marked as asynchronous on the command line, as well as any of their potential callers, but the code size overhead might still add up to approximately 50% before compression.

A graph showing code
size overhead for various benchmarks, from near-0% under fine-tuned conditions to over 100% in worst
cases

This isn't ideal, but in many cases acceptable when the alternative is not having the functionality altogether or having to make significant rewrites to the original code.

Make sure to always enable optimizations for the final builds to avoid it going even higher. You can also check Asyncify-specific optimization options to reduce the overhead by limiting transforms only to specified functions and/or only direct function calls. There is also a minor cost to runtime performance, but it's limited to the async calls themselves. However, compared to the cost of the actual work, it's usually negligible.

Real-world demos

Now that you've looked at the simple examples, I'll move on to more complicated scenarios.

As mentioned in the beginning of the article, one of the storage options on the web is an asynchronous File System Access API . It provides access to a real host filesystem from a web application.

On the other hand, there is a de-facto standard called WASI for WebAssembly I/O in the console and the server-side. It was designed as a compilation target for system languages, and exposes all sorts of file system and other operations in a traditional synchronous form.

What if you could map one to another? Then you could compile any application in any source language with any toolchain supporting the WASI target, and run it in a sandbox on the web, while still allowing it to operate on real user files! With Asyncify, you can do just that.

In this demo, I've compiled Rust coreutils crate with a few minor patches to WASI, passed via Asyncify transform and implemented asynchronous bindings from WASI to File System Access API on the JavaScript side. Once combined with Xterm.js terminal component, this provides a realistic shell running in the browser tab and operating on real user files - just like an actual terminal.

Check it out live at https://wasi.rreverser.com/ .

Asyncify use-cases are not limited just to timers and filesystems, either. You can go further and use more niche APIs on the web.

For example, also with the help of Asyncify, it's possible to map libusb —probably the most popular native library for working with USB devices—to a WebUSB API , which gives asynchronous access to such devices on the web. Once mapped and compiled, I got standard libusb tests and examples to run against chosen devices right in the sandbox of a web page.

Screenshot of libusb
debug output on a web page, showing information about the connected Canon camera

It's probably a story for another blog post though.

Those examples demonstrate just how powerful Asyncify can be for bridging the gap and porting all sorts of applications to the web, allowing you to gain cross-platform access, sandboxing, and better security, all without losing functionality.