Перейти к содержимому

В чем различия goroutine от потока системы

  • автор:

Goroutines — суть потоки?

В документации Golang как-то неясно раскрывается суть goroutines. Скажите, goroutines — это ничто иное как потоки операционной системы? (т.е. как я пониманию, компилятор Go сам заботится о внутренней реализации этих потоков в зависимости от операционной системы)

Отслеживать
задан 21 июн 2013 в 2:08
136 1 1 серебряный знак 8 8 бронзовых знаков

3 ответа 3

Сортировка: Сброс на вариант по умолчанию

по простому — это более высокоуровневые потоки. К примеру, если нужно выполнять очень мелкие действия в отдельных потоках, то обычные потоки будут накладными — система будет дольше их создавать. для goroutines run-time может переиспользовать один и тот же поток, эмулируя работу настоящих потоков. В результате получается быстро и дешево. Проблема номер два — некоторые процессы могут создавать очень большое количество потоков (например, из за ошибки программиста). goroutines такого не должно случиться — они будут прост в очереди.

Можно считать, что goroutines — это такой встроенный в язык thread pool.

Отслеживать
ответ дан 21 июн 2013 в 7:08
112k 6 6 золотых знаков 93 93 серебряных знака 159 159 бронзовых знаков

Не ясно совершенно. Что значит эмуляция потоков? В документации ясно говориться, что если goroutine «зависает» блокирующей io-операцией, то это никак не сказывается на другие потоки. Но если процесс всего один и псевдопотоки это суть один процесс, то как это все решается?

21 июн 2013 в 14:10

ну пусть себе зависает, кто мешает? есть технологии, которые это позволяют обработать. К примеру (это я фантазирую, просто предполагаю, как я бы это сделал!), когда горутине нужно сделать i/o, то она останавливается (при этом она тратит немного памяти), а сам запрос обрабатывается асинхронно run-time’ом. Когда данные придут, горутине выделяются новый поток или тот же + данные, и она продолжает работать. Так как за этот период горутина не работала, то она не мешала другим. Ожидание легко делаются на select/poll/другая технология. В принципе, ОС это и сама делает.

21 июн 2013 в 14:28
Что подразумевается под run-time’ом в данном случае? Отдельный поток операционной системы?
21 июн 2013 в 15:06

run-time — это набор функций, которые вставляет компилятор, чтобы обеспечить работу программы. Он может быть как внутри exe файла (Delphi), так и отдельными (Builder), так и целыми сложными системами с кучей dll и exe. Он может и создавать потоки (как в Java для GC), и по сети с другими компами связываться. Это личное дело run-time. В случае Go я думаю, что он создает несколько потоков, по которым раскидывает goroutines. Но может и не создавать. Например, если это горутина, которая просто складывает два числа.

Backend interview

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

Отличия горутин от потоков

  • Каждый поток операционной системы имеет блок памяти фиксированного размера (зачастую до 2 Мбайт) для стека — рабочей области, в которой он хранит локальные переменные вызовов функций, находящиеся в работе или приостановленные на время вызова другой функции. В противоположность этому go-подпрограмма начинает работу с небольшим стеком, обычно около 2 Кбайт. Стек go-подпрограммы, подобно стеку потока операционной системы, хранит локальные переменные активных и приостановленных функций, но, в отличие от потоков операционной системы, не является фиксированным; при необходимости он может расти и уменьшаться. Максимальный размер стека go-подпрограммы может быть около 1 Гбайта, на порядки больше типичного стека с фиксированным размером, хотя, конечно, такой большой стек могут использовать только несколько go-подпрограмм.
  • Потоки операционной системы планируются в ее ядре, а у go есть собственный планировщик (m:n) мультиплексирующий(раскидывающий) горутинки(m) по потокам(n). Основной плюс = отсуствие оверхеда на переключение контекста.
  • Планировщик Go использует параметр с именем GOMAXPROCS для определения, сколько потоков операционной системы могут одновременно активно выполнять код Go. Его значение по умолчанию равно количеству процессоров компьютера, так что на машине с 8 процессорами планировщик будет планировать код Go для выполнения на 8 потоках одновременно (GOMAXPROCS равно значению п в т:п-планировании). Спящие или заблокированные в процессе коммуникации go-подпрограммы потоков для себя не требуют. Go-подпрограммы, заблокированные в операции ввода-вывода или в других системных вызовах, или при вызове функций, не являющихся функциями Go, нуждаются в потоке операционной системы, но GOMAXPROCS их не учитывает.
  • В большинстве операционных систем и языков программирования, поддерживающих многопоточность, текущий поток имеет идентификацию, которая может быть легко получена как обычное значение (обычно — целое число или указатель). Это облегчает построение абстракции, именуемой локальной памятью потока, которая, по существу, является глобальным отображением, использующим в качестве ключа идентификатор потока, так что каждый поток может сохранять и извлекать значения независимо от других потоков. У горутин нет идентификации, доступной программисту. Так решено во время проектирования языка, поскольку локальной памятью потока программисты злоупотребляют.

context.Context

Хорошей практикой, считается «управлять» горутинами через контекст:

// Контекст предоставляет механизм дедлайнов, сигнал отмены, и доступ к запросозависимым значениям. // Эти методы безопасны для одновременного использования в разных go-рутинах. type Context interface < // Done возвращает канал, который закрывается когда Context отменяется // или по таймауту. Done() // Err объясняет почему контекст был отменен, после того как закрылся канал Done. Err() error // Deadline возвращает время когда этот Context будет отменен. Deadline() (deadline time.Time, ok bool) // Value возвращает значение ассоциированное с ключем или nil. Value(key interface<>) interface<> > 

Лучшие практики

  1. context.Background следует использовать только на самом высоком уровне, как корень всех производных контекстов.
  2. context.TODO должен использоваться, когда вы не уверены, что использовать, или если текущая функция будет использовать контекст в будущем.
  3. Отмены контекста рекомендуются, но эти функции могут занимать время, чтобы выполнить очистку и выход.
  4. context.Value следует использовать как можно реже, и его нельзя применять для передачи необязательных параметров. Это делает API непонятным и может привести к ошибкам. Такие значения должны передаваться как аргументы.
  5. Не храните контексты в структуре, передавайте их явно в функциях, предпочтительно в качестве первого аргумента.
  6. Никогда не передавайте nil-контекст в качестве аргумента. Если сомневаетесь, используйте TODO.
  7. Структура Context не имеет метода cancel, потому что только функция, которая порождает контекст, должна его отменять.
  • https://habr.com/ru/company/nixys/blog/461723/

Конкурентность в Golang

Прежде чем приступать к раскрытию темы нашей статьи, неплохо бы прояснить, что же такое параллелизм и конкурентность и в чём разница между ними. Golang — это тот язык, который делает возможной работу приложения в условиях конкурентности и параллелизма.

Давайте разберёмся, чем они друг от друга отличаются.

Что такое конкурентность?

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

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

  1. Запуск на выполнение баннера с последними предложениями и продуктами cверху страницы.
  2. Показ количества пользователей на сайте в данный момент.
  3. Обновление содержимого корзины при выборе продуктов.
  4. Ведение счётчика времени до следующей распродажи и так далее.

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

На картинке выше у нас несколько задач, выполняемых одновременно, но есть разница в том, как они выполняются. Рассмотрим теперь подробнее.

Конкурентное и параллельное выполнение

Работа с конкурентными приложениями

Допустим, у нас одноядерная система и надо выполнить несколько задач, но есть ограничение: одномоментно может быть выполнена лишь одна задача.

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

Фактор параллельного выполнения здесь отсутствует: параллельные процессы не могут выполняться вместе просто потому, что наша система одноядерная.

На второй картинке в нижней части проиллюстрирована конкурентность без параллелизма. Здесь показано конкурентное выполнение двух задач с переключением контекста: одномоментно может быть выполнена лишь одна задача.

