Анализ потокобезопасности в С++

в 9:24, , рубрики: c++, clang, Анализ и проектирование систем, Блог компании Инфопульс Украина, отладка, параллельное программирование

Писать многопоточные приложения нелегко. Некоторые средства статического анализа кода позволяют помочь разработчикам, давая возможность чётко определить политики поведения потоков и обеспечить автоматическую проверку выполнения этих политик. Благодаря этому появляется возможность отлавливать состояния гонки потоков или их взаимной блокировки. Эта статья описывает инструмент анализа потокобезопасности С++ кода, встроенный в компилятор Clang. Его можно включить с помощью опции командной строки −Wthread−safety. Данный подход широко распространён в компании Google — полученные от его применения преимущества привели к повсеместному добровольному использованию данной технологии различными командами. Вопреки популярному мнению, необходимость в дополнительных аннотациях кода не стала бременем, а наоборот, дала свои плоды выражающиеся в упрощении поддержки и развития кода.

Предисловие

Писать многопоточные приложения нелегко, поскольку разработчикам приходится представлять себе все многочисленные варианты взаимодействия потоков и использования ими ресурсов. Опыт показывает, что программистам не помешал бы для этого некоторый инструментарий. Многие библиотеки и фреймворки налагают на программиста некоторые требования по потокобезопасности при их использовании, однако далеко не всегда эти требования чётко указаны. И даже в тех случаях, когда всё хорошо задокументировано — это всего лишь выраженная в тексте документации просьба, а не строгая автоматическая проверка.

Средства статического анализа кода помогают разработчикам определить политики потокобезопасности и проверять их при сборке проекта. Примером таких политик могут быть утверждения «мьютекс mu всегда должен использоваться при доступе к переменной accountBalance» или «метод draw() должен вызываться только из GUI-потока». Формальное определение политик даёт два основных преимущества:

  1. Компилятор может показывать предупреждения в случае обнаружения нарушений политик. Нахождение ошибки на этапе компиляции значительно дешевле, чем отладка упавших юнит-тестов или, что ещё хуже, появление «плавающих» багов в продакшн-коде.
  2. Явно выраженные в коде спецификации потокобезопасности играют роль документации. Подобная документация очень важна для библиотек и SDK, поскольку программистам нужно знать, как их корректно использовать. Данную информацию, конечно, можно поместить в комментарии, однако практика показывает, что подобные комментарии имеют свойство устаревать, поскольку при обновлении кода они не всегда меняются синхронно.

Данная статья рассказывает о применении данного подхода в Clang, хотя изначально он был разработан для GCC, однако версия для GCC более не поддерживается. В Clang данная возможность реализована как предупреждение компилятора. В Google на данный момент вся кодовая база C++ компилируется с включенным по умолчанию анализом потокобезопасности.

Работает всё это следующим образом: в дополнению к типу переменной (int, float, и т.д.) программист может опционально определить как доступ к данной переменной должен контролироваться в многопоточной среде. Clnag использует для этого аннотации. Аннотации могут быть написаны либо в GNU-стиле атрибутов (то есть attribute ((...))) или в стиле атрибутов С++11 (то есть [[...]] ). Для переносимости атрибуты обычно спрятаны внутри макроса, который определён только если код компилируется с помощью Clang. Примеры в данной статье предполагают использования данного макроса. Настоящие имена атрибутов могут быть найдены в документации к Clang.

Код в примере ниже демонстрирует базовый случай применения технологии, на примере классического банковского аккаунта. Аттрибут GUARDED_BY требует использования мьютекса mu для чтения или записи баланса, что даст гарантию атомарности операций по его изменению. Аналогично, макрос REQUIRES требует от того, кто вызовет метод withdrawImpl, перед его вызовом заблокировать мьютекс mu — лишь после этого операция по изменению баланса в теле метода будет считаться безопасной.

В примере метод depositImpl() не имеет атрибута REQUIRES и не блокирует мьютекс mu перед изменением баланса, а значит компиляция данного кода покажет предупреждение о потенциальной ошибке в этом методе. Анализ потокобезопасности не проверяет, был ли мьютекс использован в методе, который вызвал depositImpl(), так что атрибут REQUIRES должен быть определён явно. Также мы получим предупреждение о потенциальной ошибке в методе transferFrom(), поскольку он должен использовать мьютекс b.mu, а использует this->mu. Анализ понимает, что это два разных мьютекса в двух разных объектах. И, наконец, ещё одно предупреждение ждёт нас в методе withdraw(), где мы забываем разблокировать мьютекс mu после изменения баланса. Каждой операции блокирования мьютекса должна соответствовать операция разблокирования; анализ также корректно определяет двойные блокировки и двойные разблокировки. Функция может, при необходимости, осуществить блокировку без разблокировки (или разблокировку без блокировки), но такое поведение должно быть аннотированно специальным образом.

