До вмісту
fr0staman
Усі статті

Дедлок, який я лагодив тричі

3 хв читання

У моєму боті є дуелі: хряки двох користувачів б'ються, і стан обох сторін змінюється одночасно. Це єдине місце, де два запити торкаються тих самих рядків, — і, передбачувано, єдине місце, яке взагалі колись зависало.

Переглядаючи історію, я бачу, що лагодив це чотири рази протягом двох місяців. Перші три не спрацювали. Повідомлення комітів — чесний запис того, як мало я розумів:

Try avoid deadlocks
Possible avoid deadlocks
Try to fix rare deadlocks
Fixed duel deadlocks

Форма помилки

Кожна дуель брала лок на користувача, щоб дві дуелі за участю однієї людини не перепліталися. Локи жили в конкурентній мапі:

static DUEL_LOCKS: Lazy<DashMap<u64, Mutex<Vec<u64>>>>

Виглядає розумно. DashMap дає конкурентну хеш-мапу без загального лока на все, а внутрішній Mutex захищає окремий запис. Два різні користувачі потрапляють у два різні записи й не конкурують.

Проблема в тому, що саме повертає DashMap::get. Він віддає guard, і цей guard тримає лок на шарді мапи доти, доки живий. Тобто така послідовність:

let entry = DUEL_LOCKS.get(&key);      // тут захоплено лок шарда
let mut list = entry.lock().await;      // ...і він досі тримається через await

тримає лок шарда через точку await. Якщо інша задача потребує будь-якого ключа, що хешується в той самий шард, поки перша задача призупинена, вона чекає, — а якщо задача, на яку вона чекає, чекає на неї, це і є зависання.

Воно було рідкісним, бо потрібні дві дуелі, чиї ID користувачів потрапляють в один шард, ще й із перекриттям у часі. На боті з кількома сотнями людей це подія раз на тиждень. Достатньо рідко, щоб кожне моє виправлення виглядало як таке, що спрацювало.

Три виправлення, які не були виправленнями

is_locked() і рядок у лог. Якщо запис виглядає зайнятим — записати помилку й вийти. Це перевірка стану лока з наступною дією за результатом, тобто гонка за побудовою: відповідь може змінитися до того, як ви нею скористаєтесь. Що це справді дало — докази, що проблема реальна, тому воно й прожило одну версію.

Перестановка одного рядка. Я вже не пам'ятаю, у що саме вірив. Не змінилося нічого.

try_get замість get. Брати запис лише тоді, коли шард вільний, інакше пропускати. Це справді зменшило частоту — найгірший можливий результат: помилка «раз на тиждень» стала помилкою «раз на місяць» і відсунулася ще далі від зміни, яка її спричинила.

Кожне з цих виправлень лікує симптом — «іноді ми застрягаємо на цьому локу», — а не причину, яка полягає в тому, що лок узагалі тримався.

Виправлення

static DUEL_LOCKS: Lazy<RwLock<HashMap<u64, Arc<Mutex<Vec<u64>>>>>>

Взяти read-лок на мапу, склонувати Arc, відпустити guard мапи — і аж потім чекати на внутрішньому м'ютексі. Мапа залишається залоченою лише на час пошуку, а не на час очікування. Це більше писанини й нітрохи не хитро — і воно не зависало відтоді жодного разу.

Чому компілятор мене не врятував

Ось частина, яку варто засвоїти. Rust не дасть вам небезпечно ділити стан між потоками й відмовиться відправляти ф'ючер, який тримає не-Send guard. Але він не завадить тримати цілком Send guard через await і заблокувати себе. Питання системи типів — «чи безпечно це переміщувати між потоками», а не «чи має воно й далі бути залоченим, поки ви чекаєте на мережу».

Тож правило, за яким я тепер живу: будь-який guard, живий через await, — це помилка, доки не доведено протилежне. Знайти, витягти потрібне, відпустити guard, і тільки тоді чекати.

Постскриптум

Через три роки я робив зовсім інше — WASM-хост для плагінів, — і один з перших комітів там замінює dashmap на звичайний RwLock<HashMap<_, _>>. Не тому, що dashmap поганий: він добрий у тому, для чого створений. А тому, що форму тієї помилки я тепер упізнаю здалеку, і в хост-процесі, де інстанси з'являються й зникають на кожен запит, мені не хотілося витратити ще два місяці на її перевідкриття.

Поділитися