Skip to content

Mutex de Exclusão Mútua sync.Mutex

Lock (trava/mutex) é uma primitiva de sincronização importante em sistemas operacionais. A linguagem Go fornece implementações de mutex de exclusão mútua e mutex de leitura-escrita em sua biblioteca padrão, correspondendo respectivamente a:

  • sync.Mutex, mutex de exclusão mútua, leitura-leitura exclusiva, leitura-escrita exclusiva, escrita-escrita exclusiva
  • sync.RWMutex, mutex de leitura-escrita, leitura-leitura compartilhada, leitura-escrita exclusiva, escrita-escrita exclusiva

Seus cenários de uso são muito comuns, usados para proteger acesso ordenado e modificação de memória compartilhada em situações concorrentes, como no exemplo abaixo:

go
import (
	"fmt"
	"sync"
)

func main() {
	var i int
	var wg sync.WaitGroup
	var mu sync.Mutex

	for range 10 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			mu.Lock()
			viewI := i
			mu.Unlock()

			viewI++

			mu.Lock()
			i = viewI
			mu.Unlock()
		}()
	}

	wg.Wait()
	fmt.Println(i)
}

Sem a proteção do lock, o resultado de saída desta função pode ser diferente a cada execução, imprevisível. Obviamente, na maioria dos cenários não queremos que tal situação ocorra. Este caso é muito simples para a maioria das pessoas, talvez você já esteja familiarizado com o uso de locks, mas pode não entender como os locks da linguagem Go são implementados internamente. O código em si não é complexo, e este artigo explicará em detalhes.

Locker

Antes de começar, vamos olhar um tipo sync.Locker, que é um conjunto de interfaces definidas pelo Go:

go
// A Locker represents an object that can be locked and unlocked.
type Locker interface {
	Lock()
	Unlock()
}

Seus métodos fornecidos são muito simples e fáceis de entender: apenas travar e destravar. Devido à característica do Go de que interfaces são implementadas implicitamente, muitas pessoas podem nunca tê-lo visto. Ele é mencionado aqui apenas brevemente porque realmente não é tão importante. Os dois locks discutidos a seguir também implementam esta interface.

Mutex

A definição de tipo do mutex de exclusão mútua Mutex está localizada no arquivo sync/mutex.go. É um tipo struct, como segue:

go
type Mutex struct {
	state int32
	sema  uint32
}

Definição dos campos:

  • state, campo que representa o estado do lock
  • sema, ou seja, semáforo semaphore, cuja explicação será dada posteriormente

Primeiro, vamos falar sobre este state:

go
const (
    mutexLocked = 1 << iota // mutex is locked
    mutexWoken
    mutexStarving
)

state é um tipo inteiro de 32 bits. Os 3 bits mais baixos são usados para representar os três estados acima. Estes três estados não são independentes; eles podem coexistir.

  • mutexLocked=1, travado
  • mutexWoken=2, despertado
  • mutexStarving=4, modo de fome

Os 29 bits mais altos são usados para representar quantas goroutines estão esperando pelo lock. Teoricamente, um mutex pode ser usado simultaneamente por 2^29+1 goroutines. No entanto, na realidade, é improvável que haja tantas goroutines. Mesmo que cada uma ocupe apenas 2KB (tamanho inicial do stack), o espaço de memória necessário para criar tal número de goroutines seria de aproximadamente 1TB.

+-----------------------------------+---------------+------------+-------------+
|              waiter               | mutexStarving | mutexWoken | mutexLocked |
+-----------------------------------+---------------+------------+-------------+
|              29 bits              | 1 bit         | 1 bit      | 1 bit       |
+-----------------------------------+---------------+------------+-------------+

O mutex tem dois modos de operação: modo normal e modo de fome. O modo normal segue a ordem em que as goroutines chegam na fila de espera bloqueada para possuir o lock, ou seja, FIFO (First In First Out). Esta é a situação geral e também quando o desempenho é melhor, pois não há problemas quando todos possuem o lock na ordem de acesso. O modo de fome é a situação não usual. Esta fome refere-se a uma goroutine em espera que não consegue possuir o lock por um longo tempo e permanece bloqueada. Não significa que o mutex esteja em estado de fome. Então, quando uma goroutine entra em estado de fome? O oficial dá um exemplo: há uma goroutine que chegou primeiro e fica bloqueada por não conseguir possuir o mutex. Quando é despertada devido à liberação do lock, chega outra goroutine que acabou de executar até este ponto tentando possuir o lock (gosta de furar fila). Como esta última está em estado de execução (ocupando fatia de tempo da CPU), a chance de conseguir competir pelo lock é muito alta. Em situações extremas, pode haver muitas goroutines como esta, e a goroutine recém-despertada nunca conseguirá possuir o lock (sempre furando a fila sem fim). Foi ela quem chegou primeiro, mas nunca consegue obter o lock.

