В чем различия goroutine от потока системы
Перейти к содержимому

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

  • автор:

Потоки в C++ против потоков в Go

После поста про потоки в Go я прочитал другое мнение про общую целесообразность Go в плане продвинутости в многопоточном программировании.

Признаюсь, я не боец в бусте и новом C++, но благодаря предоставленному примеру, было очевидно, что и на С++ решение получается весьма изящное.

Интересно было сравнить производительнось потоков во обоих языках в плане скорости из создания и назначения им работы. Как я понял, это битва между pthreads и системой Go-рутин, которые не являются потоками операционной системы. Как сказано в документации:

Goroutines are multiplexed onto multiple OS threads so if one should block, such as while waiting for I/O, others continue to run. Their design hides many of the complexities of thread creation and management.

Я взял последний boost, и на той же восьми процессорной машине провел эксперимент.

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

Итак, программа на Go:

package main import ( "flag" "fmt" ) var jobs *int = flag.Int("jobs", 8, "number of concurrent jobs") var n *int = flag.Int("tasks", 1000000, "number of tasks") func main() flag.Parse() fmt.Printf("- running %d concurrent job(s)\n", *jobs) fmt.Printf("- running %d tasks\n", *n) tasks := make(chan int, *jobs) done := make(chan bool) for i := 0; i < *jobs; i++ go runner(tasks, done) > for i := 1; i n; i++ tasks  i > for i := 0; i < *jobs; i++ tasks  0  done > > func runner(tasks chan int, done chan bool) for  if arg := tasks; arg == 0  break > worker() > done  true > func worker() int  return 0 > 

Makefile для прогона по серии параметров:

target = go_threading all: build build: 6g $(target).go 6l -o $(target) $(target).6 run: (time -p ./$(target) -tasks=$(args) \ 1>/dev/null) 2>&1 | head -1 | awk '< print $$2 >' n = \ 10000 \ 100000 \ 1000000 \ 10000000 \ 100000000 test: @for i in $(n); do \ echo "`printf '% 10d' $$i`" `$(MAKE) args=$$i run`; \ done
#include #include  #include  #include #include #include class thread_pool  typedef boost::function0void> worker; boost::thread_group threads_; std::queueworker> queue_; boost::mutex mutex_; boost::condition_variable cv_; bool done_; public: thread_pool() : done_(false) for(int i = 0; i  boost::thread::hardware_concurrency(); ++i) threads_.create_thread(boost::bind(&thread_pool::run, this)); > void join() threads_.join_all(); > void run() while (true) worker job;  boost::mutex::scoped_lock lock(mutex_); while (queue_.empty() && !done_) cv_.wait(lock); if (queue_.empty() && done_) return; job = queue_.front(); queue_.pop(); > execute(job); > > void execute(const worker& job) job(); > void add(const worker& job) boost::mutex::scoped_lock lock(mutex_); queue_.push(job); cv_.notify_one(); > void finish() boost::mutex::scoped_lock lock(mutex_); done_ = true; cv_.notify_all(); > >; void task() volatile int r = 0; > int main(int argc, char* argv[]) thread_pool pool; int n = argc > 1 ? std::atoi(argv[1]) : 10000; int threads = boost::thread::hardware_concurrency(); std::cout  <"- executing "   <" concurrent job(s)"  ::endl; std::cout  <"- running "   <" tasks"  ::endl; for (int i = 0; i  n; ++i) pool.add(task); > pool.finish(); pool.join(); return 0; > 
BOOST = ~/opt/boost-1.46.1 target = boost_threading build: g++ -O2 -I $(BOOST) -o $(target) \ -lpthread \ -lboost_thread \ -L $(BOOST)/stage/lib \ $(target).cpp run: (time -p LD_LIBRARY_PATH=$(BOOST)/stage/lib ./$(target) $(args) \ 1>/dev/null) 2>&1 | head -1 | awk '< print $$2 >' n = \ 10000 \ 100000 \ 1000000 \ 10000000 \ 100000000 test: @for i in $(n); do \ echo "`printf '% 10d' $$i`" `$(MAKE) args=$$i run`; \ done

В обоих языках число потоков будет равно количеству процессоров — 8. Количество задач, прогоняемых через эти восемь поток будет варьироваться.

Запускаем программу на C++:

make && make -s test g++ -O2 -I ~/opt/boost-1.46.1 -o boost_threading \ -lpthread \ -lboost_thread \ -L ~/opt/boost-1.46.1/stage/lib \ boost_threading.cpp (time -p LD_LIBRARY_PATH=~/opt/boost-1.46.1/stage/lib ./boost_threading \ 1>/dev/null) 2>&1 | head -1 | awk '< print $2 >' 10000 0.03 100000 0.35 1000000 3.43 10000000 29.57 100000000 327.37 
make && make -s test 6g go_threading.go 6l -o go_threading go_threading.6 10000 0.00 100000 0.03 1000000 0.35 10000000 3.72 100000000 38.27 

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

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/

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. Но может и не создавать. Например, если это горутина, которая просто складывает два числа.

Конкурентность в 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#!

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

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