Трохи балачок про assert-и: еволюція підходів від C до залежних типів

19 листопада 2024 року понад 2 мільйони вебсайтів одночасно впали. Не через хакерську атаку. Не через перевантаження серверів. А через один-єдиний assert! у Rust-коді Cloudflare.

Обговорень було море. Але, як це часто буває, більшість з них швидко перетворилися на холівар. За цими баталіями загубилося головне: цікаве технічне питання про те, як різні мови програмування підходять до assert-ів. Це і спонукало мене написати цю статтю.

Вступ

Тож що ж таке ці assert-и? Вони є майже в кожній мові програмування. У C це assert(condition), у Python теж assert condition, у Java assert condition : message. Rust пропонує цілу низку варіантів: assert!(), debug_assert!(), assert_eq!(), assert_ne!(). C# має Debug.Assert() та Trace.Assert(). Ada пропонує pragma Assert. Навіть JavaScript, який довгий час обходився без них у стандарті, отримав їх через Node.js (assert модуль).

Кожна мова програмування має свої особливості у реалізації та поведінці assert’ів. Однак щоб уникнути плутанини та зосередитися на спільних концепціях, нам необхідно сформулювати універсальне визначення, яке дозволить обговорювати їх незалежно від конкретної мови.

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

Головне призначення assert-ів полягає у тому, щоб допомогти знайти помилки під час розробки та тестування. Вони кричать нам: «Стоп! Тут щось пішло не так! Твої припущення неправильні!» Це інструмент для виявлення багів на ранніх стадіях, коли їх ще легко виправити.

Які бувають assert-и

Найпростіший варіант, який всі бачили, це звичайний assert(condition). Декларуємо умову, яка має виконуватися. Просто і зрозуміло.

Але в розрізі нашого визначення твердження цим не обмежуються. У Rust, наприклад, є unwrap() та expect(). Ці методи містять assert як складову частину їхньої логіки.

Твердженнями також є pre-conditions та post-conditions. Це умови, які мають виконуватися до та після виклику функції. Ada дозволяє описати їх прямо в сигнатурі функції:

function Find_Max (A : My_Array_Type) return Integer with
  Pre  => A'Length > 0,
  Post => (for all J in A'Range => Find_Max'Result >= A(J)) and
          (for some J in A'Range => Find_Max'Result = A(J));

Тут Pre => A'Length > 0 це твердження, яке буде перевірено перед викликом функції: масив не може бути порожнім. А післяумова складніше та навіть містить квантори, про які можна думати як про цикли перевірок. Перший рядок переконується, що результат не менший за всі елементи. Другий рядок — що результат дорівнює якомусь елементу масиву. Такий контракт майже не залишає простору для неправильної реалізації функції.

Є ще більш екзотичний варіант: інваріанти класу. Це твердження, які мають виконуватися завжди, коли об’єкт знаходиться в «стабільному» стані. Синтаксичний цукор, щоб не вставляти один і той же assert на початку та в кінці кожного методу. В Eiffel це була центральна концепція Design by Contract:

class sorted_pair
feature
  first, second: integer

  set(a, b: integer)
    local
      temp: integer
    do
      first := a
      second := b
      -- тут інваріант може бути порушено!
      if first > second then
        temp := first
        first := second
        second := temp
      end
    end

invariant
  first <= second
end

Інваріант first <= second автоматично перевіряється на вході та виході з кожного публічного методу. Всередині методу set він може тимчасово порушуватися, бо на початку ми просто присвоюємо значення з довільних аргуменів. Але на виході з методу інваріант гарантовано відновлюється. Так, це було необов’язково, але ми це зробили більше для демонстрації ідеї.

Аналогічно до інваріантів класу існують і інваріанти циклу. Це твердження, яке має виконуватися на початку та в кінці кожної ітерації циклу.

Поведінка assert-ів

З поведінкою assert-ів під час розробки все більш-менш зрозуміло. Їхня головна мета полягає в допомозі нам, розробникам. Коли твердження хибне, програма має негайно зупинитися і надати максимум інформації (файл, рядок, умова, стек), щоб розробник швидко зрозумів, що пішло не так. Це вірний помічник, який б’є на сполох при перших ознаках проблеми.

Але що робити, коли код потрапляє у прод? Коли «ситуація, якої ніколи не мало статися» раптом трапляється на бойовому сервері? Ось тут і з’являється вибір. Подивимося, які основні варіанти існують.

Варіант 1: Ігнорувати проблему і сподіватися на краще

Найперший і, мабуть, найпростіший підхід полягає в тому, щоб у релізній версії коду просто вимикати всі перевірки. Багато мов програмування, як от C++, роблять це за замовчуванням. Логіка тут проста: assert-и потрібні для розробки, а в продакшені вони лише сповільнюють роботу. Колись, за часів динозаврів та повільних процесорів, це був вагомий аргумент. Сьогодні ж це вже звучить не так переконливо.