go
const (
	starvationThresholdNs = 1e6
)

Para evitar esta situação, o Go define um limiar de espera starvationThresholdNs. Se uma goroutine não conseguir possuir o lock por mais de 1ms, o mutex entra em modo de fome. No modo de fome, a propriedade do mutex é diretamente transferida para a primeira goroutine na fila de espera. Novas goroutines não tentarão possuir o lock e vão para o final da fila. Assim, no modo de fome, a propriedade do mutex é possuída sequencialmente pelas goroutines na fila de espera (quem está na fila obtém o lock primeiro, quem fura fila vai para o final). Quando uma goroutine consegue possuir o lock com sucesso, se for a última goroutine em espera ou se o tempo de espera for menor que 1ms, o mutex volta para o modo normal. Este design de modo de fome evita que algumas goroutines fiquem "morrendo de fome" por não conseguirem possuir o lock por um longo tempo.

TryLock

O mutex fornece dois métodos para adquirir o lock:

  • Lock(), adquire o lock de forma bloqueante
  • TryLock(), adquire o lock de forma não bloqueante

Vamos primeiro ver o código de TryLock, pois sua implementação é mais simples:

go
func (m *Mutex) TryLock() bool {
	old := m.state
	if old&(mutexLocked|mutexStarving) != 0 {
		return false
	}

	if !atomic.CompareAndSwapInt32(&m.state, old, old|mutexLocked) {
		return false
	}

	return true
}

Ele começa verificando se o lock já foi adquirido ou se está em modo de fome (ou seja, muitas goroutines estão esperando pelo lock). Se sim, a goroutine atual não pode obter o lock. Caso contrário, tenta atualizar o estado para mutexLocked através de operação CAS. Se a operação CAS retornar false, indica que outra goroutine conseguiu obter o lock durante este período, então a goroutine atual não pode obter o lock. Caso contrário, obtém o lock com sucesso. Do código aqui pode-se ver que o chamador de TryLock() é aquele que tenta furar a fila, pois tenta抢夺 o lock independentemente de haver goroutines esperando (old pode não ser igual a 0).

Lock

Abaixo está o código de Lock. Ele também usa operação CAS para tentar possuir o lock diretamente, mas é mais "honesto": só tenta possuir o lock diretamente quando não há goroutines bloqueadas esperando (old=0).

go
func (m *Mutex) Lock() {
    // Fast path: grab unlocked mutex.
    if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
       return
    }
    // Slow path (outlined so that the fast path can be inlined)
    m.lockSlow()
}

Se descobrir que há goroutines bloqueadas esperando, ele "honestamente" vai para o final da fila, entrando no fluxo de espera lockslow (o núcleo do mutex). Primeiro prepara algumas variáveis:

go
func (m *Mutex) lockSlow() {
    var waitStartTime int64
    starving := false
    awoke := false
    iter := 0
    old := m.state
  • waitStartTime: usado para registrar o tempo de início da espera, verifica se entra em modo de fome
  • starving: indica se a goroutine atual já está sem obter o lock por mais de 1ms
  • awoke: marca se a goroutine atual foi despertada
  • iter: contador, registra o número de vezes de spin
  • old: obtém o estado atual do mutex

Depois entra no loop for para julgar se a goroutine atual pode entrar em estado de spin.

Spin é um mecanismo de sincronização entre threads, também chamado de busy-waiting. Quando uma thread não possui o lock, não suspende diretamente trocando o contexto da thread, mas entra em estado de espera ativa, ocupando a fatia de tempo da CPU durante este processo. Se a competição pelo lock não for grande ou o tempo de posse do lock for muito curto, fazer isso pode realmente evitar trocas frequentes de contexto de thread, melhorando efetivamente o desempenho. No entanto, não é uma solução universal. Na linguagem Go, o abuso de spin pode levar às seguintes consequências perigosas:

  • Alto uso de CPU: Muitas goroutines em spin consomem muitos recursos de CPU, especialmente quando o lock é ocupado por um longo tempo
  • Afeta o escalonamento de goroutines: O número total de processadores P é limitado. Se muitas goroutines em spin ocuparem P, outras goroutines executando código de usuário não podem ser escalonadas oportunamente
  • Problemas de consistência de cache: A característica de busy-wait do spin lock faz com que a thread leia repetidamente o estado do lock no cache (cache). Se goroutines em spin estiverem executando em núcleos diferentes e o estado do lock não for atualizado oportunamente na memória global, levando a leituras imprecisas do estado do lock pelas goroutines, e a sincronização frequente de consistência de cache também reduz significativamente o desempenho

Portanto, nem todas as goroutines podem entrar em estado de spin. Elas precisam passar pelas seguintes verificações rigorosas:

go
for {
    if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
        if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
        atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
            awoke = true
        }
        runtime_doSpin()
        iter++
        old = m.state
        continue
    }
    ...
}

