Initialisation du programmateur de goroutine de langue Go

Le contenu suivant est reproduit à partir de  https://mp.weixin.qq.com/s/W9D4Sl-6jYfcpczzdPfByQ

Awa adore écrire des programmes originaux Zhang  source Voyages  2019-05-05

Cet article est le douzième chapitre de la série «Analyse des scénarios de code source du planificateur de langage Go» et constitue également la deuxième sous-section du chapitre 2.


 

Ce chapitre prendra le programme simple Hello World suivant comme exemple pour analyser l'initialisation du planificateur de langage Go, la création et la sortie de goroutines, la boucle de planification des threads de travail et le changement de goroutines en traçant le processus en cours d'exécution complet depuis le démarrage. pour quitter. Et tout autre contenu important.

package main

import "fmt"

func main() {
    fmt.Println("Hello World!")
}

Tout d'abord, nous analysons l'initialisation de l'ordonnanceur depuis le début du programme.

Avant d'analyser le processus de démarrage du programme, examinons d'abord l'état initial de la pile du programme avant d'exécuter la première instruction.

Tout programme écrit dans un langage compilé (que ce soit C, C ++, go ou langage d'assemblage) passera par les étapes suivantes dans l'ordre lorsqu'il est chargé et exécuté par le système d'exploitation:

  1. Lisez le programme exécutable du disque dans la mémoire;

  2. Créer un processus et un fil principal;

  3. Allouez de l'espace de pile pour le thread principal;

  4. Copiez les paramètres saisis par l'utilisateur sur la ligne de commande dans la pile du thread principal;

  5. Placez le thread principal dans la file d'attente d'exécution du système d'exploitation et attendez qu'il soit programmé pour s'exécuter.

Avant que le thread principal ne soit programmé pour exécuter la première instruction pour la première fois, la pile de fonctions du thread principal est illustrée dans la figure suivante:

image

Après avoir compris l'état initial du programme, commençons officiellement.

Entrée de programme

Utilisez go build pour compiler hello.go sur la ligne de commande Linux pour obtenir le programme exécutable hello, puis utilisez gdb pour déboguer. Dans gdb, nous utilisons d'abord la commande info files pour trouver l'adresse du point d'entrée du programme est 0x452270, et puis utilisez b * 0x452270 à 0x452270 À côté du point d'arrêt à l'adresse, gdb nous indique que le code source correspondant à cette entrée est la ligne 8 du fichier runtime / rt0_linux_amd64.s.

bobo@ubuntu:~/study/go$ go build hello.go 
bobo@ubuntu:~/study/go$ gdb hello
GNU gdb (GDB) 8.0.1
(gdb) info files
Symbols from "/home/bobo/study/go/main".
Local exec file:
`/home/bobo/study/go/main', file type elf64-x86-64.
Entry point: 0x452270
0x0000000000401000 - 0x0000000000486aac is .text
0x0000000000487000 - 0x00000000004d1a73 is .rodata
0x00000000004d1c20 - 0x00000000004d27f0 is .typelink
0x00000000004d27f0 - 0x00000000004d2838 is .itablink
0x00000000004d2838 - 0x00000000004d2838 is .gosymtab
0x00000000004d2840 - 0x00000000005426d9 is .gopclntab
0x0000000000543000 - 0x000000000054fa9c is .noptrdata
0x000000000054faa0 - 0x0000000000556790 is .data
0x00000000005567a0 - 0x0000000000571ef0 is .bss
0x0000000000571f00 - 0x0000000000574658 is .noptrbss
0x0000000000400f9c - 0x0000000000401000 is .note.go.buildid
(gdb) b *0x452270
Breakpoint 1 at 0x452270: file /usr/local/go/src/runtime/rt0_linux_amd64.s, line 8.

Ouvrez l'éditeur de code et recherchez le fichier runtime / rt0_linx_amd64.s, qui est un fichier de code source écrit en langage d'assemblage go. Nous avons discuté de son format dans la première partie de ce livre. Regardez maintenant la ligne 8:

exécution / rt0_linx_amd64.s: 8

TEXT _rt0_amd64_linux(SB),NOSPLIT,$-8
    JMP_rt0_amd64(SB)

La première ligne de code ci-dessus définit le symbole _rt0_amd64_linux, qui n'est pas une véritable instruction CPU. L'instruction JMP de la deuxième ligne est la première instruction du thread principal. Cette instruction passe simplement à (équivalent à go language ou c Goto in) _rt0_amd64 continue de s'exécuter au niveau du symbole. La définition de _rt0_amd64 se trouve dans le fichier runtime / asm_amd64.s:

runtime / asm_amd64.s: 14

TEXT _rt0_amd64(SB),NOSPLIT,$-8
    MOVQ0(SP), DI// argc 
    LEAQ8(SP), SI // argv
    JMPruntime·rt0_go(SB)

Les deux premières lignes d'instructions placent respectivement les adresses des paramètres argc et argv array passés par le noyau du système d'exploitation dans les registres DI et SI, et la troisième ligne d'instructions passe à rt0_go pour exécution.

La fonction rt0_go termine tout le travail d'initialisation au démarrage du programme go, donc cette fonction est relativement longue et compliquée, mais ici nous nous concentrons uniquement sur certaines initialisations liées au planificateur, regardons-le dans les sections:

runtime / asm_amd64.s: 87

TEXT runtime·rt0_go(SB),NOSPLIT,$0
    // copy arguments forward on an even stack
    MOVQDI, AX// AX = argc
    MOVQSI, BX// BX = argv
    SUBQ$(4*8+7), SP// 2args 2auto
    ANDQ$~15, SP     //调整栈顶寄存器使其按16字节对齐
    MOVQAX, 16(SP) //argc放在SP + 16字节处
    MOVQBX, 24(SP) //argv放在SP + 24字节处

La quatrième instruction ci-dessus est utilisée pour ajuster la valeur du registre supérieur de la pile à aligner sur 16 octets, c'est-à-dire pour rendre l'adresse de la mémoire pointée par le registre supérieur SP de la pile un multiple de 16, et la raison pour laquelle il est aligné sur 16 octets est que l'UC dispose d'un ensemble d'instructions SSE Les adresses mémoire apparaissant dans ces instructions doivent être des multiples de 16. Les deux dernières instructions déplacent argc et argv vers de nouveaux emplacements. Les autres parties de ce code ont été commentées plus en détail, donc je n'en expliquerai pas trop ici.

Initialiser g0

En continuant à regarder le code suivant, la variable globale g0 sera initialisée ci-dessous. Comme nous l'avons dit précédemment, la fonction principale de g0 est de fournir une pile pour l'exécution du code d'exécution, donc ici nous initialisons principalement plusieurs membres liés à la pile de g0. De là, on peut voir que la pile de g0 est d'environ 64 Ko et que la plage d'adresses est SP-64 * 1024 + 104 ~ SP.

runtime / asm_amd64.s: 96

// create istack out of the given (operating system) stack.
// _cgo_init may update stackguard.
//下面这段代码从系统线程的栈空分出一部分当作g0的栈,然后初始化g0的栈信息和stackgard
MOVQ$runtime·g0(SB), DI       //g0的地址放入DI寄存器
LEAQ(-64*1024+104)(SP), BX //BX = SP - 64*1024 + 104
MOVQBX, g_stackguard0(DI) //g0.stackguard0 = SP - 64*1024 + 104
MOVQBX, g_stackguard1(DI) //g0.stackguard1 = SP - 64*1024 + 104
MOVQBX, (g_stack+stack_lo)(DI) //g0.stack.lo = SP - 64*1024 + 104
MOVQSP, (g_stack+stack_hi)(DI) //g0.stack.hi = SP

La relation entre g0 et la pile après l'exécution des lignes d'instructions ci-dessus est illustrée dans la figure suivante:

image

 

Le thread principal est lié à m0

Après avoir configuré la pile g0, nous ignorons la vérification du modèle de processeur et le code lié à l'initialisation de cgo, et continuons l'analyse directement à partir de la ligne 164.

exécution / asm_amd64.s: 164

  //下面开始初始化tls(thread local storage,线程本地存储)
LEAQruntime·m0+m_tls(SB), DI //DI = &m0.tls,取m0的tls成员的地址到DI寄存器
CALLruntime·settls(SB) //调用settls设置线程本地存储,settls函数的参数在DI寄存器中

// store through it, to make sure it works
//验证settls是否可以正常工作,如果有问题则abort退出程序
get_tls(BX) //获取fs段基地址并放入BX寄存器,其实就是m0.tls[1]的地址,get_tls的代码由编译器生成
MOVQ$0x123, g(BX) //把整型常量0x123拷贝到fs段基地址偏移-8的内存位置,也就是m0.tls[0]= 0x123
MOVQruntime·m0+m_tls(SB), AX //AX = m0.tls[0]
CMPQAX, $0x123 //检查m0.tls[0]的值是否是通过线程本地存储存入的0x123来验证tls功能是否正常
JEQ 2(PC)
CALLruntime·abort(SB) //如果线程本地存储不能正常工作,退出程序

Ce code appelle d'abord la fonction settls pour initialiser le stockage local du thread (TLS) du thread principal. Le but est d'associer m0 au thread principal. Quant à savoir pourquoi m et le thread de travail sont liés ensemble, nous l'avons déjà introduit dans la section précédente. Maintenant, je ne vais pas la répéter ici. Après avoir défini le stockage local des threads, les instructions suivantes consistent à vérifier si la fonction TLS est normale et, si ce n'est pas normal, à abandonner directement le programme.

Examinons en détail comment la fonction settls implémente les variables globales privées des threads.

exécution / sys_linx_amd64.s: 606

// set tls base to DI
TEXT runtime·settls(SB),NOSPLIT,$32
//......
//DI寄存器中存放的是m.tls[0]的地址,m的tls成员是一个数组,读者如果忘记了可以回头看一下m结构体的定义
//下面这一句代码把DI寄存器中的地址加8,为什么要+8呢,主要跟ELF可执行文件格式中的TLS实现的机制有关
//执行下面这句指令之后DI寄存器中的存放的就是m.tls[1]的地址了
ADDQ$8, DI// ELF wants to use -8(FS)

  //下面通过arch_prctl系统调用设置FS段基址
MOVQDI, SI //SI存放arch_prctl系统调用的第二个参数
MOVQ$0x1002, DI// ARCH_SET_FS //arch_prctl的第一个参数
MOVQ$SYS_arch_prctl, AX //系统调用编号
SYSCALL
CMPQAX, $0xfffffffffffff001
JLS2(PC)
MOVL$0xf1, 0xf1 // crash //系统调用失败直接crash
RET

Comme vous pouvez le voir dans le code, l'adresse de m0.tls [1] est définie sur l'adresse de base du segment fs via l'appel système arch_prctl. Il existe un registre de segment appelé fs dans la CPU qui lui correspond, et chaque thread a son propre ensemble de valeurs de registre de CPU. Le système d'exploitation nous aidera à enregistrer les valeurs dans tous les registres de la mémoire lorsque le thread est réglé loin de le processeur. Lorsque le thread de planification est opérationnel, les valeurs de ces registres sont restaurées de la mémoire vers le processeur, de sorte qu'après cela, le code du thread de travail peut trouver m.tls via le registre fs. Les lecteurs peuvent reportez-vous à la fonction tls après l'initialisation de tls ci-dessus. Vérifiez le code pour comprendre ce processus.

Continuons à analyser rt0_go,

runtime / asm_amd64.s: 174

ok:
// set the per-goroutine and per-mach "registers"
get_tls(BX) //获取fs段基址到BX寄存器
LEAQruntime·g0(SB), CX //CX = g0的地址
MOVQCX, g(BX) //把g0的地址保存在线程本地存储里面,也就是m0.tls[0]=&g0
LEAQruntime·m0(SB), AX //AX = m0的地址

//把m0和g0关联起来m0->g0 = g0,g0->m = m0
// save m->g0 = g0
MOVQCX, m_g0(AX) //m0.g0 = g0
// save m0 to g0->m 
MOVQAX, g_m(CX) //g0.m = m0

Le code ci-dessus place d'abord l'adresse de g0 dans le stockage local du thread principal, puis passe

m0.g0 = &g0
g0.m = &m0

Liez m0 et g0 ensemble, de sorte que g0 puisse être obtenu via get_tls dans le thread principal, et m0 peut être trouvé via le membre m de g0, donc l'association entre m0 et g0 et le thread principal est réalisée ici. On peut également voir d'ici que la valeur stockée dans le stockage local du thread principal est l'adresse de g0, ce qui signifie que la variable globale privée du thread de travail est en fait un pointeur vers g au lieu d'un pointeur vers m. présent, ce pointeur pointe vers g0. Indique que le code est en cours d'exécution sur la pile g0. À ce stade, la relation entre la pile du thread principal, m0, g0 et g0 est illustrée dans la figure suivante:

image

 

 

Initialiser m0

Le code suivant commence à traiter les paramètres de ligne de commande. Nous ne nous soucions pas de cette partie, alors ignorez-la. Une fois les paramètres de ligne de commande traités, la fonction osinit est appelée pour obtenir le nombre de cœurs de processeur et stockée dans la variable globale ncpu. Lorsque l'ordonnanceur est initialisé, il doit connaître le nombre de cœurs de processeur du système actuel.

exécution / asm_amd64.s: 189

//准备调用args函数,前面四条指令把参数放在栈上
MOVL16(SP), AX// AX = argc
MOVLAX, 0(SP)       // argc放在栈顶
MOVQ24(SP), AX// AX = argv
MOVQAX, 8(SP)       // argv放在SP + 8的位置
CALLruntime·args(SB)  //处理操作系统传递过来的参数和env,不需要关心

//对于linx来说,osinit唯一功能就是获取CPU的核数并放在global变量ncpu中,
//调度器初始化时需要知道当前系统有多少CPU核
CALLruntime·osinit(SB)  //执行的结果是全局变量 ncpu = CPU核数
CALLruntime·schedinit(SB) //调度系统初始化

Ensuite, continuez à voir comment le planificateur est initialisé.

runtime / proc.go: 526

func schedinit() {
// raceinit must be the first call to race detector.
// In particular, it must be done before mallocinit below calls racemapshadow.
   
    //getg函数在源代码中没有对应的定义,由编译器插入类似下面两行代码
    //get_tls(CX) 
    //MOVQ g(CX), BX; BX存器里面现在放的是当前g结构体对象的地址
    _g_ := getg() // _g_ = &g0

    ......

    //设置最多启动10000个操作系统线程,也是最多10000个M
    sched.maxmcount = 10000

    ......
   
    mcommoninit(_g_.m) //初始化m0,因为从前面的代码我们知道g0->m = &m0

    ......

    sched.lastpoll = uint64(nanotime())
    procs := ncpu  //系统中有多少核,就创建和初始化多少个p结构体对象
    if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
        procs = n //如果环境变量指定了GOMAXPROCS,则创建指定数量的p
    }
    if procresize(procs) != nil {//创建和初始化全局变量allp
        throw("unknown runnable goroutine during bootstrap")
    }

    ......
}

Comme nous l'avons vu précédemment, l'adresse de g0 a été définie sur le stockage local du thread, et schedinit utilise la fonction getg (la fonction getg est implémentée par le compilateur, et nous ne pouvons pas trouver sa définition dans le code source) à partir du stockage local du thread. Obtenez le g en cours d'exécution, voici g0, puis appelez la fonction mcommoninit pour initialiser m0 (g0.m) si nécessaire. Une fois l'initialisation de m0 terminée, appelez procresize pour initialiser l'objet de structure p que le système besoins, selon go Selon la langue officielle, p est la signification du processeur, et son nombre détermine qu'il peut y avoir au plus moins de goroutines fonctionnant en parallèle en même temps. Outre l'initialisation de m0 et p, la fonction schedinit définit également le membre maxmcount de la variable globale sched sur 10000, ce qui limite le nombre de threads du système d'exploitation pouvant être créés à 10000 pour fonctionner.

Ici, nous devons nous concentrer sur la façon dont mcommoninit initialise m0 et comment la fonction procresize crée et initialise les objets de structure p. Tout d'abord, nous plongeons dans la fonction mcommoninit pour le découvrir. Il convient de noter ici que cette fonction n'est pas seulement exécutée lors de l'initialisation, mais également si un thread de travail est créé pendant le fonctionnement du programme, nous verrons donc le verrou et vérifierons si le nombre de threads a dépassé le maximum dans la fonction Et autres code.

runtime / proc.go: 596

func mcommoninit(mp *m) {
    _g_ := getg() //初始化过程中_g_ = g0

    // g0 stack won't make sense for user (and is not necessary unwindable).
    if _g_ != _g_.m.g0 {  //函数调用栈traceback,不需要关心
        callers(1, mp.createstack[:])
    }

    lock(&sched.lock)
    if sched.mnext+1 < sched.mnext {
        throw("runtime: thread ID overflow")
    }
    mp.id = sched.mnext
    sched.mnext++
    checkmcount() //检查已创建系统线程是否超过了数量限制(10000)

    //random初始化
    mp.fastrand[0] = 1597334677 * uint32(mp.id)
    mp.fastrand[1] = uint32(cputicks())
    if mp.fastrand[0]|mp.fastrand[1] == 0 {
        mp.fastrand[1] = 1
    }

    //创建用于信号处理的gsignal,只是简单的从堆上分配一个g结构体对象,然后把栈设置好就返回了
    mpreinit(mp)
    if mp.gsignal != nil {
        mp.gsignal.stackguard1 = mp.gsignal.stack.lo + _StackGuard
    }

    //把m挂入全局链表allm之中
    // Add to allm so garbage collector doesn't free g->m
    // when it is just in a register or thread-local storage.
    mp.alllink = allm 

    // NumCgoCall() iterates over allm w/o schedlock,
    // so we need to publish it safely.
    atomicstorep(unsafe.Pointer(&allm), unsafe.Pointer(mp))
    unlock(&sched.lock)

    // Allocate memory to hold a cgo traceback if the cgo call crashes.
    if iscgo || GOOS == "solaris" || GOOS == "windows" {
        mp.cgoCallers = new(cgoCallers)
    }
}

On peut voir à partir du code source de cette fonction qu'il n'y a pas d'initialisation liée à la planification pour m0, donc vous pouvez simplement penser que cette fonction met juste m0 dans la liste chaînée globale allm et retourne.

Une fois que m0 a terminé l'initialisation de base, continuez d'appeler procresize pour créer et initialiser l'objet de structure p. Dans cette fonction, un nombre spécifié d'objets de structure p (déterminé par le nombre de cœurs de processeur ou de variables d'environnement) sera créé et placé dans le full variable allp, et Bind m0 et allp [0] ensemble, donc quand cette fonction est exécutée, il y aura

m0.p = allp[0]
allp[0].m = &m0

À ce stade, m0, g0 et p requis par m sont complètement liés.

Initialiser allp

Examinons la fonction procresize. Une fois l'initialisation terminée, le code utilisateur peut également l'appeler via la fonction GOMAXPROCS () pour recréer et initialiser l'objet de structure p. L'ajustement dynamique de p pendant l'opération pose de nombreux problèmes. Le traitement de cette fonction est plus compliqué, mais si vous ne considérez que l'initialisation, c'est relativement plus simple, donc ici seul le code qui sera exécuté lors de l'initialisation est conservé:

runtime / proc.go: 3902

func procresize(nprocs int32) *p {
    old := gomaxprocs //系统初始化时 gomaxprocs = 0

    ......

    // Grow allp if necessary.
    if nprocs > int32(len(allp)) { //初始化时 len(allp) == 0
        // Synchronize with retake, which could be running
        // concurrently since it doesn't run on a P.
        lock(&allpLock)
        if nprocs <= int32(cap(allp)) {
            allp = allp[:nprocs]
        } else { //初始化时进入此分支,创建allp 切片
            nallp := make([]*p, nprocs)
            // Copy everything up to allp's cap so we
            // never lose old allocated Ps.
            copy(nallp, allp[:cap(allp)])
            allp = nallp
        }
        unlock(&allpLock)
    }

    // initialize new P's
    //循环创建nprocs个p并完成基本初始化
    for i := int32(0); i < nprocs; i++ {
        pp := allp[i]
        if pp == nil {
            pp = new(p)//调用内存分配器从堆上分配一个struct p
            pp.id = i
            pp.status = _Pgcstop
            ......
            atomicstorep(unsafe.Pointer(&allp[i]), unsafe.Pointer(pp))
        }

        ......
    }

    ......

    _g_ := getg()  // _g_ = g0
    if _g_.m.p != 0 && _g_.m.p.ptr().id < nprocs {//初始化时m0->p还未初始化,所以不会执行这个分支
        // continue to use the current P
        _g_.m.p.ptr().status = _Prunning
        _g_.m.p.ptr().mcache.prepareForSweep()
    } else {//初始化时执行这个分支
        // release the current P and acquire allp[0]
        if _g_.m.p != 0 {//初始化时这里不执行
            _g_.m.p.ptr().m = 0
        }
        _g_.m.p = 0
        _g_.m.mcache = nil
        p := allp[0]
        p.m = 0
        p.status = _Pidle
        acquirep(p) //把p和m0关联起来,其实是这两个strct的成员相互赋值
        if trace.enabled {
            traceGoStart()
        }
    }
   
    //下面这个for 循环把所有空闲的p放入空闲链表
    var runnablePs *p
    for i := nprocs - 1; i >= 0; i-- {
        p := allp[i]
        if _g_.m.p.ptr() == p {//allp[0]跟m0关联了,所以是不能放任
            continue
        }
        p.status = _Pidle
        if runqempty(p) {//初始化时除了allp[0]其它p全部执行这个分支,放入空闲链表
            pidleput(p)
        } else {
            ......
        }
    }

    ......
   
    return runnablePs
}

Ce code de fonction est relativement long, mais pas compliqué, voici un résumé du flux principal de cette fonction:

  1. Utilisez make ([] * p, nprocs) pour initialiser la variable globale allp, c'est-à-dire allp = make ([] * p, nprocs)

  2. Créez et initialisez cycliquement les objets de structure nprocs p et enregistrez-les dans toutes les tranches à tour de rôle

  3. Liez m0 et allp [0] ensemble, c'est-à-dire m0.p = allp [0], allp [0] .m = m0

  4. Mettez tout p sauf allp [0] dans la file d'attente libre de pidle de la variable globale sched

Une fois la fonction procresize exécutée, le travail d'initialisation lié au planificateur est pratiquement terminé. À ce stade, la relation entre les différents composants de l'ensemble du planificateur est illustrée dans la figure suivante:

image

 

Après avoir analysé l'initialisation de base de l'ordonnanceur, dans la section suivante, nous verrons comment la première goroutine du programme est créée.


Enfin, si vous pensez que cet article vous est utile, aidez-moi à cliquer sur «Regarder» en bas à droite de l'article ou à le transmettre au cercle d'amis, merci beaucoup!

image

Je suppose que tu aimes

Origine blog.csdn.net/pyf09/article/details/115238748
conseillé
Classement