Skip to content

Instantly share code, notes, and snippets.

@gus3inov
Created July 4, 2022 15:40
Show Gist options
  • Save gus3inov/3374d28f9511cb7317f153f304efdd3f to your computer and use it in GitHub Desktop.
Save gus3inov/3374d28f9511cb7317f153f304efdd3f to your computer and use it in GitHub Desktop.
Threads

Потоки

Одной из ключевых особенностей Rust является "безбоязненная конкурентность" (fearless concurrency). Однако тот вид конкурентности, который нужен для обработки большого количества задач, зависящих от производительности ввода/вывода (I/O), и который имеется в Go, Elixir, Erlang — отсутствует в Rust.

Давайте предположим, что вы хотите собрать что-то наподобие веб-сервера. Он будет обрабатывать тысячи запросов в каждый момент времени (проблема c10k). Говоря общими словами, рассматриваемая нами проблема состоит из многих задач, выполняющих в основном I/O операции (особенно связанных с сетевым взаимодействием).

"Одновременная обработка N задач" — такая задача лучше всего решается использованием нитей. Однако… Тысячи нитей? Наверное, это слишком много. Работа с нитями может быть довольно ресурсозатратной: каждая нить должна выделить большой стек (stack), настроить нить, используя набор системных вызовов. Ко всему прочему переключение контекста тоже затратно.

Конечно, тысячи одновременно работающих нитей не будет: вы имеете ограниченное число ядер (core), и в любой момент времени только одна нить будет исполняться на этом ядре.

Но в примерах подобных данному веб-серверу большинство этих нитей не будут делать никакой работы. Они будут ждать или поступления запроса, или отправки ответа.

C обычными нитями, когда вы производите блокирующую I/O операцию, системный вызов возвращает управление ядру, которое не возвратит нити управление обратно, потому что, вероятно, I/O операция еще не завершилась. Вместо этого ядро будет использовать данный момент как возможность "подгрузить" (swap in) другую нить и продолжить выполнение исходной нити (начавшей I/O операцию) когда I/O операция будет завершена, то есть когда исходная нить будет "разблокирована" (unblocked). Вот так вы решаете такие задачи в Rust, когда не используете Tokio и подобные ей библиотеки — запускаете миллион нитей и позволяете ОС самостоятельно планировать (schedule) запуск и завершение нитей в зависимости от I/O.

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

Модель многопоточности основанная на легковесных нитях (lightweight threads). Думаю, для того чтобы лучше понять данную модель, нужно на некоторое время отвлечься от Rust и посмотреть на язык, который справляется с этим хорошо, Go.

Так, Go имеет легковесные нити, называемые горутинами (goroutines). Вы запускаете их посредством ключевого слова go. Веб-сервер может выполнять код, подобный следующему:

listener, err = net.Listen(...)
// обработать ошибку
for {
    conn, err := listener.Accept()
    // обработать ошибку

    // запустить горутину
    go handler(conn)
}

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

Если это не настоящие (поддерживаемые ОС) нити, то что тогда происходит?

Горутина является примером легковесной нити. ОС ничего о них не знает, она видит N нитей, которые находятся в распоряжении системы выполнения Go (Go runtime, далее СВ Go).

СВ Go отображает на них M горутин (2), подгружая и выгружая горутины подобно планировщику (scheduler) ОС. СВ Go может это делать благодаря тому, что Go-код допускает прерывание (interruptible), позволяя сборщику мусора (GC) делать свою работу. Все это делает возможным для планировщика намеренную остановку работы горутины.

Планировщик осведомлен о системе I/O, поэтому когда горутина ждёт завершения операции I/O, она возвращает право на исполнение планировщику обратно.

В сущности скомпилированная Go-функция будет иметь набор разбросанных по ней мест, где она говорит планировщику и GC: "Возьмите управление себе, если вы хотите" (и также "Я ожидаю то-то и то-то, пожалуйста, возьмите контроль себе).

Когда горутина подгружена в нить ОС, некоторые регистры будут сохранены и указатель на текущую инструкцию (program counter, PC) будет переведён на новую горутину.

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

Go использует сегментированные стеки (segmented stacks). Причиной того, почему нить нуждается в большом стеке, заключается в том, что большинство языков программирования (ЯП), включая C, ожидают, что стек будет непрерывным, и стеки не могут быть перевыделены (reallocated), как мы поступаем с растущими буферами памяти, потому что мы ожидаем, что данные на стеке будут оставаться на том же месте, позволяя указателям на данные на стеке продолжать работать. Так что мы благоразумно резервируем для себе весь стек, который по нашему мнению, нам может понадобиться (примерно 8 МБ). При этом мы ожидаем, что этого нам хватит.

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

На самом деле сегодня Go делает немножко другое: он копирует стеки. Я упомянул, что стеки не могут быть просто так перевыделены, Ожидается, что данные на стеке будут оставаться на том же месте. Но это не всегда так, потому что Go имеет GC, поэтому в любом случае знает, какие указатели на что указывают, и может при необходимости переписывать указывающие на стек указатели.

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

Rust поддерживал легковесные/green нити (Я верю, что он использовал сегментированные стеки). Однако Rust тщательно следит за тем, чтобы вы не платили за те вещи, которые не используете, и это (использование легковесных нитей) накладывает ограничения на весь ваш код, даже если вы эти самые легковесные нити не используете.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment