← Назад к вопросам

Что потоки используют совместно?

2.0 Middle🔥 121 комментариев
#Конкурентность и горутины

Комментарии (1)

🐱
deepseek-v3.2PrepBro AI7 апр. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

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

Основной принцип: Память процесса общая, но общение должно быть явным.

Горутины, запущенные в рамках одного процесса Go, совместно используют практически всё адресное пространство этого процесса, но философия языка призывает к общению через каналы (channels), а не через прямую разделяемую память. Давайте структурируем ответ.

1. Общая память процесса (Разделяемое по умолчанию, но опасное)

Вся выделенная куча (heap) и глобальные переменные — общие. Это фундаментальная характеристика любого многопоточного процесса.

package main

import (
    "fmt"
    "sync"
)

// Глобальная переменная — разделяемый ресурс
var counter int
var mu sync.Mutex // Мьютекс для синхронизации доступа

func increment() {
    mu.Lock()
    counter++ // Прямой доступ к общей памяти — требует синхронизации!
    mu.Unlock()
}

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            increment()
        }()
    }
    wg.Wait()
    fmt.Println(counter) // 1000
}

Здесь counter — яркий пример разделяемой памяти. Без мьютекса mu произошло бы состояние гонки (data race). Go предоставляет примитивы синхронизации в пакете sync (мьютексы, WaitGroup, Once, Pool), которые также являются разделяемыми объектами для координации доступа к другим разделяемым данным.

2. Каналы (Channels) — Идиоматичный способ "разделения"

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

package main

import "fmt"

func worker(id int, jobs <-chan int, results chan<- int) {
    for j := range jobs { // Горутины совместно используют доступ к каналу `jobs`
        results <- j * 2
    }
}

func main() {
    jobs := make(chan int, 100)
    results := make(chan int, 100)

    // Запуск рабочих горутин
    for w := 1; w <= 3; w++ {
        go worker(w, jobs, results)
    }

    // Отправка заданий в разделяемый канал
    for j := 1; j <= 5; j++ {
        jobs <- j
    }
    close(jobs)

    // Чтение результатов
    for r := 1; r <= 5; r++ {
        <-results
    }
}

Каналы jobs и results являются разделяемыми объектами, через которые горутины координируют работу и передают данные без явной блокировки памяти (блокировка инкапсулирована внутри операций с каналом).

3. Ресурсы под управлением рантайма и ОС

Горутины также неявно совместно используют более глубокие ресурсы:

  • Потоки ОС (M в модели планировщика): Горутины multiplex'ятся на пуле потоков ОС. Эти потоки — разделяемый ресурс.
  • Сетевые поллиннеры (kqueue, epoll, I/O Completion Ports): Используются для асинхронного сетевого I/O всеми горутинами процесса.
  • Менеджер памяти (Garbage Collector) и его структуры данных: Куча управляется одним сборщиком мусора.
  • Файловые дескрипторы и другие ресурсы процесса: Открытый файл или сетевое соединение, полученное в одной горутине, может быть использовано в другой.

4. Что НЕ является общим? Локальный стек.

Каждая горутина имеет свой собственный, независимый стек вызовов, который обычно начинается с небольшого размера (несколько КБ) и динамически растёт/уменьшается. Локальные переменные функции, выполняемой в горутине, размещаются на этом стеке и недоступны напрямую из других горутин.

func main() {
    localVar := 42 // Локальная переменная в main
    go func() {
        // fmt.Println(localVar) // ТАК ДЕЛАТЬ ОПАСНО! Захват переменной по ссылке.
        // Это создает замыкание, и `localVar` теперь фактически размещается в куче (общей памяти)
        // и время ее жизни продлевается. Это пример неявного "разделения".
    }()
}

Ключевые выводы и философия Go

  1. Разделяема по умолчанию: Вся куча и глобальные переменные общие, что потенциально приводит к состояниям гонки (data races).
  2. Идиоматичный подход — общение, а не разделение: Лозунг "Do not communicate by sharing memory; instead, share memory by communicating" является краеугольным камнем. Предпочтительный способ — передавать владение данными между горутинами через каналы, что делает передачу данных синхронизированным событием.
  3. Синхронизация обязательна: Если вы всё же делите память (используете общие структуры данных, кэши и т.д.), вы ОБЯЗАНЫ использовать примитивы синхронизации из пакета sync или атомарные операции (sync/atomic). Инструмент go run -race — ваш лучший друг для обнаружения гонок.

Таким образом, горутины совместно используют практически всё адресное пространство процесса, системные ресурсы и механизмы рантайма, но модель программирования Go через каналы и примитивы sync предоставляет структурированные и безопасные способы управления этим разделением, минимизируя ошибки, присущие традиционному многопоточному программированию.