WebAssembly объединит их всех

от автора

Задумался о том что бы прикрутить к своему пет проекту систему плагинов на WebAssembly. Это потенциально позволит переиспользовать существующий код на Go, C++, Rust, если конечно же он есть. А так же избавится от so/dll, что удобно при распространении плагинов, когда проект представляет собой десктопное приложение и собирается под Windows, OSX, GNU/Linux. Поэтому пошел смотреть как это сделано в Envoy.

Предыстория Envoy

На начало 2019 Envoy представляет собой статический бинарник со всеми расширениями скомпилированными во время сборки, поэтому нужно поддерживать несколько бинарных сборок, вместо использования официального и не модифицированного бинарного файла Envoy. Для проектов, которые не контролируют свои деплой, еще более проблематично, потому что обновление расширений требует пересборки и деплоя всего Envoy.

Преимущества

  • Гибкость. Расширения можно доставлять и перезагружать во время выполнения. Любый изменения или исправления могут быть протестированы во время выполнения, без необходимости обновления и редиплоя нового бинарника.

  • Надежность и изоляция. Расширения запускаются в песочнице и могут быть ограничены по потреблению CPU и памяти.

  • Безопасность. Расширения запускаются в песочнице с четко определенным API для связи с прокси(envoy, nginx и т.д.). Имеют ограниченный доступ к свойствам, которые могут менять.

  • Разнообразие. Большой выбор языков программирования, которые могут скомпилировать в WebAssembly, что позволяет разработчикам с любым опытом(C, Go, Rust, Java, TypeScript, и т.д.) писать расширения.

  • Переносимость. Поскольку интерфейс между хост-средой и расширениями не зависит от прокси-сервера, расширения, написанные с использованием Proxy-Wasm, могут выполняться в различных прокси-серверах, например Envoy, NGINX, ATS или даже внутри библиотеки gRPC (при условии, что все они реализуют стандарт).

Недостатки

  • Более высокое потребления памяти из-за необходимости запуска множества виртуальных машин, каждая со своим блоком памяти.

  • Более низкая производительность для расширений, преобразующий полезные данные, из-за необходимости копировать значительные объемы данных в песочницу и из нее.

  • Более низкая производительность для CPU-bound задач. Ожидается, что замедление будет менее чем в 2 раза по сравнению с нативным кодом.

  • Увеличенный размер бинарника из-за необходимости включать среду выполнения Wasm. Это ~20 MB для WAVM и ~10 MB для V8.

  • Экосистема WebAssembly все еще молода, и в настоящее время разработка сосредоточена на использовании в браузере, где JavaScript считается хост-средой.

Общая схема

В Envoy взяли C++ API, прикрутили Wasm VM и перенаправляют вызовы в wasm модуль.

В одном wasm модуле могут быть несколько фильтров. Экземпляры Wasm VM размножены и размещены в thread-local storage

Коммуникация между экземплярами Wasm VM осуществляется примитивами shared data и message queue. Службы представляют из себя singleton и выполняется в основном потоке Envoy. Они выполняются параллельно фильтрам и осуществляют вспомогательные функции: логи, статистика и т.д.

Рантайм

Wasm VM это один из следующих рантаймов

Спецификация

Спецификация ABI разбита на два больших блока: функции реализованные в модуле и функции реализованные в хост-среде. Выделю две функции: выделение памяти proxy_on_memory_allocate, точка входа _start.

Спецификация представляет набор функций в формате proxy_log

аргументы:

  • i32 (proxy_log_level_t) log_level

  • i32 (const char*) message_data

  • i32 (size_t) message_size

возвращаемое значение:

  • i32 (proxy_result_t) call_result

i32 это числовой тип в wasm, а так она выглядит в разных SDK

extern "C" WasmResult proxy_log(LogLevel level,                                 const char *logMessage,                                 size_t messageSize);
package internal  //export proxy_log func ProxyLog(logLevel LogLevel,               messageData *byte,               messageSize int) Status
// @ts-ignore: decorator @external("env", "proxy_log") export declare function proxy_log(level: LogLevel,                                   logMessage: ptr<char>,                                   messageSize: size_t): WasmResult;

Владение памятью

Наверное это одна из самых важных тем при построении такой системой, где есть память на стороне хоста и память в wasm модуле. Никакая управляемая память не передается в wasm модуль при вызове обработчиков. Вместо этого wasm модуль сам запрашивает данные. Например proxy_on_http_request_body передает информацию о количестве доступных байтов в теле запроса, модуль должен запросить эти данные используя proxy_get_buffer. Когда это происходит, хост выделяет помять у wasm модуля вызовом proxy_on_memory_allocate, копирует туда данные и отдает память во владение wasm модулю, в надежде, что он освободит ее.

proxy_on_memory_allocate

аргументы:

  • i32 (size_t) memory_size

возвращаемое значение:

  • i32 (void*) allocated_ptr

Реализация на AssemblyScript malloc.ts

import {   __pin,   __unpin, } from "rt/itcms";  /// Allow host to allocate memory. export function malloc(size: i32): usize {   let buffer = new ArrayBuffer(size);   let ptr = changetype<usize>(buffer);   return __pin(ptr); }  /// Allow host to free memory. export function free(ptr: usize): void {   __unpin(ptr); }

Обратное преобразование

class ArrayBufferReference {   private buffer: usize;   private size: usize;    constructor() {   }    sizePtr(): usize {     return changetype<usize>(this) + offsetof<ArrayBufferReference>("size");   }   bufferPtr(): usize {     return changetype<usize>(this) + offsetof<ArrayBufferReference>("buffer");   }    // Before calling toArrayBuffer below, you must call out to the host to fill in the values.   // toArrayBuffer below **must** be called once and only once.   toArrayBuffer(): ArrayBuffer {     if (this.size == 0) {       return new ArrayBuffer(0);     }      let array = changetype<ArrayBuffer>(this.buffer);     // host code used malloc to allocate this buffer.     // release the allocated ptr. array variable will retain it, so it won't be actually free (as it is ref counted).     free(this.buffer);     // should we return a this sliced up to size?     return array;   } }

В AssemblyScript заложили поведение при котором объекты можно отдавать во внешнюю среду(в первую очередь в JS). Для этого есть __pin/__unpin, что бы сборщик мусора не собрал объекты на которых уже нет ссылок. В Go

//nolint //export proxy_on_memory_allocate func proxyOnMemoryAllocate(size uint) *byte { buf := make([]byte, size) return &buf[0] }

Обратное преобразование

import ( "reflect" "unsafe" )  func RawBytePtrToString(raw *byte, size int) string { //nolint return *(*string)(unsafe.Pointer(&reflect.SliceHeader{ Data: uintptr(unsafe.Pointer(raw)), Len:  size, Cap:  size, })) }  func RawBytePtrToByteSlice(raw *byte, size int) []byte { //nolint return *(*[]byte)(unsafe.Pointer(&reflect.SliceHeader{ Data: uintptr(unsafe.Pointer(raw)), Len:  size, Cap:  size, })) }

Что тут можно сказать? В спецификации Go ничего не сказано про работу сборщика мусора. Компилятор с https://go.dev/ запрещает передавать указатели на память из Go в Си. Есть пакет go-pointer, который немного напоминает pin/unpin из AssemblyScript.

C.pass_pointer(pointer.Save(&s)) v := *(pointer.Restore(C.get_from_pointer()).(*string))

Внутри довольно простой

package pointer  // #include <stdlib.h> import "C" import ( "sync" "unsafe" )  var ( mutex sync.RWMutex store = map[unsafe.Pointer]interface{}{} )  func Save(v interface{}) unsafe.Pointer { if v == nil { return nil }  // Generate real fake C pointer. // This pointer will not store any data, but will bi used for indexing purposes. // Since Go doest allow to cast dangling pointer to unsafe.Pointer, we do rally allocate one byte. // Why we need indexing, because Go doest allow C code to store pointers to Go data. var ptr unsafe.Pointer = C.malloc(C.size_t(1)) if ptr == nil { panic("can't allocate 'cgo-pointer hack index pointer': ptr == nil") }  mutex.Lock() store[ptr] = v mutex.Unlock()  return ptr }

НО разработчики используют TinyGo, в котором сборщик мусора попроще и запускается когда недостаточно места в куче. Если между вызовом proxy_on_memory_allocate и моментом возврата владения в Go нет выделения памяти, то это условно безопасно.

А вот в C++ SDK proxy_on_memory_allocate не увидите. Идет поиск malloc, который экспортирует компилятор

$ em++ --no-entry -s EXPORTED_FUNCTIONS=['_malloc'] ... 

На стороне хоста выполняется поиск malloc, если нет, то ищется proxy_on_memory_allocate

void WasmBase::getFunctions() { #define _GET(_fn) wasm_vm_->getFunction(#_fn, &_fn##_); #define _GET_ALIAS(_fn, _alias) wasm_vm_->getFunction(#_alias, &_fn##_);   _GET(_initialize);   if (_initialize_) {     _GET(main);   } else {     _GET(_start);   }    _GET(malloc);   if (!malloc_) {     _GET_ALIAS(malloc, proxy_on_memory_allocate);   }   if (!malloc_) {     fail(FailState::MissingFunction, "Wasm module is missing malloc function.");   } #undef _GET_ALIAS #undef _GET    // Try to point the capability to one of the module exports, if the capability has been allowed. #define _GET_PROXY(_fn)                                                                            \   if (capabilityAllowed("proxy_" #_fn)) {                                                          \     wasm_vm_->getFunction("proxy_" #_fn, &_fn##_);                                                 \   } else {                                                                                         \     _fn##_ = nullptr;                                                                              \   } #define _GET_PROXY_ABI(_fn, _abi)                                                                  \   if (capabilityAllowed("proxy_" #_fn)) {                                                          \     wasm_vm_->getFunction("proxy_" #_fn, &_fn##_abi##_);                                           \   } else {                                                                                         \     _fn##_abi##_ = nullptr;                                                                        \   }    FOR_ALL_MODULE_FUNCTIONS(_GET_PROXY);    if (abiVersion() == AbiVersion::ProxyWasm_0_1_0) {     _GET_PROXY_ABI(on_request_headers, _abi_01);     _GET_PROXY_ABI(on_response_headers, _abi_01);   } else if (abiVersion() == AbiVersion::ProxyWasm_0_2_0 ||              abiVersion() == AbiVersion::ProxyWasm_0_2_1) {     _GET_PROXY_ABI(on_request_headers, _abi_02);     _GET_PROXY_ABI(on_response_headers, _abi_02);     _GET_PROXY(on_foreign_function);   } #undef _GET_PROXY_ABI #undef _GET_PROXY }

Точка входа _start

Написав в Go

package main  import ( "math/rand" "time"  "github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm" "github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm/types" )  const tickMilliseconds uint32 = 1000  func main() { proxywasm.SetVMContext(&vmContext{}) }  type vmContext struct { // Embed the default VM context here, // so that we don't need to reimplement all the methods. types.DefaultVMContext }

AssemblyScript

export * from "@solo-io/proxy-runtime/proxy"; // this exports the required functions for the proxy to interact with us. import { RootContext, Context, registerRootContext, FilterHeadersStatusValues, stream_context } from "@solo-io/proxy-runtime";  class AddHeaderRoot extends RootContext {   createContext(context_id: u32): Context {     return new AddHeader(context_id, this);   } }  class AddHeader extends Context {   constructor(context_id: u32, root_context: AddHeaderRoot) {     super(context_id, root_context);   }   onResponseHeaders(a: u32, end_of_stream: bool): FilterHeadersStatusValues {     const root_context = this.root_context;     if (root_context.getConfiguration() == "") {       stream_context.headers.response.add("hello", "world!");     } else {       stream_context.headers.response.add("hello", root_context.getConfiguration());     }     return FilterHeadersStatusValues.Continue;   } }  registerRootContext((context_id: u32) => { return new AddHeaderRoot(context_id); }, "add_header");

C++

#include <string> #include <string_view> #include <stdlib.h>  #include "proxy_wasm_intrinsics.h"  class ExampleContext : public Context { public:   explicit ExampleContext(uint32_t id, RootContext *root) : Context(id, root) {}    FilterHeadersStatus onRequestHeaders(uint32_t headers, bool end_of_stream) override; };  static RegisterContextFactory register_ExampleContext(CONTEXT_FACTORY(ExampleContext));   FilterHeadersStatus ExampleContext::onRequestHeaders(uint32_t, bool) {   LOG_DEBUG(std::string("print from wasm, onRequestHeaders, context id: ") + std::to_string(id()));    auto result = getRequestHeaderPairs();   auto pairs = result->pairs();   for (auto &p : pairs) {     LOG_INFO(std::string("print from wasm, ") + std::string(p.first) + std::string(" -> ") + std::string(p.second));   }    return FilterHeadersStatus::Continue; }

Нужна точка входа, которая инициализирует рантайм C++/Go/AssemblyScript и выполнит что-нибудь вида main. WASI для таких целей предлагает _start и _initialize. Хотя в спеке есть только _start, но на хосте доступны оба варианта

class WasmBase : public std::enable_shared_from_this<WasmBase> {  //s.. protected:   //...   WasmCallVoid<0> _initialize_; /* WASI reactor (Emscripten v1.39.17+, Rust nightly) */   WasmCallVoid<0> _start_;      /* WASI command (Emscripten v1.39.0+, TinyGo) */    WasmCallWord<2> main_;   WasmCallWord<1> malloc_;   //... };

Строки и ассоциативный контейнеры

Один из недостатков это копирование памяти. Может потребоваться не только копирование, но и преобразование. В Go строки это просто последовательность байт, но обычно там UTF-8. В AssemblyScriptэто последовательность UCS-2 и требует преобразования.

export function log(level: LogLevelValues, logMessage: string): void {   // from the docs:   // Like JavaScript, AssemblyScript stores strings in UTF-16 encoding represented by the API as UCS-2,    let buffer = String.UTF8.encode(logMessage);   imports.proxy_log(level as imports.LogLevel, changetype<usize>(buffer), buffer.byteLength); }

А передача привычного контейнера map потребует дополнительной упаковки/распаковки

func DeserializeMap(bs []byte) [][2]string { numHeaders := binary.LittleEndian.Uint32(bs[0:4]) var sizeIndex = 4 var dataIndex = 4 + 4*2*int(numHeaders) ret := make([][2]string, numHeaders) for i := 0; i < int(numHeaders); i++ { keySize := int(binary.LittleEndian.Uint32(bs[sizeIndex : sizeIndex+4])) sizeIndex += 4 keyPtr := bs[dataIndex : dataIndex+keySize] key := *(*string)(unsafe.Pointer(&keyPtr)) dataIndex += keySize + 1  valueSize := int(binary.LittleEndian.Uint32(bs[sizeIndex : sizeIndex+4])) sizeIndex += 4 valuePtr := bs[dataIndex : dataIndex+valueSize] value := *(*string)(unsafe.Pointer(&valuePtr)) dataIndex += valueSize + 1 ret[i] = [2]string{key, value} } return ret }  func SerializeMap(ms [][2]string) []byte { size := 4 for _, m := range ms { // key/value's bytes + len * 2 (8 bytes) + nil * 2 (2 bytes) size += len(m[0]) + len(m[1]) + 10 }  ret := make([]byte, size) binary.LittleEndian.PutUint32(ret[0:4], uint32(len(ms)))  var base = 4 for _, m := range ms { binary.LittleEndian.PutUint32(ret[base:base+4], uint32(len(m[0]))) base += 4 binary.LittleEndian.PutUint32(ret[base:base+4], uint32(len(m[1]))) base += 4 }  for _, m := range ms { for i := 0; i < len(m[0]); i++ { ret[base] = m[0][i] base++ } base++ // nil  for i := 0; i < len(m[1]); i++ { ret[base] = m[1][i] base++ } base++ // nil } return ret }
function serializeHeaders(headers: Headers): ArrayBuffer {   let result = new ArrayBuffer(pairsSize(headers));   let sizes = Uint32Array.wrap(result, 0, 1 + 2 * headers.length);   sizes[0] = headers.length;    // header sizes:   let index = 1;    // for in loop doesn't seem to be supported..   for (let i = 0; i < headers.length; i++) {     let header = headers[i];     sizes[index] = header.key.byteLength;     index++;     sizes[index] = header.value.byteLength;     index++;   }    let data = Uint8Array.wrap(result, sizes.byteLength);    let currentOffset = 0;   // for in loop doesn't seem to be supported..   for (let i = 0; i < headers.length; i++) {     let header = headers[i];     // i'm sure there's a better way to copy, i just don't know what it is :/     let wrappedKey = Uint8Array.wrap(header.key);     let keyData = data.subarray(currentOffset, currentOffset + wrappedKey.byteLength);     for (let i = 0; i < wrappedKey.byteLength; i++) {       keyData[i] = wrappedKey[i];     }     currentOffset += wrappedKey.byteLength + 1; // + 1 for terminating nil      let wrappedValue = Uint8Array.wrap(header.value);     let valueData = data.subarray(currentOffset, currentOffset + wrappedValue.byteLength);     for (let i = 0; i < wrappedValue.byteLength; i++) {       valueData[i] = wrappedValue[i];     }     currentOffset += wrappedValue.byteLength + 1; // + 1 for terminating nil   }   return result; }  function deserializeHeaders(headers: ArrayBuffer): Headers {   if (headers.byteLength == 0) {     return [];   }   let numheaders = Uint32Array.wrap(headers, 0, 1)[0];   let sizes = Uint32Array.wrap(headers, sizeof<u32>(), 2 * numheaders);   let data = headers.slice(sizeof<u32>() * (1 + 2 * numheaders));   let result: Headers = [];   let sizeIndex = 0;   let dataIndex = 0;   // for in loop doesn't seem to be supported..   for (let i: u32 = 0; i < numheaders; i++) {     let keySize = sizes[sizeIndex];     sizeIndex++;     let header_key_data = data.slice(dataIndex, dataIndex + keySize);     dataIndex += keySize + 1; // +1 for nil termination.      let valueSize = sizes[sizeIndex];     sizeIndex++;     let header_value_data = data.slice(dataIndex, dataIndex + valueSize);     dataIndex += valueSize + 1; // +1 for nil termination.      let pair = new HeaderPair(header_key_data, header_value_data);     result.push(pair);   }    return result; }

На этом все. Полезные ссылки


ссылка на оригинал статьи https://habr.com/ru/post/671048/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *