Unless you’ve been living under a rock for the past few years you will have heard of C-ruby’s GIL/GVL, the Global Interpreter/VM Lock (I prefer to think of it as the Giant VM Lock). While your ruby threads may sit on top of native pthreads, this big bad lock stops more than one thread running at the same time. Allegedly one of the main reasons behind this was to protect non threadsafe ruby extensions and also to shield us from the horrors of threading. Personally it feels like a lot of 3rd party gems needed updating for 1.9 anyway (particularly with encoding related issues) and so it would have been a good opportunity to make that change. The complexities of threading, locking etc. could be handled by providing higher level abstractions over them (actors etc.).
True concurrency isn’t completely dead though. Ruby can already release the GVL when a thread is blocked on IO and if you are writing a C extension you can release the lock too. The mysql2 gem does this: clearly there is no point in holding onto the GVL when you’re just waiting on mysql to return results. Similarly Eric Hodel recently submitted a patch to the zlib extension so that the lock is released while zlib is doing its thing. This obviously doesn’t make mysql queries or zlib run any faster individually but it means you can run many in parallel and that these operations don’t block other unrelated threads. When even laptops have hyperthreaded quad-core processors, this is a good thing.
The magic API is rb_thread_blocking_region
, whose header documentation comes with copious warnings. Threading after all is hard (and should not be mixed with alcohol (I speak from experience)).
A call to rb_thread_blocking_region
looks like this
rb_thread_blocking_region(do_some_work, argument,
unblocker, unblocker_argument);
When you call this ruby
- releases the GVL (other ruby threads can now run)
- calls your
do_some_work
function , passing itargument
- reacquires the GVL
Operating without the GVL is a scary place to be. You can’t in general call any of the C-ruby api, because they all assume they hold the GVL. If you’ve ever written MP threaded code on Apple’s OS 8 you’ll feel right at home.
The second pair of arguments is the so called unblocking function (ubf) and its argument. If ruby needs to kill your thread (in response to Thread.kill
, the VM exiting, etc) this function will be called. Your do_some_work
should then exit. For example ruby’s bignum.c
has this code that runs inside rb_thread_blocking_region
1 2 3 4 5 6 7 8 9 |
|
The ubf just sets the bds->stop
flag so that bigdivrem1
returns early.
You can specify the constants RUBY_UBF_PROCESS
or RUBY_UBF_IO
to use the ruby provided ubf_select
function which handles the common case of being blocked on a call to select
or accept
or other such functions (it’s not a general purpose ubf - see posting to ruby-core. The overall intent is that you don’t do an awful lot inside your ubf, just enough to get your main do_some_work
function to stop. Sometimes there just isn’t a good way to stop what you’re doing; it’s not the end of the world if your ubf does nothing.
This works best when you can isolate a chunk of C code that doesn’t need any interaction with the ruby world. Code that wants to frequently call back into ruby (for example a sax style xml parser delivering events to a ruby class) isn’t a good fit.
In some cases you might consider rb_thread_call_with_gvl
, which reacquires the GVL, executes some code for you and releases it. The headers around it are plastered with warnings about it being experimental, might be removed in ruby 1.9.2 but it would seem that it is here to stay. If you end up calling it a lot then you’re pretty much back to executing serially.
A simple example
The rdiscount gem (a markdown parser) presents a reasonable opportunity for this sort of work. RDiscount objects have a to_html
method doesn’t do much other than call into the discount c library. Initially the core of this method looked like this
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
It just pushes a string through the markdown library and creates a ruby string from the result. The first thing I did was to write a small benchmark that loads a few hundred markdown file and converts them to html. I pulled the IO part out of the benchmark part because that wasn’t the bit I was interested in.
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 |
|
With vanilla rdiscount/master, this produces the following output on my quad-core iMac
1 2 3 4 |
|
This is pretty fast - the folder contained nearly 1400 files that we parsed 40 times over and it still only took a handful of seconds. However the results are identical no matter how many threads (to within 0.5%). The cpu usage (as measured by top) never goes above 100% (On OS X 100% means 100% of one core, so values of 400% on a 4 core machine are possible).
My changed version of rdiscount.c
looks like this
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
|
The setup code now marshals all of the parameters needed into a struct of type rb_discount_to_html_param_block
and then uses rb_thread_blocking_region
to execute rb_rdiscount_to_html_no_gvl
which does the bulk of the work.
This time the output looks like this, on the same quad-core iMac.
1 2 3 4 |
|
The 2 thread case now runs in 55% of the time it took to run the 1 thread case. and the 4 thread case runs in 34% of the time. The most we could hope for was to halve execution time with 2 threads and quarter it for 4 threads, so we got pretty close. CPU utilisation is also much higher. There is of course some code which for which the GVL is held and there’s always some overhead. Still, not bad for what was essentially a 5 minute change!
Cleaning up after yourself
This code has one flaw: should ruby try to kill the thread then rb_thread_blocking_region
won’t ever return. When the GVL is reacquired ruby will check whether the thread should be killed and bail out if appropriate. In our case we would leak the resources allocated by mkd_string
. One way around this is to ensure anything that is allocated by rb_discount_no_gvl
is also disposed of insode rb_discount_no_gvl
. That doesn’t really work for in this case: we need to be able to convert the result back into a ruby object, and we can’t do that in the no-man’s land that is rb_thread_blocking_region
. rb_thread_call_with_gvl
doesn’t help since it also checks whether the thread should be killed when the GVL is reacquired.
In pure ruby when you want to make sure code gets executed even in the presence of such things you use ensure
, and things are not so different (if more verbose) when using the C api: the rb_ensure
function allows you to call a function while specifying a second function that should be called after, no matter what.
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 |
|
This ensures that the cleanup/conversion code always run. Properly cleaning up after yourself in this sort of situation is definitely tricky.
When it’s a good fit, rb_thread_blocking_function
can be pretty handy but it’s definitely a little verbose (if consistent with the rest of the ruby API). Perhaps exposing the internal BLOCKING_REGION
macro would help ease some of the callback spaghetti. A better way of dealing with libraries that want to callback into ruby would be great too.