Добавим приложению параллелизма

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

Конкурентность и параллелизм — очень похожие понятия, но мне кажется, что разницу вы уже уловили.

Таким образом, увеличивая сложность системы, можно увеличить и сложность решаемых с её помощью задач: работая с Golang, мы можем масштабировать приложение, с лёгкостью переходя от конкурентного исполнения к параллельному. Масштабируемость в Golang — это легко!

Работа с горутинами

Прежде чем разбирать конкурентность и параллелизм в Golang, первым делом нужно понять, что из себя представляют горутины. Горутины реализуют в Golang обёрточный функционал потоков, а управляются они скорее из среды выполнения Go, нежели из операционной системы.

Среда выполнения Go распределяет или забирает ресурсы памяти у горутин. Горутина во многом похожа на поток тем, что касается выполнения множества задач, но потребляет меньше ресурсов, чем потоки операционной системы. Горутина не имеет полного соответствия с потоками.

Мы можем разделить приложение на множество конкурентных задач, которые могут выполняться с помощью различных горутин. Это предоставит возможность использовать конкурентности в приложении.

Если приложение выполняется на нескольких ядрах, то добавляется и параллелизм.

  1. Они легковесны.
  2. Легко и без проблем масштабируют.
  3. Они — практически потоки.
  4. Требуют меньше памяти (2KB).
  5. Предоставляют дополнительную память горутинам во время выполнения.

Теперь обратимся к простой программе на Golang:

package main

import (
"fmt"
"time"
)

func main() start := time.Now()
func() for i:=0; i < 3; i++ fmt.Println(i)
>
>()

func() for i:=0; i < 3; i++ fmt.Println(i)
>
>()

elapsedTime := time.Since(start)

fmt.Println("Total Time For Execution: " + elapsedTime.String())

time.Sleep(time.Second)
>

Этот код последовательно исполняет внутри основной функции Golang две функции, которые вызываются немедленно.

Здесь мы не используем горутины, а программа выполняется в том же потоке. Никакой конкурентности в приложение мы не добавили. При выполнении получаем такой вывод:

Эта программа выполняется последовательно, начиная с основного потока, после выполняется первая функция немедленного вызова, затем вторая, и потом завершается после выполнения всего, что осталось в теле функции.

В этом коде не было никакого конкурентного исполнения. Можете попробовать проделать нечто подобное в виртуальном редакторе:

А в этом сценарии мы добавляем ключевое слово go к функциям немедленного выполнения. Выполнение начинается с функции main .

Как только доходим до ключевого слова go , создаётся отдельная горутина, добавляющая к приложению другой поток Go, отвечающий за выполнение функции на отдельном конкурентном потоке.

Аналогично будет создана следующая горутина, как только встретится второе ключевое слово go . Она затем выполняет функцию немедленного вызова внутри другого потока горутины.

В данном сценарии в конкурентном режиме будут выполняться три потока: основной main , поток первой функции немедленного выполнения first и поток второй такой функции.

Попробуйте выполнить это в виртуальном редакторе ниже:

В Go мы можем увеличить количество ядер простой строчкой кода. Приложению будет дана команда перейти на несколько ядер:

runtime.GOMAXPROCS(4)

Здесь мы указали, что приложение может использовать четыре ядра для исполнения.

Создаваемые нами горутины могут выполняться вместе на разных ядрах, задействуя параллельное выполнение и ускоряя приложение.

package main

import (
"fmt"
"time"
"runtime"
)

func main() runtime.GOMAXPROCS(4)
start := time.Now()
go func() for i:=0; i < 3; i++ fmt.Println(i)
>
>()

go func() for i:=0; i < 3; i++ fmt.Println(i)
>
>()

elapsedTime := time.Since(start)

fmt.Println("Total Time For Execution: " + elapsedTime.String())

time.Sleep(time.Second)
>

