Как стать автором
Обновить

Укрощение примитивов синхронизации: сравниваем решения задачи с построением пула потоков на С и Go

Время на прочтение21 мин
Количество просмотров4.8K
Всего голосов 16: ↑14 и ↓2+17
Комментарии16

Комментарии 16

У вас один и тот же код для блокирующего и не блокирующего сокета

UPD: спасибо! был один и тот же код, поправили. Сейчас актуальный вариант.
Для блокирующего варианта используется простой вызов accept(), для неблокирующего варианта уже accept4 с флагом SOCK_NONBLOCK дополнительно обернутый в do/while для разрешения ситуации с EAGAIN.

для не блокирующего я еще ставлю sleep - электричество экономлю

Обычный простой sleep или вариации smart sleep из области мультиплексирования (select/poll/epoll)?

Спасибо за внимательность! Поправили

пробежал бегло по тексту глаз зацепился за 2 утверждения

""При использовании pthread из набора стандартной библиотеки С glibc в Linux доступен богатый выбор примитивов синхронизации: мьютексы, условные переменные, спинлоки и барьеры, которые обычно редко где используются в коде на С ""

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


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

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

Спасибо за размышления!
Из личного опыта мысль про редкость использования относится в основном к pthread барьерам, остальные примитивы используются почти повсеместно. Вероятно оттого что данный примитив не так сильно распространен и используется для ограниченного круга задач. Встречались различные вариации других примитивов явно напоминающие использование барьера, например, счетный вариант (EFD_SEMAPHORE) eventfd или сами семафоры в чистом виде.

Не полностью понял часть комментария про примитивы С и основу ОС.
Про привязку примитивов синхронизации к ОС вопрос достаточно обширный только потому что у Golang необходимо их рассматривать как часть рантайма: там в целом очень большое число оптимизаций для того чтобы избегать накладных расходов в вызовы ОС и обходиться поддержкой уровня горутин. Сам golang в чистом виде не использует вызовы С для имплементации примитивов синхронизации (системные вызовы, например, futex тут не рассматриваю).
Но основа так или иначе это одна - ОС которая предоставляет основные возможности для базовых операций.

я имел ввиду что примитивы С и примитивы ОС это одно и тоже
С не добаляет не убовляет ничего

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

Что насчет сравнения потребления памяти в реализации GO по сравнению с Си от 1к потоков например?

Чисто с теоретической точки зрения, у Go никаких проблем не будет, потому что там стек горутины (собственно, гошного легковесного потока) 2-8 кбайт в зависимости от версии. И работать это будет поверх реальных потоков, которых будет выделено не больше, чем их доступно в ОС.

На Си, если я правильно понимаю, два варианта:

  1. Поднимать 1000 потоков, мне кажется, будет крайне жирно, вес будет зависеть от реализации в ОС (1-4 мбайт вроде в зависимости от битности)

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

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

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

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

Зачем? Просто зачем он держит мьютекс не только во время получения задачи, а постоянно всё время работы? Это похоже на неправильное использование мьютекса


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

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

  • как пробудить поток и сообщить ему о новом запросе на обработку,

  • что если на очередном запросе не будет свободных потоков в пуле.


Конечно это далеко неидеальный тредпул, но я не вижу недостатков по сравнению с предложенным в статье. Плюс можно рассмотреть дальнейшие варианты с несколькими очередями и разными стратегиями выбора потока и тд (https://github.com/kelbon/kelcoro/blob/main/include/thread_pool.hpp). Тут уже добавляет своих проблем странное ограничение на запрет С++, при том что всё апи линукса почему-то разрешено

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

Вот замеры для Linux - Goroutines Are Not Significantly Smaller Than Threads и комментарии к ним

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

Конкретно я говорю про Go и userver, я не отрицаю что можно сделать хорошо, только вот люди не хотят думать и писать хороший код

"Создавать потоки в Linux даже на С, используя glibc, несложно.."

--------------------

Создавать потоки в Windows на С еще проще. Для этого нужна всего одна функция QueueUserWorkItem , которая помещает рабочий элемент(функцию пользователя) в очередь рабочего потока в пуле потоков, который создает OС. ttps://learn.microsoft.com/en-us/windows/win32/procthread/thread-pooling

https://learn.microsoft.com/ru-ru/windows/win32/procthread/thread-pools

А вы не рассматривали вопрос насколько эффективно иметь в пул-потоков количество потоков большее (или меньшее) чем количество физических процессоров (аппаратных ядер) в системе?

Как выбирается/чем определяется количество доступных потоков в пуле?

Зарегистрируйтесь на Хабре, чтобы оставить комментарий