Efficient Use of Core Reader and Writer
05 Oct 2014In a previous blog post, I used Core, Async and bitstring to write a tiny library for talking to Memcached using the binary protocol. Reader and Writer are the primary I/O abstractions used in Core and also underlies my implementation. To better understand the mechanics of Reader and Writer and how to use them efficiently, I decided to examine the implementations and APIs more closely. The following is a summary of my findings.
Reader
Reader is an abstraction for asynchronously reading data from a file descriptor. It does so by issuing read system calls, either non-blocking in the same thread or blocking in another thread. Data from the file descriptor is read into an internal buffer of type Bigstring.t
.
Here’s a simple example for reading into a string buffer:
In more details, this is what happens:
- A string of 10 bytes is allocated.
- 10 or more bytes is read from the file descriptor into the internal buffer of
reader
using read system call. - 10 bytes are blitted from internal buffer of
reader
tobuffer
using memcpy.
Copying data from the internal buffer to buffer
may be wasteful if buffer
is not later mutated. If buffer
is used in an immutable fashion, we can achieve the same without the wasteful copying:
Using this approach we skip step 1) and 3), as the result is simply exposed as a substring of the internal buffer. No additional copying is done.
Accessing the internal buffer of reader as Bigstring also allows us to read binary data in an easy fashion:
Bigstring
has a family of functions for parsing binary values with the naming scheme unsafe_get_[type]_[endian]
, e.g. unsafe_get_uint32_be
. Using any function prefixed with unsafe_
should make you think twice. The documentation has the following note:
The “unsafe_” prefix indicates that these functions do no bounds checking. […] In practice, message parsers can check the size of an outer message once, and use the unsafe accessors for individual fields, so many bounds checks can end up being redundant as well.
As we check the buffer length up front we should be safe.
The above approach parses and copies data from the internal buffer, and is as such not zero copy. To achieve zero copying, you could use a library like cstruct, which might be a topic for a future blog post.
Writer
Writer is an abstraction for asynchronously writing data to a file descriptor. A Writer.t
maintains a queue of Bigstring.t
to be written to the file descriptor, and issues writev system calls every so often (either based on time or once per scheduler cycle). Data can be added to the queue either by providing Bigstrings directly, or by writing to the internal buffer of the Writer.t
(type Bigstring.t
).
This is the simplest way to write a string to a Writer is as follows:
Under the covers, this is what happens:
- The string
"123"
is blitted to the internal buffer ofwriter
, which gets added to the writer’s queue. - Writer will asynchronously write to the file descriptor using writev system call.
If you already have a Bigstring.t
, it can be added directly to the queue:
This avoids all copying, since buffer
is simply added to the writer’s internal queue. Note that since writing to the file descriptor is done asynchronously, it’s not safe to modify buffer
in the meantime.
Like with Reader, the internal buffer of a Writer can be exposed. This is done through the Writer.write_gen
function:
That is, given a function length : 'a -> int
and a function blit_to_bigstring : src:'a -> src_pos:int -> len:int -> dst:Bigstring.t -> dst_post:int -> unit
, write_gen
will return a function to write a value of type 'a
to the internal buffer of a writer: ?pos:int -> ?len:int -> Writer.t -> 'a -> unit
.
Like for Reader we can use the exposed Bigstring for writing binary data using the family of functions Bigstring.unsafe_set_[type]_[endian]
, e.g. Bigstring.unsafe_set_uint32_be
. Let’s look at an example for writing a Memcached header:
The same considerations above about safety apply here.
So in terms of efficiency if your data is represented as a Bigstring, it’s most efficient to schedule it for writing with Writer.schedule_bigstring
(or any other Writer.schedule_*
function). This avoids all copying. If not, then you can either serialize to a Bigstring and schedule it, or write it to the writers internal buffer with write_gen
. This is not zero copy, but is better than serializing to a string and then writing that to the writer.
Wrap up
The most efficient use of Reader and Writer is achieved by not copying data needlessly. For Reader this is done with Reader.read_one_chunk_at_a_time
and returning “views” of the internal buffer. For Writer it’s most efficient to schedule Bigstrings for writing with Writer.schedule_*
or use Writer.write_gen
to write directly to the writer’s internal buffer.
Achieving minimal or zero copying by obeying the mentioned guidelines make APIs a little more cumbersome though. Instead of reading and writing data with strings, reads need to return Bigsubstrings and writes must be done with Bigstrings or Bigsubstrings. Depending on your application it may or may not be worth this extra complexity.
If you like this post, please vote on Hacker News.