Теперь программа сможет выполняться на нескольких ядрах параллельно и делать это быстрее. Исполнение кода будет вот таким:

С помощью этого GOMAXPROCS мы запрашиваем переход приложения на несколько ядер. И ключевые слова go , добавляющиеся перед исполнением функции, могут исполняться уже отдельно на разных ядрах, увеличивая производительность приложения.

Тут-то мы добавляем вместе с конкурентностью и параллелизм. Можете попробовать выполнить программу в данном виртуальном редакторе:

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

Благодарю за внимание.

  • Обработка ошибок в Golang с помощью Panic, Defer и Recover
  • Примитивы синхронизации в Go
  • 4 golang-сниппета, которые вводят в заблуждение разработчиков C#!

Многопоточность и параллелизм в Go: Goroutines и каналы

Язык программирования Go, разработанный с упором на простоту и эффективность, предлагает уникальный подход к реализации параллельных вычислений через Goroutines и каналы.

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

Goroutines

Разработка Go началась в 2007 году в Google, когда Роб Пайк, Кен Томпсон, и Роберт Грисемер начали работу над новым языком программирования. Одной из ключевых целей было создание языка, который упрощал бы разработку многопоточных программ и управление параллелизмом, особенно в контексте современных многопроцессорных и сетевых систем.

Основным источником вдохновения для Goroutines послужила модель CSP (Коммуникативные последовательные процессы или Взаимодействующие последовательные процессы), разработанная Тони Хоаром в 1970-х годах. CSP подчеркивает важность коммуникации между параллельными процессами через каналы, что стало основой для Goroutines и каналов в Go.

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

Со временем было сделано множество улучшений в планировщике Go, который управляет Goroutines. Эти улучшения направлены на повышение производительности и эффективности, особенно в системах с большим количеством процессоров.

Горутина состоит из 70 строк в коде и выглядит следующим образом:

type g struct
  1. stack : Структура, описывающая стек этой Goroutine. Содержит указатели на нижнюю и верхнюю границы стека.
  2. stackguard0 и stackguard1 (uintptr): Используются для реализации проверок переполнения стека.
  3. _panic : Указатель на структуру паники, если эта Goroutine находится в состоянии паники.
  4. _defer : Указатель на структуру отложенного вызова, используемую для реализации механизма `defer`.
  5. m : Указатель на структуру машины (M), с которой связана эта Goroutine. В Go машина представляет поток ОС.
  6. sched (gobuf): Структура, содержащая контекст планировщика, включая указатели на стек и регистры.
  7. syscallsp , syscallpc (uintptr): Используются при выполнении системных вызовов.
  8. param (unsafe.Pointer): Произвольный параметр, используемый для передачи данных между Goroutines.
  9. atomicstatus (uint32): Статус Goroutine, используемый для управления ее состоянием (например, выполнение, ожидание и т.д.).
  10. stackLock (uint32): Блокировка для управления доступом к стеку Goroutine.
  11. goid (int64): Глобальный уникальный идентификатор Goroutine.
  12. preempt , preemptStop , preemptShrink (bool): Флаги, используемые для управления прерыванием выполнения Goroutine.
  13. waiting (*sudog): Указатель на объект ожидания, связанный с Goroutine.
  14. gcAssistBytes (int64): Количество байтов, которые Goroutine должна помочь выделить для сборщика мусора.

Для создания новой Goroutine достаточно использовать ключевое слово go , за которым следует вызов функции или метода.

 go myFunction()

myFunction будет выполнена как отдельная Goroutine.

Также можно использовать анонимные функции для запуска Goroutines.

 go func() < // Код для выполнения в Goroutine >()

Это позволяет легко передавать аргументы и создавать более сложные структуры управления.

Планировщик горутин

Планировщик обеспечивает эффективное и справедливое управление параллельным выполнением Goroutines. Он работает на уровне языка и отличается от традиционных планировщиков потоков операционной системы.

