Gestion des Threads en Go

Découvrez cette mésaventure palpitante et pleine de rebondissements !

Le problème exposé dans ce sujet a été résolu.

Bonjour à tous,

Je fais aujourd’hui face à la gestion des Threads en Go, qui m’apporte quelques mésaventures, notamment dues au fait qu’on ne sait pas vraiment ce qu’il se cache là derrière.

Pour les besoins de mon projet, je dois faire des interactions avec des machines à commande numérique (CNC) au travers du protocole Focas. (Il s’agit d’un protocole de communication développé par Fanuc, qui développe des commandes numériques pour les machines)

Pour celles et ceux qui ne sauraient pas, voici de quoi je parle:

Commande numérique Fanuc
Commande numérique Fanuc

J’ai à ma disposition une DLL (Fwlib) me permettant d’interagir avec ces commandes numériques, dont on retrouve la documentation ici.

Dans mon projet en Go, j’interagis ainsi avec cette DLL.


Tout se passait bien, jusqu’au moment où … je commence à travailler avec les Goroutines, à ce moment-là, plus rien n’a fonctionné. Je me dis que la librairie ne supporte pas les accès concurrents, ce qui ne me poserait pas de problème.

Je me mis donc à mettre des lock (mutex) sur toutes les opérations faisant appel à la DLL. Et la, au moment de Run mon programme, toujours le même problème !
Je me dis que c’est des problèmes de temporisation, qu’il faut un peu calmer le jeu, je rajoute donc quelques sleep.

Et bien, toujours pas ! Quand ça ne veut pas, ça ne veut pas. Du coup, je retire mes Goroutine tout en laissant les sleeps, et la, stupeur, ça fonctionne parfois.

Me disant que c’est une bibliothèque très mal faite, je vais tout de même faire un essai en C# pour voir ce que ça donne et étrangement, ça fonctionne (uniquement les sleep, pas les tâches asynchrones).


Recherche d’informations dans la documentation du protocole

Once the library handle number is acquired, it must be held until the application program terminates, because it is necessary to pass the number as an argument to each CNC/PMC Data window library function.

The library handle is owned by the thread that got it. Even if the thread-A which has already got a library handle shows the library handle to another thread-B, the thread-B cannot use that library handle.

Je tiens enfin une piste ! C’est donc de là que viens le problème avec les tâches exécutées en parallèle.

Je comprends aussi rapidement que le problème avec les sleep viens du fait que le runtime Go peut changer de Thread à tout moment. Pour confirmer cela, j’ai fait appel à la méthode runtime.LockOSThread(), qui permet de bloquer l’exécution en cours sur le même Thread jusqu’à l’appel de runtime.UnlockOSThread(). Et effectivement, ça fonctionne !

Sur le code suivant, qui ne fonctionnait pas, fonctionne après l’ajout de runtime.LockOSThread. (Précision: focas.Channel() est une méthode que j’ai implémentée qui va faire un appel avec l'handle à la DLL).

runtime.LockOSThread()
for i := 0; i < 200; i++ {
	_, e := focas.Channel()
	if e != nil {
		log.Fatalln(i, e)
	}

	time.Sleep(time.Millisecond * 10)
}
runtime.UnlockOSThread()

Comme vous le devinez surement, on en arrive enfin à mon problème.

  • Ma première idée pour résoudre ce problème est de demander un handle à chaque appel à une méthode, le problème ? La demande d’un handle prend du temps (~250ms), le temps d’initialiser la connexion TCP/IP à la machine CNC.
  • Mon idée suivante, c’est de récupérer l’ID du Thread en cours, et de vérifier dans un map si un handle a déjà été généré pour ce Thread, si ce n’est pas le cas on en génère un, sinon on utilise l’existant.

La deuxième idée me paraît être idéale, mais un petit problème se pose. Il n’est pas possible en Go de savoir sur quel Thread l’exécution se fait ! Selon l’issue suivante, les développeurs du langage Go ne souhaitent pas rendre cela accessible, pour éviter que les développeurs en fassent n’importe quoi.

Finalement, ma dernière idée est d’ouvrir un handle au démarrage dans une Goroutine, locké à son Thread. Ensuite à chaque appel à la DLL, je fais la demande d’un nouvel handle que je libère ensuite.

Et le résultat est sans appel. Si j’ouvre un handle que je maintiens, 100 exécutions de ma méthode me prennent 150ms, si j’enlève le handle maintenu, l’exécution passe à 22s ! L’exécution prend environ 150x plus de temps.

Vous trouverez ici le code source de mon essai. https://gist.github.com/WinXaito/c4eba0e808c8587dbdbd2a2687260bd5 (Pour passer de 150ms à 22s, j’ai simplement commenter les lignes 38 et 39)


Et finalement mes questions,

Concernant la dernière solution, qui semble fonctionner, je ne sais pas si elle est idéale. Qu’en pensez-vous ?

Concernant la deuxième proposition (récupérer un ID sur le Thread et maintenir une correspondance ID<->Handle), savez-vous si c’est faisable (malgré mes trouvailles je n’ai peut-être pas cherché assez loin) et surtout, est-ce que même si ça avait été possible, est-ce que ça aurait été une bonne idée ?

Auriez-vous peut-être d’autres idées plus idéales à me proposer ?


Je vous remercie, et vous souhaite d’avance de belles fêtes de fin d’années à toutes et tous !

Je ne voyais pas les Goroutines comme des Threads. Si tu veux faire des threads, pourquoi ne pas utiliser de vrai threads ? Comme ça tu peux applique ta solution 2, non ?

+1 -0

Je ne voyais pas les Goroutines comme des Threads. Si tu veux faire des threads, pourquoi ne pas utiliser de vrai threads ? Comme ça tu peux applique ta solution 2, non ?

ache

Il n’est pas possible de gérer des Thread directement en Go (du moins pas à ma connaissance). Et j’ai besoin de garder l’avantage goroutines/channels.

Hello,

Je te rejoins sur l’analyse et la solution du problème. La librarie utilise très certainement du thread local storage, et comme les goroutines peuvent être schédulées sur n’importe quel thread, ça pète la lib si tu appelles depuis un autre thread. Donc si tu pin la goroutine à un thread tu corriges le problème.

Ça m’a l’air d’être une solution propre et adaptée :)

Il n’est pas possible de gérer des Thread directement en Go (du moins pas à ma connaissance). Et j’ai besoin de garder l’avantage goroutines/channels.

WinXaito

O_o ! Désolé ! Je ne savais pas. Du coup Go ne permet pas une gestion fine des fils d’exécution. C’est curieux.

+0 -0

Je ne suis pas sûr que chercher à manipuler explicitement les threads en Go soit une bonne idée. Ça me semble aller contre le langage. Du coup c’est plutôt vers la solution 1 que je me pencherais.

Si jamais tu te retrouvais limité en perfs, tu pourrais avoir des solutions intermédiaires acceptables, comme maintenir un pool de goroutines qui sont chacune pinned à un thread (tu peux locker plusieurs OS threads comme ça, le runrime sait s’en accommoder), qui ont chacune leur handle, et qui sont responsables d’exécuter des appels/commandes qui viennent des autres goroutines de ton programme.

Ça demanderait de créer un peu de logique pour abstraire la communication avec ces goroutines (au moyen de channels), mais rien d’insurmontable. Par exemple tu pourrais passer à la goroutine pinned une fonction (ou closure) qui prend un handle en argument, et la goroutine se contente de :

  • Recevoir la fonction sur son chan d’entrée,
  • L’exécuter en lui passant son handle en argument,
  • Signaler sur son chan de sortie qu’elle a fini.

En tout cas ça ne me semble pas "sale" comme solution, c’est le problème qui est sale, mais cette façon d’y répondre me semble idiomatique.

+2 -0