As condições são as seguintes:

  1. O lock atual já foi adquirido e não pode estar em modo de fome, caso contrário significa que já há goroutines sem obter o lock por um longo tempo, então entra diretamente no fluxo de bloqueio.

  2. Entra no fluxo de verificação runtime.sync_runtime_canSpin:

    go
     const (
    	active_spin = 4
    )
    func sync_runtime_canSpin(i int) bool {
    	if i >= active_spin || ncpu <= 1 || gomaxprocs <= sched.npidle.Load()+sched.nmspinning.Load()+1 {
    		return false
    	}
    	if p := getg().m.p.ptr(); !runqempty(p) {
    		return false
    	}
    	return true
    }
  3. Número de spins menor que runtime.active_spin, padrão é 4 vezes, mais que isso desperdiça recursos

  4. Número de núcleos da CPU maior que 1, spin em sistema de núcleo único não tem significado

  5. gomaxprocs atual maior que a soma do número de P ociosos e P em spin mais 1, ou seja, indica que não há processadores disponíveis suficientes para spin

  6. A fila local do P atual deve estar vazia, caso contrário indica que há outras tarefas de usuário para executar, não pode fazer spin

Se for determinado que pode fazer spin, chama runtime.sync_runtime_doSpin para entrar em spin. Na verdade, executa 30 instruções PAUSE:

go
const (
	active_spin_cnt = 30
)

func sync_runtime_doSpin() {
	procyield(active_spin_cnt)
}
asm
TEXT runtime·procyield(SB),NOSPLIT,$0-0
	MOVL	cycles+0(FP), AX
again:
	PAUSE
	SUBL	$1, AX
	JNZ	again
	RET

Se não puder fazer spin, há apenas dois resultados: conseguir obter o lock com sucesso ou entrar na fila de espera e ficar bloqueado. Antes disso, há muitas coisas para processar:

  1. Se não estiver em modo de fome, tenta obter o lock:
go
new := old
if old&mutexStarving == 0 {
	new |= mutexLocked
}
  1. Se o lock já foi adquirido ou agora está em modo de fome, o número de goroutines em espera aumenta em 1:
go
if old&(mutexLocked|mutexStarving) != 0 {
	new += 1 << mutexWaiterShift
}
  1. Se a goroutine atual já está em estado de fome e o lock ainda está ocupado, entra em modo de fome:
go
if starving && old&mutexLocked != 0 {
	new |= mutexStarving
}
  1. Se a goroutine atual foi despertada durante spin, adiciona a flag mutexWoken:
go
if awoke {
	new &^= mutexWoken
}

Depois tenta atualizar o estado do lock através de CAS. Se a atualização falhar, vai diretamente para a próxima iteração do loop:

go
if atomic.CompareAndSwapInt32(&m.state, old, new) {
    ...
}else {
    ...
}

Se a atualização for bem-sucedida, começa o seguinte julgamento:

  1. O estado original não é modo de fome e nenhuma goroutine ocupa o lock, então a goroutine atual pode possuir o lock diretamente, sai do fluxo e continua executando o código de usuário.

    go
    if old&(mutexLocked|mutexStarving) == 0 {
    		break
    }
  2. Falha ao tentar obter o lock, registra o tempo de espera, onde LIFO se for true indica que a fila é LIFO (Last In First Out), caso contrário é FIFO (First In First Out).

    go
    queueLifo := waitStartTime != 0
    if waitStartTime == 0 {
    	waitStartTime = runtime_nanotime()
    }
  3. Tenta obter o semáforo, entra na função runtime.semacquire1. Se conseguir obter o semáforo, retorna diretamente sem bloquear. Caso contrário, chama runtime.gopark para suspender a goroutine atual e esperar pela liberação do semáforo.

    go
    runtime_SemacquireMutex(&m.sema, queueLifo, 1)
  4. Chegar a este passo tem duas possibilidades: uma é obter o semáforo diretamente, outra é ser despertado do bloqueio e conseguir obter o semáforo com sucesso. Independentemente de qual, conseguiu obter o semáforo com sucesso. Se agora estiver em modo de fome, pode obter o lock diretamente.

    go
    starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
    old = m.state
    if old&mutexStarving != 0 {
    	delta := int32(mutexLocked - 1<<mutexWaiterShift)
    	if !starving || old>>mutexWaiterShift == 1 {
    		delta -= mutexStarving
    	}
    	atomic.AddInt32(&m.state, delta)
    	break
    }
  5. Se não estiver em modo de fome, redefine iter e reinicia o fluxo de spin.

    go
    awoke = true
    iter = 0