Планировщик Goroutines в Go мультиплексирует все активные Goroutines на ограниченное количество потоков ОС. Это позволяет эффективно использовать многопроцессорность без создания избыточного количества потоков ОС.

В отличие от преемптивного планирования в традиционных системах, где поток может быть прерван в любой момент, Go использует кооперативную планировку. Это означает, что контекстные переключения в основном происходят в определенных точках выполнения программы, например, при операциях ввода-вывода или при явных вызовах функций для блокировки/разблокировки.

Планировщик использует алгоритм «кражи работы» для балансировки нагрузки между потоками. Если один поток ОС завершает выполнение своих Goroutines, он может «украсть» Goroutines из очереди другого потока для обеспечения равномерного распределения работы.

Пример, иллюстрирующий работу планировщика Goroutines:

package main import ( "fmt" "runtime" "time" ) func printNumbers(prefix string) < for i := 0; i < 5; i++ < fmt.Printf("%s: %d\n", prefix, i) time.Sleep(1 * time.Millisecond) // Имитация длительной работы >> func main() < runtime.GOMAXPROCS(1) // Ограничение использования одним процессорным ядром go printNumbers("Goroutine1") go printNumbers("Goroutine2") time.Sleep(100 * time.Millisecond) // Дать время для завершения Goroutines >
  • Устанавливается GOMAXPROCS(1) , чтобы ограничить выполнение на одном процессорном ядре.
  • Запускаются две Goroutines, каждая из которых печатает числа с задержкой.
  • Планировщик Go будет мультиплексировать эти Goroutines на одном потоке ОС, попеременно выделяя им время CPU для выполнения.

Стэк

По умолчанию каждая Goroutine начинается с небольшого размера стека, обычно несколько килобайт. Это существенно меньше, чем стековый размер стандартных потоков операционной системы, что позволяет создавать тысячи или даже миллионы Goroutines в одном приложении с минимальным потреблением памяти.

Стек Goroutine может динамически расширяться и сжиматься в зависимости от потребностей. Это означает, что Goroutine использует только тот объем памяти, который ей действительно необходим, и может адаптироваться к изменяющимся требованиям памяти во время выполнения.

Goroutines мультиплексируются на меньшее количество потоков операционной системы. Это означает, что множество Goroutines могут выполняться в контексте небольшого количества потоков ОС, что снижает накладные расходы на управление потоками.

Когда Goroutine создается, ей выделяется стек небольшого размера. Этот размер достаточен для выполнения большинства операций, не требующих значительного объема памяти.

Если Goroutine требует больше памяти, чем доступно в ее текущем стеке, стек автоматически расширяется (как правило, удваивается) для предоставления дополнительной памяти. Подобным образом, стек может сжиматься, когда большой объем памяти больше не требуется.

Переменные, выделенные в Goroutine, которые должны сохраняться после завершения функции, автоматически перемещаются на кучу. Это управление памятью выполняется автоматически компилятором Go.

Легковесность Goroutines позволяет создавать и управлять большим количеством параллельных задач с минимальными затратами ресурсов, что делает Go особенно подходящим для высокопроизводительных и масштабируемых приложений.

Несмотря на автоматическое управление памятью, мы должны быть осторожны с утечками памяти и неправильным использованием ресурсов, особенно при работе с большим количеством Goroutines.

Блокировка и неблокировка в контексте Goroutines в языке программирования Go относятся к способу, которым Goroutines взаимодействуют друг с другом и с различными ресурсами системы. Эти концепции имеют ключевое значение для понимания параллелизма и конкуренции в Go.

Блокировка в Goroutines происходит, когда выполнение Goroutine приостанавливается до тех пор, пока не будет выполнено определенное условие или действие.

При отправке данных в небуферизированный канал, Goroutine блокируется, пока другая Goroutine не прочитает данные из этого канала. Аналогично, чтение из небуферизированного канала блокируется, пока в него не будут отправлены данные.

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

