select
Select ist eine Struktur, die gleichzeitig den Status mehrerer Channels überwachen kann. Die Syntax ähnelt switch:
import (
"context"
"log/slog"
"os"
"os/signal"
"time"
)
func main() {
finished := make(chan struct{})
ctx, stop := signal.NotifyContext(context.Background(), os.Kill, os.Interrupt)
defer stop()
slog.Info("running")
go func() {
time.Sleep(time.Second * 2)
finished <- struct{}{}
}()
select {
case <-ctx.Done():
slog.Info("shutting down")
case <-finished:
slog.Info("finished")
}
}Dieser Code implementiert eine einfache Logik für das sanfte Beenden des Programms durch die Kombination von Context, Channel und Select. Der Select-Block überwacht gleichzeitig die beiden Channels ctx.Done und finished. Es gibt zwei Bedingungen zum Beenden: Entweder sendet das Betriebssystem ein Beendigungssignal, oder der finished-Channel hat eine Nachricht zum Lesen, was bedeutet, dass die Benutzercode-Aufgabe abgeschlossen ist. Auf diese Weise können wir beim Beenden des Programms Aufräumarbeiten durchführen.
Select hat zwei sehr wichtige Eigenschaften: Erstens ist es nicht-blockierend. In den Quellcode für das Senden und Empfangen von Channels ist zu sehen, dass für Select spezielle Behandlungen implementiert wurden, die es ermöglichen, ohne Blockierung zu prüfen, ob ein Channel verfügbar ist. Zweitens erfolgt die Auswahl zufällig. Wenn mehrere Channels verfügbar sind, wird zufällig einer zur Ausführung ausgewählt. Das Nicht-Befolgen einer festgelegten Reihenfolge ermöglicht jedem Channel eine relativ faire Ausführung, da in extremen Fällen einige Channels sonst niemals verarbeitet werden könnten. Da die gesamte Arbeit mit Channels zu tun hat, wird empfohlen, zuerst den Artikel über chan zu lesen. Nachdem man Channels verstanden hat, wird das Verständnis von Select viel einfacher.
Struktur
Zur Laufzeit gibt es nur eine Struktur runtime.scase, die einen Select-Zweig repräsentiert. Jeder case wird zur Laufzeit durch ein scase repräsentiert.
type scase struct {
c *hchan // chan
elem unsafe.Pointer // data element
}Dabei ist c der Channel und elem ist der Zeiger auf das zu empfangende oder zu sendende Element. Tatsächlich wird das Select-Schlüsselwort durch die Funktion runtime.selectgo repräsentiert.
Prinzip
Die Verwendung von Select wird von Go in vier Fälle unterteilt, die optimiert werden. Dies ist in der Funktion cmd/compile/internal/walk.walkSelectCases zu sehen, die die Behandlungslogik für diese vier Fälle enthält.
func walkSelectCases(cases []*ir.CommClause) []ir.Node {
ncas := len(cases)
sellineno := base.Pos
// optimization: zero-case select
if ncas == 0 {
return []ir.Node{mkcallstmt("block")}
}
// optimization: one-case select: single op.
if ncas == 1 {
...
}
// optimization: two-case select but one is default: single non-blocking op.
if ncas == 2 && dflt != nil {
...
}
...
return init
}Optimierung
Der Compiler optimiert die ersten drei Fälle. Der erste Fall ist, wenn die Anzahl der Cases 0 ist, also ein leeres Select. Es ist bekannt, dass ein leeres Select-Statement die aktuelle Goroutine dauerhaft blockiert.
select{}Die Blockierung tritt auf, weil der Compiler sie in einen direkten Aufruf der Funktion runtime.block übersetzt:
func block() {
gopark(nil, nil, waitReasonSelectNoCases, traceBlockForever, 1) // forever
}Die block-Funktion ruft wiederum die Funktion runtime.gopark auf, wodurch die aktuelle Goroutine in den Zustand _Gwaitting versetzt wird und dauerhaft blockiert, ohne jemals wieder eingeplant zu werden.
Der zweite Fall: Es gibt nur einen case und dieser ist nicht default. In diesem Fall übersetzt der Compiler ihn direkt in eine Sende- oder Empfangsoperation für den Channel, und diese ist blockierend. Beispiel:
func main() {
ch := make(chan int)
select {
case <-ch:
// do something
}
}func main() {
ch := make(chan int)
select {
case <-ch:
// do something
}
}Es wird in einen direkten Aufruf der Funktion runtime.chanrecv1 übersetzt, was aus dem Assembler-Code ersichtlich ist:
TEXT main.main(SB), ABIInternal, $2
...
LEAQ type:chan int(SB), AX
XORL BX, BX
PCDATA $1, $0
CALL runtime.makechan(SB)
XORL BX, BX
NOP
CALL runtime.chanrecv1(SB)
ADDQ $16, SP
POPQ BP
...Auch beim Senden von Daten an einen Channel mit nur einem case gilt dasselbe Prinzip: Es wird in einen direkten Aufruf der Funktion runtime.chansend1 übersetzt, der ebenfalls blockierend ist.
Der dritte Fall: Es gibt zwei cases und einer davon ist default:
func main() {
ch := make(chan int)
select {
case ch <- 1:
// do something
default:
// do something
}
}In diesem Fall wird es in eine if-Anweisung übersetzt, die runtime.selectnbsend aufruft:
if selectnbsend(ch, 1) {
// do something
} else {
// do something
}Wenn es sich um den Empfang von Channel-Daten handelt, wird es in einen Aufruf von runtime.selectnbrecv übersetzt:
ch := make(chan int)
select {
case x, ok := <-ch:
// do something
default:
// do something
}if selected, ok = selectnbrecv(&v, c); selected {
// do something
} else {
// do something
}Es ist erwähnenswert, dass in diesem Fall das Senden oder Empfangen am Channel nicht-blockierend ist. Dies ist deutlich daran zu erkennen, dass der block-Parameter false ist.
func selectnbsend(c *hchan, elem unsafe.Pointer) (selected bool) {
return chansend(c, elem, false, getcallerpc())
}
func selectnbrecv(elem unsafe.Pointer, c *hchan) (selected, received bool) {
return chanrecv(c, elem, false)
}Egal ob beim Senden oder Empfangen von Channel-Daten, wenn block auf false gesetzt ist, gibt es einen schnellen Pfad, um ohne Sperren zu prüfen, ob gesendet oder empfangen werden kann, wie unten gezeigt:
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
if !block && empty(c) {
if atomic.Load(&c.closed) == 0 {
return
}
return true, false
}
...
}
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
if !block && c.closed == 0 && full(c) {
return false
}
...
}Beim Lesen aus einem Channel wird direkt zurückgekehrt, wenn der Channel leer ist. Beim Schreiben in einen Channel wird direkt zurückgekehrt, wenn der Channel nicht geschlossen ist und bereits voll ist. Normalerweise würden diese Operationen die Goroutine blockieren, aber in Kombination mit Select geschieht dies nicht.
Verarbeitung
Die oben genannten drei Fälle sind nur Optimierungen für Sonderfälle. Ein normal verwendetes Select-Schlüsselwort wird in einen Aufruf der Funktion runtime.selectgo übersetzt, deren Verarbeitungslogik über 400 Zeilen umfasst.
func selectgo(cas0 *scase, order0 *uint16, pc0 *uintptr, nsends, nrecvs int, block bool) (int, bool)Der Compiler sammelt alle case-Anweisungen in einem scase-Array und übergibt dieses an die selectgo-Funktion. Nach Abschluss der Verarbeitung werden zwei Rückgabewerte zurückgegeben:
- Der erste ist der zufällig ausgewählte Channel-Index, der angibt, welcher Channel verarbeitet wurde. Wenn keiner, wird -1 zurückgegeben.
- Der zweite gibt an, ob der Lesevorgang am Channel erfolgreich war.
Hier eine kurze Erklärung der Parameter:
cas0: Der Kopfzeiger desscase-Arrays. Die erste Hälfte speichert die Schreib-Channel-Cases, die zweite Hälfte die Lese-Channel-Cases, getrennt durchnsends.order0: Seine Länge ist doppelt so groß wie dasscase-Array. Die erste Hälfte wird dempollorder-Array zugewiesen, die zweite Hälfte demlockorder-Array.nsendsundnrecvsgeben die Anzahl der Lese-/Schreib-Channel-Cases an. Ihre Summe ist die Gesamtzahl der Cases.blockgibt an, ob blockiert werden soll. Wenn eindefault-Case vorhanden ist, bedeutet dies nicht-blockierend, der Wert istfalse, andernfallstrue.pc0: Zeigt auf den Kopf eines[ncases]uintptr-Arrays, verwendet für Race-Analysen. Kann ignoriert werden, da es für das Verständnis von Select nicht hilfreich ist.
Angenommen, wir haben folgenden Code:
func main() {
ch := make(chan int)
select {
case ch <- 1:
println(1)
case ch <- 2:
println(2)
case ch <- 3:
println(3)
case ch <- 4:
println(4)
default:
println("default")
}
}Betrachten wir die Assembler-Form, hier wurde ein Teil des Codes weggelassen, um das Verständnis zu erleichtern:
0x0000 00000 TEXT main.main(SB), ABIInterna
...
0x0023 00035 CALL runtime.makechan(SB)
0x0028 00040 MOVQ $1, main..autotmp_2+72(SP) // 1 2 3 4 mehrere temporäre Variablen
0x0031 00049 MOVQ $2, main..autotmp_4+64(SP)
0x003a 00058 MOVQ $3, main..autotmp_6+56(SP)
0x0043 00067 MOVQ $4, main..autotmp_8+48(SP)
...
0x00c8 00200 CALL runtime.selectgo(SB) // Aufruf der runtime.selectgo-Funktion
0x00cd 00205 TESTQ AX, AX
0x00d0 00208 JLT 352 // Sprung zum default-Zweig
0x00d6 00214 PCDATA $1, $-1
0x00d6 00214 JEQ 320 // Sprung zum Zweig 4
0x00d8 00216 CMPQ AX, $1
0x00dc 00220 JEQ 288 // Sprung zum Zweig 3
0x00de 00222 NOP
0x00e0 00224 CMPQ AX, $2
0x00e4 00228 JNE 258 // Sprung zum Zweig 2
0x00e6 00230 PCDATA $1, $0
0x00e6 00230 CALL runtime.printlock(SB)
0x00eb 00235 MOVL $3, AX
0x00f0 00240 CALL runtime.printint(SB)
0x00f5 00245 CALL runtime.printnl(SB)
0x00fa 00250 CALL runtime.printunlock(SB)
0x00ff 00255 NOP
0x0100 00256 JMP 379
0x0102 00258 CALL runtime.printlock(SB)
0x0107 00263 MOVL $4, AX
0x010c 00268 CALL runtime.printint(SB)
0x0111 00273 CALL runtime.printnl(SB)
0x0116 00278 CALL runtime.printunlock(SB)
0x011b 00283 JMP 379
0x011d 00285 NOP
0x0120 00288 CALL runtime.printlock(SB)
0x0125 00293 MOVL $2, AX
0x012a 00298 CALL runtime.printint(SB)
0x012f 00303 CALL runtime.printnl(SB)
0x0134 00308 CALL runtime.printunlock(SB)
0x0139 00313 JMP 379
0x013b 00315 NOP
0x0140 00320 CALL runtime.printlock(SB)
0x0145 00325 MOVL $1, AX
0x014a 00330 CALL runtime.printint(SB)
0x014f 00335 CALL runtime.printnl(SB)
0x0154 00340 CALL runtime.printunlock(SB)
0x0159 00345 JMP 379
0x015b 00347 NOP
0x0160 00352 CALL runtime.printlock(SB)
0x0165 00357 LEAQ go:string."default\n"(SB)
0x016c 00364 MOVL $8, BX
0x0171 00369 CALL runtime.printstring(SB)
0x0176 00374 CALL runtime.printunlock(SB)
0x017b 00379 PCDATA $1, $-1
0x017b 00379 ADDQ $160, SP
0x0182 00386 POPQ BP
0x0183 00387 RETMan kann sehen, dass nach dem Aufruf der selectgo-Funktion eine Logik für Bedingung und Sprung existiert. Anhand dieser können wir leicht die ursprüngliche Form rekonstruieren:
casei, ok := runtime.selectgo()
if casei == -1 {
println("default")
} else if casei == 3 {
println(4)
} else if casei == 2 {
println(3)
} else if casei == 1 {
println(2)
} else {
println(1)
}Der vom Compiler generierte tatsächliche Code kann hiervon abweichen, aber die Grundbedeutung ist ähnlich. Der Compiler verwendet also nach dem Aufruf der selectgo-Funktion gleichzeitig eine if-Anweisung, um zu bestimmen, welcher Channel ausgeführt werden soll. Vor dem Aufruf generiert der Compiler außerdem eine for-Schleife, um das scase-Array zu sammeln, was hier jedoch weggelassen wurde.
Nachdem wir nun verstanden haben, wie die selectgo-Funktion extern verwendet wird, wollen wir uns ansehen, wie sie intern arbeitet. Zuerst initialisiert sie mehrere Arrays. nsends+nrecvs gibt die Gesamtzahl der Cases an. Aus dem folgenden Code ist auch ersichtlich, dass die maximale Anzahl an Cases 1 << 16 beträgt. pollorder bestimmt die Ausführungsreihenfolge der Channels und lockorder bestimmt die Sperrreihenfolge der Channels.
cas1 := (*[1 << 16]scase)(unsafe.Pointer(cas0))
// Seine Länge ist doppelt so groß wie das scase-Array. Die erste Hälfte wird dem pollorder-Array zugewiesen, die zweite Hälfte dem lockorder-Array.
order1 := (*[1 << 17]uint16)(unsafe.Pointer(order0))
ncases := nsends + nrecvs
scases := cas1[:ncases:ncases]
pollorder := order1[:ncases:ncases]
lockorder := order1[ncases:][:ncases:ncases]Als Nächstes wird das pollorder-Array initialisiert. Es speichert die Indizes des scases-Arrays für die auszuführenden Channels:
norder := 0
for i := range scases {
cas := &scases[i]
// Omit cases without channels from the poll and lock orders.
if cas.c == nil {
cas.elem = nil // allow GC
continue
}
j := fastrandn(uint32(norder + 1))
pollorder[norder] = pollorder[j]
pollorder[j] = uint16(i)
norder++
}
pollorder = pollorder[:norder]
lockorder = lockorder[:norder]Es durchläuft das gesamte scases-Array und generiert dann mit runtime.fastrandn eine Zufallszahl zwischen [0, i], die dann mit i getauscht wird. Dabei werden Cases mit nil-Channel übersprungen. Nach Abschluss des Durchlaufs erhalten wir ein pollorder-Array mit gemischten Elementen, wie in der folgenden Abbildung gezeigt:

Danach wird das pollorder-Array mittels Heapsort nach der Adresse der Channels sortiert, um das lockorder-Array zu erhalten. Anschließend wird runtime.sellock aufgerufen, um die Channels in der entsprechenden Reihenfolge zu sperren:
func sellock(scases []scase, lockorder []uint16) {
var c *hchan
for _, o := range lockorder {
c0 := scases[o].c
if c0 != c {
c = c0
lock(&c.lock)
}
}
}Es ist erwähnenswert, dass die Sortierung der Channels nach Adresse erfolgt, um Deadlocks zu vermeiden, da Select-Operationen selbst keine Sperren für Nebenläufigkeit benötigen. Angenommen, wir würden die Sperren in zufälliger Reihenfolge gemäß pollorder setzen, dann betrachten wir folgende Codesituation:
ch1 := make(chan int)
ch2 := make(chan int)
ch3 := make(chan int)
ch4 := make(chan int)
poll := func() {
select {
case ch1 <- 1:
println(1)
case ch2 <- 2:
println(2)
case ch3 <- 3:
println(3)
case ch4 <- 4:
println(4)
default:
println("default")
}
}
// A
go poll()
// B
go poll()
// C
go poll()Die drei Goroutinen A, B und C haben alle den Schritt des Sperrens erreicht, und ihre Sperrenreihenfolgen sind zufällig und voneinander verschieden. Dies kann zu einer Situation wie in der folgenden Abbildung führen:

Angenommen, die Sperrenreihenfolge von A, B und C entspricht der obigen Abbildung, dann ist die Wahrscheinlichkeit eines Deadlocks sehr groß. Zum Beispiel hält A zuerst die Sperre von ch2 und versucht dann, die Sperre von ch1 zu erhalten. Aber angenommen, ch1 wurde bereits von Goroutine B gesperrt, und Goroutine B versucht wiederum, die Sperre von ch2 zu erhalten, dann entsteht ein Deadlock.

Wenn alle Goroutinen in derselben Reihenfolge sperren, tritt kein Deadlock-Problem auf. Das ist der grundlegende Grund, warum lockorder nach der Adresse sortiert wird.
Nachdem die Sperren gesetzt wurden, beginnt die eigentliche Verarbeitungsphase. Zuerst wird das pollorder-Array durchlaufen, um in der zuvor gemischten Reihenfolge auf die Channels zuzugreifen und nacheinander einen verfügbaren Channel zu finden:
for _, casei := range pollorder {
casi = int(casei)
cas = &scases[casi]
c = cas.c
if casi >= nsends { // 读管道
sg = c.sendq.dequeue()
if sg != nil {
goto recv
}
if c.qcount > 0 {
goto bufrecv
}
if c.closed != 0 {
goto rclose
}
} else { // 写管道
if c.closed != 0 {
goto sclose
}
sg = c.recvq.dequeue()
if sg != nil {
goto send
}
if c.qcount < c.dataqsiz {
goto bufsend
}
}
}Man sieht, dass hier 6 Fälle für das Lesen/Schreiben von Channels behandelt werden. Diese werden im Folgenden einzeln erklärt. Der erste Fall: Lesen aus einem Channel, wobei ein Sender darauf wartet zu senden. Hier wird die Funktion runtime.recv aufgerufen, deren Funktion bereits erläutert wurde. Sie weckt schließlich die Sender-Goroutine auf, und vor dem Aufwecken entsperrt die Rückruffunktion alle Channels.
recv:
// can receive from sleeping sender (sg)
recv(c, sg, cas.elem, func() { selunlock(scases, lockorder) }, 2)
recvOK = true
goto retcDer zweite Fall: Lesen aus einem Channel, kein Sender wartet, und die Anzahl der Pufferelemente ist größer als 0. Hier werden die Daten direkt aus dem Puffer gelesen. Die Logik ist identisch mit runtime.chanrecv, anschließend wird entsperrt:
bufrecv:
recvOK = true
qp = chanbuf(c, c.recvx)
if cas.elem != nil {
typedmemmove(c.elemtype, cas.elem, qp)
}
typedmemclr(c.elemtype, qp)
c.recvx++
if c.recvx == c.dataqsiz {
c.recvx = 0
}
c.qcount--
selunlock(scases, lockorder)
goto retcDer dritte Fall: Lesen aus einem Channel, aber der Channel ist bereits geschlossen und es befinden sich keine Elemente mehr im Puffer. Hier wird zuerst entsperrt und dann direkt zurückgekehrt:
rclose:
selunlock(scases, lockorder)
recvOK = false
if cas.elem != nil {
typedmemclr(c.elemtype, cas.elem)
}
goto retcDer vierte Fall: Senden von Daten an einen bereits geschlossenen Channel. Hier wird zuerst entsperrt und dann panic ausgelöst:
sclose:
selunlock(scases, lockorder)
panic(plainError("send on closed channel"))Der fünfte Fall: Es gibt einen Empfänger, der blockierend wartet. Hier wird die Funktion runtime.send aufgerufen, die schließlich die Empfänger-Goroutine aufweckt. Vor dem Aufwecken entsperrt die Rückruffunktion alle Channels:
send:
send(c, sg, cas.elem, func() { selunlock(scases, lockorder) }, 2)
goto retcDer sechste Fall: Es wartet keine Empfänger-Goroutine, die zu sendenden Daten werden in den Puffer gelegt, dann wird entsperrt:
bufsend:
typedmemmove(c.elemtype, chanbuf(c, c.sendx), cas.elem)
c.sendx++
if c.sendx == c.dataqsiz {
c.sendx = 0
}
c.qcount++
selunlock(scases, lockorder)
goto retcAlle oben genannten Fälle führen schließlich zum retc-Zweig, der nur den ausgewählten Channel-Index casi und den recvOK-Wert zurückgibt, der angibt, ob das Lesen erfolgreich war.
retc:
return casi, recvOKDer siebte Fall: Es wurde kein verfügbarer Channel gefunden und der Code enthält einen default-Zweig. Dann werden die Channels entsperrt und direkt zurückgekehrt. Der zurückgegebene casi-Wert ist -1, was anzeigt, dass kein verfügbarer Channel existiert.
if !block {
selunlock(scases, lockorder)
casi = -1
goto retc
}Der letzte Fall: Es wurde kein verfügbarer Channel gefunden und der Code enthält keinen default-Zweig. Dann geht die aktuelle Goroutine in den Blockierungszustand über. Zuvor fügt selectgo die aktuelle Goroutine in die recvq/sendq-Warteschlangen aller überwachten Channels ein:
gp = getg()
nextp = &gp.waiting
for _, casei := range lockorder {
casi = int(casei)
cas = &scases[casi]
c = cas.c
sg := acquireSudog()
sg.g = gp
sg.isSelect = true
sg.elem = cas.elem
sg.releasetime = 0
sg.c = c
*nextp = sg
nextp = &sg.waitlink
if casi < nsends {
c.sendq.enqueue(sg)
} else {
c.recvq.enqueue(sg)
}
}Hier werden mehrere sudog erstellt und mit den entsprechenden Channels verknüpft, wie in der folgenden Abbildung gezeigt:

