3. Basic Interaction¶
You are writing a Mu client, which controls the Mu micro VM via the Mu client interface, usually simply called “the API”.
In Scala, the class uvm.refimpl.MicroVM
, which implements the MuVM
struct in the specification, represents a micro VM instance. You can start Mu by
creating a MicroVM
instance:
val microVM = MicroVM()
Just as simple as this. The instance also internally allocates memory for its heap. The default heap size is quite big, and is usually enough for experiment.
3.1. Mu Contexts¶
The client more often interacts with the micro VM via “Mu context”. They are
called “MuCtx” in the spec, and uvm.refimpl.MuCtx
in Holstein. A MuCtx is
a context created from a MicroVM instance. It can hold Mu values for the client,
access the Mu memory, load bundles and let the client perform many tasks on Mu.
Why not directly do these things on the MicroVM instance? Why add another layer? There are two reasons.
Reason 1: because Mu is designed to be multi-threaded. By design, multiple client threads can interact with the Mu micro VM concurrently.
Caution
In fact, unfortunately, the reference implementation is based on a single-thread interpreter. The program itself is not thread safe. Do not run more than one client threads in the reference implementation.
But Mu is designed for multi-threaded envirionments. The limitation in the implementation does not change the fact that Mu and its clients need to think in a multi-threaded way. A carefully designed interface will eventually allow a more efficient implementation.
Despite this limitation, the reference implementation can still run multiple Mu threads. Mu threads are interpreted one instruction for each thread on a round robin scheduler.
If multiple client threads were accessing the single MicroVM object concurrently, synchronisation (such as locks) must be employed to guarantee thread safety. This is where the “context” comes in. In Mu’s design, MuCtx instances are not allowed to be used by two threads concurrently, thus avoided many cases where synchronisation were necessary. For example, accessing the garbage-collected Mu memory via MuCtx does not need locking. A MuCtx instance can also hold a thread-local memory pool so that a client thread can allocate memory in the garbage-collected Mu heap without having to deal with the shared global memory pool every time unless the local pool is exhausted.
Reason 2: because the type system of Mu is usually very different from the client’s. Notably, the Mu’s type system contain object reference types which points to the Mu heap and must be traced by the garbage collector. MuCtx can hold Mu references for the client so that whenever garbage collection happens, it always knows what objects are still kept alive by a reference held for the client.
If you used JNI before, you may find this design familiar. In fact, this design is inspired by the JNI. JNI uses opaque “handles” to refer to Java object, so does the Mu API. However, for performance reason, opaque handles are not the only way to expose garbage-collected Mu objects to native programs. Mu has a more efficient but unsafe native interface which supports “object pinning”. That is an advanced topic.
Note
There is a difference between a Mu client and a native program.
A Mu client is a program that controls the Mu micro VM. In theory, a Mu client can be written in any languages, from C to Scala, Python, JavaScript, etc. The Mu API is the interface between the client and the micro VM. It includes the IR and the API, and the purpose is to control the micro VM.
A native program is a program that does not run in the Mu micro VM, and is
usually written in C or other unmanaged languages. libc is one such example.
The native interface involves pointer-based raw memory access and calling
conventions that allow Mu programs to call C programs and vice versa in some
specific ways. The main purpose is to make system calls (obviously a VM
that cannot read
or write
is almost useless), and to interact with
programs that do not run on Mu, including C programs and those written in
other languages.
3.1.1. Using Mu Contexts¶
You can create a MuCtx
instance by invoking the newContext()
method on
the MicroVM
instance:
val ctx = microVM.newContext()
and you need to close the context in order to release the resources it is holding inside:
ctx.closeContext()
The API, i.e. the methods of MicroVM
and MuCtx
, are defined by the API
chapter of the
specification. The scala binding
matches the spec.
Let’s see what MuCtx can do. You don’t need to understand all of them now, since they will be covered in more depth in later chapters.
Create contexts from MicroVM
instance:
val microVM = MicroVM()
val ctx = microVM.newContext()
Holstein can load Mu bundles from the text form. This method of loading bundle is deprecated in favour for the IR building API which avoids the text-form IR parser.
ctx.loadBundle("""
.typedef @i64 = int<64>
// more Mu IR code here
""")
MuCtx
can hold Mu values for the client. Mu values have a specific int size.
val handle1 = ctx.handleFromInt(0x123456789abcdef0L, 64)
val handle2 = ctx.handleFromInt(0x12345678L, 32)
val handle3 = ctx.handleFromInt(0x1234L, 16)
val handle4 = ctx.handleFromDouble(3.14)
val handle5 = ctx.handleFromPtr(ctx.idOf("@someType"), 0x7fff0000018L)
It can allocate objects in the Mu heap. The handle is held in ctx so that GC can find all of them.
val handle6 = ctx.newFixed(ctx.idOf("@someType"))
It can create Mu stacks and Mu threads
val hFunc = ctx.handleFromFunc(ctx.idOf("@some_function"))
val hStack = ctx.newStack(hFunc)
val hArg0 = ....
val hArg1 = ....
val hArg2 = ....
val hThread = ctx.newThread(hFunc, PassValues(Seq(hArg0, hArg1, hArg2)))
It can access the Mu memory
val hObjRef = ctx.newFixed(ctx.idOf("@int_of_64_bits"))
val hIRef = ctx.getIRef(hObjRef)
val hValue = ctx.load(MemoryOrder.SEQ_CST, hIRef)
It can introspect the stack states
val hStack2 = .....
val hCursor = ctx.newCursor(hStack2)
val funcID = ctx.curFunc(hCursor) // function ID
val hVars = ctx.dumpKeepalives(hCursor) // local variables
It can modify the stack states (a.k.a. on-stack replacement, OSR)
ctx.nextFrame(hCursor)
ctx.popFramesTo(hCursor)
val hFunc2 = ctx.handleFromFunc(...)
ctx.pushFrame(hFunc2)
3.2. Threads and Stacks¶
Mu programs are executed on Mu threads. A thread is the unit of CPU scheduling, and Mu threads are usually implemented mirroring operating system threads. Multiple Mu threads may execute concurrently.
Each Mu thread runs on a Mu stack. A stack, commonly known as a control stack, is the state of execution, represented in Mu as a list of frames. Each frame corresponds to a Mu function version, and records which instruction should be executed next and what are the values of local variables.
Mu clearly distinguish between threads and stacks. If you used traditional thread APIs, such as the Java or the PThread API, you may already have the mental model that “a thread has a stack, which has many frames, so threads and stacks are interchangeable”. But in Mu, the relation of stacks and threads is much more flexible. A thread can stop executing on one stack and resume another stack, which gives “coroutine” behaviours. Multiple threads can also share a much bigger stack pool and implement the M*N threading model.
In order to start executing a Mu program, the client should create a Mu stack
and a Mu thread. In order to stop executing, the Mu thread should execute the
@uvm.thread_exit
instruction.
3.3. Trap Handling¶
There is one special instruction, TRAP
, that needs special attention since
the beginning. During the execution of Mu programs, if a Mu thread executes a
TRAP
instruction, the thread temporarily detaches from its stack and gives
control back to the client. At any moment, there is one trap handler registered
in a Mu instance. A trap handler is a client function that will be called
whenever a TRAP
instruction is executed. The trap handler gains access to
the thread and the stack that caused the TRAP
.
Using the API, the client can to introspect the execution state of each of its frames, see the values of local variables, and even replace existing frames with new frames for new functions (this is called on-stack replacement, or OSR).
The trap handler is a great opportunity for the client to do many things. The
clever placement of TRAP
instructions and the implementation of the trap
handler is key to a good language implementation. Traps can be placed after
sufficient run-time statistics are collected so that the client can optimise the
program. Traps can also be used for lazy code loading, de-optimising
speculatively generated code, and debugging.
3.3.1. Scala API¶
The trap handler is registered by the setTrapHandler
method on the
MicroVM
instance.
microVM.setTrapHandler(theTrapHandler)
The trap handler is an instance of the uvm.refimpl.TrapHandler
trait.
trait TrapHandler {
def handleTrap(ctx: MuCtx, thread: MuThreadRefValue, stack: MuStackRefValue, watchPointID: Int): TrapHandlerResult
}
A new MuCtx
instance is created for this particular trap event. It is passed
to the trap handler as the first argument ctx
. The thread
and the
stack
argument are handles of the thread that executed the TRAP
, and the
stack it was bound to, respectively. These two handles are held by ctx
. The
watchPointID
argument is about “watch points”, which will be discussed
later.
You probably only need one trap handler per program, so it is recommended to register it after created the Mu instance.
Inside the trap handler, you can use any API functions.
The return value of the trap handler tells Mu “how the current thread should continue”. There are three options:
- Terminate the current thread.
- Rebind the thread to a stack.
- When rebinding, pass some values to the top frame and let it continue normally.
- When rebinding, raise an exception and continue exceptionally.
When rebinding, the stack could be the previous stack, i.e. the stack
argument of the trap handler, or a totally different stack. In the former case,
it will continue after the TRAP
instruction. In the latter case, the trap
handler swaps the thread to a different stack, so the thread will continue in a
totally different context.
The return type uvm.refimpl.TrapHandlerResult
has several cases:
abstract class TrapHandlerResult
object TrapHandlerResult {
case class ThreadExit() extends TrapHandlerResult
case class Rebind(newStack: MuStackRefValue, htr: HowToResume) extends TrapHandlerResult
}
abstract class HowToResume
object HowToResume {
case class PassValues(values: Seq[MuValue]) extends HowToResume
case class ThrowExc(exc: MuRefValue) extends HowToResume
}
So you can return one of the cases from the trap handler:
// Just for convenience
import TrapHandlerResult._
import HowToResume._
// Terminate the thread.
return ThreadExit()
// Rebind to the old stack, pass some values and contunue normally
// Assume "stack" is the argument of the handleTrap method
val v1 = ctx.handleFrom......(...)
val v2 = ctx.handleFrom......(...)
val v3 = ctx.handleFrom......(...)
return Rebind(stack, PassValues(Seq(v1, v2, v3)))
// Rebind to the old stack, pass an empty list of values and contunue normally
// Assume "stack" is the argument of the handleTrap method
return Rebind(stack, PassValues(Seq()))
// Rebind to the old stack, throw an exception.
// Assume "stack" is the argument of the handleTrap method
// In Mu, an exception is just an object reference.
val e = ctx.newFixed(......)
return Rebind(stack, ThrowExc(e))
// Rebind to a different stack, passing 0 values.
val func = ctx.handleFromFunc(...)
val stack2 = ctx.newStack(func)
return Rebind(stack2, PassValues(Seq()))
3.3.2. C API¶
In the C API, you should use mvm->set_trap_handler(mvm, handler, user_data)
to register the trap handler.
The signature of the trap handler is a bit complicated:
typedef void (*MuTrapHandler)(MuCtx *ctx, MuThreadRefValue thread,
MuStackRefValue stack, int wpid, MuTrapHandlerResult *result,
MuStackRefValue *new_stack, MuValue **values, int *nvalues,
MuValuesFreer *freer, MuCPtr *freerdata, MuRefValue *exception,
MuCPtr userdata);
The first four arguments ctx
, thread
, stack
and wpid
are the
same as Scala. The next seven arguments result
, new_stack
, values
,
nvalues
, freer
, freedata
and exception
are output arguments.
They allow the C program to encode the counterpart of TrapHandlerResult
.
Since the client in C needs to pass an array of values to Mu, it also needs to
tell Mu how to de-allocate that array because there is not a standard way to
de-allocate C objects. The userdata
is an arbitrary pointer the client
provided when registering the trap handler. This allows the trap handler to
depend on extra client-decided contexts, because C does not have closures.
See the trap handling section of the Mu Specification for more information about trap handling in C.
3.4. Working Example¶
The following Scala program will create a Mu micro VM and execute a simple Mu IR program. You can ignore details of the Mu IR now (except the TRAP instruction), but instead focus on the interaction between the client, the Mu thread, and the trap handler.
The order of execution is labelled from #1
to #26
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 | package tutorial
import uvm.refimpl._
object Interact extends App {
// Create the Mu instance
val microVM = MicroVM() // #1
// Implicitly convert names to IDs
implicit def idOf(name: String) = microVM.idOf(name) // #2
// Create the context
val ctx = microVM.newContext() // #3
ctx.loadBundle(""" // #4
.typedef @i64 = int<64>
.const @I64_1 <@i64> = 1
.funcsig @main.sig = (@i64) -> ()
.funcdef @main VERSION %v1 <@main.sig> { // #12
%entry(<@i64> %n):
%n2 = ADD <@i64> %n @I64_1 // #13
[%trap] TRAP <> KEEPALIVE (%n2) // #14
COMMINST @uvm.thread_exit // #21
}
""")
// Create the trap handler
val myTrapHandler = new TrapHandler {
def handleTrap(ctx: MuCtx, thread: MuThreadRefValue,
stack: MuStackRefValue, watchPointID: Int): TrapHandlerResult = {
// Create a cursor to introspect the stack
val cursor = ctx.newCursor(stack) // #15
val curInstID = ctx.curInst(cursor) // #16
ctx.nameOf(curInstID) match {
case "@main.v1.entry.trap" => { // #17
// Dump the keep-alive variables
val Seq(n2: MuIntValue) = ctx.dumpKeepalives(cursor) // #18
ctx.closeCursor(cursor)
// Print the value
val n2Int = ctx.handleToSInt(n2) // #19
printf("The value of n2 is %d.\n", n2Int)
// Return to Mu from the trap handler
TrapHandlerResult.Rebind(stack, HowToResume.PassValues(Seq())) // #20
}
}
}
}
// Set the trap handler
microVM.setTrapHandler(myTrapHandler) // #5
// Create the stack and the thread
val mainFunc = ctx.handleFromFunc("@main") // #6
val st = ctx.newStack(mainFunc) // #7
val fortyTwo = ctx.handleFromInt(42, 64) // #8
val th = ctx.newThread(st, None, HowToResume.PassValues(Seq(fortyTwo))) // #9
// Close the context
ctx.closeContext() // #10
// Let the reference implementation run
microVM.execute() // #11
// #22
}
|
There are some key steps:
#1
and#3
creates the Mu instance and a context, respectively.#4
loads the initial bundle using the MuCtx. You can directly embed the text-form Mu IR in your source code.#5
registers the trap handler. This handler will handle all traps in the future.- Using the MuCtx,
#6-9
creates a Mu thread from a given Mu function, whose name happens to be@main
. “main” is not a special name. When creating the thread, the initial argument, the 64-bit integer 42, is passed to the@main
function. At#10
, the MuCtxctx
is no longer useful and can be closed. - Once created, the Mu thread can execute. But the reference implementation
needs to call the
microVM.execute()
method#11
to execute all Mu threads in the only Scala (JVM) thread. - During the execution of the Mu thread, it hits the trap at line
#14
. The control then transfers to the trap handler. Note that theKEEPALIVE
clause specifies which local variables are eligible for introspection. In this case, it is only%n2
. - In the trap handler, the value of client-specified local variables (keep-alive
variables) are dumped at
#18
and printed. Since the previousADD
instruction#13
adds one to the number, it should print “43” here. - Then at
#20
, the trap handler re-binds the thread with the old stack, passing an empty list of values back to the TRAP. - Then it continues from the Mu function after TRAP
#14
. Thethread_stop
instruction at#21
stops the Mu thread. - In the reference implementation, the
execute()
function at#11
ends when the last Mu thread stopped. Then the example program quits. The specification does not specify how a Mu micro VM should end.
3.5. Summary¶
- A MicroVM instance is the heart of the Mu micro VM.
- The client interacts with the micro VM mostly via MuCtx. A context serves only one client thread. It holds Mu values, including garbage-collected object references.
- In Mu, threads and stacks are loosely coupled. Threads can swap from one stack to another.
- The
TRAP
instruction gives the control back to the client from an executing Mu thread. - To start everything: create a MicroVM, create a MuCtx, load a bundle, create a
stack and create a thread. The
MicroVM.execute()
API function is specific to the reference implementation.