Що відбувається, коли такий assert спрацював би, але його немає? Програма опиняється в стані, на який вона не була розрахована. Це дуже слизька доріжка, що веде прямісінько до горезвісного «Undefined Behavior» (UB). Програма може мовчки видавати неправильні результати, пошкодити дані або впасти значно пізніше, коли знайти причину буде майже неможливо.

Але чи завжди це погано? Іноді дивна поведінка краща за падіння. Уявіть собі комп’ютерну гру. Нехай буде стара добра Jagged Alliance 2. Там характеристики персонажів були в діапазоні від 0 до 100. Але через різні бонуси та модифікатори непомітність бійця могла перевищити 127. Це призводило до переповнення char, і наш ніндзя раптом ставав помітніше за стадо буйволів. Це смішно, це баг, але це краще, ніж якби гра просто вилетіла наприкінці довгої битви, яку ви не зберегли. Інколи краще глюк на екрані, ніж повна зупинка програми. Користувач може не помітити дивну поведінку, але креш помітять усі. До того ж наш світ без глітчей та спідранів був би трошечки сумнішим.

Варіант 2: Завжди падати (Rust-стиль)

Повною протилежністю ігноруванню буде нещадне падіння програми як тільки так відразу. Це своєрідна філософія, яку активно сповідує Rust. Тут логіка така: якщо ми дійшли до стану, який неможливий за нашими припущеннями, це означає, що програма знаходиться в некоректному стані. Продовження роботи в такому стані може призвести до непередбачуваних наслідків, тобто того самого «Undefined Behavior». Краще вже впасти відразу, ніж потім ламати голову над причинами дивних помилок.

Саме це і сталося 19 листопада 2024 року з Cloudflare. Assert спрацював, демон впав, а з ним і 2+ мільйони сайтів. Технічно все правильно: система виявила невалідний стан і зупинилася замість того, щоб ризикувати. Але для кінцевого користувача це нагадує сумний анекдот: «Чи потів хворий перед смертю?». З точки зору користувача результат той самий: програма не працює.

Варіант 3: «Золота середина» — підміняємо логіку

Між двома крайнощами — ігноруванням і падінням — існує ціла низка проміжних варіантів. Замість класичного assert, який або зникає, або все валить, ми можемо «підкласти» під помилкову ситуацію якусь іншу логіку. Часто, ще з часів Delphi, це реалізується через механізм винятків (exceptions). Інколи можна побачити й кастомні макроси на кшталт ASSERT_WITH_CODE, що виконують певний код при спрацьовуванні. По суті це можна розглядати як визнання того, що assert-и на проді є небажаними, і будь-яку потенційну помилку краще обробляти явно.

На перший погляд це може здатися ідеальною «золотою серединою». Програма не падає, а продовжує роботу, намагаючись коректно відреагувати на несподівану ситуацію. Проте тут криються свої підводні камені. Нам доводиться писати обробники помилок для ситуацій, які, за нашими припущеннями, ніколи не мають виникнути. Такий код часто буває важко протестувати. Щоб досягти 100% покриття тестами, доводиться вигадувати складні «моки» на assert-и або йти на інші хитрощі. Іноді при помилці потрібно нетривіально відкотити всі зміни, що вже були зроблені.

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

Зазвичай у таких сценаріях ми зазвичай якось логуємо випадок, «прокидаємо» помилку нагору по стеку, щоб її обробив вищий рівень програми. Але буває й так, що помилка просто не вписується в бізнес-логіку. Наприклад, при авторизації користувача нам потрібна бінарна відповідь: «дозволено» або «не дозволено». Будь-яка відповідь при помилці буде поганою. Якщо ми відповімо «ні», для користувача це виглядає як непрацююча система. Якщо ми відповімо «так», то це ще гірше: система не працює з точки зору безпеки.

Варіант 4: Математика на службі

Здається, ми перебрали основні варіанти поведінки: ігнорувати, падати або намагатися обробити помилку. Лишилося обрати, що буде краще працювати в конкретному випадку, залежно від конкретної задачі та філософії проєкту, та змиритися з компромісами. Але існує ще один, зовсім інший шлях: можна довести, що assert ніколи не спрацює!

Цей підхід звучить як щось із наукової фантастики, але він цілком реальний. Ідея полягає в тому, щоб не думати, що станеться, коли assert спрацює, а математично довести, що він не спрацює ніколи. В такому разі перевірка під час виконання стає зайвою, адже ми маємо залізну гарантію її істинності.

Це, так би мовити, святий Грааль надійності. Щобільше, тут є навіть кілька способів досягти цієї мети, які активно використовуються в критичних системах.

SMT-солвери: автоматичні математики

Один зі способів це делегування роботи спеціальним інструментам, які називаються SMT-солверами (Satisfiability Modulo Theories). Якщо простими словами, то це така розумна програма, яка вміє автоматично доводити або спростовувати математичні твердження. Яскравий приклад реалізації цього підходу — мова Ada разом з інструментом SPARK.