Пример кода:

#include ” mutex.h ”
class BankAcct {
	Mutex mu;
	int balance GUARDED BY(mu);
	void depositImpl(int amount) {
		// WARNING! Must lock mu.
		balance += amount;
	}
	void withd rawImpl(int amount) REQUIRES (mu) {
		// OK. Caller must have locked mu.
		balance −= amount;
	}
	
public:
	void withdraw(int amount) {
		mu.lock();
		// OK. We’ve locked mu.
		withdrawImpl(amount);
		// WARNING! Failed to unlock mu.
	}
	void transferFrom(BankAcct& b, int amount) {
		mu.lock();
		// WARNING! Must lock b.mu.
		b.withdrawImpl(amount);
		// OK. depositImpl() has no requirements.
		depositImpl(amount);
		mu.unlock();
	}
};

Анализ потокобезопасности был изначально спроектирован для случаев, подобных вышеуказанному. Но требования использования мьютексов при доступе к определённым объектам — не единственное, что необходимо проверять для обеспечения надёжности. Другой часто распространённый сценарий — это назначение потокам определённых ролей, например «рабочий поток», «GUI-поток». Те же концепции, о которых мы говорили касаемо мьютексов, могут быть применены и к ролям потоков. В примере ниже мы видим некоторый класс Widget, который может быть использован из двух потоков. В одном из потоков происходит обработка событий (например, кликов мышью), а в другом — рендеринг. При этом метод draw() должен вызываться только из потока рендеринга, и никогда не задерживать работу потока, обрабатывающего пользовательские действия. Анализ предупредит, если метод draw() вызовется не из того потока. Далее в статье будет идти речь о мьютексах, но аналогичные примеры можно привести и для ролей потоков.

#include ”ThreadRole.h”
ThreadRole Input_Thread;
ThreadRole GUI_Thread ;
class Widget {
public :
	virtual void onClick() REQUIRES (Input_Thread);
	virtual void draw() REQUIRES (GUI_Thread);
};

class Button : public Widget {
public :
	void onClick() override {
		depressed = true;
		draw(); // WARNING!
	}
};
Базовые концепции

Анализ потокобезопасности в Clang построен на расчёте возможностей. Для чтения или записи определённой области памяти поток должен обладать возможностью (или правами) на это. Эту возможность можно представить себе как некий ключ или токен, который поток должен предоставить чтобы получить права на чтение или запись. Возможность может быть «уникальной» или «разделяемой». «Уникальная»" возможность не может быть скопирована, то есть только один поток может иметь к ней доступ в каждый момент времени. «Разделяемая» возможность может иметь несколько дубликатов, принадлежащих разным потокам. Анализ использует подход «один писательмного читателей», то есть для записи в определённую область памяти поток должен обладать «уникальной» возможностью, а вот для чтения этой же области у потока может быть как «уникальная», так и одна из «разделяемых» возможностей. Другими словами, много потоков могут читать область памяти одновременно, поскольку они могут разделять возможность, но только один поток в каждый момент времени может писать. Более того, поток не может писать в то время как другой поток читает данную область памяти, поскольку возможность не может быть одновременно «разделяемой» и «уникальной».

Данный подход позволяет убедиться, что программа свободна от состояния гонки, где «состояние гонки» определяется как попытка нескольких потоков получить доступ к одной и той же области памяти, при этом как минимум один из потоков пытается осуществить запись. Поскольку операция записи требует от потока наличия «уникальной» возможности, ни один другой поток не получит доступа к этой памяти в то же самое время.

Уникальность и линейная логика

Линейная логика это формальная теория, которая может быть использована, например, для выражения логических утверждений вроде «Вы не можете иметь целый торт и в то же время уже его съесть». Уникальная, или линейная, переменная может быть использована ровно один раз. Её нельзя скопировать, использовать несколько раз или забыть использовать. Уникальный объект может быть создан в одной точке программы, а затем позже использован. Функции, имеющие доступ к объекту, но не использующие его, могут лишь передать его дальше. Например, если бы std::stringstream был линейным типом, программы писались бы следующим образом:

