QVINTVS · SCRIBET

GIL umgehen, oder: Eintauchen in Rubys C-API

Wer meine letzten Bemerkungen zu Rubinius gelesen hat, weiß, dass ich den GIL des MRI für unbeschreiblich nervig halte. Seit Ruby 1.9 gibt es aber zumindest für C-Extensions die Möglichkeit, den GIL zu umgehen.

Was ist der GIL eigentlich?

Dafür muss man ein wenig ausholen. Wir schreiben das Jahr 1992, alle Welt (oder zumindest ein kleines Dorf in Japan) feiert das Release von Ruby 1.0. Leistungsfähige Hardware ist etwas für Universitäten und große Firmen, Leute, die erschwingliche Rechner mit mehreren Prozessorkernen prophezeihen, werden als Phantasten verschrieen oder gleich als Hexer verbrannt. Langsam erobert Ruby die Welt (oder zumindest Japan) und Hardware wird günstiger. Das neu gebildete »Core-Team« um den Erfinder der Programmiersprache Ruby Yukihiro »Matz« Matsumoto passt Ruby an diese Gegebenheiten an. Ruby 1.2 erscheint. 1.4. Die damals noch als Abtrünnige verfolgten Advokaten der Mehrkernmaschinen finden sich bestätigt, und die Welt (oder zumindest Japan) muss anerkennen, dass sie doch recht gehabt haben. Die Hexer werden rehabilitiert und Threads halten in die Programmiesprache Einzug — doch ist die Codebasis von Ruby schon groß und eigenwillig geworden, und niemand fühlt sich in der Lage, gleich den gesamten Code den neuen Gegebenheiten anzupassen. Die Idee: Wir brauchen gar keine echte Parallelität! Wir tun einfach nur so!
Die Idee wird schleunigst eingebaut. Ruby 1.6 erscheint, und wir haben: Threads! Alle Rubyisten verfallen plötzlich dem einen Gedanken: Wir können die neuen Zweikernprozessoren auslasten! Doch, wie so oft, es sollte wohl nicht sein. Es gab nur Green Threads, d.h. Ruby kümmerte sich selbst um das Scheduling von Threads anstatt es das Betriebssystem tun zu lassen. Und es gab noch etwas: Den GIL, Global Interpreter Lock. Um nämlich die alte Codebasis vor allzuvielen Modifikationen zu schützen, hat sich das Core-Team folgendes überlegt: Es darf nur ein VALUE (C-Typ eines Ruby-Objekts) zu einem Zeitpunkt verändert werden, andernfalls entstehen im Ruby-Kern Race-Conditions. Um das umzusetzen, wurde der GIL eingebaut: Egal, wie viele Threads laufen, nur eine Ruby-Methode kann zeitgleich ausgeführt werden. Keine Race-Conditions im Ruby-Kern, und alles ist in Butter.
Dann veröffentlichen die Pragmatic Programmers »Programming Ruby«, das erste Buch über Ruby in nicht-japanischer Sprache und plötzlich will die ganze Welt (diesmal wirklich) die junge Sprache aus Japan kennenlernen. Und dann geschah, was selbst die Propheten aus den ersten Jahren nicht vorhersehen konnten: Moore’s Law neigt sich dem Ende zu. Anstatt immer mehr Transistoren in einen Kern zu packen, fangen die Computerhersteller an, immer mehr Kerne in einen Prozessor zu packen! Plötzlich wird der als Schutzmechanismus gedachte GIL implizit zur Bremse, weil er durch die Blockade aller Ruby-Anweisungen bis auf eine den Ruby-Interpreter auf einem einzelnen Prozessorkern einsperrt. Auch das Release von Ruby 1.8 ändert an dieser Situation nichts.
Unter Zugzwang, beginnt das Core-Team damit, Überlegungen über die weitere Zukunft von Ruby anzustellen. Große Umwälzungen werden geplant, syntaktische Ungereimtheiten sollen entfernt und ein konsistentes API soll erstellt werden. Und: Der GIL soll weg. Große Mengen an Quellcodeänderungen stehen an, und man bereitet sich darauf vor, die Major-Versionsnummer zu erhöhen: Ruby 2.0 soll die neue Inkarnation heißen!
Doch die Entwicklung verzögert sich. Immer neue Bugs und neue Features strömen in den Interpreter hinein und wieder hinaus. Das geplante Release-Datum verstreicht. Ein Jahr später spricht Matz ein Machtwort: Wir brauchen eine neue Version!
Die unfertige Entwicklerversion 1.9, zu erkennen an der ungeraden Versionsnummer, wird in aller Eile einem Feature Freeze unterzogen und ein weiteres Jahr später erscheint Ruby 1.9.0. In der Ruby-Community bricht ein Sturm los. Die Änderungen zur Vorversion sind abgrundtief und ozeanweit, nicht zu rechtfertigen, dass dies keinen Sprung der Major-Versionsnummer gab! Die Ruby-Community teilt sich in 1.8-Fanatiker und 1.9-Phantasten. Das deutschsprachige Ruby-Forum führt zwei verschiedene Rubriken für 1.8 und 1.9. Neben zahlreichen anderen Neuerungen wurde in Ruby 1.9 der GIL angegriffen: Ruby überlässt das Thread-Scheduling nun wie es sich gehört dem Betriebssystem, bleibt aber bei der Blockade aller Ruby-Anweisungen bis auf einer. Jedoch haben die Entwickler einen Kniff eingebaut, der es C-Extensions ermöglicht, dem Interpreter mitzuteilen: »Eh du, ich brauche die Ruby-Runtime gerade nicht, ich mache externe Berechnungen. Lass’ mal einen anderen Thread laufen, während ich arbeite, ich sag’ dir schon bescheid, wenn ich wieder einen VALUE brauche.«
Ruby 1.9.1 und 1.9.2 erscheinen, dann dieser Blogpost, der sich mit dem oben beschriebenen Kniff namens rb_thread_blocking_region() befasst.

Umgehen des GIL

Wie man sieht, handelt es sich beim GIL um eine historisch gewachsene Struktur, die Ruby auch nicht in aller Eile wird abwerfen können. Ich hege nach wie vor die Hoffnung, dass mit Ruby 2 auch der MRI endlich Parallelität ermöglich wird, aber es ist glaube ich wahrscheinlicher, dass Rubinius vorher 1.9-Kompatibilität erreicht. Andererseits, Duke Nukem forever ist erschienen und damit auch GNU Hurd! Wie oben beschrieben, kann man den Ruby-Interpreter aber dazu bewegen, Nicht-Ruby-Code nebenher laufen zu lassen, während anderer Ruby-Code läuft. Das geht nur innerhalb von C-Extensions und auch dort nur mithilfe einer Funktion namens rb_thread_blocking_region. Das liest man am besten als (rb_thread)(blocking_region), denn das hat nichts damit zu tun, dass hier ein Thread blockiert wird, sondern damit, dass hier im Namespace rb_thread eine Funktion blocking_region() existiert.

Man kann sich den GIL wie eine gigantische Mutex vorstellen. Wer schonmal nebenläufig in Ruby mit Threads programmiert hat (soweit es eben ging), kennt folgendes Muster:

mutex = Mutex.new
mutex.synchronize
  #Hier darf nur ein Thread drin werkeln...
end

Wäre der GIL in Ruby implementiert, sähe das wohl etwa so aus:

class RubyVM

  def initialize(argv)
    #...
    @GIL = Mutex.new
  end

  # ...

  def execute_expr(expr)
    @GIL.synchronize do
      evaluate(expr)
    end
  end

end

rb_thread_blocking_region() nun kann man sich so vorstellen:

class RubyVM

  # Man beachte, dies wird irgendwo innerhalb von
  # #execute_expr aufgerufen.
  def rb_thread_blocking_region(blocking_function, arg, unblock_function, arg)
    @GIL.unlock
    blocking_function(arg)
    #...
    @GIL.lock
  end

end

Sinn und zweck von rb_tbr() (ich kürze diesen langen Namen jetzt einfach mal ab, das ist aber kein gültiger C-Code) ist es, blocking_function aufzurufen, ohne der Funktion aber den GIL zuzugestehen, den sich somit ein anderer Thread besorgen kann. Selbstverständlich darf man innerhalb von blocking_function dann nicht Gebrauch von der Ruby-API machen, da wir dann bei den von mir weiter oben beschriebenen Race-Conditions im Ruby-Kern ankämen.

Anwendung im C-Code

Die Funktionssignatur von rb_tbr() findet sich in intern.h und sieht so aus:

VALUE rb_thread_blocking_region(rb_blocking_function_t *func, void *data1,
                                rb_unblock_function_t *ubf, void *data2);

Wer einen Blick auf meinen Pseudo-Ruby-Code weiter oben wirft, erkennt, dass ich die Signatur in etwa übernommen hatte und errät vielleicht auch schon den Sinn der ganzen Sache. Nun, eins nach dem anderen. Das erste Argument, rb_blocking_function_t *func, ist ein Funktionspointer zu der Funktion, die ohne GIL auskommen soll. Ruby erwartet folgende Signatur von dieser Funktion:

VALUE funcname(void* ptr);

