Потоки в 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<> >
Лучшие практики
- context.Background следует использовать только на самом высоком уровне, как корень всех производных контекстов.
- context.TODO должен использоваться, когда вы не уверены, что использовать, или если текущая функция будет использовать контекст в будущем.
- Отмены контекста рекомендуются, но эти функции могут занимать время, чтобы выполнить очистку и выход.
- context.Value следует использовать как можно реже, и его нельзя применять для передачи необязательных параметров. Это делает API непонятным и может привести к ошибкам. Такие значения должны передаваться как аргументы.
- Не храните контексты в структуре, передавайте их явно в функциях, предпочтительно в качестве первого аргумента.
- Никогда не передавайте nil-контекст в качестве аргумента. Если сомневаетесь, используйте TODO.
- Структура 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 — это тот язык, который делает возможной работу приложения в условиях конкурентности и параллелизма.
Давайте разберёмся, чем они друг от друга отличаются.
Что такое конкурентность?
Конкурентность предполагает работу приложения с двумя и более задачами одновременно, когда происходит создание нескольких процессов, выполняющихся независимо друг от друга.
Приложения могут иметь дело с большим количеством процессов сразу для достижения желаемого поведения. Допустим, есть простенький интернет-магазин. Посмотрим, какие могут быть одновременно выполняемые задачи. Вот их список:
- Запуск на выполнение баннера с последними предложениями и продуктами cверху страницы.
- Показ количества пользователей на сайте в данный момент.
- Обновление содержимого корзины при выборе продуктов.
- Ведение счётчика времени до следующей распродажи и так далее.
Для интернет-магазина важно, чтобы все эти задачи выполнялись одновременно, ведь нужно удержать пользователей на сайте или в приложении, сделав его максимально привлекательным для них, чтобы они оставили здесь свои деньги. Поэтому можно сделать так, чтобы на простом сайте в фоновом режиме выполнялось множество задач.
На картинке выше у нас несколько задач, выполняемых одновременно, но есть разница в том, как они выполняются. Рассмотрим теперь подробнее.
Конкурентное и параллельное выполнение
Работа с конкурентными приложениями
Допустим, у нас одноядерная система и надо выполнить несколько задач, но есть ограничение: одномоментно может быть выполнена лишь одна задача.
В модели конкурентного выполнения имеет место переключение контекста между задачами: приложение работает с несколькими задачами, но не может выполнять их все вместе, ведь ядро всего одно. Переключение контекста происходит настолько быстро, что создаётся ощущение, что задачи выполняются одновременно.
Фактор параллельного выполнения здесь отсутствует: параллельные процессы не могут выполняться вместе просто потому, что наша система одноядерная.
На второй картинке в нижней части проиллюстрирована конкурентность без параллелизма. Здесь показано конкурентное выполнение двух задач с переключением контекста: одномоментно может быть выполнена лишь одна задача.
Добавим приложению параллелизма
В случае с одноядерной системой у нас были ограничения по ресурсам. Если мы добавим несколько ядер, ресурсов станет больше и приложение сможет одновременно выполнять на разных ядрах множество задач. В верхней части той же картинки показано, как на разных ядрах одновременно и параллельно выполняются две задачи.
Конкурентность и параллелизм — очень похожие понятия, но мне кажется, что разницу вы уже уловили.
Таким образом, увеличивая сложность системы, можно увеличить и сложность решаемых с её помощью задач: работая с Golang, мы можем масштабировать приложение, с лёгкостью переходя от конкурентного исполнения к параллельному. Масштабируемость в Golang — это легко!
Работа с горутинами
Прежде чем разбирать конкурентность и параллелизм в Golang, первым делом нужно понять, что из себя представляют горутины. Горутины реализуют в Golang обёрточный функционал потоков, а управляются они скорее из среды выполнения Go, нежели из операционной системы.
Среда выполнения Go распределяет или забирает ресурсы памяти у горутин. Горутина во многом похожа на поток тем, что касается выполнения множества задач, но потребляет меньше ресурсов, чем потоки операционной системы. Горутина не имеет полного соответствия с потоками.
Мы можем разделить приложение на множество конкурентных задач, которые могут выполняться с помощью различных горутин. Это предоставит возможность использовать конкурентности в приложении.
Если приложение выполняется на нескольких ядрах, то добавляется и параллелизм.
- Они легковесны.
- Легко и без проблем масштабируют.
- Они — практически потоки.
- Требуют меньше памяти (2KB).
- Предоставляют дополнительную память горутинам во время выполнения.
Теперь обратимся к простой программе на 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#!