std::string stream ss; // produce ss
auto& ss2 = ss << ”Hello” ; // consume ss
auto& ss3 = ss2 << ”World. ” ; // consume ss2
return ss3.str() ; // consume ss3

Обратите внимание на то, что каждая переменная потока использовалсь ровно один раз. Линейная система типов не знает о том, что ss и ss2 ссылаются на одни и те же данные, вызов << концептуально использует один поток и создаёт другой с новым именем. Попытка использовать ss ещё раз приведёт к ошибке. Аналогично ошибкой будет вернуть что-то, не использовав вызов ss3.str(), поскольку тогда ss3 останется созданным, но неиспользованным.

Именование возможностей

Передача уникальных возможностей в явном виде, похожем на примеры выше, была бы невероятно безблагодатным занятием, поскольку каждая операция чтения и каждая операция записи требовали бы новых имён. Вместо этого, Clang в своём механизме анализа потокобезопасности отслеживает возможности как анонимные объекты, передаваемые неявно. Полученная в итоге система типов формально эквивалентна линейной логике, но более проста в практическом программировании.

Каждая возможность ассоциирована с именованным С++ объектом, который определяет возможность и предоставляет операции по её созданию и использованию. Сам по себе С++ объект не уникален. Например, если mu это мьютекс, то mu.lock() создаёт уникальную анонимную возможность типа Cap<mu>. Аналогично, mu.unlock() неявно принимает и использует возможность типа Cap<mu>. Операции, которые читают или пишут данные, защищаемые мьютексом mu, следуют протоколу передачи возможностей: они принимают и используют неявный параметр типа Cap<mu> и создают неявный результат того же типа Cap<mu>.

Аннотации потокобезопасности

В этом разделе коротко описываются все основные аннотации, которые поддерживаются статическим анализом потокобезопасности в Clang.

GUARDED_BY(...) и PT_GUARDED_BY(...)

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

Mutex mu;
int *p2 PT_GUARDED BY(mu) ;
void test() {
*p2 = 42; / / Warning !
p2 = new int; / / OK (no GUARDED_BY) .
}
REQUIRES(...) и REQUIRES_SHARED(...)

REQUIRES — это атрибут функции. Он требует от вызывающего потока наличия «уникальной» возможности. Можно указать более одной возможности. REQUIRES_SHARED работает аналогично, но требуемая возможность может быть как «уникальной», так и «разделяемой». Формально REQUIRES определяет поведение функции таким образом, что она принимает возможность в виде неявного аргумента и возвращает её в виде неявного результата.

Mutex mu;
int a GUARDED_BY(mu);
void foo() REQUIRES (mu) {
	a = 0; // OK.
}
void test() {
	foo(); // Warning ! Requi res mu.
}
ACQUIRE(...) и RELEASE(...)

Аттрибует ACQUIRE показывает, что функция создаёт «уникальную» возможность (или возможности), например, получая её от какого-то потока. Вызывающий эту функцию поток не должен передавать ей возможность, но получит её от функции, когда она вернёт результат. Атрибут RELEASE указывает, что функция использует «уникальную» возможность (например, отдавая её другому потоку). Вызывающий поток должен передать функции эту возможность, но не получит её обратно, когда функция вернёт результат.

ACQUIRE_SHARED и RELEASE_SHARED

Эти аттрибуты работают аналогично описанным выше, но создают и используют «разделяемые» возможности.

CAPABILITY(...)

Аттрибует CAPABILITY может быть применён к структуре, классу или typedef. Он показывает, что объект этого класса может быть использован для идентификации возможностей. Например, класс мьютекса в библиотеках Google определяется следующим образом:

class CAPABILITY (”mutex”) Mutex {
public :
void lock() ACQUIRE (this);
void readerLock() ACQUIRE_SHARED(this);
void unlock() RELEASE(this);
void readerUnlock() RELEASE_SHARED(this);
};

Мьютексы это обычные С++ объекты. Однако, каждый мьютекс имеет ассоциированную с ним возможность. Методы lock() и unlock() создают и освобождают данную возможность. Заметьте, что Clang не делает попыток проверить, действительно ли данные методы выполняют соответствующие операции с мьютексом. Аннотации применяются лишь к интерфейсу класса мьютекса и выражают то, как различные его методы создают и используют возможности.