Assim, o fluxo de travamento termina. Todo o processo é complexo, usando dois métodos de espera por spin e espera por bloqueio de semáforo, equilibrando desempenho e justiça, adequado para a maioria dos cenários de competição por lock.

Unlock

O fluxo de destravamento é relativamente mais simples. Primeiro tenta destravamento rápido. Se new for 0, indica que não há goroutines em espera e não está em modo de fome, ou seja, destravamento bem-sucedido, pode retornar diretamente.

go
func (m *Mutex) Unlock() {
	new := atomic.AddInt32(&m.state, -mutexLocked)
	if new != 0 {
		m.unlockSlow(new)
	}
}

Caso contrário, precisa entrar no fluxo de unlockSlow:

  1. Primeiro julga se já foi destravado:

    go
    if (new+mutexLocked)&mutexLocked == 0 {
    	fatal("sync: unlock of unlocked mutex")
    }
  2. Se estiver em modo de fome, libera diretamente o semáforo, completando o destravamento. No modo de fome, a goroutine atual que destrava transfere diretamente a propriedade do lock para a próxima goroutine em espera.

    go
    if new&mutexStarving == 0 {
    	...
    } else {
    	runtime_Semrelease(&m.sema, true, 1)
    }
  3. Se não estiver em modo de fome, entra no fluxo de destravamento normal:

    1. Se não houver goroutines em espera, ou se houver outra goroutine despertada que já obteve o lock, ou se o lock entrou em modo de fome:

      go
      if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
          return
      }
    2. Caso contrário, libera o semáforo para despertar a próxima goroutine em espera, definindo o estado atual do lock como mutexWoken:

      go
      new = (old - 1<<mutexWaiterShift) | mutexWoken
      if atomic.CompareAndSwapInt32(&m.state, old, new) {
          runtime_Semrelease(&m.sema, false, 1)
          return
      }
      old = m.state

Finalmente, o fluxo de destravamento termina.

RWMutex

A definição de tipo do mutex de leitura-escrita RWMutex está localizada no arquivo sync/rwmutex.go. Sua implementação também é baseada no mutex de exclusão mútua.

go
type RWMutex struct {
	w           Mutex        // held if there are pending writers
	writerSem   uint32       // semaphore for writers to wait for completing readers
	readerSem   uint32       // semaphore for readers to wait for completing writers
	readerCount atomic.Int32 // number of pending readers
	readerWait  atomic.Int32 // number of departing readers
}

Abaixo está a definição de cada campo:

  • w, um mutex de exclusão mútua. Quando uma goroutine escritora possui este mutex, outras goroutines escritoras e leitoras serão bloqueadas
  • writerSem, semáforo de escrita, usado para bloquear goroutines escritoras esperando goroutines leitoras. Goroutines escritoras obtêm o semáforo, goroutines leitoras liberam o semáforo
  • readerSem, semáforo de leitura, usado para bloquear goroutines leitoras esperando goroutines escritoras. Goroutines leitoras obtêm o semáforo, goroutines escritoras liberam o semáforo
  • readerCount, campo central, todo o estado do mutex de leitura-escrita é mantido por ele
  • readerWait, indica o número de goroutines leitoras que precisam esperar quando goroutines escritoras estão bloqueadas

O princípio geral do mutex de leitura-escrita é: através do mutex de exclusão mútua faz com que goroutines escritoras sejam mutuamente exclusivas, através dos dois semáforos writerSem e readerSem faz com que leitura e escrita sejam mutuamente exclusivas e leitura-leitura seja compartilhada.

readerCount

Como este readerCount tem muitas variações e é muito importante, é tratado separadamente. Ele resume aproximadamente os seguintes estados:

  1. 0, atualmente não há goroutines leitoras ativas nem goroutines escritoras ativas, em estado ocioso
  2. -rwmutexMaxReaders, uma goroutine escritora já possui o mutex de exclusão mútua, atualmente não há goroutines leitoras ativas
  3. -rwmutexMaxReaders+N, uma goroutine escritora já possui o lock de escrita, goroutines leitoras atuais precisam bloquear e esperar a goroutine escritora liberar o lock de escrita
  4. N-rwmutexMaxReaders, uma goroutine escritora já possui o mutex de exclusão mútua, precisa bloquear e esperar as goroutines leitoras restantes liberarem o lock de leitura
  5. N, atualmente há N goroutines leitoras ativas, ou seja, adicionados N locks de leitura, não há goroutines escritoras ativas

Onde rwmutexMaxReaders é uma constante, seu valor é 2 vezes o número de goroutines que podem bloquear e esperar no mutex de exclusão mútua, porque metade são goroutines leitoras e metade são goroutines escritoras.

go
const rwmutexMaxReaders = 1 << 30

Toda a parte do mutex de leitura-escrita é complexa devido a este readerCount. Entender suas variações significa entender o fluxo de trabalho do mutex de leitura-escrita.

TryLock

Como de costume, vamos primeiro ver o mais simples TryLock():

go
func (rw *RWMutex) TryLock() bool {
	if !rw.w.TryLock() {
		return false
	}
	if !rw.readerCount.CompareAndSwap(0, -rwmutexMaxReaders) {
		rw.w.Unlock()
		return false
	}
	return true
}

Começa tentando chamar TryLock() do mutex de exclusão mútua. Se falhar, retorna diretamente. Depois usa operação CAS para tentar atualizar o valor de readerCount de 0 para -rwmutexMaxReaders. 0 representa que não há goroutines leitoras trabalhando, -rwmutexMaxReaders indica que agora a goroutine escritora possui o mutex de exclusão mútua. Se a operação CAS falhar, destrava o mutex de exclusão mútua. Se tiver sucesso, retorna true.

Lock

A seguir está Lock(), sua implementação também é muito simples.

go
func (rw *RWMutex) Lock() {
	rw.w.Lock()
	r := rw.readerCount.Add(-rwmutexMaxReaders) + rwmutexMaxReaders
	if r != 0 && rw.readerWait.Add(r) != 0 {
		runtime_SemacquireRWMutex(&rw.writerSem, false, 0)
	}
}

Primeiro compete com outras goroutines escritoras até possuir o mutex de exclusão mútua, depois realiza esta operação: primeiro subtrai atomicamente -rwmutexMaxReaders, depois adiciona não atomicamente o novo valor obtido com rwmutexMaxReaders:

go
r = rw.readerCount.Add(-rwmutexMaxReaders) + rwmutexMaxReaders

Dividindo em dois passos:

  1. Isso é para notificar outras goroutines leitoras que agora há uma goroutine escritora tentando possuir o lock, como já foi discutido na parte de TryLock.

    go
    rw.readerCount.Add(-rwmutexMaxReaders)
  2. Depois adiciona rwmutexMaxReaders para obter r, que representa o número de goroutines leitoras trabalhando atualmente.

    go
    r = rw.readerCount.Add(-rwmutexMaxReaders) + rwmutexMaxReaders

Depois julga se há goroutines leitoras trabalhando. Se sim, adiciona r ao valor de readerWait. Se ainda não for 0, indica que precisa esperar estas goroutines leitoras terminarem o trabalho, então entra no fluxo runtime_SemacquireRWMutex para tentar obter o semáforo writerSem. Este semáforo é liberado por goroutines leitoras. Se conseguir obter o semáforo, indica que as goroutines leitoras já terminaram o trabalho. Caso contrário, precisa entrar na fila de bloqueio para esperar (a lógica do semáforo nesta parte é basicamente consistente com a do mutex de exclusão mútua).

UnLock

Depois é UnLock(), libera o lock de escrita.

go
func (rw *RWMutex) Unlock() {
    r := rw.readerCount.Add(rwmutexMaxReaders)
    if r >= rwmutexMaxReaders {
       fatal("sync: Unlock of unlocked RWMutex")
    }

    for i := 0; i < int(r); i++ {
       runtime_Semrelease(&rw.readerSem, false, 0)
    }

    rw.w.Unlock()
}

