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

Писати багатопотокові програми нелегко. Деякі засоби статичного аналізу коду дозволяють допомогти розробникам, даючи можливість чітко визначити політики поведінки потоків і забезпечити автоматичну перевірку виконання цих політик. Завдяки цьому з'являється можливість відловлювати стану гонки потоків або їх взаємного блокування. Ця стаття описує інструмент аналізу потокобезопасности С++ коду, вбудований компілятор 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 для читання або запису балансу, що дасть гарантію атомарности операцій щодо його зміни. Аналогічно, макрос ПОТРІБНО вимагає від того, хто викличе метод withdrawImpl, перед його викликом заблокувати м'ютекс mu — лише після цього операція по зміні балансу в тілі методу буде вважатися безпечною.

У прикладі метод depositImpl() не має атрибуту ПОТРІБНО і не блокує м'ютекс mu перед зміною балансу, а значить компіляція даного коду покаже попередження про потенційну помилку в цьому методі. Аналіз потокобезопасности не перевіряє, чи був м'ютекс використаний метод, який викликав depositImpl(), так що атрибут ПОТРІБНО повинен бути визначений явно. Також ми отримаємо попередження про помилку у методі 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 вимога.
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_SHARED працює аналогічно, але необхідна можливість може бути як «унікальною», так і «розділяється». Формально ПОТРІБНО визначає поведінку функції таким чином, що вона приймає можливість у вигляді неявної аргумент і повертає її у вигляді неявної результату.

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% попереджень компілятора були спровоковані не помилками в коді, а помилки на зразок забутої, застарілої або невірно використаної анотацією (зразок відсутності ПОТРІБНО методи get\set). В цьому плані анотації потокобезопасности схожі на використання кваліфікатор const. Як дивитися на ці помилки залежить від вашої точки зору. У Google вони вважаються помилками в документації. Оскільки API читається часто і багатьма інженерами — дуже важливо підтримувати публічні інтерфейси в актуальному стані. Якщо виключити випадки явно неправильного використання анотацій, залишився кількість хибно-позитивних спрацьовувань досить низька — менше 5%. Такі випадки в основному пов'язані з використанням доступу до однієї і тієї ж області пам'яті через різні покажчики, умовному використанні м'ютексів, доступом до внутрішнім даними з конструктора об'єкта, де синхронізація ще не потрібна.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.