Anschließend wird durch runtime.gopark blockiert. Vor der Blockierung werden die Channels entsperrt. Die Entsperrung erfolgt durch die Funktion runtime.selparkcommit, die als Rückruffunktion an gopark übergeben wird:
gp.param = nil
// Signal to anyone trying to shrink our stack that we're about
// to park on a channel. The window between when this G's status
// changes and when we set gp.activeStackChans is not safe for
// stack shrinking.
gp.parkingOnChan.Store(true)
gopark(selparkcommit, nil, waitReasonSelect, traceBlockSelect, 1)
gp.activeStackChans = falseDas Erste, was nach dem Aufwecken getan wird, ist die Verknüpfung zwischen sudog und dem Channel zu lösen:
sellock(scases, lockorder)
gp.selectDone.Store(0)
sg = (*sudog)(gp.param)
gp.param = nil
casi = -1
cas = nil
caseSuccess = false
sglist = gp.waiting
// Clear all elem before unlinking from gp.waiting.
for sg1 := gp.waiting; sg1 != nil; sg1 = sg1.waitlink {
sg1.isSelect = false
sg1.elem = nil
sg1.c = nil
}
gp.waiting = nilAnschließend wird sudog aus den Warteschlangen der vorherigen Channels entfernt:
for _, casei := range lockorder {
k = &scases[casei]
if sg == sglist {
// sg has already been dequeued by the G that woke us up.
casi = int(casei)
cas = k
caseSuccess = sglist.success
if sglist.releasetime > 0 {
caseReleaseTime = sglist.releasetime
}
} else {
c = k.c
if int(casei) < nsends {
c.sendq.dequeueSudoG(sglist)
} else {
c.recvq.dequeueSudoG(sglist)
}
}
sgnext = sglist.waitlink
sglist.waitlink = nil
releaseSudog(sglist)
sglist = sgnext
}Im oben genannten Prozess wird mit Sicherheit ein Channel gefunden, der von der aufweckenden Goroutine verarbeitet wurde. Anschließend wird basierend auf caseSuccess die endgültige Verarbeitung durchgeführt. Für Schreiboperationen bedeutet sg.success gleich false, dass der Channel bereits geschlossen wurde. In der gesamten Go-Laufzeitumgebung setzt nur die close-Funktion dieses Feld aktiv auf false, was darauf hinweist, dass die aktuelle Goroutine durch die close-Funktion aufgeweckt wurde. Für Leseoperationen, wenn sie durch einen Sender aufgeweckt wurden, wurde der Datenlesevorgang bereits vor dem Aufwecken vom Sender durch die Funktion runtime.send abgeschlossen, und der Wert ist true. Wenn sie durch die close-Funktion aufgeweckt wurden, wird wie zuvor direkt zurückgekehrt.
c = cas.c
if casi < nsends {
if !caseSuccess {
goto sclose
}
} else {
recvOK = caseSuccess
}
selunlock(scases, lockorder)
goto retcDamit ist die gesamte Logik von Select im Wesentlichen geklärt. Wie oben an den verschiedenen Fällen zu sehen ist, ist die Verarbeitung von Select recht komplex.