Пам’ятаєте приклад коду на Ada з pre- та post-умовами для функції Find_Max? SPARK працює так: він «згодовує» SMT-солверу всі pre-умови вашої функції як початкові факти. Потім він аналізує код рядок за рядком, додаючи нові факти. Наприклад, якщо у вас є факт, що X > 5, а в коді написано Y := X + 1, то солвер робить висновок, що Y > 6. Коли SPARK зустрічає assert(Y > 0), він намагається довести його істинність на основі вже відомих фактів. Якщо йому це вдається, перевірка вважається математично доведеною, тож її можна сміливо викинути з рантайму.

Звучить круто, правда? Але є нюанси. По-перше, SMT-солвер не всесильний. Існує цілий клас задач, які він не може довести автоматично. По-друге, щоб допомогти солверу, програмісту часто доводиться додавати проміжні assert-и, які підказують правильний хід думок. SMT-солвер не впорався? Додамо ще assert. Ще раз не впорався? Ще assert. Ще раз? Треба думати...

Це вимагає від розробника не тільки навичок програмування, а й специфічного математичного мислення та розуміння того, як працює солвер під капотом, та якого факту йому не вистачає для повного доказу. Тож це досить потужний інструмент, але складний. Точно не чарівна паличка.

Залежні типи: сам собі математик

Якщо SMT-солвери це складно, то тримайтеся, бо ми переходимо до важкої артилерії. Це мови програмування із залежними типами, такі як Idris, Agda або Coq. Вони інтегрують математичні докази безпосередньо в систему типів.

У звичайній мові ми можемо написати тип «масив цілих чисел». У мові з залежними типами можна написати «масив з рівно 10 цілих чисел». В C++ теж так можна! Але йдемо далі, «масив довжиною N», де N це змінна або параметр функції. Або навіть «відсортований масив цілих чисел».

Тип стає математичним твердженням, це всім відомий ізоморфізм Карі-Говарда. Програма містить докази цих тверджень. Якщо компілятор прийняв код, значить, доказ коректний.

Звісно, є «невеличкий» нюанс: цей доказ доведеться побудувати власноруч. Якщо SMT-солвер робить докази автоматично: ви пишете код, він намагається довести, то тут усе навпаки: ви маєте побудувати доказ власноруч. Компілятор лише перевіряє, чи доказ коректний.

І це, м’яко кажучи, непросто. Це вимагає від програміста бути не просто інженером, а ще й трішки математиком. А це, погодьтеся, досить рідкісний і високооплачуваний скіл.

Якщо ви заінтриговані, як це може виглядати в коді, читайте наступний розділ. Якщо ні — мотайте до висновків про Cloudflare, там все зібрано докупи.

Давайте подивимося на невеличкий приклад мовою Agda. Припустимо, ми хочемо написати функцію, яка бере два непорожні масиви (вектори в C++ або списки в Python), з’єднує їх і повертає другий елемент результату.

-- Takes two non-empty vectors and returns the second element of their concatenation
second : {A : Set} {n m : ℕ} → 𝕍 A (suc n) → 𝕍 A (suc m) → A
second {A} {n} {m} vec1 vec2 =
  let one = suc zero
      concatenated : 𝕍 A (suc n + suc m)
      concatenated = vec1 ++ vec2
      -- Proof that index 1 is in bounds
      index-proof : one < suc n + suc m
      index-proof = 1<suc-n+suc-m
  in nth concatenated one index-proof

Що ми бачимо? Функція second приймає аж п’ять аргументів. Перші три — {A : Set} {n m : ℕ} — записані у фігурних дужках. Це неявні аргументи, такі собі параметри за замовчуванням. Тільки на відміну від більшості мов програмування, їх значення не задає програміст, а виводить компілятор.

A — це аргумент типу Set, який містить типи. Для педантів поясню, що Set містить типи першого порядку, бо тип усіх типів призводить до парадоксу Расела. Тобто A задає тип елементів масиву (чи то числа, чи рядки, чи щось інше).

n та m — це натуральні числа, довжини масивів, точніше, довжина мінус один, бо реальна довжина suc n та suc m.

А далі — це два аргументи, які нам треба задавати явно. Перший — 𝕍 A (suc n) — масив довжини n + 1. Другий — 𝕍 A (suc m) — масив довжини m + 1. Тут suc це наступне натуральне число. Оскільки n та m — це натуральні числа (можуть бути нулем), то довжини масивів щонайменше 1. А значить обидва масиви не можуть бути порожніми, бо мінімальна довжина завжди 1. Компілятор не дозволить передати порожній масив — це буде помилка типу.

Коли ви викликаєте second vec1 vec2, компілятор дивиться на типи vec1 та vec2 і сам розуміє: Ага, A — це тип Int (вбудований тип для цілих, не плутайте з ℕ — математичним типом натуральних чисел, які існують на етапі компіляції), n = 5, m = 3. Схоже на generics у Java або templates у C++, тільки набагато потужніше.

Тіло функції second це дві дії: конкатенація масивів (нічого перевіряти, завжди можемо виконати) та отримання другого елемента, який має індекс 1. Для цього ми викликаємо функцію nth, і тут найцікавіше:

nth : {A : Set} {n : ℕ} → 𝕍 A n → (i : ℕ) → i < n → A
nth []       i       ()
nth (x ∷ xs) zero    (z<s)     = x
nth (x ∷ xs) (suc i) (s<s i<n) = nth xs i i<n
Подивімось на сігнатуру цієї функції. Тут теж п’ять аргументів! Спочатку A — тип елементів масиву. Потім n — довжина масиву. Потім сам масив. Потім індекс. Останній аргумент має тип i < n, тобто там має бути доказ того, що i завжди менше довжини масиву! І ми передаємо там лему 1<suc-n+suc-m, і так, це такий ідентифікатор з символами менше, плюс та мінус!

Ще раз, подумаймо про індекси. Щоб взяти другий елемент, нам треба передати доказ, що довжина масиву більше одиниці. Перший масив має довжину n+1, другий — m+1, конкатенація має довжину n+m+2. Інтуїтивно очевидно, що довжина масиву concatenated більше одиниці. Але... Agda потребує формальний доказ цього факту! Ми приведемо тут його, щоб трошечки залякати читача:

subst : {A : Set} {x y : A} (P : A → Set) → x ≡ y → P x → P y
subst P refl px = px

sym : {A : Set} {x y : A} → x ≡ y → y ≡ x
sym refl = refl

+-suc : (n m : ℕ) → n + suc m ≡ suc (n + m)
+-suc zero    m = refl
+-suc (suc n) m = subst (λ k → suc n + suc m ≡ suc k) (+-suc n m) refl

1<suc-n+suc-m : {n m : ℕ} → suc zero < suc n + suc m
1<suc-n+suc-m {n} {m} = s<s (subst (λ k → zero < k) (sym (+-suc n m)) z<s)

В нашу задачу не входить розбирати покроково, як саме побудовано ці докази, бо це окрема велика тема. Головне: якщо доказ неправильний — код не скомпілюється. Якщо доказ правильний, то функції nth та second гарантовано не можуть впасти з помилкою «індекс за межами масиву». Система типів мови виключає цю помилку.

Красиво? Безумовно. Складно? Ще й як.

Погляньте на код: половина його — це не бізнес-логіка, а побудова доказів. Леми +-suc та 1<suc-n+suc-m — це математичні конструкції. Щоб писати такий код, треба розуміти конструктивну математику, теорію типів та мати терпіння святого.

example.agda

Підсумки: повертаємося до Cloudflare

Отже, ми пройшли довгий шлях від простих assert-ів до залежних типів. Тепер повернімось до того, з чого почали: до інциденту з Cloudflare. Чи можна було його уникнути, і як виглядало б ідеальне рішення?

На мій погляд, ключ до розв’язання цієї проблеми лежить у чіткому розділенні двох світів: Небезпечного зовнішнього світу та безпечного внутрішнього. Ми цієї теми не торкалися, це монада IO. Якщо функція повертає цей тип даних, це означає, що вона працює зі зовнішнім світом: читає конфігураційний файл, працює з базою даних, робить мережевий запит. Тут може піти не так абсолютно все: файл пошкоджено, база недоступна, у JSON-і не ті поля, тому така функція має бути параноїком.

Монада IO маркує такий код. Вона натякає, що це місце, де треба повертати Maybe Config. Це нотація Haskell, для інших, можливо, зрозуміліше буде Option<Config>.

Окремо є функція для перевірки доступу. Це чиста логіка, ми працюємо тільки з гарантовано коректними даними, assert знову стає нашим другом. Це сигнал, що ми щось не врахували у своїх алгоритмах. А в ідеальному світі, доведеному за допомогою SPARK або Agda, їх істинність доведена математично.

Щоб розділити ці дві функції, типи змушують нас побудувати правильну архітектуру: ми читаємо або оновлюємо конфіг. Якщо ми отримали помилку, то ми або не запускаємо демон, або логуємо помилку та не чіпаємо старий конфіг. В результаті сервер не запуститься, що ми побачимо відразу. А якщо запуститься, то вже не впаде через конфіг.

Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

👍ПодобаєтьсяСподобалось5
До обраногоВ обраному2
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

1. Принципово: не все можна довести математично. У багатьох випадках ви закладаєтесь на коректність виконання чогось в API — від сисколлів ядра і до викликів якогось REST, а воно починає робити те, що не дозволено. Ви не зможете всю ОС покрити математичною верифікацією.

Це серйозна проблема, і в загальному випадку невідомо, як з нею працювати. Але результатом стає, що код, який працює з чимось, часто пишуть в розрахунку і на такі неочікувані результати... і він стає реально дивним з точки зору «чистого» підходу...

2. Ще один варіант, що робити з assertʼами, це лоґувати спрацювання. Реально, велика практична система так робить навіть коли пропускає помилки далі. Та хоч лічильник інкрементувати, якщо лоґ не пишеться.

А так — звісно, корисно робити так, щоб максимально покласти на якийсь інструмент (буде він в компіляторі, лінтері, іншому статичному аналізаторі — вже деталі) пошук помилок, додавши твердження, які такий аналізатор зможе перевіряти.

PS: Поки писав, Денис прокоментував схожими тезисами. Ну це добре.

