Le blog

Goroutines, threads, et IDs de threads

Publié le 20 juin 2022

Si vous avez assisté à l’AFUP Day 2022 le 20 mai à Lille, vous savez peut-être que je travaille actuellement sur un module PHP pour serveurs web écrit en Go.

En testant ma future bibliothèque, j'ai rencontré d'étranges problèmes d'accès à la mémoire liés aux threads créés par le runtime Go (vous savez, cgo...).

Par défaut, le planificateur de Go exécute de nombreuses goroutines sur les mêmes threads système. Ce comportement est incompatible avec le code C qui s'appuie sur le stockage local des threads, comme PHP compilé avec l'option ZTS.

Heureusement, la bibliothèque standard de Go fournit une fonction utilitaire pour contourner ce problème : runtime.LockOSThread(). Go garantit que la goroutine qui appelle cette fonction s'exécute toujours dans le même thread système, et qu'aucune autre goroutine ne peut s'exécuter dans le même thread jusqu'à ce que runtime.UnlockOSThread() soit appelée.

En déboguant ma bibliothèque, j'ai découvert que PHP utilise l'ID du thread actuel comme clé pour accéder aux données locales du thread. Cela peut-il être un problème ? Sommes-nous sûrs que le système ne réutilise jamais le même ID de thread alors que le même processus est en cours d'exécution ? 

La page de manuel de pthread_self() fournie par Apple sur Mac OS X (de 1996 !) ne donne aucune information à ce sujet, cependant, la page de manuel de Linux indique ceci :

Un identifiant de thread peut être réutilisé après qu'un thread terminé ait été rejoint, ou qu'un thread détaché se soit terminé.

Écrivons un programme de test pour comprendre comment runtime.LockOSThread() fonctionne, et si le système réutilise vraiment les IDs des threads :

// This Go program demonstrates the behavior of runtime.LockOSThread() and runtime.UnlockOSThread().
// runtime.LockOSThread() forces the wiring of a Goroutine to a system thread.
// No other goroutine can run in the same thread, unless runtime.UnlockOSThread() is called at some point.
//
// According to the manual of phthread_self, when many threads are created, the system may reassign an ID that was used by a terminated thread to a new thread.
//
// This programs shows that thread IDs are indeed reused (tested on Linux and Mac), but that the thread itself is actually destroyed.
// To prove this, we store used thread IDs in a global variable, and some data in each local thread using pthread_setspecific().
//
// When we hint the Go runtime to reuse the threads by calling runtime.UnlockOSThread(), we can see that the local data is still available when a thread is reused.

package main

/*
#include <stdlib.h>
#include <pthread.h>

int setspecific(pthread_key_t key, int i) {
	int *ptr = calloc(1, sizeof(int)); // memory leak on purpose
	*ptr = i;
	return pthread_setspecific(key, ptr);
}
*/
import "C"
import (
	"bytes"
	"fmt"
	"runtime"
	"strconv"
	"sync"
)

const nbGoroutines = 1000

type goroutine struct {
	num int    // App specific goroutine ID
	id  uint64 // Internal goroutine ID (debug only, do not rely on this in real programs)
}

var seenThreadIDs map[C.pthread_t]goroutine = make(map[C.pthread_t]goroutine, nbGoroutines+1)
var seenThreadIDsMutex sync.RWMutex

// getGID gets the current goroutine ID (copied from https://blog.sgmansfield.com/2015/12/goroutine-ids/)
func getGID() uint64 {
	b := make([]byte, 64)
	b = b[:runtime.Stack(b, false)]
	b = bytes.TrimPrefix(b, []byte("goroutine "))
	b = b[:bytes.IndexByte(b, ' ')]
	n, _ := strconv.ParseUint(string(b), 10, 64)
	return n
}

