Lever's libuv integration
Although I integrated libuv into Lever weeks ago, I figured out some improvements only about a week ago. I was motivated to do a walkthrough from the implementation details. This may help you if you are studying Lever's internals in general because we go through lot of details.
Lets study a bit what happens when we issue a read call.
Currently our libuv read function is accessible through
fs.raw_read
as a temporary measure.
fs.raw_read(fd, [buffer], 0)
When the evaluator runs the 'call' opcode, it does an
internal .call(argv)
. The code presented below is RPython
code. It is 'source code' of the Lever runtime.
Here's the implementation of the raw_read
, located in
runtime/stdlib/fs.py
:
@builtin
@signature(Integer, List, Integer)
def raw_read(fileno, arrays, offset):
L = len(arrays.contents)
bufs = lltype.malloc(rffi.CArray(uv.buf_t), L, flavor='raw', zero=True)
try:
i = 0
for obj in arrays.contents:
obj = cast(obj, Uint8Array, u"raw_read expects uint8arrays")
bufs[i].c_base = rffi.cast(rffi.CCHARP, obj.uint8data)
bufs[i].c_len = rffi.r_size_t(obj.length)
i += 1
return Integer(rffi.r_long(fs_read(
fileno.value, bufs, L, offset.value)))
finally:
lltype.free(bufs, flavor='raw')
The @builtin
and @signature
are python decorators that
have ran before the rpython translates this program. Roughly
they wrap the raw_read with the signature and builtin
object, then place it inside the fs -module.
Objects that can be called in the runtime have a
.call(argv)
-method. The argv is a list of objects passed
as arguments. The signature points out they have to be
converted to be (Integer, List, Integer) before they are
passed to the implementation.
Most of the code in raw_read
is just doing conversions to
place values into their places. The fs_read
is doing the
actual work. Here is the implementation of the fs_read
:
def fs_read(fileno, bufs, nbufs, offset):
req = lltype.malloc(uv.fs_ptr.TO, flavor='raw', zero=True)
try:
response = uv_callback.fs(req)
response.wait(uv.fs_read(response.ec.uv_loop, req,
fileno, bufs, nbufs, offset,
uv_callback.fs.cb))
if req.c_result < 0:
raise uv_callback.to_error(req.c_result)
return req.c_result
finally:
uv.fs_req_cleanup(req)
lltype.free(req, flavor='raw')
Here it actually calls uv_fs_read
. The C type signature of
that function is:
int uv_fs_read(uv_loop_t* loop,
uv_fs_t* req,
uv_file file,
const uv_buf_t bufs[],
unsigned int nbufs,
int64_t offset,
uv_fs_cb cb)
Libuv requires that it gets a callback, that is called when
the function completes. You should note we pass
response.ec.uv_loop
as a loop, and uv_callback.fs.cb
as
a callback.
ec
stands for the execution context. It contains the
context for the interpreter. The contents are described in
the runtime/core.py
. Lever starts up directly into the
libuv event loop. In identical manner to node.js. The call
to uv_run
can be found from the runtime/main.py
.
The uv_callback.fs.cb
is created with a decorator, and the
implementation of the callback inside the decorator looks
like this:
def _callback_(handle, *data):
ec = core.get_ec()
resp = pop_handle(getattr(ec, uv_name), handle)
if len(data) > 0:
resp.data = data
resp.pending = False
if resp.greenlet is not None:
core.root_switch(ec, [resp.greenlet])
core.get_ec()
call returns the current execution context. We expect to return in the same thread as where we started a request.pop_handle
retrieves a response object for the handle from the execution context. Thegetattr(ec, uv_name)
is equivalent toec.uv__fs
. This is roughly equivalent toec.uv__fs.pop(rffi.cast_ptr_to_adr(handle))
but the cast operation is not understood by the JIT, therefore it is cloaked from the JIT generator into a small wrapper function.- If there's arguments to the callback, they are passed
into
.data
-field. The resp.wait() returns these fields as a tuple. resp.pending = False
means that response was processed. It is possible for libuv callbacks to invoke as soon as the request was made. In such cases we can avoid switching into the eventloop.- If the response didn't return immediately, the
resp.wait
has set a greenlet where to resume after the request has completed. In that case the call is coming from the eventloop so it needs some handling for exceptions. They are provided by thecore.root_switch
.
I found it necessary that the callback handler is not doing other extra work in the callback handler than forwarding the information.
There's yet one detail unmentioned here that is important for the libuv integration. This:
response = uv_callback.fs(req)
response.wait(...)
The uv_callback.fs
is a class. It is derived from a
decorator just like the fs.cb was. Here's the
implementation:
class response:
_immutable_fields_ = ["ec", "handle"]
cb = _callback_
def __init__(self, handle):
self.ec = core.get_ec()
self.handle = handle
self.data = None
self.greenlet = None
self.pending = True
push_handle(getattr(self.ec, uv_name), handle, self)
def wait(self, status=0):
if status < 0:
pop_handle(getattr(self.ec, uv_name), self.handle)
raise to_error(status)
elif self.pending:
self.greenlet = self.ec.current
core.switch([self.ec.eventloop])
# TODO: prepare some mechanic, so that
# we can be interrupted.
return self.data
The .wait()
jumps into the eventloop if the request has
not completed when it runs.
There are potentially complex interactions between the requests and the greenlets. For example, greenlets may require locks that prevent them from being switched into when they're waiting for content from an eventloop.
Another question is whether synchronously running requests should be allowed. I will wait for the implementations of such complex concepts until I find out more in practice.
Summary
In short, when Lever calls an async libuv function. It stores the current greenlet into a request and switches into the eventloop. Eventually the eventloop calls back as response and switches back into the greenlet that made the request.