Je travaille actuellement sur un projet écris en Go ou les closure sont massivement utilisées. Sachant que l’appel d’un fonction n’est pas une chose neutre, je m’interroge ici sur l’impact de ces closures sur les performances du code. Je propose donc de comprendre comment fonctionne tout ceci et de faire un comparatif avec une méthode de gestion d’erreur plus classique à base de « goto ».
Voila donc donc trois approches pour gérer une séquence d’opération ou l’on souhaite s’arrêter dès la première erreur:
func test_closure(str string) {
var err error
func() {
if len(str) > 3 {
err = globalErr
return
}
if len(str) > 4 {
err = globalErr
return
}
} ()
if err != nil {
globalErrorCount++
}
}
func test_lambda(str string) {
var err error
err = func(str_test string) error {
if len(str_test) > 3 {
return globalErr
}
if len(str_test) > 4 {
return globalErr
}
return nil
} (str)
if err != nil {
globalErrorCount++
}
}
func test_goto(str string) {
var err error
if len(str) > 3 {
err = globalErr
goto process_error
}
if len(str) > 4 {
err = globalErr
goto process_error
}
process_error:
if err != nil {
globalErrorCount++
}
}
Chaque fonction est testé dans un cas ou l’opération s’effectue sans erreur, et dans un cas ou l’opération s’effectue avec une erreur dès le premier traitement. Le test de chaque type d’opération est mesuré durant 1 000 000 000 d’itérations afin d’avoir une mesure moyenne.
Voila les résultats. Il s'agit de la durée de l'exécution des 1 000 000 000 d'itérations et de l'écart au plus rapide en pourcentage.
Closure | Lambda | Goto | |
---|---|---|---|
Return error | 3.87s
+7.82% | 4.4s
+23.6% | 3.56s |
Return no error | 4.37s
+23.4% | 4.06s
+14.7% | 3.54s |
Les systèmes a closure ou lambda sont significativement plus lents. Voyons pourquoi :
Une closure est une fonction qui conserve une référence a son environnement lexical. L’environnement lexical comprends les variables accessibles à l’endroit ou est défini la closure.
Techniquement, le compilateur analyse la closure et détermine les variables qu’elle doit utiliser. Pour que ces variables soient accessibles, elles seront allouées dans le tas plutôt que dans la pile.
Appeler une fonction n’est pas une chose sans impact. Le programme doit parfois sauvegarder ses registres dans la stack (le compilateur Go optimise cela), positionner les paramètre d’appel dans des registres selon la convention de l’OS et finalement exécuter l’instruction CALL qui va sauvegarder le point de retour de la fonction et sauter vers la première instruction de la fonction. Par ailleurs, un CALL peut impliquer un flush du pipeline d’instructions du CPU.
Le tas est une grande zone mémoire accessible de tout le programme. Afin de gérer les variable positionnées dans le tas, on utilise généralement un système d’allocation de mémoire. Dans le cas du Go, on utilise en plus un « garbage collector ». La mémoire dans la pile est plus simple à allouer, sa quantité est connue à l’avance par le compilateur et il suffit de changer la valeur du pointeur de pile.
Ce qui est allouée dans la pile est plus rapide à allouer et n’est pas soumis au « garbage collector ». en revanche, l’espace la pile est limité.
Voila le code assembleur généré pour ces trois méthodes. On peut noter que les méthode lambda et closure se traduisent par deux fonctions alors que la méthode goto n’en présente qu’une seule:
Voila le code de chaque fonction décompilé.
TEXT main.test_closure(SB)
comp.go:119: CMPQ SP, 0x10(R14)
comp.go:119: JBE 0x10b49ed
comp.go:119: PUSHQ BP
comp.go:119: MOVQ SP, BP
comp.go:119: SUBQ $0x28, SP
comp.go:119: MOVQ AX, 0x38(SP)
comp.go:119: MOVQ BX, 0x40(SP)
comp.go:120: MOVUPS X15, 0x18(SP)
comp.go:131: MOVQ 0x38(SP), AX
comp.go:131: MOVQ 0x40(SP), BX
comp.go:131: LEAQ 0x18(SP), CX
comp.go:131: CALL main.test_closure.func1(SB)
comp.go:133: CMPQ 0x18(SP), $0x0
comp.go:133: JNE 0x10b49dc
comp.go:133: JMP 0x10b49e5
comp.go:134: INCQ main.globalErrorCount(SB)
comp.go:134: JMP 0x10b49e7
comp.go:133: JMP 0x10b49e7
comp.go:136: ADDQ $0x28, SP
comp.go:136: POPQ BP
comp.go:136: RET
comp.go:119: MOVQ AX, 0x8(SP)
comp.go:119: MOVQ BX, 0x10(SP)
comp.go:119: CALL runtime.morestack_noctxt.abi0(SB)
comp.go:119: MOVQ 0x8(SP), AX
comp.go:119: MOVQ 0x10(SP), BX
comp.go:119: JMP main.test_closure(SB)
TEXT main.test_closure.func1(SB)
comp.go:122: CMPQ SP, 0x10(R14)
comp.go:122: JBE 0x10b4ad3
comp.go:122: PUSHQ BP
comp.go:122: MOVQ SP, BP
comp.go:122: SUBQ $0x8, SP
comp.go:122: MOVQ AX, 0x18(SP)
comp.go:122: MOVQ BX, 0x20(SP)
comp.go:122: MOVQ CX, 0x28(SP)
comp.go:123: MOVQ BX, 0(SP)
comp.go:123: CMPQ BX, $0x3
comp.go:123: JG 0x10b4a4d
comp.go:123: JMP 0x10b4a87
comp.go:124: MOVQ main.globalErr+8(SB), AX
comp.go:124: MOVQ main.globalErr(SB), DX
comp.go:124: MOVQ DX, 0(CX)
comp.go:124: CMPL runtime.writeBarrier(SB), $0x0
comp.go:124: JE 0x10b4a69
comp.go:124: JMP 0x10b4a6b
comp.go:124: JMP 0x10b4a7d
comp.go:124: CALL runtime.gcWriteBarrier2(SB)
comp.go:124: MOVQ AX, 0(R11)
comp.go:124: MOVQ 0x8(CX), DX
comp.go:124: MOVQ DX, 0x8(R11)
comp.go:124: JMP 0x10b4a7d
comp.go:124: MOVQ AX, 0x8(CX)
comp.go:125: ADDQ $0x8, SP
comp.go:125: POPQ BP
comp.go:125: RET
comp.go:127: MOVQ BX, 0(SP)
comp.go:127: CMPQ BX, $0x4
comp.go:127: JG 0x10b4a93
comp.go:127: JMP 0x10b4acd
comp.go:128: MOVQ main.globalErr+8(SB), AX
comp.go:128: MOVQ main.globalErr(SB), DX
comp.go:128: MOVQ DX, 0(CX)
comp.go:128: CMPL runtime.writeBarrier(SB), $0x0
comp.go:128: JE 0x10b4aaf
comp.go:128: JMP 0x10b4ab1
comp.go:128: JMP 0x10b4ac3
comp.go:128: CALL runtime.gcWriteBarrier2(SB)
comp.go:128: MOVQ AX, 0(R11)
comp.go:128: MOVQ 0x8(CX), DX
comp.go:128: MOVQ DX, 0x8(R11)
comp.go:128: JMP 0x10b4ac3
comp.go:128: MOVQ AX, 0x8(CX)
comp.go:129: ADDQ $0x8, SP
comp.go:129: POPQ BP
comp.go:129: RET
comp.go:131: ADDQ $0x8, SP
comp.go:131: POPQ BP
comp.go:131: RET
comp.go:122: MOVQ AX, 0x8(SP)
comp.go:122: MOVQ BX, 0x10(SP)
comp.go:122: MOVQ CX, 0x18(SP)
comp.go:122: CALL runtime.morestack_noctxt.abi0(SB)
comp.go:122: MOVQ 0x8(SP), AX
comp.go:122: MOVQ 0x10(SP), BX
comp.go:122: MOVQ 0x18(SP), CX
comp.go:122: JMP main.test_closure.func1(SB)
TEXT main.test_lambda(SB)
comp.go:138: CMPQ SP, 0x10(R14)
comp.go:138: JBE 0x10b4b4f
comp.go:138: PUSHQ BP
comp.go:138: MOVQ SP, BP
comp.go:138: SUBQ $0x20, SP
comp.go:138: MOVQ AX, 0x30(SP)
comp.go:138: MOVQ BX, 0x38(SP)
comp.go:139: MOVUPS X15, 0x10(SP)
comp.go:149: MOVQ 0x30(SP), AX
comp.go:149: MOVQ 0x38(SP), BX
comp.go:149: CALL main.test_lambda.func1(SB)
comp.go:141: MOVQ AX, 0x10(SP)
comp.go:141: MOVQ BX, 0x18(SP)
comp.go:151: TESTQ AX, AX
comp.go:151: JNE 0x10b4b3e
comp.go:151: JMP 0x10b4b47
comp.go:152: INCQ main.globalErrorCount(SB)
comp.go:152: JMP 0x10b4b49
comp.go:151: JMP 0x10b4b49
comp.go:154: ADDQ $0x20, SP
comp.go:154: POPQ BP
comp.go:154: RET
comp.go:138: MOVQ AX, 0x8(SP)
comp.go:138: MOVQ BX, 0x10(SP)
comp.go:138: CALL runtime.morestack_noctxt.abi0(SB)
comp.go:138: MOVQ 0x8(SP), AX
comp.go:138: MOVQ 0x10(SP), BX
comp.go:138: JMP main.test_lambda(SB)
TEXT main.test_lambda.func1(SB)
comp.go:141: PUSHQ BP
comp.go:141: MOVQ SP, BP
comp.go:141: SUBQ $0x18, SP
comp.go:141: MOVQ AX, 0x28(SP)
comp.go:141: MOVQ BX, 0x30(SP)
comp.go:141: MOVUPS X15, 0x8(SP)
comp.go:142: MOVQ 0x30(SP), CX
comp.go:142: MOVQ CX, 0(SP)
comp.go:142: CMPQ CX, $0x3
comp.go:142: JG 0x10b4c49
comp.go:142: JMP 0x10b4c67
comp.go:143: MOVQ main.globalErr(SB), AX
comp.go:143: MOVQ main.globalErr+8(SB), BX
comp.go:143: MOVQ AX, 0x8(SP)
comp.go:143: MOVQ BX, 0x10(SP)
comp.go:143: ADDQ $0x18, SP
comp.go:143: POPQ BP
comp.go:143: RET
comp.go:145: MOVQ 0x30(SP), CX
comp.go:145: MOVQ CX, 0(SP)
comp.go:145: CMPQ CX, $0x4
comp.go:145: JG 0x10b4c78
comp.go:145: JMP 0x10b4c96
comp.go:146: MOVQ main.globalErr(SB), AX
comp.go:146: MOVQ main.globalErr+8(SB), BX
comp.go:146: MOVQ AX, 0x8(SP)
comp.go:146: MOVQ BX, 0x10(SP)
comp.go:146: ADDQ $0x18, SP
comp.go:146: POPQ BP
comp.go:146: RET
comp.go:148: MOVUPS X15, 0x8(SP)
comp.go:148: XORL AX, AX
comp.go:148: XORL BX, BX
comp.go:148: ADDQ $0x18, SP
comp.go:148: POPQ BP
comp.go:148: RET
TEXT main.test_goto(SB)
comp.go:156: PUSHQ BP
comp.go:156: MOVQ SP, BP
comp.go:156: SUBQ $0x18, SP
comp.go:156: MOVQ AX, 0x28(SP)
comp.go:156: MOVQ BX, 0x30(SP)
comp.go:157: MOVUPS X15, 0x8(SP)
comp.go:159: MOVQ 0x30(SP), AX
comp.go:159: MOVQ AX, 0(SP)
comp.go:159: CMPQ AX, $0x3
comp.go:159: JG 0x10b4ba9
comp.go:159: JMP 0x10b4bc3
comp.go:160: MOVQ main.globalErr(SB), AX
comp.go:160: MOVQ main.globalErr+8(SB), CX
comp.go:160: MOVQ AX, 0x8(SP)
comp.go:160: MOVQ CX, 0x10(SP)
comp.go:161: JMP 0x10b4bf0
comp.go:163: MOVQ 0x30(SP), AX
comp.go:163: MOVQ AX, 0(SP)
comp.go:163: CMPQ AX, $0x4
comp.go:163: JG 0x10b4bd4
comp.go:163: JMP 0x10b4bee
comp.go:164: MOVQ main.globalErr(SB), AX
comp.go:164: MOVQ main.globalErr+8(SB), CX
comp.go:164: MOVQ AX, 0x8(SP)
comp.go:164: MOVQ CX, 0x10(SP)
comp.go:165: JMP 0x10b4bf0
comp.go:170: JMP 0x10b4bf0
comp.go:170: CMPQ 0x8(SP), $0x0
comp.go:170: JNE 0x10b4bfa
comp.go:170: JMP 0x10b4c03
comp.go:171: INCQ main.globalErrorCount(SB)
comp.go:171: JMP 0x10b4c05
comp.go:170: JMP 0x10b4c05
comp.go:173: ADDQ $0x18, SP
comp.go:173: POPQ BP
comp.go:173: RET
Voila ce que l'on peut noter sur le code compilé:
Voilà un vieux débat qui n’a pas lieu d’être. Ce débat vit toujours car beaucoup de personnes répètent ad nauseam la diatribe « Il ne faut jamais utiliser « goto » car on peut toujours le remplacer par une structure à base de boucles ». Hélas ces braves gens ne savent pas pourquoi ils répètent ça, et leur assurance crée des adeptes qui prêchent à leur tour la bonne parole.
Ce vieux débat provient de Dijkstra qui considère que « goto » à tendance à rendre le code illisible, et qu’il peut toujours être remplacé par autre chose, ce qu’il défend dans cet article ( https://homepages.cwi.nl/~storm/teaching/reader/Dijkstra68.pdf ). C’est un point de vue intéressant. Seulement, Dijkstra fait de l’algorithmique et pas du développement, dans l’algorithmique, on s’affranchit de la gestion des cas d’erreur, de la libération de ressources, de perte de connexion, et d’autre concepts qui sont facilement traités par un « goto ». Quand on développe, on ne peut pas s’affranchir de ces cas-là, aussi le « goto » est une manière particulièrement élégante de les traiter.
Par ailleurs, si le prêcheur fait un appel à l’autorité en citant « Dijkstra », il sera facile de trouver d’autre figure d’autorité qui soutiennent le contraire comme par exemple « Torvalds » ( https://lkml.org/lkml/2003/1/12/128 ).
Oui, l’abus de « goto » rends le code difficile à lire. Faut-il pour autant se radicaliser et proscrire « goto » de tout code ? L’important est que l’usage ou non de cette pratique corresponde à vos enjeux. Personnellement, je n’ai jamais rencontré un enjeu qui nécessite de proscrire l’usage raisonnable du « goto ».
Tout le code de ces tests sont compilés en désactivant les optimisations. Si on les active (elles sont activées par défaut) le compilateur détecte que ces 3 fonctions font la même chose et il les réduit à leur plus petite expression, notamment en supprimant les closures et fonctions lambda. Il produit donc 3 fois le même code.
TEXT main.test_closure(SB)
comp.go:119: MOVQ AX, 0x8(SP)
comp.go:119: NOPL
comp.go:123: CMPQ BX, $0x3
comp.go:123: JLE 0x108b395
comp.go:124: MOVQ main.globalErr(SB), AX
comp.go:131: JMP 0x108b397
comp.go:131: XORL AX, AX
comp.go:133: TESTQ AX, AX
comp.go:133: JE 0x108b3a3
comp.go:134: INCQ main.globalErrorCount(SB)
comp.go:136: RET
TEXT main.test_lambda(SB)
comp.go:138: MOVQ AX, 0x8(SP)
comp.go:138: NOPL
comp.go:142: CMPQ BX, $0x3
comp.go:142: JLE 0x108b3d5
comp.go:149: MOVQ main.globalErr(SB), AX
comp.go:149: JMP 0x108b3d7
comp.go:149: XORL AX, AX
comp.go:151: TESTQ AX, AX
comp.go:151: JE 0x108b3e3
comp.go:152: INCQ main.globalErrorCount(SB)
comp.go:154: RET
TEXT main.test_goto(SB)
comp.go:156: MOVQ AX, 0x8(SP)
comp.go:159: CMPQ BX, $0x3
comp.go:159: JLE 0x108b414
comp.go:160: MOVQ main.globalErr(SB), AX
comp.go:161: JMP 0x108b416
comp.go:161: XORL AX, AX
comp.go:170: TESTQ AX, AX
comp.go:170: JE 0x108b422
comp.go:171: INCQ main.globalErrorCount(SB)
comp.go:173: RET
Bien que le compilateur soit très efficace pour détecter des situations à optimiser, n’est-il pas préférable d’écrire directement du code économe, plutôt que d’écrire du code expansif pour lequel on espèrera un optimisation par le compilateur ?
Pour le reste, ce test mesure principalement le temps d’appel d’une fonction. Dans le cas ou le code contenu dans la closure est conséquent, ce temps d’appel deviendra négligeable devant le temps d’exécution. Dans ce cas, je dirais qu’il faut prioriser la lisibilité du code.
Lorsque je lit le code avec une fonction lambda ou une closure, je ne peux pas lire le code linéairement, je suis obligé de sauter à la fin de la fonction (➋ et ➌) afin de savoir si je défini une fonction qui sera appelée plus tard ou immédiatement et dans ce second cas, quel sont les arguments qui lui sont passés.
En revanche, la lecture d’une fonction à base de goto est parfaitement linéaire et donc plus compréhensible.
Les fichiers source: