Конкуренция в Go

Теория: Атомарные операции

В конкурентных программах часто встречаются одинаковые задачи: увеличить счетчик, установить флаг, прочитать актуальную конфигурацию. Эти операции кажутся простыми, но выполняются из разных горутин одновременно. Самый очевидный способ защитить общую переменную — обернуть доступ к ней в sync.Mutex.

Однако такой подход быстро начинает тормозить систему. Один мьютекс блокирует доступ ко всей структуре сразу: несколько горутин ждут друг друга даже тогда, когда им нужно выполнить всего одну элементарную операцию — например, прибавить единицу или проверить значение флага. Под нагрузкой очередь на Lock() растет, и задержки увеличиваются без реальной необходимости.

Такая ситуация поднимает естественный вопрос: нужно ли держать полноценную блокировку ради простейших операций над одним словом памяти?

Для этих случаев в Go предусмотрены атомарные операции. Они дают возможность безопасно изменять отдельные значения без мьютексов — быстро, неделимо и с корректным порядком видимости между горутинами. Атомики не заменяют мьютексы полностью, но отлично подходят там, где критическая секция состоит из одной простой операции.

Пакет sync/atomic

Пакет sync/atomic дает примитивы для работы с отдельными машинными словами: целыми числами, флагами, указателями. Операции выполняются атомарно — либо целиком, либо никак. Процессор не может «вклинить» чужую запись в середину, а другие потоки видят изменения в правильном порядке.

Важно ограничение: атомики работают только с простыми значениями фиксированного размера. При необходимости, часто используется сторонняя библиотека

https://github.com/uber-go/atomic. Это не «легкий мьютекс» для любых структур. Если нужно обновлять сразу несколько полей согласованно или изменять сложный объект, без Mutex или каналов не обойтись.

В пакете есть две группы API:

  • функции вроде atomic.AddInt64, atomic.LoadUint32, atomic.StorePointer, CompareAndSwap…, которые принимают адрес обычной переменной;
  • типобезопасные обертки (atomic.Int64, atomic.Bool и т. д. в новых версиях Go).

Общее правило одно: если переменная хоть раз читается или пишется через atomic, все обращения к ней должны быть только атомарными. Нельзя частично использовать атомики, частично — обычный x++ и прямое чтение

atomic.AddInt64, atomic.Load, atomic.Store

Самые частые сценарии — счетчики и флаги. Для них достаточно трех операций: добавить, прочитать и записать.

Счетчик через atomic.AddInt64

Вместо mu.Lock(), counter++, mu.Unlock() можно использовать атомарный инкремент:

var counter int64
var wg sync.WaitGroup

func main() {
	for i := 0; i < 4; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for j := 0; j < 100_000; j++ {
				atomic.AddInt64(&counter, 1)
			}
		}()
	}

	wg.Wait()
	fmt.Println("total:", atomic.LoadInt64(&counter))
}

Здесь:

  • atomic.AddInt64(&counter, 1) атомарно читает текущее значение, прибавляет единицу и записывает обратно;
  • atomic.LoadInt64(&counter) безопасно читает итог.

Ни один Mutex не нужен: все происходит на уровне инструкций процессора.

Флаг остановки через Load/Store

Второй популярный пример — флаг «пора остановиться». Его удобно хранить в uint32 и читать без блокировок:

var stop uint32
var wg sync.WaitGroup

func main() {
	wg.Add(1)
	go func() {
		defer wg.Done()
		for {
			if atomic.LoadUint32(&stop) == 1 {
				fmt.Println("stopped")
				return
			}
			time.Sleep(20 * time.Millisecond)
		}
	}()

	time.Sleep(80 * time.Millisecond)
	atomic.StoreUint32(&stop, 1) // отправляем сигнал остановки
	wg.Wait()
}
  • Воркер периодически вызывает atomic.LoadUint32(&stop) и проверяет, не стал ли флаг равен 1.
  • Основная горутина один раз делает atomic.StoreUint32(&stop, 1), и все воркеры гарантированно увидят это изменение.