Goroutine может быть заблокирована при попытке захвата мьютекса, который уже занят другой Goroutine. Goroutine также может блокироваться при вызове Wait() на sync.WaitGroup , пока другие Goroutines не вызовут Done() .

Сетевые запросы или чтение/запись файлов могут вызвать блокировку Goroutine до завершения операции.

Неблокировка означает, что выполнение Goroutine продолжается без остановки, даже если другие операции еще не завершены.

Можно использовать конструкцию select с веткой default для неблокирующей отправки или получения данных из канала:

 select < case ch 

Неправильное управление блокировками может привести к снижению производительности и даже к мертвым замкам (deadlocks), когда несколько Goroutines взаимно блокируют друг друга.

Каналы

Канал в Go — это механизм передачи данных, который позволяет безопасно передавать значения между различными Goroutines.

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

Вы можете запустить Goroutine для выполнения некоторой задачи, а результат отправить обратно в основную Goroutine через канал. Например:

 ch := make(chan int) go func() < result := someLongComputation() ch ()

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

Основная Goroutine может ожидать данных из другой Goroutine через канал. Это обычно используется для синхронизации выполнения задач или сбора результатов:

 result := 

При получении данных из канала, можно проверить, был ли канал закрыт. Это делается с помощью второго возвращаемого значения в операции чтения: value, ok :=

Это полезно, когда вы работаете с несколькими Goroutines, отправляющими данные в один канал, и нужно знать, когда все Goroutines завершили свою работу:

 for < result, ok := fmt.Println("Received:", result) > 

Ключевая особенность работы с каналами в Go — возможность использования оператора select для мультиплексирования операций отправки и приема по нескольким каналам. Это позволяет Goroutine ожидать на нескольких каналах одновременно, продолжая выполнение, как только одна из операций становится возможной.

 select

Типы каналов

Небуферизированные каналы

Когда Goroutine отправляет данные в небуферизированный канал, она блокируется до тех пор, пока другая Goroutine не прочитает эти данные. Аналогично, если Goroutine пытается прочитать данные из канала, она будет блокирована, пока другая Goroutine не отправит данные.

Небуферизированные каналы часто используются для синхронизации двух Goroutines, где одна ждет данных от другой. Отправка сообщений между Goroutines, где каждое сообщение должно быть обработано перед отправкой следующего.

Создание: ch := make(chan int) — создает небуферизированный канал для передачи целых чисел.

Буферизированные каналы

Буферизированный канал имеет внутренний буфер, позволяющий отправлять данные в канал без блокировки до тех пор, пока буфер не заполнится. Отправка в полностью заполненный буферизированный канал блокирует Goroutine, пока в канале не появится свободное место.

Идеален для случаев, когда данные производятся и потребляются разными темпами.

Создание: ch := make(chan int, 100) — создает буферизированный канал для передачи целых чисел с буфером на 100 элементов.

Функция close(ch) используется для закрытия канала. После закрытия канала нельзя отправлять в него данные, но можно продолжать читать данные, которые были в нем до закрытия.

select позволяет ожидать несколько операций с каналами одновременно, блокируясь до тех пор, пока одна из операций не станет выполнимой.

Используется для реализации тайм-аутов, обработки событий от нескольких каналов и других сложных сценариев взаимодействия между Goroutines.

Пример кода с select :

 select

Паттерны использования каналов

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

1. Fan-out и Fan-in

Распределение задач между несколькими Goroutines для параллельной обработки. Подходит для сценариев, где задачи могут выполняться независимо и параллельно, например, обработка запросов к базе данных или выполнение вычислений.

Технически представляет собой отправку задачи в несколько Goroutines, каждая из которых читает из общего канала.

Собирает результаты из нескольких Goroutines в один канал. Полезно для агрегации результатов параллельно выполняемых задач.

Несколько Goroutines отправляют свои результаты в один канал, который затем читается для получения окончательных данных.

Пайплайн (Pipeline)

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

Каждый этап пайплайна реализован как Goroutine, читающая из входного канала и пишущая в выходной.

Cоздадим пайплайн из трех этапов, каждый из которых представлен отдельной Goroutine. Каждый этап принимает данные, обрабатывает их и передает результат на следующий этап:

package main import ( "fmt" "strconv" ) func generator(nums . int) close(out) >() return out > func square(in close(out) >() return out > func toString(in close(out) >() return out > func main() < gen := generator(2, 3, 4) sq := square(gen) str := toString(sq) for s := range str < fmt.Println(s) >>

В этом примере generator функция генерирует числа, square функция возводит их в квадрат, а toString преобразует числа в строки. Данные передаются через каналы между этапами.

Контроль времени ожидания (Timeout сontrol)

Ограничивает время ожидания операции. Предотвращает бесконечное ожидание в случае задержек или сбоев, например, при запросах к внешним сервисам.

Реализуется использованием select с каналом таймера для ограничения времени выполнения операции.

package main import ( "fmt" "time" ) func operation(ch chan func main() < ch := make(chan string) go operation(ch) select < case res := >

В этом примере operation функция имитирует длительную операцию. Основная Goroutine ожидает результат или тайм-аут в течение одной секунды с использованием select . Если операция не завершается в течение одной секунды, срабатывает ветка тайм-аута.

Оркестрация с помощью select

Управление несколькими каналами одновременно. Подходит для ситуаций, когда Goroutine должна обрабатывать несколько входных каналов.

Реализуется использованием select для одновременного ожидания на нескольких каналах, обработки событий, поступающих из этих каналов.

Пример ожидания сообщений из нескольких каналов:

package main import ( "fmt" "time" ) func worker(id int, ch chan string) < for < time.Sleep(time.Second) ch > func main() < ch1 := make(chan string) ch2 := make(chan string) go worker(1, ch1) go worker(2, ch2) for i := 0; i < 5; i++ < select < case msg1 := > >

В этом примере две Goroutines (worker(1, ch1) и worker(2, ch2)) отправляют сообщения в свои каналы. Основная Goroutine использует select для обработки сообщений из обоих каналов.

Завершение работы

Позволяет одной Goroutine сообщить другой о необходимости завершить работу. Останавливает Goroutines, когда они больше не нужны, предотвращая утечки ресурсов.

Реализуется так же использованием канала для отправки сигнала о завершении, обычно в сочетании с select .

Как использовать select в сочетании с каналом для управления завершением работы Goroutine:

package main import ( "fmt" "time" ) func worker(stopCh chan struct<>) < for < select < case > > func main() < stopCh := make(chan struct<>) go worker(stopCh) // Предположим, что основная Goroutine занимается своими задачами time.Sleep(3 * time.Second) // Отправляем сигнал о завершении работе close(stopCh) // Даем время для завершения работы time.Sleep(time.Second) >

В этом примере Goroutine worker выполняет свою работу, пока не получит сигнал о завершении через канал stopCh . Закрытие канала stopCh в основной Goroutine служит сигналом для worker прекратить свою работу.

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

Синхронизация данных

Синхронизация данных относится к процессу координации доступа к данным между несколькими потоками (или Goroutines в случае Go), чтобы предотвратить конфликты и неконсистентность данных.

1. Мьютексы (Mutexes)

Мьютекс используется для обеспечения взаимного исключения доступа к общим ресурсам, тем самым предотвращая одновременное изменение данных несколькими Goroutines.

Реализация в Go:

В стандартной библиотеке sync Go предоставляется тип Mutex, который используется для создания мьютекса.

Блокировка и разблокировка:

var mu sync.Mutex mu.Lock() // Блокировка мьютекса перед доступом к общим данным // Критический раздел: здесь происходит работа с общими данными mu.Unlock() // Разблокировка мьютекса после завершения работы
var counter int var mu sync.Mutex func increment() < mu.Lock() counter++ // безопасный доступ к общей переменной mu.Unlock() >func main() < for i := 0; i < 10; i++ < go increment() >// дополнительный код для ожидания завершения Goroutines
2. Read-Write Mutexes

Read-Write Mutex (представленный как sync.RWMutex ) позволяет множественный доступ для чтения, но исключительный доступ для записи.

Множество Goroutines могут одновременно захватывать мьютекс в режиме чтения. Только одна Goroutine может захватывать мьютекс в режиме записи, и при этом должна быть гарантирована полная исключительность (никакие другие Goroutines не могут читать или писать).

import "sync" var rw sync.RWMutex var sharedResource int func readOperation() < rw.RLock() // Захват мьютекса для чтения _ = sharedResource // Чтение общего ресурса rw.RUnlock() // Освобождение мьютекса после чтения >func writeOperation() < rw.Lock() // Захват мьютекса для записи sharedResource++ // Модификация общего ресурса rw.Unlock() // Освобождение мьютекса после записи >

В этом примере rw используется для управления доступом к sharedResource. Операция чтения не блокирует другие операции чтения, но операция записи требует полной исключительности.

1. Каналы

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

 ch := make(chan int) go func() < // выполнение задачи ch ()
2. WaitGroups

sync.WaitGroup используется для ожидания завершения группы Goroutines. Полезно для сценариев, где нужно дождаться завершения всех запущенных Goroutines, прежде чем продолжить выполнение программы.

Каждая Goroutine при запуске вызывает wg.Add(1) , а по завершении — wg.Done() . Главная Goroutine использует wg.Wait() для ожидания завершения всех Goroutines.

 var wg sync.WaitGroup wg.Add(1) go func() < defer wg.Done() // выполняемая задача >() wg.Wait()
3. Атомарные операции

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

В Go атомарные операции реализованы в пакете sync/atomic.

 var counter int32 atomic.AddInt32(&counter, 1)

5 распространенных ошибок

1. Утечка Goroutines

Неуправляемое создание Goroutines может привести к их утечке, что означает, что Goroutines продолжают существовать, даже когда они больше не нужны, что приводит к избыточному использованию ресурсов.

Решение:
Используйте контекст (`context.Context`) для контроля жизненного цикла Goroutines. Аккуратно управляйте условиями завершения Goroutines, используя каналы или другие механизмы синхронизации.

2. Заблокированные goroutines

Goroutine ожидает на канале или другом примитиве синхронизации и никогда не получает необходимых данных или сигнала для продолжения, что приводит к бессмысленному ожиданию.

Решение:
Используйте паттерны с тайм-аутом, например, с помощью select и time.After. Убедитесь, что каналы правильно закрываются, и другие Goroutines отправляют ожидаемые сигналы.

3. Гонки данных

Одновременный доступ к общим данным из разных Goroutines без должной синхронизации приводит к гонкам данных.

Решение:
Используйте мьютексы ( sync.Mutex ) или каналы для безопасного доступа к общим данным. Применяйте паттерн «один писатель, множество читателей» при использовании каналов.

4. Мертвые замки (Deadlocks)

Программа полностью «зависает», потому что две или более Goroutines ожидают друг от друга действий, которые никогда не произойдут.

Решение:
Тщательно проектируйте логику синхронизации и избегайте ситуаций, когда Goroutine ожидает ресурс, захваченный другой Goroutine. Используйте инструменты анализа кода для обнаружения потенциальных мертвых замков.

5. Неправильное использование закрытия каналов

Попытка записать в закрытый канал или закрытие канала более одного раза вызывает панику в программе.

Решение:
Убедитесь, что канал закрывается только один раз и только отправляющей стороной. Перед отправкой в канал проверяйте, не закрыт ли он уже, особенно в условиях конкурентного доступа.

Заключение

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

Использование Goroutines и каналов позволяет добиться высокой производительности, но также важно сохранять читаемость и поддерживаемость кода.

  • Блог компании М.Видео-Эльдорадо
  • Программирование
  • Go
  • Параллельное программирование

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *