Une des caractéristiques de signature de Go est le support intégré pour la concurrence.Goroutines et canaux sont des primitifs simples et efficaces pour écrire des programmes simultanés.
Toutefois, le test de programmes concomitants peut être difficile et sujet à des erreurs.
Dans Go 1.24, nous présentons un nouveau package expérimental testing/synctest
pour soutenir le test du code concurrent.Ce post expliquera la motivation derrière cette expérience, démontrera comment utiliser le package synctest et discutera de son avenir potentiel.
évaluation / synchronisation
Dans Go 1.24, le paquet testing/synctest
est expérimental et n'est pas soumis à la promesse de compatibilité Go. Il n'est pas visible par défaut. Pour l'utiliser, compile ton code avec GOEXPERIMENT=synctest
défini dans ton environnement.
évaluation / synchronisation
Synthèse = synthèse
Il est difficile de tester des programmes simultanés
Pour commencer, prenons un exemple simple.
La fonction context.AfterFunc
dispose qu'une fonction soit appelée dans sa propre goroutine après l'annulation d'un contexte. Voici un test possible pour AfterFunc
:
context.AfterFunction
Après fonctionnement
func TestAfterFunc(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) calledCh := make(chan struct{}) // clos quand AfterFunc est appelé context.AfterFunc(ctx, func() { close(calledCh) }) // TODO: Affirmer que l'AfterFunc n'a pas été appelé. cancel() // TODO: Affirmer que l'AfterFunc a été appelé. }
func TestAfterFunc(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) calledCh := make(chan struct{}) // clos quand AfterFunc est appelé context.AfterFunc(ctx, func() { close(calledCh) }) // TODO: Affirmer que l'AfterFunc n'a pas été appelé. cancel() // TODO: Affirmer que l'AfterFunc a été appelé. }
Nous voulons vérifier deux conditions dans ce test: La fonction n'est pas appelée avant que le contexte ne soit annulé, et la fonction est appelée après que le contexte est annulé.
il est
Une approche courante consiste à attendre un certain temps avant de conclure qu'un événement ne se produira pas.
// funcCalled indique si la fonction a été appelée. funcCalled := func() bool { select { case <-calledCh: return true case <-time.After(10 * time.Millisecond): return false } } si funcCalled() { t.Fatalf("AfterFunc function called before context is canceled") } cancel() si!funcCalled() { t.Fatalf("AfterFunc function not called after context is canceled") }
// funcCalled indique si la fonction a été appelée. funcCalled := func() bool { select { case <-calledCh: return true case <-time.After(10 * time.Millisecond): return false } } si funcCalled() { t.Fatalf("AfterFunc function called before context is canceled") } cancel() si!funcCalled() { t.Fatalf("AfterFunc function not called after context is canceled") }
Ce test est lent: 10 millisecondes n'est pas beaucoup de temps, mais il s'ajoute à de nombreux tests.
Ce test est également flou: 10 millisecondes est un long temps sur un ordinateur rapide, mais il n'est pas rare de voir des pauses de plusieurs secondes sur des systèmes partagés et surchargés CI.
CI
Nous pouvons rendre le test moins flou au détriment de le rendre plus lent, et nous pouvons le rendre moins lent au détriment de le rendre plus flou, mais nous ne pouvons pas le rendre à la fois rapide et fiable.
Introduction au package de test/synctest
Le paquet testing/synctest
résout ce problème.Il nous permet de réécrire ce test pour être simple, rapide et fiable, sans aucune modification au code qui est testé.
évaluation / synchronisation
Run
et Wait
.
Exécution Précédent
Run
appelle une fonction dans une nouvelle goroutine. Cette goroutine et toutes les goroutines commencées par elle existent dans un environnement isolé que nous appelons une bubble. Wait
attend que chaque goroutine dans la bulle de la goroutine actuelle bloque une autre goroutine dans la bulle.
Exécution bubble et Précédent
Récrivons notre test ci-dessus en utilisant le paquet testing/synctest
.
évaluation / synchronisation
func TestAfterFunc(t *testing.T) { synctest.Run(func() { ctx, cancel := context.WithCancel(context.Background()) funcCalled := false context.AfterFunc(ctx, func() { funcCalled = true }) synctest.Wait() si funcCalled { t.Fatalf("AfterFunc fonction appelée avant le contexte est annulé") } cancel() synctest.Wait() si!funcCalled { t.Fatalf("AfterFunc fonction n'est pas appelée après le contexte est annulé") }) } func TestAfterFunc(t *testing.T) { synctest.Run(func() { ctx, cancel := context.WithCancel(context.Background()) funcCalled := false context.AfterFunc(ctx, func() { funcCalled = true }) synctest.Wait() si funcCalled { t.Fatalf("AfterFunc function called before context is canceled") } cancel() synctest.Wait() si!funcCalled { t.Fatalf("AfterFunc function not called after context is canceled") } } }
Cela est presque identique à notre test original, mais nous avons emballé le test dans un appel synctest.Run
et nous appelons synctest.Wait
avant d'affirmer que la fonction a été appelée ou non.
connexion.Run
connexion. attendez
La fonction Wait
attend que chaque goroutine dans la bulle de l'appelant bloque.Lorsqu'elle revient, nous savons que le paquet contextuel a soit appelé la fonction, soit ne l'appellera pas jusqu'à ce que nous prenions une autre action.
Précédent
Ce test est maintenant à la fois rapide et fiable.
Le test est aussi plus simple : nous avons remplacé le canal calledCh
par un boolean.Avant, nous avions besoin d'utiliser un canal pour éviter une course de données entre le goroutine de test et le goroutine AfterFunc
, mais la fonction Wait
fournit maintenant cette synchronisation.
calledCh
Le codeAprès fonctionnement
Précédent
Le détecteur de course comprend les appels Wait
, et ce test passe lors de l'exécution avec -race
. Si nous supprimons le deuxième appel Wait
, le détecteur de course signalera correctement une course de données dans le test.
Précédent -course
Précédent Test du temps
Le code concurrentiel traite souvent du temps.
Tester le code qui fonctionne avec le temps peut être difficile.L'utilisation du temps réel dans les tests provoque des tests lents et flaks, comme nous l'avons vu ci-dessus.L'utilisation du temps faux nécessite d'éviter les fonctions de package time
et de concevoir le code en cours de test pour fonctionner avec une horloge faux facultative.
temps
Le package testing/synctest
simplifie le test du code qui utilise le temps.
évaluation / synchronisation
Goroutines dans la bulle commençant par Run
utilisent une fausse horloge. Dans la bulle, les fonctions du paquet temps
fonctionnent sur la fausse horloge.Exécution temps
Pour démontrer, écrivons un test pour la fonction context.WithTimeout
. WithTimeout
crée un enfant d'un contexte, qui expire après un délai donné.
context.WithTimeout
Le code Avec le temps
func TestWithTimeout(t *testing.T) { synctest.Run(func() { const timeout = 5 * time.Second ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() // Wait juste moins que le timeout. time.Sleep(timeout - time.Nanosecond) synctest.Wait() si err : ctx.Err(); err!= nil { t.Fatalf("before timeout, ctx.Err() = %v; want nil", err) } // Wait le reste du chemin jusqu'au timeout time.Sleeptime(Nanosecond) synctest.Wait() si err : ct= ctx.Err(); err!= context.Deadlinefunc TestWithTimeout(t *testing.T) { synctest.Run(func() { const timeout = 5 * time.Second ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() // Wait juste moins que le timeout. time.Sleep(timeout - time.Nanosecond) synctest.Wait() si err := ctx.Err(); err!= nil { t.Fatalf("before timeout, ctx.Err() = %v; want nil", err) } // Wait le reste du chemin jusqu'au timeout time.Sleeptime(Nanosecond) synctest.Wait() si : err= ctx.Err(); err!= context.DeadlineExceededNous écrivons ce test comme si nous travaillions en temps réel.La seule différence est que nous emballons la fonction de test dans synctest.Run
, et appelons synctest.Wait
après chaque appel time.Sleep
pour attendre que les chronomètres du paquet contextuel soient terminés.
connexion.Run
connexion. attendez
temps.Dormir
Bloquage et la bulle
Un concept clé dans testing/synctest
est que la bulle devient permanemment bloquée. Cela se produit lorsque chaque goroutine dans la bulle est bloquée, et ne peut être débloquée que par une autre goroutine dans la bulle.
évaluation / synchronisation
durablement bloqué
Quand une bulle est bloquée durablement :
- S'il y a un appel
Wait
en suspens, il retourne. - D'autre part, le temps avance jusqu'à la prochaine fois qui pourrait débloquer une goroutine, le cas échéant.
- D'autre part, la bulle est verrouillée et
Run
panique.
S'il y a un appel Wait
en suspens, il retourne. Précédent Autrement, le temps avance jusqu'à la prochaine fois qui pourrait débloquer une goroutine, le cas échéant. Sans cela, la bulle est verrouillée et Run
panique. Exécution
Une bulle n'est pas bloquée de façon durable si une goroutine est bloquée mais peut être réveillée par un événement à l'extérieur de la bulle.
La liste complète des opérations qui bloquent durablement une goroutine est :
- a envoyer ou recevoir sur un canal nil
- a envoyer ou recevoir bloqué sur un canal créé dans la même bulle
- une déclaration sélectionnée où chaque cas est bloquant durablement
time.Sleep
sync.Cond.Wait
sync.WaitGroup.Wait
a envoyer ou recevoir sur un canal nil un envoi ou une réception bloqué sur un canal créé dans la même bulle une déclaration sélectionnée où chaque cas est bloquant durablement temps.Dormir
temps.Dormir
sync.Cond.Wait
sync.Cond.Wait
Le codesync.WaitGroup.Wait
sync.WaitGroup.Wait
Le codeMutexes
Les opérations sur un sync.Mutex
ne sont pas bloquées de façon durable.
sync.Mutex et
Il est courant pour les fonctions d'acquérir un mutex global. Par exemple, un certain nombre de fonctions dans le paquet réfléchir utilisent un cache global gardé par un mutex. Si une goroutine dans une bulle synctest bloque tout en acquérant un mutex détenu par une goroutine en dehors de la bulle, elle n'est pas bloquée de façon durable - elle est bloquée, mais sera débloquée par une goroutine à partir de l'extérieur de sa bulle.
Parce que les mutexes ne sont généralement pas tenus pendant de longues périodes de temps, nous les excluons simplement de la considération de testing/synctest
.
évaluation / synchronisation
Canaux
Les canaux créés à l'intérieur d'une bulle se comportent différemment de ceux créés à l'extérieur.
Les opérations de canal ne bloquent durablement que si le canal est en bulle (créé dans la bulle).
Ces règles garantissent qu'une goroutine n'est bloquée durablement que lorsqu'elle communique avec des goroutines à l'intérieur de sa bulle.
I/O
Les opérations I/O externes, telles que la lecture à partir d'une connexion réseau, ne sont pas bloquées de façon durable.
Les lectures réseau peuvent être débloquées par des écrits provenant de l'extérieur de la bulle, peut-être même d'autres processus.Même si le seul écrivain d'une connexion réseau est également dans la même bulle, le temps d'exécution ne peut pas distinguer entre une connexion attendant d'arriver plus de données et celle où le noyau a reçu des données et est en cours de livraison.
Par exemple, la fonction net.Pipe
crée une paire de net.Conn
qui utilisent une connexion réseau en mémoire et peuvent être utilisées dans les tests de synchronisation.
net.Pipe
Le codenet.Conn
Le codeLa durée de vie de la bulle
La fonction Run
déclenche une goroutine dans une nouvelle bulle. Il revient lorsque chaque goroutine dans la bulle a quitté. Il panique si la bulle est bloquée de façon durable et ne peut pas être débloquée en avançant le temps.
Exécution
L'exigence que chaque goroutine dans la sortie de la bulle avant le retour de Run signifie que les tests doivent être prudents pour nettoyer les goroutines de fond avant de terminer.
Test du code réseau
Voyons un autre exemple, cette fois en utilisant le paquet testing/synctest
pour tester un programme en réseau.
évaluation / synchronisation
net / HTTP
Un client HTTP qui envoie une demande peut inclure un en-tête « Expect: 100-continue» pour dire au serveur que le client a des données supplémentaires à envoyer.Le serveur peut alors répondre avec une réponse d'information continue 100 pour demander le reste de la demande, ou avec un autre statut pour dire au client que le contenu n'est pas nécessaire.Par exemple, un client téléchargeant un grand fichier pourrait utiliser cette fonction pour confirmer que le serveur est prêt à accepter le fichier avant de l'envoyer.
Notre test confirme que lors de l'envoi d'un en-tête « Expect: 100-continue», le client HTTP n'envoie pas le contenu d'une demande avant que le serveur ne le demande, et qu'il envoie le contenu après avoir reçu une réponse continue.
Souvent, les tests d'un client et d'un serveur de communication peuvent utiliser une connexion réseau loopback.En travaillant avec testing/synctest
, cependant, nous voudrions généralement utiliser une connexion réseau fausse pour nous permettre de détecter quand toutes les goroutines sont bloquées sur le réseau.Nous allons commencer ce test en créant un http.Transport
(un client HTTP) qui utilise une connexion réseau en mémoire créée par net.Pipe
.
évaluation / synchronisation
HTTP Transport
net.Pipe
Le codefunc Test(t *testing.T) { synctest.Run(func() { srvConn, cliConn := net.Pipe() defer srvConn.Close() defer cliConn.Close() tr := &http.Transport{ DialContext: func(ctx context.Context, réseau, chaîne d'adresse) (net.Conn, error) {retour cliConn, nil }, // Réglage d'un délai non zéro permet de gérer "Expect: 100-continue". // Puisque le test suivant ne dort pas, // nous ne rencontrerons jamais ce délai, // même si le test prend beaucoup de temps pour fonctionner sur une machine lente.func Test(t *testing.T) { synctest.Run(func() { srvConn, cliConn := net.Pipe() defer srvConn.Close() defer cliConn.Close() tr := &http.Transport{ DialContext: func(ctx context.Context, réseau, chaîne d'adresse) (net.Conn, error) {retour cliConn, nil }, // Réglage d'un délai non-zéro permet de gérer "Expect: 100-continue". // Puisque le test suivant ne dort pas, // nous ne rencontrerons jamais ce délai, // même si le test prend beaucoup de temps pour fonctionner sur une machine lente.
Nous envoyons une demande sur ce transport avec l'en-tête « Expect: 100-continue».La demande est envoyée dans une nouvelle goroutine, car elle ne sera pas complétée avant la fin du test.
corps := "request body" go func() { req, _ := http.NewRequest("PUT", "http://test.tld/", strings.NewReader(body)) req.Header.Set("Expect", "100-continue") resp., err := tr.RoundTrip(req) si err!= nil { t.Errorf("RoundTrip: une erreur inattendue %v", err) } autre resp. {.Body.Close() }()
body := "request body" go func() { req, _ := http.NewRequest("PUT", "http://test.tld/", strings.NewReader(body)) req.Header.Set("Expect", "100-continue") resp., err := tr.RoundTrip(req) si err!= nil { t.Errorf("RoundTrip: une erreur inattendue %v", err) } autre { resp.Body.Close() }()
Nous lisons les en-têtes de requête envoyés par le client.
req, err := http.ReadRequest(bufio.NewReader(srvConn)) si err!= nil { t.Fatalf("ReadRequest: %v", err) }
req, err := http.ReadRequest(bufio.NewReader(srvConn)) si err!= nil { t.Fatalf("ReadRequest: %v", err) }
Maintenant, nous arrivons au cœur du test.Nous voulons affirmer que le client n'enverra pas encore le corps de la demande.
Nous commençons une nouvelle goroutine en copiant le corps envoyé au serveur dans un strings.Builder
, en attendant que tous les goroutines dans la bulle bloquent, et en vérifiant que nous n'avons pas encore lu quoi que ce soit du corps.
Strings.Builder et
Si nous oublions l'appel synctest.Wait
, le détecteur de course se plaindra correctement d'une course de données, mais avec le Wait
ceci est sûr.
connexion. attendez
Précédent var gotBody strings.Builder go io.Copy(&gotBody, req.Body) synctest.Wait() si got := gotBody.String(); got!= "" { t.Fatalf("avant d'envoyer 100 Continuer, lire inattendu corps: %q", got) }
var gotBody strings.Builder go io.Copy(&gotBody, req.Body) synctest.Wait() si obtenu := gotBody.String(); obtenu!= "" { t.Fatalf("avant d'envoyer 100 Continuer, lire inattendu corps: %q", obtenu) }
Nous écrivons une réponse « 100 Continues » au client et vérifions qu’il envoie maintenant le corps de la demande.
srvConn.Write([]byte("HTTP/1.1 100 Continuer\r\n\n\n")) synctest.Wait() si obtenu := gotBody.String(); obtenu!= corps { t.Fatalf("après envoyer 100 Continuer, lire corps %q, vouloir %q", obtenu, corps) }
srvConn.Write([]byte("HTTP/1.1 100 Continuer\r\n\r\n")) synctest.Wait() si obtenu := gotBody.String(); obtenu!= corps { t.Fatalf("après envoyer 100 Continuer, lire corps %q, vouloir %q", obtenu, corps) }
Et finalement, nous finissons par envoyer la réponse « 200 OK » pour conclure la demande.
Nous avons lancé plusieurs goroutines au cours de ce test. L'appel synctest.Run
attendra que tous sortent avant de revenir.
connexion.Run
srvConn.Write([]byte("HTTP/1.1 200 OK\r\n\r\n") }) }
srvConn.Write([]byte("HTTP/1.1 200 OK\r\n\r\n") }) }
Ce test peut être facilement étendu pour tester d'autres comportements, tels que la vérification que le corps de la demande n'est pas envoyé si le serveur ne le demande pas, ou qu'il est envoyé si le serveur ne répond pas dans un délai.
Status de l'expérience
Nous introduisons testing/synctest
dans Go 1.24 en tant que package expérimental. En fonction des commentaires et de l'expérience, nous pouvons le libérer avec ou sans modifications, continuer l'expérience ou le supprimer dans une version future de Go.
évaluation / synchronisation
expérimentale
Le paquet n'est pas visible par défaut. Pour l'utiliser, compile ton code avec GOEXPERIMENT=synctest
défini dans ton environnement.
Synthèse = synthèse
Si vous essayez testing/synctest
, veuillez signaler vos expériences, positives ou négatives, sur go.dev/issue/67434.évaluation / synchronisation
go.dev/issue/67434« HR »Crédits: Damien Neil
Crédits :Damien Neil
Photo par Gabriel Gusmao sur Unsplash
Photo de Gabriel Gusmao sur UnsplashGabriel GusmaoUnsplash
Cet article est disponible sur The Go Blog sous une licence CC BY 4.0 DEED.
Cet article est disponible sur The Go Blog sous licence CC BY 4.0 DEED.
Le blog de Go Le blog de la ville