Seu fluxo é o seguinte:

  1. Como mencionado anteriormente, ao travar, readerCount é atualizado para valor negativo. Aqui adiciona rwmutexMaxReaders, indicando que agora não há goroutines escritoras trabalhando. O valor obtido é o número de goroutines leitoras bloqueadas em espera.

    go
    r := rw.readerCount.Add(rwmutexMaxReaders)
  2. Se for 0 ou maior que 0, representa que o lock de escrita já foi liberado:

    go
    if r >= rwmutexMaxReaders {
    	fatal("sync: Unlock of unlocked RWMutex")
    }
  3. Libera o semáforo readerSem, despertando goroutines leitoras em espera:

    go
    for i := 0; i < int(r); i++ {
    	runtime_Semrelease(&rw.readerSem, false, 0)
    }
  4. Finalmente libera o mutex de exclusão mútua, despertando goroutines escritoras em espera.

    go
    rw.w.Unlock()

Liberação do lock de escrita completada.

TryRLock

A seguir vamos ver a parte do lock de leitura, este é o código de TryRLock:

go
func (rw *RWMutex) TryRLock() bool {
	for {
		c := rw.readerCount.Load()
		if c < 0 {
			return false
		}
		if rw.readerCount.CompareAndSwap(c, c+1) {
			return true
		}
	}
}

Ele faz apenas duas coisas:

  1. Julga se há goroutines escritoras trabalhando. Se houver, falha ao adquirir o lock.

    go
    c := rw.readerCount.Load()
    if c < 0 {
    	return false
    }
  2. Tenta adicionar 1 a readerCount. Se a atualização for bem-sucedida, o lock foi adquirido com sucesso:

    go
    if rw.readerCount.CompareAndSwap(c, c+1) {
    	return true
    }
  3. Caso contrário, continua o loop de julgamento até sair

Pode-se ver que aqui depende do readerCount mantido na parte do lock de escrita. Esta é a razão pela qual o lock de escrita foi discutido primeiro, porque a parte complexa e central está mantida na parte do lock de escrita.

RLock

A lógica de RLock é ainda mais simples:

go
func (rw *RWMutex) RLock() {
	if rw.readerCount.Add(1) < 0 {
		runtime_SemacquireRWMutexR(&rw.readerSem, false, 0)
	}
}

Tenta adicionar 1 ao valor de readerCount. Se o novo valor ainda for menor que 0, indica que goroutines escritoras estão trabalhando, então entra no fluxo de bloqueio do semáforo readerSem. A goroutine atual entra na fila de bloqueio para esperar.

RUnLock

RUnLock também é simples e fácil de entender:

go
func (rw *RWMutex) RUnlock() {
    if r := rw.readerCount.Add(-1); r < 0 {
       rw.rUnlockSlow(r)
    }
}

func (rw *RWMutex) rUnlockSlow(r int32) {
	if r+1 == 0 || r+1 == -rwmutexMaxReaders {
		fatal("sync: RUnlock of unlocked RWMutex")
	}
	if rw.readerWait.Add(-1) == 0 {
		runtime_Semrelease(&rw.writerSem, false, 1)
	}
}

Primeiro tenta subtrair 1 de readerCount, indicando que o número de goroutines leitoras ativas diminui em 1. Se o valor obtido for maior que 0, indica que pode liberar diretamente, pois agora não há goroutines escritoras possuindo o mutex de exclusão mútua. Se for menor que 0, indica que há goroutines escritoras que já possuem o mutex de exclusão mútua e estão esperando todas as goroutines leitoras atuais terminarem o trabalho. Depois entra no fluxo de runlockSlow:

  1. Se o valor original de readerCount for 0 (lock está ocioso) ou for -rwmutexMaxReaders (goroutines escritoras não têm goroutines leitoras para esperar, ou seja, todos os locks de leitura já foram liberados), indica que não há goroutines leitoras ativas e não precisa destravar:

    go
    if r+1 == 0 || r+1 == -rwmutexMaxReaders {
    	fatal("sync: RUnlock of unlocked RWMutex")
    }
  2. Se houver goroutines leitoras ativas, subtrai 1 de readerWait. Se a goroutine leitora atual for a última goroutine leitora ativa, libera o semáforo writerSem, despertando goroutines escritoras em espera.

    go
    if rw.readerWait.Add(-1) == 0 {
    	runtime_Semrelease(&rw.writerSem, false, 1)
    }

Fluxo de liberação do lock de leitura terminado.

Semaphore