Si jamais tu te retrouvais limité en perfs, tu pourrais avoir des solutions intermédiaires acceptables, comme maintenir un pool de goroutines qui sont chacune pinned à un thread (tu peux locker plusieurs OS threads comme ça, le runrime sait s’en accommoder), qui ont chacune leur handle, et qui sont responsables d’exécuter des appels/commandes qui viennent des autres goroutines de ton programme.

Merci pour cette proposition.

ça m’a l’air en effet un petit peu plus compliqué que ma dernière solution, mais comme tu l’as dit, rien d’insurmontable.

Pour le moment, je pense partir sur la dernière solution imaginé, à savoir, la demande d’un handle à chaque appel (mais afin de gagner en performance, on maintient un handle durant toute la durée de vie de la lib).

En fait, la première demande prend du temps car la DLL Focas négocie la connexion TCP/IP avec la machine, mais, si une connexion est déjà ouverte, elle est réutilisé. Ce qui fait que les demande d’handle suivant sont très rapide.

Voici sur le schéma suivant la solution adopté.

Demande des handles
Demande des handles

Rien ne m’empêchera d’utiliser une autre solution par la suite (c’est surtout la gestion interne de ma bibliothèque Focas qui changera, l’interface elle ne devrait pas trop bouger).

ça m’a l’air en effet un petit peu plus compliqué que ma dernière solution

Ça se fait plutôt bien avec la lib standard et la structure sync.Pool :

package main

import (
	"fmt"
	"runtime"
	"sync"
	"time"
)

// Ici je simule des "handles"
type Handle int

var (
	handle    Handle
	handleMtx sync.Mutex
)

func newHandle() Handle {
	time.Sleep(10 * time.Millisecond) // obtenir un handle coûte 10ms
	handleMtx.Lock()
	defer handleMtx.Unlock()
	handle++
	return handle
}

// Ici l'implémentation du pool
type ClientFunc func(Handle) error

type ClientPool struct {
	sync.Pool
}

func NewClientPool() *ClientPool {
	c := &ClientPool{
		Pool: sync.Pool{
			New: func() interface{} {
				in := make(chan ClientFunc)
				out := make(chan error)
				go runner(in, out)
				return &executor{in, out}
			},
		},
	}

	return c
}

type executor struct {
	In  chan<- ClientFunc
	Out <-chan error
}

func runner(in <-chan ClientFunc, out chan<- error) {
	defer close(out)
	runtime.LockOSThread()
	defer runtime.UnlockOSThread()
	handle := newHandle()
	for f := range in {
		err := f(handle)
		out <- err
	}
}

func (c *ClientPool) Run(f ClientFunc) error {
	e := c.Get().(*executor)
	e.In <- f
	err, ok := <-e.Out
	if !ok || err != nil {
		// une erreur s'est produite, 
		// ou alors la goroutine a paniqué (ce qui a fermé e.Out)
		close(e.In) 
		return err
	}
	// pas d'erreur, on peut retourner le runner au pool
	c.Put(e)
	return nil
}

func main() {
	start := time.Now()
	pool := NewClientPool()
	var wg sync.WaitGroup
	wg.Add(100000)
	for i := 0; i < 100000; i++ {
		go func() {
			defer wg.Done()
			pool.Run(func(h Handle) error {
				time.Sleep(time.Millisecond)
				return nil
			})
		}()
	}
	wg.Wait()
	fmt.Println("elapsed: ", time.Since(start))
	fmt.Println("allocated handles: ", handle)
}

Ici, avec 100K goroutines qui vont taper sur le pool et bosser pendant 1ms chacune, ça me donne :

elapsed:  3.09040842s
allocated handles:  602

C’est sûrement un exemple un peu violent, mais l’intérêt du pool, c’est surtout de réutiliser facilement des ressources qui sont coûteuses à obtenir : ici on voit que même en stressant le pool en lui balançant 100K tâches d’un coup, il n’a créé que 600 (c’est variable en fait, ça oscille entre 400 et 800 chez moi) connexions/goroutines épinglées.

+2 -0
Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte