Водитель написал, что у него сломался микроавтобус... Поэтому нам придётся возвращаться в город на электричке.
Саша
Что ж поделаешь, поехали.
Не будем терять времени и начнём разбирать новую тему. Никогда ещё не проводил урок в электричке — это будет весьма интересный опыт.
Саша
На станции есть буфет — зайдём туда перекусить?
Лиза
У меня еда с собой. Зайдите, если хотите.
Саша
Окей. Займём очередь.
Здесь уже столпотворение.
Саша
Кажется, за стойкой работает одна буфетчица. И там кто-то очень долго выбирает, что будет заказывать.
А готовит тоже она? Похоже на то. Жарит яичницу. Делает салат. Теперь режет хлеб.
Саша
Считает на калькуляторе что ли? Ну да, и принимает только наличные. В очереди уже все на взводе.
Как же она мееедленно всё делает...
Саша
Это напоминает мне то, как работали первые компьютеры. Сейчас объясню, что я имею в виду.
Первые компьютеры не отличались быстротой. Вычисления представляли собой длинную последовательность задач, которые выполнялись «в порядке живой очереди». Причём часть операций — в основном ввод-вывод — были медленнее вычислений. Процессор на таких этапах простаивал.
Тогда возникла идея многозадачности. Вычисления стали делить на процессы, использующие общий ресурс. В таком режиме процессы выполняются конкурентно, то есть порядок их выполнения не предопределён. Представьте, что в привокзальном кафе наняли на работу ещё одну буфетчицу. Пока одна жарит яичницу, другая может резать салат или принимать заказы.
Сегодня умение писать многопоточные программы — одно из базовых требований к программистам. Сложно представить себе собеседование на Go-разработчика без вопросов о горутинах, каналах и мьютексах. Инструменты языка Go очень удобны для создания многопоточных приложений, именно поэтому он так популярен, но реализовать такие сервисы без знаний о многопоточности в Go невозможно.
В заключительной теме курса расскажем:
как устроена многопоточность в Go;
как определяются и вызываются горутины;
какие примитивы синхронизации есть в стандартной библиотеке;
что такое каналы и как с ними работать;
зачем нужны атомарные операции и как их использовать;
какие паттерны многопоточного кода применяются в Go.
Лиза
Ребят, наша электричка идёт. Бегом сюда!
Саша
Чёрт, нам так и не удалось ничего взять в буфете.
Основы многопоточности
Представьте себе, как работал бы обычный веб-сервер без многопоточности. Вместо параллельной обработки HTTP-запросов он бы последовательно обрабатывал и отвечал на каждый запрос. В этом случае даже одна страница загружалась бы дольше из-за последовательной подгрузки изображений, скриптов и прочих ресурсов. А о тысячах запросах в секунду даже речи не идёт.
С активным развитием интернета и появлением многоядерных процессоров возникли новые требования к проектированию ПО, которое должно было эффективно использовать доступные ядра процессора. Язык Go успешно решает эту задачу, так как содержит готовые решения и инструменты для написания многопоточного кода.
Процессы и потоки
Для начала разберёмся, что такое многопоточность. Выделим два ключевых понятия — процессы операционной системы и ядра процессора.
Процесс — это выполняющаяся программа и её ресурсы: heap-память, дескрипторы и так далее. Сам процесс хотя и хранит исполняемый код, не может его выполнить. За выполнение кода отвечают потоки.
Поток выполнения — это часть процесса. Каждый поток имеет доступ к контексту родительского процесса и кроме этого содержит собственные ресурсы: стек, специфические данные потока.
В одном процессе может быть несколько потоков. Планировщик операционной системы распределяет потоки по ядрам процессора, чтобы дать каждому потоку равное время выполнения.
Потоков значительно больше, чем ядер процессора. Когда планировщик переключает ядро с выполнения одного потока на другой, контекст предыдущего потока выгружается из памяти и загружаются данные потока, который будет выполняться. Если потоков слишком много, то на переключение между ними уходит много времени: из-за постоянных операций выгрузки-загрузки контекста программа выполняется дольше.
Поэтому некоторые современные языки программирования реализуют легковесные потоки (green threads). Такие потоки легче, потому что легче их контекст. Они могут занимать меньший объём stack-памяти и использовать не все регистры.
За планирование работы лёгких потоков отвечает среда выполнения — рантайм. Благодаря оптимизациям языка переключение между лёгкими потоками происходит быстрее, чем переключение между потоками операционной системы. Операционная система ничего не знает о легковесных потоках, поэтому в каждом языке могут быть свои особенности в реализации таких потоков.
Многопоточность в Go
В Go реализована модель CSP-модель. Программа представляет собой множество одновременно работающих подзадач, которые общаются друг с другом с помощью каналов связи.
В Go модель CSP реализована абстракциями:
Горутина (goroutine) — это легковесный поток, который занимает гораздо меньше памяти, чем поток ОС. Среда выполнения Go может выполнять несколько горутин на одном потоке операционной системы и быстро переключаться с выполнения одной горутины на другую благодаря их малому размеру.
Канал (channel) — это второй ключевой элемент в многопоточности на Go. На каналах построены все механизмы обмена и синхронизации потоков в Go. Одна горутина может записать данные в канал, а другая горутина — прочитать их.
Планировщик рантайма Go управляет всеми создаваемыми горутинами. Он формирует очереди, каждая из которых привязана к потоку операционной системы. При работе программы очередная горутина берётся из очереди и вместе со своим контекстом отправляется на выполнение в поток ОС.
Вытесняющий планировщик старается равномерно распределять процессорное время между горутинами. Таким образом, достигается иллюзия параллельности выполнения задач при количестве горутин, многократно превышающем количество доступных системных потоков.
Принцип работы планировщика рантайма Go представлен на схеме:
Саша
Наша станция! Выходим. Нас много, поэтому предлагаю вызвать несколько машин и доехать до офиса на такси.
Лиза
Шофёр, Goни, плачу два счётчика!
Проблемы многопоточности
Проблемы с многопоточностью возникают, когда несколько потоков работают с общими данными: обращаются к одной и той же памяти внутри процесса. Самая частая проблема — состояние гонки (race condition).
Все арифметические и логические действия в коде — это неатомарные операции. Если несколько потоков одновременно запустятся на разных ядрах процессора и начнут записывать данные в одну и ту же область памяти, возникнет конкуренция потоков. Один поток скопирует в свой регистр устаревшую копию данных, другой поток перезапишет данные и поменяет на свою версию и так далее.
Но есть способы предотвратить состояние гонки. Вот некоторые из них:
Мьютексы (mutex — от mutual exclusion, что переводится как «взаимное исключение»). Мьютекс — это ресурс, который может быть занят только одним потоком. Для других потоков мьютекс заблокируется — они будут ждать, пока он освободится, чтобы продолжить работу.
Атомарные операции. В современных процессорах запрограммированы команды, которые позволяют производить некоторые арифметические и логические действия атомарно. Используя атомарные операции, можно, к примеру, увеличивать целочисленный счётчик из нескольких потоков одновременно без дополнительных блокировок.
Обмен сообщениями. Даёт потокам возможность обмениваться сообщениями или сигналами. Идея обмена сообщениями в том, чтобы создать очередь, в которую один поток может скопировать данные и из которой другой поток может прочитать свою копию.
В Go используются все три способа. Но самый предпочтительный — обмен сообщениями через каналы. Благодаря им можно написать более простой и надёжный многопоточный код. При передаче данных через канал не требуется дополнительное ограничение доступа к ресурсам, поэтому каналы позволяют минимизировать вероятность ошибки программиста.
Пречислим самые частые ошибки:
Взаимная блокировка (deadlock). Рассмотрим на примере. Представьте, что в буфете осталась одна ложка и одна вилка. Приходят два покупателя, каждому из которых нужна вилка и ложка. Они одновременно подходят к столовым приборам. Один берёт вилку, другой — ложку. Далее оба будут ждать, когда принесут другой прибор и не смогут начать есть свой заказ. Если в коде используются несколько мьютексов или каналов, то может возникнуть ситуация, когда все потоки ожидают сигналов один от другого и не могут продолжить работу.
Живая блокировка (livelock). Ещё один пример. Вы договорились созвониться с человеком в определённое время. Каждый раз, когда вы набираете его номер, он звонит вам. Вы отменяете вызов и ждёте какое-то время, но он делает то же самое. Живая блокировка — это cостояние программы, при котором потоки что-то делают, но не выполняют полезной работы. Например, два потока пытаются взять несколько мьютексов. Один берёт А и B, а другой B и A. Если второй мьютекс занят, каждый поток откатывается назад. Оба потока формально не будут заблокированы, но они будут постоянно то блокировать, то разблокировать мьютексы.
Голодание (starvation). Представьте, что человек едет с вокзала в аэропорт. Вместе с билетом на самолёт он взял priority pass (Priority Pass — международная программа доступа в VIP-залы аэропортов), прошёл вне очереди, но его не пускают — из-за большой сумки в ручной клади. Он ругается, занимает время стюардесс. Из-за этого никто не может пройти дальше. В коде такая ситуация возникает, когда горутина захватывает мьютекс на большее время, чем ей нужно на самом деле. Из-за этого другим потокам приходится ждать разблокировки мьютекса.
Лиза
Фух, добрались до офиса. Скидываемся на пиццу?
Задание 2/2
Соедините инструменты с задачами, которые они решают.
Лиза
Курьер приехал. Встречаемся в любимой Перекусочной!
В этом уроке вы рассмотрели основы многопоточного программирования. Ознакомились с наиболее распространёнными ошибками, возникающими при работе потоков, и узнали, как их избежать. Далее погрузимся в особенности многопоточного программирования на Go и рассмотрим, как использовать каналы для передачи данных между горутинами.