Проблемы многопоточной разработки
В наше время существуют десятки крупных компаний, одновременно обслуживающих миллионы клиентов по всему миру и оказывающих им различные услуги. При этом, многие из них используют Go в качестве основного языка программирования для разработки своих микросервисов, архитектура которых порой работает на нескольких миллионах процессорных ядер. К примеру, монорепозиторий известного перевозчика Uber содержит около 46 миллионов строк кода и около 2100 уникальных сервисов. Использование языка Go для этих целей обусловлено рядом весомых преимуществ: грамотно настроенный параллелизм, простая настройка сборки мусора, минималистичный подход к синтаксису, отличные инструменты для дальнейшей обработки и обслуживания кода, поддержка библиотек, а также растущее с каждым днем, сообщество разработчиков.
В своей работе, Go-разработчики для передачи и обмена информацией используют легковесные потоки – горутины. Так вот, при параллельном обращении нескольких горутин к одним и тем же данным и начинают возникать большие проблемы, называемые в Go – состоянием гонки (Race conditions). А учитывая обилие кодовой базы и количество обрабатываемых одновременно процессов – проблема принимает глобальный масштаб. В статье попробуем разобраться, что это за напасть такая и как от нее избавиться.
Параллелизм и конкурентность
Прежде чем поговорить о гонках данных (data races), необходимо упомянуть о концепциях параллелизма и конкурентности, с которыми обязательно нужно разобраться, перед тем как заниматься многопоточной разработкой. Эти понятия схожи, но имеют существенные различия – «Concurrency is not Parallelism!». Суть этого расхожего выражения в том, что конкурентность – это методика проектирования вашей программы, параллелизм – это один из способов ее выполнения.
Говорить о конкурентности можно, когда работа приложения предполагается с несколькими одновременно запущенными задачами при создании процессов, выполняющихся независимо друг от друга. При этом, для достижения необходимого поведения, процессов в приложении может быть большое количество.
В одноядерных системах конкурентное выполнение потоков имеет модель, при которой контекст переключается между задачами, то есть программа может работать сразу с несколькими задачами, однако, не сможет выполнить их все вместе, ведь ядро всего одно. Причем переключается он в этом случае настолько быстро, что может создаться впечатление, что выполняемые процессы завершаются одновременно.
При этом говорить о параллелизме мы не можем, ведь задачи не могут выполняться вместе просто потому, что система у нас – одноядерная.
Проще говоря, когда мы имеем несколько одновременно выполняющихся потоков инструкций – надо делать так, чтобы код выполнялся параллельно, а этот процесс требует конкурентности. Ведь при отсутствии конкурентного дизайна распараллелить нашу программу не выйдет. А вот сама по себе концепция конкурентности не обязательно нуждается в параллелизме, ведь если программа способна работать на нескольких ядрах, она спокойно может функционировать и на одном.
Состояния гонки в Go
Проблемы, относящиеся к категории состояние гонки, довольно трудно обнаружить и поэтому они относятся к группе самых непредсказуемых ошибок программирования. Довольно часто при запуске кода они вызывают загадочные сбои, понять природу которых не может и матерый разработчик. При этом, даже упрощающий написание чистого кода механизм конкурентности, не сможет предотвратить Race conditions. В таких случаях, от программиста требуется осторожность, больше усердия и грамотное тестирование.
Гонка происходит, когда две или более горутины обращаются к одним и тем же данным. Сбои, вызванные гонками данных в программах Go, повторяются и зачастую снижают эффективность и производительность наиболее важных функций, что будет доставлять неудобства вашим клиентам и повлияет на их доход. Порой разработчикам довольно трудно отлаживать data races и иногда некоторые из них исправляют такие ошибки с помощью консервативных методов. Одним из которых является отключение параллелизма в подозрительных областях кода.
Для того, чтобы иметь возможность заранее диагностировать приложение для обнаружения такого рода ошибок разработчики языка создали функционал, называемый детектор гонки.
Детектор гонки
Впервые этот набор инструментов, определяющих состояния гонки в Go-коде, появился в версии 1.1 и по сей день, успешно работает на операционках Linux, OS X и Windows с 64-разрядными процессорами x86.
В основе детектора лежит библиотека времени выполнения (runtime library) C/C++ – ThreadSanitizer, используемая для обнаружения ошибок в кодовой экосистеме Google и Chromium. Внедрили эту технологию в Go в сентябре 2012, и после этого она стала частью непрерывного процесса сборки по отслеживанию условий гонки при их возникновении.
Race detector встроен в цепочку инструментов Go и работает следующим образом:
- В необходимых нам подозрительных местах мы устанавливаем флаг командной строки
-race
, показывающий компилятору полный список обращений к памяти с помощью кода, описывающего основные параметры осуществления доступ к памяти. - А тем временем,
ThreadSanitizer
ищет в коде несинхронизированные обращения к общим переменным и, обнаружив такое «грубое» поведение, выдает предупреждение.
Детектор сконструирован таким образом, что он в состоянии обнаружить Race conditions только при фактическом запуске кода. Поэтому важно осуществлять запуск двоичных файлов приложения при реалистичных рабочих нагрузках.
Но двоичный файл с детектором гонки может сильно влияет на производительность процессора и памяти, вызывая перегрузку системы – поэтому не стоит держать детектор гонки включенным постоянно. Хорошей практикой является запуск его, вместе с нагрузочными и интеграционными тестами, ведь они, в большинстве своем, используют конкурентные части кода.
Как использовать детектор гонки
Как мы уже упоминали ранее, для того, чтобы подключить к необходимому участку кода включенный детектор гонки, к нему нужно добавить флаг -race
, например:
Рассмотрим пример упрощенной версии фактической ошибки, которую обнаружил детектор гонки. Здесь он с помощью таймера выводит сообщения с произвольным интервалом от 0 до 1 секунды. Все это действие повторяется пять секунд.
Он использует time.AfterFunc
для создания Timer
для первого сообщения, а затем использует метод Reset
для планирования следующего сообщения, каждый раз повторно используя Timer
.
Race detector – мощнейший инструмент, проверяющий корректность всех параллельных программ, к предупреждениям которого нужно отнестись со всей серьезностью, ведь ложных срабатываний он не выдает. Обратите внимание, что детектор находит лишь те гонки, которые могут появиться в процессе выполнения приложения, поэтому он не может найти их на участках кода, которые не выполняются.
Комментарии