PPS: Відредаґуйте форматування, місцями всякі <div> вилазять прямим текстом.

Ви не зможете всю ОС покрити математичною верифікацією.

Вже зроблено у seL4.

Ще один варіант, що робити з assertʼами, це лоґувати спрацювання.

Ну... це в принципі підпадає під варіант 3, підміну логіки, можна логувати, можна перезапустити операцію/worker, можна повернути помилку, ...

Вже зроблено у seL4.

Бачив. Виняток, який підкреслює існування правила. Ніхто не здатен зробити таке з реальним ядром розмаху Linux чи Windows включаючи драйвери, одна NVidia дає стільки складности, що десятиліття уйдуть на його покриття.

Так що залишається тільки вірити і тестувати.

Саме тому на критичних системах в літаку ніколи не буде Linux. seL4 має свою нішу: системи реального часу, медичне обладнання, військова техніка, автопілоти...

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

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

Верифікація базується на тому, що є хтось, хто вірно зрозумів та задокументував очікувану поведінку системи. Чим більша системи, тим менша імовірність цього.

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

Багатопоточна система на кілька мільйонів рядків коду матиме емержентні властивості — і їх і перевіряють енд-ту-енд тести. І вони перевіряють саме коректність роботи для кінцевого користувача (дані не скораптилися, база не скрешилася й не зависла), а не відповідність поведінки до багатотомної документації.

є хтось, хто вірно зрозумів та задокументував очікувану поведінку системи. Чим більша системи, тим менша імовірність цього.

Чим більше система, тим більша ймовірність, що розробник не так зрозуміє, що треба робити, та зробить щось інше. Єдина відміна, що при при формальній верифікації ми бачимо, як розробник зрозумів задачу. А без формальної верифікації телепати у відпустці. Як проходить рев’ю? Ти читаєш задачу. У тебе виникає якесь розуміння що треба робити. Потім ти дивишся на код, намагаєшься зрозуміти, що він робить. Потім якось обґрунтовуєш, що твоє бачення що треба зробити відповілає тому що зроблено. І тут просто помилитися, бо більше роботи, і знову ніяких гарантій немає.

Натомість, реальні великі та складні системи покривають недетерміністичними тестами

Будь-який тест можна сформулювати й математично: така програма (тест) має відпрацювати без крешів та повернути, наприклад, нуль. А значить довести. Простіше кажучи, запуск тесту це перевірка на випадкових вхідних даних. Верифікація це доведення, що тест спрацює на будь якому наборі вхідних даних.

дані не скораптилися, база не скрешилася й не зависла

Це все математичні твердження, причому досить елементарні.

Простіше кажучи, запуск тесту це перевірка на випадкових вхідних даних.

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

Це все математичні твердження, причому досить елементарні.

Твердження прості, але ж хто перевірить усі варіанти подій, котрі можуть відбутися з розподіленою базою? Наприклад, одна нода може підвиснути на кілька секунд, в цей час на другій виб’є мережу, а на третій почнуть апгрейд версії бази. Одночасно відбувається зміна схеми (ALTER TABLE), завантаження даних та аналітичні запити.
Рандомізовані тести перевіряють саме таке.

Мені здається, що десь хтось казав, що таке не підлягає верифікації, бо не є скінченим автоматом.

Верифікувати ми можемо буть яку конкретну програму для машини Т’юрингу. Або переписати її так, щоб можна було верифікувати. Верифікована операційна система, верифікований компілятор... Це явно не кінцеві автомати.

Твердження прості, але ж хто перевірить усі варіанти подій, котрі можуть відбутися з розподіленою базою?

Математичний доказ так і працює: ми перебираємо усі варіанти. Власне, розробник саме так і працює, тільки неформально. Коли ми пишемо код, ми фактично будуємо доказ його правильності у своїй голові, просто не записуємо це математичними символами. Більше того формальний доказ як раз часто висвітлює проблему: починаєш доведення та знаходиш ситуацію про яку ти не подумав.

Верифікована операційна система, верифікований компілятор... Це явно не кінцеві автомати.

Такі ж автомати, як і все інше. Просто їх складність настільки велика, що ми у переважній більшости випадків не можемо проробити автомат з такою кількістю станів і переходів, навіть якщо їх параметризувати.
Якщо щось тут не автомат, то це вже машина випадкового виконання чогось і нам такі потрібні тільки в дуже обмежених випадках, як команда rdrand :)

Проблема саме в керуванні складністю. Зараз вкрай мало місць, де ми можемо повністю цю складність осягнути формальними методами. Тому створюємо шари логіки, взаємодію між ними, аналізуємо кожний шар окремо...

А тести потрібні для підтвердження, хоча б часткового, що наша верифікація (включаючи візуальну, як код-ревʼю) дійсно хоча б в основі заснована на правильних формальних умовах.

