Fragen? Antworten! Siehe auch: Alternativlos
Seit dem gibt es in Linux einige mehr oder weniger geniale Änderungen, die wir nie unterstützt haben, weil Multithreading nie so richtig wichtig war für die dietlibc. Mir schonmal eh nicht so, aber auch die Zielplattformen haben normalerweise nie mehr als einen Core gehabt. Das hat sich inzwischen geändert.
Die erste wichtige Neuerung ist Thread Local Storage. Das funktioniert so, dass man in den Threads auf x86 ein Segment-Register so belegt, dass man über das Segment ab Offset 0 auf den Thread-Parameter-Block zugreifen kann. Es ist auch vorgesehen, dass man selbst in seinem Code Variablen thread local deklarieren kann, aber den Sinn davon habe ich nie gesehen, die kann man ja auch gleich auf den Stack tun oder vom Heap allozieren, wenn sie größer sind. Der Punkt ist jedenfalls, dass alle möglichen Thread-Funktionen wissen müssen, welcher Thread sie gerade aufruft, z.B. können Mutexe rekursives Locking erlauben und müssen daher wissen, ob dieser Lockversuch vom selben Thread kam wie der ursprüngliche. Kurz gesagt: bei Threading-Code war es immer ein Flaschenhals, wenn der Code gucken musste, welcher Thread das eigentlich gerade ist. Die Alternativen sind alle doof; man könnte einen Syscall aufrufen, um die PID oder TID zu kriegen, oder man könnte den Stack Pointer nehmen und in einer globalen Datenstruktur nachgucken (allerdings muss die dafür gelockt werden und wie gesagt müssen Mutexe auch die Thread-ID wissen). Mit Thread Local Storage ist das nur noch ein einziger Speicherzugriff über das Spezial-Segment. Das war also schonmal gut, aber ich hab immer das Gefühl gehabt, ich müsste jetzt auch Futex-Support implementieren, um überhaupt in einer Liga wie NPTL spielen zu können. Das habe ich heute mal gemacht, und die Ergebnisse sind ernüchternd.
Der alte Code in dietlibc benutzt im Wesentlichen Spinlocks, d.h. eine Schleife, die immer wieder versucht, den Lock zu kriegen, bis es halt klappt. Damit dabei nicht die CPU so doll belastet wird, sagt er zwischen den Iterationen dem OS, dass mal ein anderer Thread laufen soll jetzt. Sieht sehr krude und amateurhaft aus, fand ich immer.
Futex, zum Vergleich, ist ein geradezu geniales Verfahren. Man nimmt sich dafür den Lock, also eine Speicherstelle, und zählt den mit einer atomaren Operation von 0 auf 1 hoch. Wenn nach dem Hochzählen 1 drin steht, dann weiß man, dass man den Lock hat und sonst niemand. Wenn dann da 2 oder so drinsteht, dann hat man den Lock nicht, und benutzt den futex-Syscall, um dem Kernel zu sagen, dass man auf diesen Lock hier warten will. Der Kernel suspendiert dann den Thread.
Beim Unlock geht man analog vor; man zieht atomar einen ab, und wenn man bei 0 rauskommt, ist alles gut und man ist fertig, ansonsten ruft man den Futex-Syscall auf und sagt an, dass man mal einen der wartenden Threads aufgeweckt haben will. Es gibt da noch zwei-drei Komplikationen aber im Wesentlichen ist es das. Den Syscall benutzt man nur, wenn man nicht im Userspace über die atomaren Operationen schon alles geklärt hat.
glibc hat über NPTL seit vielen Jahren Futex-Support.
Mein Test-Programm ist untypisch, denn es macht vier Threads auf, die alle dauernd um den selben Lock konkurrieren. An sich sind Locks ja genau auf den anderen Fall optimiert, nämlich dass es keinen Wettbewerb gibt im Normalfall, insofern ist das kein sonderlich guter Test, aber es ging ja ursprünglich auch gar nicht um den Durchsatz, sondern die Threads prüfen auch, ob sie tatsächlich als einzige Zugriff hatten.
Hier ist der Durchsatz (am Anfang insgesamt und die vier Zahlen am Ende sind für die einzelnen Threads):
glibc:Die Ergebnisse haben mich ja nun doch massiv überrascht. Falls sich jemand den Code mal angucken will: hier ist er. Ich habe den neuen Futex-basierten Locking-Code dann lieber doch nicht eingecheckt angesichts dieser Zahlen :-)
5840543 iterations: 1450529 1436218 1478655 1475141.dietlibc alt:
80426491 iterations: 18957811 16267831 19589958 25610891.dietlibc neu mit futex:
11477382 iterations: 2868532 2830930 2876189 2901731.
Oh, einen noch. Es gibt da noch eine Optimierung, die man gerne macht beim Locking. Man ruft nicht sofort den Kernel an, sondern probiert es erst ein paar Mal. Der Gedanke dahinter ist, dass normalerweise so ein Lock ja nur sehr kurz gehalten wird, und man sich den Syscall-Overhead auch sparen kann, wenn der, der den Lock gerade hält, nur mal kurz ein-zwei Variablen schreibt und ihn dann wieder freigibt. Das hat mein Futex-Code natürlich auch getan. Und zwar erst 100 Mal, dann nur noch 10 Mal. Hat den Durchsatz signifikant erhöht, das auf 10 zu senken. Das hat mich auch überrascht.
Update: Falls ihr mal eure libc testen wollt: das lief auf einem 64-bit Linux auf einem Phenom 1090T (3.2 GHz, 6 cores).