O semáforo dentro do mutex de exclusão mútua é apenas um simples inteiro uint32. Através de subtração atômica de 1 e adição atômica de 1 representa a obtenção e liberação do semáforo. No runtime, a estrutura responsável por manter os semáforos é runtime.semaRoot. Sua definição de tipo está localizada no arquivo runtime/sema.go. semaRoot usa uma árvore balanceada (treap) para organizar e gerenciar semáforos. Cada nó na árvore representa um semáforo. O tipo de nó é *sudog, que é uma lista encadeada bidirecional que mantém a fila de espera do semáforo correspondente. Os nós mantêm unicidade através de *sudog.elem (endereço do semáforo) e garantem o equilíbrio da árvore através do campo *sudog.ticket.

go
type semaRoot struct {
	lock  mutex
	treap *sudog        // root of balanced tree of unique waiters.
	nwait atomic.Uint32 // Number of waiters. Read w/o the lock.
}

A árvore de semáforos semaRoot depende de um mutex de nível mais baixo runtime.mutex para garantir sua segurança concorrente.

go
var semtable semTable

// Prime to not correlate with any user patterns.
const semTabSize = 251

type semTable [semTabSize]struct {
	root semaRoot
    // 用于内存对齐,提高性能
	pad  [cpu.CacheLinePadSize - unsafe.Sizeof(semaRoot{})]byte
}

semaRoot é armazenado em um semaTable global no runtime. Parece um array de comprimento fixo usado para armazenar múltiplos conjuntos de nós raiz de árvores de semáforos, mas na verdade, do ponto de vista de operação, é uma tabela hash. Cada elemento na tabela contém um semaRoot e alguns bytes de preenchimento (pad), usados para alinhamento de memória e evitar competição de linha de cache. semTabSize é uma constante de tamanho da tabela de semáforos, especificando o comprimento da tabela como 251. Geralmente escolhe-se um número primo para reduzir colisões de hash e melhorar a eficiência de dispersão.

go
func (t *semTable) rootFor(addr *uint32) *semaRoot {
	return &t[(uintptr(unsafe.Pointer(addr))>>3)%semTabSize].root
}

O método rootFor é equivalente a uma função hash. Ele recebe um ponteiro uint32 addr (ou seja, o endereço do semáforo) e retorna o ponteiro da estrutura semaRoot correspondente a este endereço. Esta linha de código primeiro converte addr para uintptr, depois desloca 3 bits para a direita, equivalente a dividir por 8 (porque um byte ocupa 8 bits, dividir o endereço do ponteiro por 8 pode mapeá-lo como índice do array). Através do módulo por semTabSize, garante que o índice esteja dentro do intervalo de tamanho da tabela de semáforos. Depois de obter o semaRoot através do índice, vai buscar na árvore balanceada a fila de espera *sudog correspondente ao semáforo.

Acquire

Obter o semáforo, a implementação correspondente é a função runtime.semacquire1:

go
func semacquire1(addr *uint32, lifo bool, profile semaProfileFlags, skipframes int, reason waitReason)

Recebe os seguintes parâmetros:

  • addr, endereço do semáforo
  • lifo, afeta a ordem de saída da árvore balanceada, padrão é FIFO, LIFO é Last In First Out. Quando o tempo de espera da goroutine não é 0 (pelo menos bloqueado uma vez), é true
  • profile, flag para análise de desempenho de lock
  • skipframes, número de frames de stack a pular
  • reason, razão do bloqueio

Abaixo é descrito brevemente todo o fluxo de obtenção do semáforo:

  1. Julga o estado da goroutine. Se a goroutine atual não estiver sendo escalonada, lança exceção diretamente:

    go
    gp := getg()
    if gp != gp.m.curg {
    	throw("semacquire not on the G stack")
    }
  2. Julga se pode obter o semáforo e tenta obter o semáforo através de método não bloqueante. Se conseguir obter, pode retornar diretamente:

    go
    for {
    	v := atomic.Load(addr)
    	if v == 0 {
    		return false
    	}
    	if atomic.Cas(addr, v, v-1) {
    		return true
    	}
    }
  3. Se não puder obter de forma não bloqueante, entra no loop para obter o semáforo através de meios normais. Primeiro obtém um *sudog do cache através de acquireSudog(). Esta estrutura representa uma goroutine bloqueada em espera:

    s := acquireSudog()
  4. Depois obtém a árvore de semáforos da tabela global:

    go
    root := semtable.rootFor(addr)
  5. Entra no loop, trava a árvore de semáforos, julga novamente se pode obter o semáforo. Se não puder, adiciona à árvore de semáforos, depois chama gopark para suspender e esperar até ser despertado. Continua repetindo este processo até obter o semáforo:

    go
    for {
    	lockWithRank(&root.lock, lockRankRoot)
    	root.nwait.Add(1)
    	if cansemacquire(addr) {
    		root.nwait.Add(-1)
    		unlock(&root.lock)
    		break
    	}
    	root.queue(addr, s, lifo)
    	goparkunlock(&root.lock, reason, traceBlockSync, 4+skipframes)
    	if s.ticket != 0 || cansemacquire(addr) {
    		break
    	}
    }
  6. Finalmente, quando for despertado, libera *sudog, retornando-o ao cache:

    go
    releaseSudog(s)

Release

Liberar o semáforo, desperta goroutines bloqueadas em espera. Esta funcionalidade é implementada pela função runtime.semrelease1:

go
func semrelease1(addr *uint32, handoff bool, skipframes int)

Recebe os seguintes parâmetros:

  • addr, endereço do semáforo
  • handoff, indica se deve trocar diretamente a G sendo escalonada pelo P atual para a G despertada, apenas é true no modo de fome
  • skipframes, número de frames de stack a pular

Abaixo é descrito brevemente todo o processo de liberação:

  1. Obtém a árvore de semáforos, depois adiciona 1 ao semáforo, indicando liberar um semáforo:

    go
    root := semtable.rootFor(addr)
    atomic.Xadd(addr, 1)
  2. Se o número de goroutines em espera for 0, retorna diretamente:

    go
    if root.nwait.Load() == 0 {
    	return
    }
  3. Trava a árvore de semáforos, julga secundariamente se há goroutines em espera:

    go
    lockWithRank(&root.lock, lockRankRoot)
    if root.nwait.Load() == 0 {
    	unlock(&root.lock)
    	return
    }
  4. Obtém uma goroutine bloqueada em espera da árvore de semáforos, nwait diminui 1, depois libera o lock do semáforo:

    go
    s, t0, tailtime := root.dequeue(addr)
    if s != nil {
    	root.nwait.Add(-1)
    }
    unlock(&root.lock)
  5. Julga se pode obter o semáforo:

    go
    if handoff && cansemacquire(addr) {
    	s.ticket = 1
    }
  6. A função readyWithTime usa diretamente a G despertada como a próxima G a ser executada pelo P, ou seja, modifica *p.runnext=g:

    go
    readyWithTime(s, 5+skipframes)
  7. Se handoff for true, então goyield faz com que a goroutine G atual que libera o semáforo se desvincule do M atual e seja adicionada novamente ao final da fila de execução local do P, depois começa um novo ciclo de escalonamento para permitir que a goroutine G despertada seja escalonada imediatamente:

    go
    if s.ticket == 1 && getg().m.locks == 0 {
    	goyield()
    }

O fluxo de obtenção e liberação do semáforo é este. O uso de semáforos na linguagem Go não se limita ao mutex de exclusão mútua. É colocado aqui porque a relação entre semáforo e mutex de exclusão mútua é a maior. O oficial até escreveu nos comentários:

// Asynchronous semaphore for sync.Mutex.

Depois de entender o semáforo, olhar o mutex de exclusão mútua fica mais claro.

TIP

Sobre a árvore de semáforos semaRoot, sua implementação de entrada e saída da fila envolve operações de auto-balanceamento que são bastante complicadas. Investigar isso não tem relação com o tema deste artigo e não tem significado, então foi omitido. Interessados podem consultar o código-fonte por conta própria.

Resumo

O mutex de exclusão mútua sync.Mutex implementa a espera de goroutines através de dois mecanismos: spin e semáforo. Spin é não bloqueante, mas precisa ser estritamente limitado, pois consome recursos de CPU; enquanto semáforo é bloqueante, podendo evitar efetivamente desperdício desnecessário de recursos. Para implementar um mecanismo de competição mais justo, o Go garante que as goroutines possam competir pelo lock de forma mais balanceada através da distinção entre modo normal e modo de fome. Comparado com locks de baixo nível como runtime.mutex, sync.Mutex, como lock orientado ao usuário, considera mais cenários de uso práticos durante o design.

O mutex de leitura-escrita sync.RWMutex implementa exclusão mútua escrita-escrita através do mutex de exclusão mútua sync.Mutex, e在此基础上 adiciona dois semáforos extras para implementar exclusão mútua de leitura-escrita e compartilhamento de leitura-leitura, suportando assim múltiplos cenários concorrentes.

Embora a implementação de locks pareça complexa, uma vez entendido o princípio de Mutex, aprender outras ferramentas de sincronização na biblioteca padrão sync se torna muito mais fácil.

Golang por www.golangdev.cn edit