Є ієрархія обчислень: кінцеві автомати, автомати з магазинною пам’яттю, машини Тʼюрінга. Десь посередині структурна рекурсія, система з гарантованим завершенням, слабша за машину Тʼюрінга, але достатньо потужна для вирішення більшості практичних задач. Компілятор це як мінімум структурна рекурсія. Компілятор принципово не є FSM хоча б з того тривіального всім відомого факту, що контекстно-вільну граматику не можна розпарсити FSM. Так, можна чіплятися до того, що у нас обмежена кількість пам’яті, але головне, що в наших доказах ми на це не закладаємося, усі вони формулюються «якщо вистачить пам’яті, то програма згенерує правильний код».

Зараз вкрай мало місць, де ми можемо повністю цю складність осягнути формальними методами.

Написання компілятора за складністю формальної верифікації на порядки складніше за ширвжиткові застосунки. Тому скоріше з точністю до навпаки: якщо формальних методів вистачило на компілятор, то для типового застосунку їх вистачить тим паче. Більше того, оскільки унівалентні основи математики достатньо сильні для того щоб описати більшість змістовної математики, то з твого твердження витікає що математика безсила в описі задач програмування, що повна маячня.

Тому створюємо шари логіки, взаємодію між ними, аналізуємо кожний шар окремо...

Верифікація так і працює: беремо функцію, доводимо що вона при певних умовах працює коректно, доводимо що в місцях виклику ці умови задовольняються, і так доходимо до функції main.

А тести потрібні для підтвердження, хоча б часткового, що наша верифікація (включаючи візуальну, як код-ревʼю) дійсно хоча б в основі заснована на правильних формальних умовах.

Код-рев’ю це сподівання що два розробники однаково зрозуміли вимоги та суб’єктивно вважають що код їх вирішує. Тест аналогічно: правильно зрозуміли вимоги, правильно написали тест, і він перевіряє скінченну кількість випадків. Тому це не підтвердження, це просто посилення сподівань: помилка може бути в коді, в розумінні вимог, у формулюванні вимог. Формальна верифікація усуває помилки в коді та в розумінні вимог, залишається лише питання формулювання.

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

Ви маєте на увазі FSM ≡ непараметризована FSM. Можливо, вас навчали в такій термінології.
Я ж маю на увазі і прості, і параметризовані. В вашій «ієрархії обчислень» це ближче до 2-го рівня. І це практично корисніше для оцінки реальних задач.

але головне, що в наших доказах ми на це не закладаємося, усі вони формулюються «якщо вистачить пам’яті, то програма згенерує правильний код».

Наскільки я бачив формалізацію верифікації, ресурсні обмеження там теж можуть мати місце, і часто мають.

Тому скоріше з точністю до навпаки: якщо формальних методів вистачило на компілятор, то для типового застосунку їх вистачить тим паче.

Я не бачу, до чого тут взагалі це «навпаки», чому моїм словам це «навпаки».

Але, компілятор компілятору різниця. Компілятор, який перетворює код мови, як C, 1:1 без оптимізації, простіше за «типовий застосунок». Компілятор на зразок сучасного GCC чи Clang, який 1) здатний компілювати C++, для якого, як відомо, треба підтримувати інтерпретацію субмножини мови під час компіляції, і 2) витворяє настільки складні оптимізації, що їх треба описувати в 20-30 етапів — складніше такого «типового застосунка».

Точно так же це стосується процесора: лінійний виконавець всіх команд по одній без кеша, і щось типу сучасного SkyLake з 224 мікрокомандами в черзі out-of-order виконання — земля і небо. І тільки «земні» варіанти дозволяються в надкритичних застосуваннях, навіть якщо вони в сто разів повільніші. А різниця між ними приводить до того, наприклад, що в перших в рази менше умов для міжтредових обгонів.

то з твого твердження витікає що математика безсила в описі задач програмування, що повна маячня.

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

Верифікація так і працює: беремо функцію, доводимо що вона при певних умовах працює коректно,

Заклавшись на коректну роботу процесора, шин, памʼяти, компілятора, ОС, і ще 100500 учасників. Хочу подивитись, як ти верифікацією доведеш коректність роботи процесора...

Тому це не підтвердження, це просто посилення сподівань:

Хто б сперечався, я не буду.

Ви маєте на увазі FSM ≡ непараметризована FSM

FSM має чітке математичне визначення: це п’ятірка, алфавіт (кінцева множина A), стейти (кінцева множина S), s0 п(початковий стейт, елемент з S), delta (функція що пепеводить пару стейт символ у стейт) та F (підмножина S, кінцеві стани). Це визначення зустрічається в бідь якій літературі від теорії обчислень до теорії компіляції. Параметризовані FSM це як мінімум нестале визначення.

Наскільки я бачив формалізацію верифікації, ресурсні обмеження там теж можуть мати місце, і часто мають.