Такая схема дешевле, чем мьютекс, потому что ничто не блокируется: читатели просто смотрят на значение, а запись — одна.

Сравнение и обмен (CompareAndSwap)

Иногда важно «выиграть гонку» за право выполнить инициализацию или установить значение только если его еще никто не трогал. Для этого есть операции CompareAndSwap (CAS):

var owner int32 // 0 — свободно, 1 — занято

if atomic.CompareAndSwapInt32(&owner, 0, 1) {
	// мы первые захватили ресурс
	fmt.Println("инициализация")
	// ...
	atomic.StoreInt32(&owner, 0)
} else {
	fmt.Println("кто-то уже инициализирует")
}

CAS читает текущее значение, сравнивает его с ожидаемым и только тогда записывает новое. Все это — одна неделимая операция.

На CAS строят неблокирующие структуры, одноразовую инициализацию и различные «один победитель — остальные ждут» механики. Но для базового курса достаточно помнить: это способ сделать «записать, только если еще ноль».

Когда атомарные операции лучше мьютексов

Атомики не «вытесняют» мьютексы, они решают другую задачу. Есть ситуации, где атомарная операция — идеальный инструмент, а есть — где она только усложнит жизнь. Любой мьютекс всегда можно заменить на atomic. И atomic будет быстрее (под капотом мьютекса - атомик, мьютекс просто обертка над ним)

Когда атомики уместны:

  • Счетчики запросов и метрик. Нужно быстро и безопасно делать +1 из множества горутин. Здесь atomic.Add… проще и дешевле, чем Mutex.

  • Флаги и простые статусы. Примеры: «остановить обработку», «конфиг загружен», «инициализация завершена». Достаточно Load и Store для uint32 или atomic.Bool.

  • Указатель на актуальный снимок данных. Часто конфиг меняется редко, а читается постоянно. Удобно хранить его в atomic.Value или атомарном указателе: писатель публикует новую структуру целиком, читатели читают ссылку без блокировок.

    type Config struct {
    	Rate int
    	Host string
    }
    
    var cfg atomic.Value
    
    func init() {
    	cfg.Store(&Config{Rate: 10, Host: "a"})
    }
    
    func currentConfig() *Config {
    	return cfg.Load().(*Config)
    }

В этих сценариях критическая секция — одна операция над словом памяти. Мьютекс будет лишь добавлять накладные расходы: очереди ожидания, переключение контекстов, лишние блокировки.

Когда атомики не подходят:

  • нужно обновлять несколько полей сразу и гарантировать, что читатель увидит их в согласованном состоянии;
  • важно выполнить цепочку действий как одну транзакцию: прочитал, проверил условие, обновил структуру;
  • логика сложная, и без мьютекса код превращается в набор CAS-циклов и флагов, которые трудно читать и сопровождать.

В таких случаях лучше использовать sync.Mutex или sync.RWMutex. Они чуть тяжелее, но яснее выражают идею «этот блок кода выполняет только один поток».

Наконец, несколько практических правил безопасности:

  • Если переменная хоть раз трогается через atomic, все чтения и записи должны быть атомарными. Никаких «иногда просто x = 1, иногда Store».
  • Структуры с атомарными полями не копируют после инициализации: копия создает второй адрес, и часть горутин начнет писать в один экземпляр, часть — в другой.
  • Атомики — не замена дизайну. Если код с ними становится трудночитаемым, почти всегда проще сделать один Mutex и четко обозначить границу критической секц

Рекомендуемые программы

+7 800 100 22 47

бесплатно по РФ

+7 495 085 21 62

бесплатно по Москве

108813 г. Москва, вн.тер.г. поселение Московский,
г. Московский, ул. Солнечная, д. 3А, стр. 1, помещ. 20Б/3
ОГРН 1217300010476
ИНН 7325174845