// isThreadIDReused checks if the passed thread ID has already be used before
func isThreadIDReused(t1 C.pthread_t, currentGoroutine goroutine) bool {
	seenThreadIDsMutex.RLock()
	defer seenThreadIDsMutex.RUnlock()
	for t2, previousGoroutine := range seenThreadIDs {
		if C.pthread_equal(t1, t2) != 0 {
			fmt.Printf("Thread ID reused (previous goroutine: %v, current goroutine: %v)\n", previousGoroutine, currentGoroutine)

			return true
		}
	}

	return false
}

func main() {
	runtime.LockOSThread()
	seenThreadIDsMutex.Lock()
	seenThreadIDs[C.pthread_self()] = goroutine{0, getGID()}
	seenThreadIDsMutex.Unlock()

	// It could be better to use C.calloc() to prevent the GC to destroy the key
	var tlsKey C.pthread_key_t
	if C.pthread_key_create(&tlsKey, nil) != 0 {
		panic("problem creating pthread key")
	}

	for i := 1; i <= nbGoroutines; i++ {
		go func(i int) {
			runtime.LockOSThread()
			// Uncomment the following line to see how the runtime behaves when threads can be reused
			//defer runtime.UnlockOSThread()

			// Check if data has already been associated with this thread
			oldI := C.pthread_getspecific(tlsKey)
			if oldI != nil {
				fmt.Printf("Thread reused, getspecific not empty (%d)\n", *(*C.int)(oldI))
			}

			g := goroutine{i, getGID()}



// Get the current thread ID
			t := C.pthread_self()
			isThreadIDReused(t, g)

			// Associate some data to the local thread
			if C.setspecific(tlsKey, C.int(i)) != 0 {
				panic("problem setting specific")
			}

			// Add the current thread ID in the list of already used IDs
			seenThreadIDsMutex.Lock()
			defer seenThreadIDsMutex.Unlock()
			seenThreadIDs[C.pthread_self()] = g
		}(i)
	}
}

Voici un exemple de sortie lorsque runtime.UnlockOSThread() est commenté.

Thread ID reused (previous goroutine: {6 9}, current goroutine: {1 4})
Thread ID reused (previous goroutine: {1 4}, current goroutine: {13 16})
Thread ID reused (previous goroutine: {3 6}, current goroutine: {32 52})

Donc oui, les ID des threads sont réutilisés, tandis que les threads eux-mêmes sont correctement détruits. Nous avons peut-être un problème avec la base de code de PHP !

Et voici un exemple lorsque runtime.UnlockOSThread() est décommenté.

Thread reused, getspecific not empty (35)
Thread ID reused (previous goroutine: {35 53}, current goroutine: {1 19})
Thread reused, getspecific not empty (26)
Thread ID reused (previous goroutine: {26 44}, current goroutine: {19 37})
Thread reused, getspecific not empty (19)
Thread reused, getspecific not empty (84)
Thread ID reused (previous goroutine: {84 102}, current goroutine: {36 54})
Thread reused, getspecific not empty (36)
Thread ID reused (previous goroutine: {36 54}, current goroutine: {37 55})
Thread reused, getspecific not empty (37)
Thread ID reused (previous goroutine: {37 55}, current goroutine: {38 56})
Thread reused, getspecific not empty (38)
Thread ID reused (previous goroutine: {38 56}, current goroutine: {39 57})
Thread reused, getspecific not empty (18)
Thread reused, getspecific not empty (39)
Thread ID reused (previous goroutine: {39 57}, current goroutine: {40 58})
Thread reused, getspecific not empty (40)
Thread ID reused (previous goroutine: {18 36}, current goroutine: {2 20})
Thread reused, getspecific not empty (1)
Thread ID reused (previous goroutine: {40 58}, current goroutine: {41 59})

Nous pouvons voir que les threads sont réutilisés, comme documentés !

Si vous cherchez des experts Go, C ou PHP, contactez Les-Tilleuls.coop ! Nous sommes là pour échanger avec vous  et voir ensemble comment on peut répondre à vos besoins !

https://api.les-tilleuls.coop/wp-content/uploads/2021/09/kevin-dunglas-244x300.jpg

Kevin Dunglas

CEO & technical director

Mots-clésgo, PHP, threads

Le blog

Pour aller plus loin