Які саме? Головний ресурс це спеціалісти та час на докази. Для більшості ширвжитку докази досить елементарні, по суті той самий розбір усіх можливих випадків, тільки комп’ютер перевіряє чи не пропустив ти щось. Навіть Lean Mathlib (найбільша формалізована бібліотека математики на сьогодні, містить понад 210 000 теорем і 100 000 визначень з теорії категорій, числових систем (натуральні, цілі, раціональні, дійсні, комплексні, p-адичні), алгебри (групи, кільця, поля, модулі, тензорний добуток), лінійної алгебри, топології (рівномірні простори, метричні простори), теорії міри та інтегрального числення (інтеграл Бохнера, теорема Фубіні), диференціального числення, алгебраїчної геометрії (спектр, схеми, Nullstellensatz), теорії графів, комбінаторики, теорії Рамсея) компілюється за декілька годин на сучасному десктопному залізі. CompCert компілюється за хвилини.

Але, компілятор компілятору різниця. Компілятор, який перетворює код мови, як C, 1:1 без оптимізації, простіше за «типовий застосунок». Компілятор на зразок сучасного GCC чи Clang,

CompCert це не «1:1 без оптимізації», це оптимізуючий компілятор з продуктивністю на рівні gcc -O1. Дослідження Csmith знайшло 325+ багів у GCC/LLVM/інших компіляторах за 2.5 роки тестування, майже все в оптимізаційних пасах з досить складними прикладами для відтворення. В CompCert жодного такого багу не знайдено, там зазвичай неправильна специфікація (читай розуміння) стандарту, дуже прості приклади. Іронія в тому що саме оптимізації в критичному коді використовувати не можна, бо це неконтрольована поведінка. Верифікований компілятор дозволяє їх залишити.

Звісно, що це не порівняти з перекладанням даних з JSON в базу

Точно так же це стосується процесора: лінійний виконавець всіх команд по одній без кеша, і щось типу сучасного SkyLake з 224 мікрокомандами в черзі out-of-order виконання

Критичні компоненти якого верифіковані, Intel це робить вже понад 20 років, тому що тестування не працює. Після FDIV бага в Pentium вони перейшли на формальну верифікацію FPU, а для Core i7 повністю замінили тестування формальною верифікацією execution engine.

Але коли це потребує ресурсів на порядки більше, ніж є в наявности, то скорочують до реальних масштабів.

Ще раз, скоріше специалістів. І то не факт, що LLM не буде це робити

Хочу подивитись, як ти верифікацією доведеш коректність роботи процесора...

То робиться... Intel верифікує FPU та execution engine з 90-х. ARM верифікує pipeline control для 8 поколінь процесорів. Є формально верифіковані RISC-V ядра в Coq, які запускають реальну ОС (Zephyr). Це не теорія, це індустріальна практика. Для Core i7 execution engine, який верифікували формально, лише 3 баги потрапили в кремній, найменше серед усіх компонентів. І всі три цепроблема неповної специфікації або незавершеної верифікації, жоден не в верифікованому коді. Більше того, як на мене, без формальної верифікації створювати сучасні процесори просто неможливо, бо всі варіанти не врахуєш.

FSM має чітке математичне визначення

Нас навчали варіанту «автомат з параметром» і «автомат з памʼяттю».
Спиратись тільки на вікіпедію некоректно, і я вже сказав, що таке обмеження означенням не має практичного сенсу. Майже всі (на швидкий погляд, більше 3/4) автоматів, що я проґрамував, мали якусь памʼять, яку змінювали перетворення станів, а іноді і впливали на вихідний стан кроку.

Параметризовані FSM це як мінімум нестале визначення.

Але корисне. Сподіваюсь, що воно поступово прийде в основні ресурси, як та ж вікіпедія.

Які саме? Головний ресурс це спеціалісти та час на докази.

Саме так. А також просто машинний час на обрахунок занадто складних моделей, який може досягати міліонів років, якщо не обмежити.

Але людський час тут, через ціну роботи, найголовніший.

CompCert це не «1:1 без оптимізації», це оптимізуючий компілятор з продуктивністю на рівні gcc -O1.

Ну ось і читаю:

> The main result of the project is the CompCert C verified compiler, a high-assurance compiler for almost all of the C language (ISO C 2011)

По-перше, C, не C++. Для C задача дуже легка. По-друге, «almost all», навіть не весь C:

The only external functions available to the program are:
— printf to display formatted text on standard output;
— malloc to dynamically allocate memory;
— free to free memory allocated by malloc;
— __builtin_... the platform-independent built-in functions of section 6.3.1 and the general annotation built-in functions of section 6.5.

(трохи відформатував)

Тобто нічого реального на ньому зробити не можна. Навіть ввести дані ззовні. І це, ти вважаєш, можна назвати результатом?

(Вже не кажу про аналог -O1, це, звісно, вже не нуль, але маловато. І що саме з ≈30 оптимізацій в -O1 туди входить? чи це оцінка «на глаз и наугад»?)

То робиться... Intel верифікує FPU та execution engine з 90-х.

Смішно. Ти в курсі історії з TSX? Воно вже працює, чи як? Вже були чотири спроби тільки ті, що я бачив, активувати його і потім екстренно вимикати через firmware update. Зараз воно тільки на Xeon і обмежено. Чому?

Це не верифікація execution engine. У кращому випадку це верифікація окремих частин того самого execution engine, але тобі ніхто не скаже, яких частин (і яка саме верифікація!) У кращому випадку будуть рекламні статті, замасковані під наукові.

