wasm_table (wasm v0.3.0)

View Source

Tables, with reference semantics.

Read this when you are linking modules that share a table. A table is mutable state two instances can share: module A exports a table, module B imports it and writes an element segment into it, and a call_indirect in A has to see B's entry. That is how dynamic linking works, and an array held inside each instance cannot do it, because array is immutable and every instance ends up initialising its own copy.

Memories already share correctly, because atomics is a reference. Tables now do too, by the same route: the handle is a reference, the contents live in one place, and every holder sees the same thing.

How reads stay fast

The contents are an array in a shared ETS row, so a naive read would copy the whole table on every call_indirect. Instead each process caches the array it last saw together with a version counter held in atomics. A hit is an atomics:get plus a process dictionary lookup, neither of which copies; any writer bumps the counter and every other process reloads on its next read.

That is the same mechanism wasm_instance:mut/1 uses, and it was measured there at 398 ns down to 19 ns.

Lifetime

A table is held by whoever can reach it, the same way a memory is: the instance that created it, every instance that imported it, or the process that made it standalone. It goes when the last of them lets go. You do not have to release anything by hand.

It was not so. The row was tied to the creating process alone, so an exported table vanished out from under an importer the moment the exporter's process exited, and call_indirect through it found nothing.

Summary

Types

An opaque, shareable table handle.

Functions

Add a holder, for an instance importing this table.

Copy a range, possibly between two different tables.

The element type this table was declared with.

Grow by Delta, returning the previous size.

Whether a term is a table handle.

The limits this table was declared with.

Create a table, saying who holds it.

Remove a holder. The table goes when the last one lets go.

This table's registry identity.

Types

heaptype()

-type heaptype() ::
          func | extern | exn | any | eq | i31 | struct | array | nofunc | noextern | noexn | none |
          {type, typeidx()}.

instr()

-type instr() :: atom() | tuple().

reftype()

-type reftype() :: {ref, null | nonull, heaptype()}.

table()

-nominal table() ::
             {wasm_table,
              reference(),
              atomics:atomics_ref(),
              #tabletype{limits ::
                             #limits{min :: non_neg_integer(),
                                     max :: undefined | non_neg_integer(),
                                     shared :: boolean(),
                                     index_type :: i32 | i64},
                         elemtype :: reftype(),
                         init :: undefined | [instr()]}}.

An opaque, shareable table handle.

The declared limits travel in the handle rather than being supplied at each call, because a table imported from another module is bounded by the defining module's declaration, not by the importer's view of it. That is also what lets an importer check that the index type it declared is the one it was handed.

typeidx()

-type typeidx() :: non_neg_integer().

Functions

acquire/3

-spec acquire(table(), wasm_keeper:token(), pid() | none) -> ok | {error, gone | keeper_unavailable}.

Add a holder, for an instance importing this table.

copy(Dst, DstIdx, Src, SrcIdx, Len)

-spec copy(table(), non_neg_integer(), table(), non_neg_integer(), non_neg_integer()) -> ok.

Copy a range, possibly between two different tables.

The source slice is read out in full before anything is written, so an overlap within one table behaves like memmove rather than smearing the first element.

elemtype/1

-spec elemtype(table()) -> reftype().

The element type this table was declared with.

Carried for the same reason a global's type is: an importer has to check that the table it was handed holds what it expects, and the contents alone cannot say whether they are funcref or (ref $t).

fill(T, Dst, Len, Value)

-spec fill(table(), non_neg_integer(), non_neg_integer(), term()) -> ok.

get(T, Idx)

-spec get(table(), non_neg_integer()) -> term().

grow/3

-spec grow(table(), non_neg_integer(), term()) -> {ok, non_neg_integer()} | {error, exceeds_max}.

Grow by Delta, returning the previous size.

Refusal is a value rather than a trap, because table.grow is specified to push -1 rather than fault.

init(T, Dst, Vals)

-spec init(table(), non_neg_integer(), [term()]) -> ok.

is_table/1

-spec is_table(term()) -> boolean().

Whether a term is a table handle.

An embedder hands over bare terms, so a module importing a table may be given a memory, a function or a number. That has to come back as a link error rather than as a function_clause from inside the runtime.

limits/1

-spec limits(table()) ->
                #limits{min :: non_neg_integer(),
                        max :: undefined | non_neg_integer(),
                        shared :: boolean(),
                        index_type :: i32 | i64}.

The limits this table was declared with.

new(Size, Default)

-spec new(non_neg_integer() |
          #limits{min :: non_neg_integer(),
                  max :: undefined | non_neg_integer(),
                  shared :: boolean(),
                  index_type :: i32 | i64} |
          #tabletype{limits ::
                         #limits{min :: non_neg_integer(),
                                 max :: undefined | non_neg_integer(),
                                 shared :: boolean(),
                                 index_type :: i32 | i64},
                     elemtype :: reftype(),
                     init :: undefined | [instr()]},
          term()) ->
             table().

new/3

-spec new(non_neg_integer() |
          #limits{min :: non_neg_integer(),
                  max :: undefined | non_neg_integer(),
                  shared :: boolean(),
                  index_type :: i32 | i64} |
          #tabletype{limits ::
                         #limits{min :: non_neg_integer(),
                                 max :: undefined | non_neg_integer(),
                                 shared :: boolean(),
                                 index_type :: i32 | i64},
                     elemtype :: reftype(),
                     init :: undefined | [instr()]},
          term(),
          map()) ->
             table().

Create a table, saying who holds it.

holder is a {Token, Owner} pair naming the registry entry to create and the process whose death removes it. Leave it out and the table belongs to the calling process; an instance passes its own token, so the table survives for as long as anything that imported it is alive.

release/2

-spec release(table(), wasm_keeper:token()) -> ok.

Remove a holder. The table goes when the last one lets go.

Drops this process's cached copy of the array as well. The cache is keyed by table id and lives in the process dictionary, so nothing but this call and process death ever removed one: an instance per request, which is the shape docs/worker.md recommends, left one whole array per request in the worker for as long as the worker lived.

Erasing here is safe even when another instance in this process still holds the table, because the entry is a cache: the next array_of/1 reloads it from the store and pays one ets:lookup for doing so.

It only reaches the destroying process, which is why the cache is bounded as well. A worker calling into instances somebody else destroys never runs this line and would otherwise keep one array per table it ever touched.

resource/1

-spec resource(table()) -> wasm_keeper:resource().

This table's registry identity.

set(T, Idx, Value)

-spec set(table(), non_neg_integer(), term()) -> ok.

size(T)

-spec size(table()) -> non_neg_integer().

to_list(T)

-spec to_list(table()) -> [term()].