Der Rückgabewert dieser Funktion wird automatisch zum Rückgabewert von rb_tbr() — egal, ob die Operation unterbrochen wird oder nicht, doch dazu später mehr. Zunächst einmal haben wir da noch das zweite Argument, *data1. Dies ist ein Argument, das einfach (wie man oben an meinem Pseudo-Ruby-Code auch sehen kann) an die blockierende Funktion weitergereicht wird. Es handelt sich hierbei um einen Pointer vom Typ void, weil die blockierende Funktion auch mehrere Argumente haben könnte und deren Typen nicht bekannt sind — auf diese Art und Weise kann man jedoch mit unorthodoxem Casting alles zurecht biegen. Wer sich schonmal intensiver mit Rubys C-API befasst hat, kennt das Problem von rb_rescue(), das sich da genauso verhält. In meinen Augen sollte man das anders lösen.
Nun aber zum dritten Argument, rb_unblock_function_t *ubf. Auch dies ist ein Funktionspointer, und die Funktion, auf die er zeigt, soll wie folgt aussehen:

void funcname(void* ptr);

Diese Funktion ist dazu gedacht, die blockierende Funktion zu unterbrechen. Der Grund für die Existenz dieses Parameters ist recht einfach: Es gibt Situationen, in denen muss der Interpreter den Thread, in dem die blockierende Funktion ausgeführt wird, abrrechen. Zwei fallen mir spontan ein: Der explizite Aufruf von Thread#terminate und das Beenden des Interpreters, entweder regulär durch Erreichen des Dateiendes des Hauptskripts oder explizit durch Aufruf von #exit. Das vierte Argument von rb_tbr() funktioniert analog zum Zweiten, nur dass der Pointer diesmal an die Abbruchfunktion weitergereicht wird.

So, nun wird es aber Zeit für ein konkretes Beispiel. Wir haben folgende externe Library, gegen die wir unsere C-Extension linken wollen (ist nicht die schönste Lib, und sie wird auch mit mehr als zwei Threads bedenklich, aber hier geht’s ums Beispiel):

/* mylib.c */
#include <unistd.h>
#include "mylib.h"

/* Checked by long_calculation() and set by abort_calculation().
 * If this is 1, long_calculation() aborts it's exection.
 */
static int abort = 0;

/**
 * Needs a long time to calculate something very important.
 * Call abort_calculation() if you want to abort the calculation.
 * If you do so, this function immediately returns 0.
 *
 * You can set the calculation time with +seconds+.
 */
int long_calculation()
{
  int i;
  
  /* Use for loop to allow aborting the calculation */
  for(i = 0; i < SLEEP_SECONDS; i++){
    if(abort){
      abort = 0; /* For next time */
      return 0;
    }
    else
      sleep(1); /* Simulate computation */
  }
  return 42;
}

/**
 * Aborts the calculation taken out by long_calculation().
 * If no calculation is taken out, prevents the next call to
 * long_calculation() from computing anything.
 */
void abort_calculation()
{
  abort = 1;
}
/* mylib.h */
#ifndef MYLIB_H
#define MYLIB_H
#define SLEEP_SECONDS 10

int  long_calculation();
void abort_calculation();

#endif

Wie man erkennt, bietet die Lib eine Funktion long_calculation() an, die irgendeine lange Berechnung durchführt und das Ergebnis zurückgibt. Dauert einem das zu lange, so kann man in einem anderen Thread abort_calculation() aufrufen, was wiederum long_calculation() zum sofortigen Zurückgeben von 0 zwingt.

Im folgenden eine C-Extension, die eine Klasse Foo erstellt, die zwei Instanzmethoden enthält: Foo#blocking_foo ohne den Aufruf von rb_tbr() und Foo#nonblocking_foo mit dem Aufruf von rb_tbr(). Beide bedienen sich einer Funktion do_long_calculation(), die der von rb_tbr() erwarteten Funktionssignatur entspricht und das Ergebnis von long_calculation() in einen Ruby-Numeric umwandelt.

Hier zunächst do_long_calculation():

/*
 * Calls the external long_calculation() and returns
 * the result as a VALUE. 
 */
static VALUE do_long_calculation(void* ptr)
{
  return INT2NUM(long_calculation());
}

Der Parameter ptr existiert nur, um es rb_tbr() recht zu machen. Ich benutze ihn ganz offensichtlich nicht, ihr könnt das in euren C-Extensions natürlich anders handhaben.

Nun die blockende Funktion:

/*
 * call-seq:
 *   blocking_foo() ==> an_integer
 *
 * Calls some long-running external function and blocks
 * the whole MRI. 
 */
static VALUE blocking_foo(VALUE self)
{
  return do_long_calculation(NULL);
}

Da do_long_calculation() ohnehin sein Argument ignoriert, kann ich einen beliebigen Wert hineingeben, hier etwa den NULL-Pointer.

Wie der Aufruf einer solchen Funktion aus Ruby heraus abläuft, sollte jedem, der schonmal eine C-Extension programmiert hat, klar sein.

require_relative "./foo"
f = Foo.new
f.blocking_foo

Die dritte Zeile wird 10 Sekunden blockieren und dann 42 zurückgeben. Interessant wird es hierbei:

require_relative "./foo"

Thread.new do
  f = Foo.new
  f.blocking_foo
end

sleep 1
#Interpreter shutdown here

Normalerweise würde das Beenden des Interpreters jeden Thread, auf den nicht explizit mit Thread#join gewartet wird, abschießen. Da Ruby aber keine Möglichkeit kennt, die blocking_foo()-Funktion zu unterbrechen, zögert sich das Beenden des Interpreters so lange heraus, bis blocking_foo() zurückgibt.

Ein anderes Beispiel ähnlich dem aus meinem vorigen Posting:

require_relative "./foo"

Thread.new do
  f = Foo.new
  f.blocking_foo
end

loop{puts "!"}

Weil f.blocking_foo den GIL hält und nicht wieder hergibt, können die Anweisungen im Hauptthread nicht ausgeführt werden, bis f.blocking_foo zurückgibt. Keine Ausrufungszeichen erscheinen.

Nun probieren wir beide Beispiele im Zusammenspiel mit rb_tbr(). Zunächst brauchen wir eine Abbruchfunktion, die der vom dritten Parameter rb_tbr()s Genüge tut:

/* Calls the external abort_calculation(). */
static void do_abort_calculation(void* ptr)
{
  abort_calculation();
}

Auch hier ist der Parameter ptr nur wieder Dekor, damit die Funktion zur gewünschten Signatur passt. Nun der interessante Teil: Wie macht man das mit rb_tbr()?

/*
 * call-seq:
 *   nonblocking_foo() ==> an_integer
 *
 * Same as #blocking_foo, but doesn't block MRI while
 * executing.
 */
static VALUE nonblocking_foo(VALUE self)
{
  return rb_thread_blocking_region(do_long_calculation,
                                   NULL,
                                   do_abort_calculation,
                                   NULL);
}

Es ist einfach nur die Anwendung von dem, was ich weiter oben beschrieben habe. Wir teilen rb_tbr() mit, dass do_long_calculation() den GIL nicht braucht und geben der Funktion einen Parameter mit (wie schon zuvor den Nullpointer). Hinzu kommt, dass wir Ruby noch mitteilen, wie es die Funktion unterbrechen kann: Indem es do_abort_calculation() aufruft, welchem wir ebenfalls einen Nullpointer als Parameter mitgeben. Da der Rückgabewert von rb_tbr() dem Rückgabewert von do_long_calculation() entspricht und ich den nicht weiterverarbeiten möchte, gebe ich ihn einfach als Rückgabewert von nonblocking_foo() zurück.
Wer sich wundert, warum ich innerhalb von do_long_calculation() INT2NUM() aufrufen kann: INT2NUM() ist keine Funktion des Ruby-APIs, sondern ein Makro (Code aus ruby.h):

#define INT2FIX(i) ((VALUE)(((SIGNED_VALUE)(i))<<1 | FIXNUM_FLAG))
/* ... */
# define INT2NUM(v) INT2FIX((int)(v))

Hier können keine Race-Conditions entstehen, weil der VALUE erst von uns »erstellt« wird und ein anderer Thread überhaupt keinen Zugriff darauf hat.

Nun dieselben Tests wie oben noch einmal:

require_relative "./foo"

Thread.new do
  f = Foo.new
  f.nonblocking_foo
end

sleep 1
# Interpreter shutdown here

Und siehe da, der Interpreter fährt plötzlich sehr viel schneller herunter als mit f.blocking_foo. Der Grund dafür ist, dass Ruby beim Herunterfahren des Interpreters Gebrauch von der Abbruchfunktion gemacht hat.

Und wie sieht es mit der Nebenläufigkeit aus?

require_relative "./foo"

Thread.new do
  f = Foo.new
  f.nonblocking_foo
end

loop{puts "!"}

Auch das funktioniert! f.nonblocking_foo hält nicht den GIL, also kann der Hauptthread, oder genauer gesagt, dessen einzelne Anweisungen, ihn sich schnappen und muss nicht auf das Ende von f.nonblocking_foo warten.

Soweit der kleine Ausflug in Rubys C-Interna in Sachen Multithreading. Der gesamte Code für diesen Blogpost kann hier heruntergeladen werden, ihr könnt

$ rake compile

nutzen, um sowohl mylib als auch die C-Extension foo zu kompilieren. Nutzt anschließend

$ rake test

um die Datei test.rb auszuführen. Grund hierfür ist, dass sich mylib nicht an einem Ort befindet, der vom Betriebssystem durchsucht wird, und das Einbinden von foo würde mit einem LoadError enden, der euch sagt, dass mylib.so nicht gefunden werden konnte.

Valete.