У тебе є IEEE membership? Якщо був би, міг би точніше сказати, що саме вони рекламують як «верифікацію».

Для Core i7 execution engine, який верифікували формально, лише 3 баги потрапили в кремній

«Core i7» якого покоління? Один раз зробили і забули? Така ж реклама, з принципово схованими під багатошаровим NDA деталями. Повторюю, де TSX на тому ж i7?

І чому тільки i7? Де таке ж для i5 чи i3? Вони ж, взагалі, просто спрощені за ресурсами еквіваленти, то та ж сама верифікація має і їх підтвердити?

Здається, ти набрав заголовків з гуглопошуку, не спробувавши проаналізувати їх доречність і цінність.

Є формально верифіковані RISC-V ядра в Coq, які запускають реальну ОС (Zephyr). Це не теорія, це індустріальна практика.

Вірю. Питання в тому, наскільки ця формалізація коректна, і що і як побудовано в цих ядрах.

Наприклад, якщо це зроблено на рівні, умовно, Verilog, то ще буде декілька кроків до реального заліза, з аналоговими оптимізаціями під конкретний техпроцес, з розміщенням компонентів на кристалі... і тут навіть на інтерференції сигналів сусідніх доріжок чи на метастабільности тригерів можна спіймати кілотони ефектів.

Звісно, формально верифіковане ядро краще, ніж не верифіковане, якщо дає ту ж функціональність, не заперечую:))

Ще один варіант, що робити з assertʼами, це лоґувати спрацювання.

Так, додав, тепер це виглядає так

Зазвичай у таких сценаріях ми зазвичай якось логуємо випадок, «прокидаємо» помилку нагору по стеку, щоб її обробив вищий рівень програми.
PPS: Відредаґуйте форматування, місцями всякі
вилазять прямим текстом.

Виправив в одному місці

Трохи випадкових зауважень:

Отут ще трохи розповідають за користь асертів en.wikipedia.org/...​iki/Offensive_programming

В принципі, за моїм досвідом, велика кількість асертів (скажімо, 10% від коду) та талановитий manual QA в некритичних доменах надають кращу якість продукту і з меншими витратами часу, ніж написання юніт- та автотестів.

Здається, в Пітоні асерти імплементовані через ексепшни.

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

Тут ідеалістична помилка. Ані комп’ютерне залізо, ані операційна система, ані бібліотеки не є настільки стабільними, щоб математичне доведення коректності вашого коду гарантувало коректність його роботи. Ви можете спіймати зміну біту в залізі, або рейс кондішн в бібліотеці, або проїзд по вашій пам’яті десь в нутрощах операційної системи. І якщо хочете коректної поведінки — то вам ліпше впасти на перевірці, най вона вважатиметься надлишковою, бо доведено що це ніколи не станеться, аніж покладатися на коректність роботи усього, чим ваш код користується.

На мій погляд, ключ до розв’язання цієї проблеми лежить у чіткому розділенні двох світів: Небезпечного зовнішнього світу та безпечного внутрішнього. Ми цієї теми не торкалися, це монада IO.

Ні, це старе правило з 90х: використовуйте асерти для внутрішньої логіки та ексепшни для даних, котрі приходять ззовні.

В Расті просто написали код, котрий швидше було написати blog.cloudflare.com/...​age/#memory-preallocation Без явного асерту, покладаючись на розрекламовану надійність Расту та те, що його компілер зловить проблеми.

Ані комп’ютерне залізо, ані операційна система, ані бібліотеки не є настільки стабільними, щоб математичне доведення коректності вашого коду гарантувало коректність його роботи.

Залізо напевно так, хоча діло до значного покращення, думаю скоро з’являться верифіковані процесори з виправленням помилок. Зараз це поодинці, варіанти steplock, коли одна інструкція виконується на двох ядрах, і т. п. Але далі ситуація вже почала змінюватися: є верифікована операційна система seL4, є верифікований компілятор CompCert, є верифіковані бібліотеки. Не кажучи про те, що є просто код не залежний від чого. Верифікованого софту стає все більше, від забавки йде рух до практичних застосувань.

Але навіть якщо ми використовуємо неверифіковану бібліотеку, то ми можемо довести, що при умові, що бібліотека працює коректно, наша система працює теж коректно. Теж хліб.

най вона вважатиметься надлишковою, бо доведено що це ніколи не станеться

Ще раз, моє визначення assert-у це математичне твердження. Тому вони можуть буть доволі затратними. Наприклад, інваріант класу, умова що червоно-чорне дерево збалансоване. На кожному виклику перевіряти сильно вдарить по швидкодії. Це навіть не кажучи про такі твердження, які неможливо взагалі перевірити кодом, наприклад, що серед строк які містять два літери «а», саме ця строка має найбільшу якусь метрику.

Ні, це старе правило з 90х: використовуйте асерти для внутрішньої логіки та ексепшни для даних, котрі приходять ззовні.

Haskell з 90-х років як раз, монада IO саме для контролю цього й була додана.

Підписатись на коментарі