TRY_ACQUIRE(b, ...) и TRY_ACQUIRE_SHARED(b, ...)

Эти аттрибуты функции или метода пробуют получить указанную возможность и возвращают true или false в зависимости от результата.

NO_THREAD_SAFETY_ANALYSIS

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

Негативные требования

Все описанные выше требования были «позитивными», т.е. оговаривалось, какая возможность должна присутствовать на момент вызова некоторой функции. Есть, однако, и «негативные» требования, описывающие каких возможностей в этот моет не должно быть. Позитивные требования позволяют избежать состояния гонки, в то время как негативные — помогают бороться с дедлоками. Многие реализации мьютексов не реэнтарабельны, поскольку сделать их реэнтерабельными возможно лишь ценой существенного падения производительности. Для таких мьютексов попытка второй раз вызвать операцию lock() приведёт к дедлоку. Для избежания дедлока мы можем в явном виде указать, что используемая в данный момент возможность не должна быть удерживаемой кем-то в данный момент. Данная «негативная возможность» выражается в виде оператора "!":

Mutex mu;
int a GUARDED_BY(mu);
void clear() REQUIRES (!mu) {
	mu.lock();
	a = 0;
	mu.unlock();
}
void reset() {
	mu.lock();
	// Warning ! Caller cannot hold ’mu’ .
	clear();
	mu.unlock();
}

Результаты и выводы

Анализ потокобезопасности С++ кода в данный момент широко используется в продуктах Google. Он включён по умолчанию, для каждой сборки каждого модуля. Более 20 000 файлов С++ кода имеют корректные аннотации согласно приведённым выше правилам, общее количество аннотаций достигает 140 000 и растёт с каждым днём. Использование данных аннотаций является в Google добровольным, и, соответственно, широкое распространение технологии является признаком того, что инженеры Google искренне считают её полезной.

Поскольку состояния гонки и взаимоблокировки — очень коварные вещи, Google использует и статический анализ кода, и средства динамического анализа, такие как Thread Sanitizer. Обнаружилось, что данные инструменты хорошо дополняют друг друга. Динамический анализ не требует аннотаций и, соответственно, может применяться шире. Он, однако, может детектировать проблемы лишь в тех путях выполнения кода, которые реально выполнялись по ходу анализа, а значит эффективность динамического анализа прямо зависит от тестового покрытия кода. Статический анализ не так гибок, но покрывает все возможные варианты исполнения кода. Кроме того, статический анализ выявляет проблемы ещё на этапе компиляции, что значительно эффективнее.

Несмотря на то, что необходимость ручного написания аннотаций может показаться недостатком, мы обнаружили, что аннотации значительно упрощают поддержку и развитие кода. Аннотации особенно широко применяются в библиотеках и API, поскольку там они служат также машинно-верифицируемой документацией. Разработчики и пользователи библиотек чаще всего относятся к разным командам, а значит тот, кто будет использовать библиотеку в реальном проекте не обязательно будет полностью понимать принятый в ней протокол управления многопоточностью. Документация может отсутствовать или быть устаревшей, а значит легко допустить ошибку. С использованием аннотаций протокол упраления средствами синхронизации становится частью API и компилятор будет предупреждать об ошибках в его использовании.

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

Нужно признать, что использование аннотаций имеет свою цену поддержки. Мы обнаружили, что около 50% предупреждений компилятора были спровоцированы не ошибками в коде, а ошибками вроде забытой, устаревшей или неверно использованной аннотацией (вроде отсутствия REQUIRES на методах getset). В этом плане аннотации потокобезопасности похожи на использование квалификатора const. Как смотреть на эти ошибки зависит от вашей точки зрения. В Google они считаются ошибками в документации. Поскольку API читается часто и многими инженерами — очень важно поддерживать публичные интерфейсы в актуальном состоянии. Если исключить случаи явно неверного использования аннотаций, оставшееся количество ложно-позитивных срабатываний достаточно низкое — менее 5%. Такие случаи в основном связаны с использованием доступа к одной и той же области памяти через разные указатели, условном использовании мьютексов, доступом к внутренним данным из конструктора объекта, где синхронизация ещё не нужна.

Автор: Инфопульс Украина

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js