Hoe Go struct-kopieën detecteert met sync.noCopy

type Mutex struct {
    _ noCopy
    ...
}

type Once struct {
    _ noCopy
    ...
}

type Map struct {
    _ noCopy
    ...
}

noCopy is een speciale marker voor types die niet gekopieerd mogen worden na hun eerste gebruik. De marker zelf is echter niets meer dan een lege struct met twee lege methoden:

type noCopy struct{}
func (*noCopy) Lock()   {}
func (*noCopy) Unlock() {}

Ondanks de namen van de methoden is er geen sprake van een lock en wordt er niets ontgrendeld. Bovendien is er niets in deze code dat ons tegenhoudt om de waarde te kopiëren. Dit artikel legt uit wat er precies kapot kan gaan na een kopie, waarom noCopy deze twee methoden nodig heeft en hoe je dezelfde marker aan je eigen types kunt toevoegen.

1. Wat noCopy wel en niet doet

De noCopy-marker voegt geen speciale regels toe aan de Go-compiler. Je kunt nog steeds een sync.Map kopiëren nadat deze in gebruik is genomen:

var a sync.Map
a.Store("k", 1)
b := a // kopiëren van een sync.Map

De toewijzing kopieert alle velden van a naar b, precies zoals dat bij elke andere struct-waarde zou gebeuren. De code passeert go build zonder problemen, omdat de compiler geen speciale betekenis hecht aan de naam noCopy of aan de methoden Lock en Unlock.

De waarschuwing komt in plaats daarvan van een aparte tool genaamd go vet. Dit is een commando voor statische analyse dat bij Go is inbegrepen en verdachte code rapporteert die de compiler wel accepteert. Wanneer go vet dezelfde toewijzing controleert, rapporteert het:

assignment copies lock value to b: sync.Map contains sync.noCopy

De zin "copies lock value" komt voort uit het doel van de copylocks-checker in go vet. Deze is gemaakt om kopieën van waarden te rapporteren die een lock bevatten, zoals sync.Mutex, omdat het kopiëren van een lock na gebruik ook de interne staat kopieert.

Dit gedrag is eenvoudig te reproduceren met een reguliere struct die een sync.Mutex bevat:

type Counter struct {
    mu    sync.Mutex
    value int
}

func main() {
    var a Counter
    b := a
    _ = b.value
}

Bij het uitvoeren van $ go vet ./... krijg je de melding: main.go:12:7: assignment copies lock value to b: example.com/nocopy-repro.Counter contains sync.Mutex

Omdat Counter een daadwerkelijke mutex bevat, kopieert het kopiëren van de buitenste struct ook de staat van de mutex.

2. Hoe go vet en noCopy werken

De copylocks-checker van go vet zoekt niet simpelweg naar een veld met de naam noCopy. In plaats daarvan gebruikt het de volgende regel bij het inspecteren van het gekopieerde type en diens velden:

if types.Implements(types.NewPointer(typ), lockerType) &&
!types.Implements(typ, lockerType) {
    return []string{typ.String()}
}

De checker begint bij het type dat wordt gekopieerd (bijv. Counter) en stelt vast of de pointer naar dat type de sync.Locker-interface implementeert, terwijl de waarde zelf dat niet doet. Als het type een struct is en het antwoord "nee" is, past de checker dezelfde regel toe op elk veld, inclusief velden in geneste structs. Voor Counter vindt deze zoektocht sync.Mutex in het veld mu.

TypeRegel: Pointer implementeert sync.Locker?Regel: Waarde implementeert sync.Locker?Resultaat
sync.Oncematchno matchcheck veld _ noCopy
sync.MutexRULE MATCHESnoCopy RULE MATCHESMatch

De checker past dezelfde regel toe op het hoofdtype en de veldtypes. Bij sync.Once vindt hij noCopy in het veld _ noCopy. Deze recursieve zoektocht is de reden waarom de waarschuwing kan melden dat een buitenste struct een lock of noCopy bevat.

Dit is waarom de methoden van de noCopy-marker Lock en Unlock heten. Zoals vermeld controleert de regel of een type de sync.Locker-interface implementeert. Deze interface bevat precies deze twee methoden:

type Locker interface {
    Lock()
    Unlock()
}

Door deze namen te gebruiken, implementeert de pointer *noCopy de sync.Locker-interface, terwijl de waarde noCopy dat niet doet.

noCopy werd in 2016 aan de standaardbibliotheek toegevoegd door Aliaksandr Valialkin (CTO van VictoriaMetrics), gebaseerd op een patroon voorgesteld door Russ Cox. Deze types waren al onveilig om te kopiëren, maar de wijziging gaf go vet een manier om kopieën te rapporteren die voorheen over het hoofd werden gezien. In 2016 had de implementatie alleen Lock. In 2018 kreeg noCopy ook Unlock toen copylocks overging op het controleren van sync.Locker.

Je zult merken dat sommige structs in het sync-pakket al een sync.Mutex bevatten, zoals sync.Map:

type Map struct {
    mu Mutex
    ...
}

Als go vet de mutex al kon vinden, waarom heeft sync.Map dan ook _ noCopy nodig? De expliciete markers in sync.Map, sync.Once en sync.Mutex dienen drie doelen:

  1. Expliciete markering: noCopy geeft elke struct een expliciete marker, zodat copylocks niet afhankelijk is van hoe die struct is geïmplementeerd. De checker vindt noCopy direct, in plaats van te moeten zoeken naar een interne mutex of te vertrouwen op de Lock- en Unlock-methoden van het hoofdtype.
  2. Duidelijkere waarschuwing: Voorheen noemde de waarschuwing voor sync.Map de mutex die erin gevonden was: assignment copies lock value to b: sync.Map contains sync.Mutex. De huidige waarschuwing noemt noCopy, wat direct aangeeft dat het hoofdtype niet gekopieerd mag worden: assignment copies lock value to b: sync.Map contains sync.noCopy.
  3. Voorkomen van vals-negatieven: Een vals-negatief betekent dat de checker een kopie zou moeten rapporteren, maar dat niet doet. De checker herkende sync.Mutex via zijn methoden, maar een nieuw benoemd type erft die methoden niet:
type LocalMutex sync.Mutex

Voordat de expliciete marker aan sync.Mutex werd toegevoegd, kon copylocks een gekopieerde LocalMutex missen. Nu bevat de onderliggende struct het _ noCopy-veld, waardoor de recursieve zoektocht de marker kan vinden, zelfs als LocalMutex niet over de methoden van sync.Mutex beschikt.

Opmerking: Interessant genoeg is sync.RWMutex de enige geëxporteerde struct in het sync-pakket die niet gekopieerd mag worden, maar geen noCopy-veld bevat. go vet vangt een directe kopie echter nog steeds op, omdat *RWMutex sync.Locker implementeert, terwijl RWMutex dat niet doet.

Hoe zit het met een nieuw gedefinieerd type dat deze methoden verliest, net als LocalMutex?

type LocalRWMutex sync.RWMutex

De broncode biedt hier een andere route voor de checker:

type RWMutex struct {
    w           Mutex
    writerSem   uint32
    readerSem   uint32
    readerCount atomic.Int32
    readerWait  atomic.Int32
}

De recursieve zoektocht bereikt het veld w, waardoor copylocks nog steeds rapporteert dat LocalRWMutex een sync.Mutex bevat. De oude LocalMutex had enkel integer-velden nadat hij de methoden verloor, en dat is waarom dat type de expliciete marker nodig had.

Eigen markers implementeren

Nu we begrijpen hoe de marker werkt, kun je een eigen versie definiëren in je pakket. Omdat sync.noCopy niet geëxporteerd is, kan code buiten het sync-pakket dit niet direct gebruiken:

type noCopy struct{}
func (*noCopy) Lock()   {}
func (*noCopy) Unlock() {}

type Session struct {
    _      noCopy
    id     string
    closed bool
}

De marker kan de compiler nog steeds niet stoppen om een waarde te kopiëren. go test voert standaard verschillende vet-checks uit, maar copylocks is daar geen onderdeel van. Een standaard go test ./... zegt dus niets over deze waarschuwing.

Veel projecten draaien go vet direct, als ontwikkelaarscommando of in CI. Als je de controle liever via go test uitvoert, kun je de copylocks-checker expliciet inschakelen:

go test -vet=copylocks ./...
# Of voer alle vet-checks uit:
go test -vet=all ./...

Beide commando's voeren vet uit op de broncode van het pakket en de testbestanden voordat de tests worden gestart.

3. Wat gaat er kapot als je de waarschuwing negeert?

Er is niet één specifiek falen achter deze waarschuwing; verschillende types kunnen verschillend reageren na een kopie.

Een WaitGroup die nooit klaar is

func finish(w sync.WaitGroup) { // w is een kopie
    w.Done()
}

var wg sync.WaitGroup
wg.Add(1)
finish(wg)
wg.Wait() // keert nooit terug

De aanroep naar finish(wg) kopieert de velden van wg naar de parameter w, inclusief de tellerwaarde van 1. w.Done() verandert alleen de gekopieerde teller van 1 naar 0. De originele teller blijft op 1 staan, waardoor wg.Wait() nooit stopt.

Twee gekopieerde variabelen delen één trie

var a sync.Map
a.Store("x", 1)
b := a
b.Store("fromB", 1)
_, seen := a.Load("fromB") // true

De eerste Store initialiseert a en geeft het een root-pointer. De toewijzing aan b kopieert die pointer, waardoor zowel a als b toegang hebben tot dezelfde trie. Als we later Store aanroepen op b, kan de nieuwe vermelding ook zichtbaar zijn via a, ondanks dat a en b aparte variabelen zijn.

Twee gekopieerde variabelen initialiseren aparte tries

Het gedrag verandert wanneer we de kopie maken vóór de eerste Store:

var c sync.Map
d := c // gekopieerd voordat een van beide is gebruikt
c.Store("fromC", 1)
d.Store("fromD", 1)
_, cSeesD := c.Load("fromD") // false
_, dSeesC := d.Load("fromC") // false

Omdat de kopie plaatsvindt vóór het eerste gebruik, heeft c geen root-pointer om naar d te kopiëren. Beide variabelen beginnen leeg en initialiseren elk een aparte map-opslag wanneer Store wordt aangeroepen. Hierdoor ziet geen van beide variabelen de vermeldingen van de ander.

Het contract van sync.Map in de documentatie van Go stelt: "A Map must not be copied after first use." De copylocks-checker rapporteert het echter altijd, omdat de tool niet bijhoudt of de map al is gebruikt. Het kopiëren van een sync.Map is over het algemeen een slecht idee, omdat de code dan afhankelijk wordt van volgorde-afhankelijk gedrag.

4. Veldpositie bepaalt de grootte van de struct

noCopy heeft een grootte van nul, maar een noCopy-veld aan het einde van een struct kan de totale grootte toch verhogen omdat Go padding kan toevoegen. Een positie als eerste veld voorkomt deze extra padding:

type plain struct {
    n int64
}
type first struct {
    _ noCopy
    n int64
}
type last struct {
    n int64
    _ noCopy
}

Het gebruik van unsafe.Sizeof op een 64-bit platform (zoals amd64 of arm64) geeft:

  • plain: 8 bytes
  • first (met noCopy): 8 bytes
  • last (met noCopy): 16 bytes

In first heeft noCopy een grootte van nul en begint op offset 0. De int64 kan ook op offset 0 beginnen en beslaat bytes 0 tot 7, waardoor de hele struct 8 bytes gebruikt.

In last beslaat de int64 bytes 0 tot 7, wat noCopy op offset 8 plaatst. Een struct met een grootte van 8 bytes eindigt op diezelfde offset, waardoor het adres van noCopy buiten het gealloceerde geheugen van de struct zou vallen.

Go voegt daarom 1 byte toe na het veld en rondt de totale grootte vervolgens af naar de alignment van de struct. Op amd64 en arm64 is de alignment 8 bytes, waardoor de totale grootte toeneemt van 8 naar 16 bytes. Op 386 en arm is de alignment 4 bytes, waardoor dezelfde struct een totale grootte van 12 bytes heeft.

De standaardbibliotheek voorkomt deze toename door noCopy vóór velden met een grootte ongelijk aan nul te plaatsen. In sync.Map, sync.Once, sync.Pool en sync.WaitGroup is het het eerste veld.

atomic.Pointer[T] heeft zelfs een ander veld met nul-grootte vóór noCopy:

type Pointer[T any] struct {
    _ [0]*T
    _ noCopy
    v unsafe.Pointer
}

Beide velden boven v hebben een grootte van nul. De pointer die de staat opslaat komt pas na noCopy.

Bronverwijzingen

  • src/sync/cond.go
  • src/sync/map.go
  • src/sync/rwmutex.go
  • src/internal/sync/hashtriemap.go
  • De wijziging uit 2016 die noCopy introduceerde
  • De wijziging uit 2018 die Unlock toevoegde
  • Berekening van struct-grootte in de compiler
  • De copylocks analyzer
  • go vet documentatie