Вступ
Когерентність(узгодженість, взаємопов’язаність) кешу - концепція, що має свої коріння у глибині проектування апаратного забезпечення, - часто може бути складною темою для комп'ютерних інженерів. Проте глибоке розуміння цієї теми не тільки розкриває більш глибоке розуміння принципів проектування систем, але й приносить суттєву користь розробникам програмного забезпечення. Тож чому розробники програмного забезпечення повинні витрачати час на розуміння конструкції кешу ЦП і, зокрема, узгодженості кешу?
Когарентність кешу: більше, ніж просто питання апаратного забезпечення
Багато принципів, отриманих вивченням взаємопов'язаності кешу, мають пряме застосування у сферах розробки програмного забезпечення, таких як архітектура розподілених систем та рівні ізоляції баз даних. Розуміння, як узгодженість реалізована у апаратних кешах, може покращити розуміння концепцій eventual and strong consistency, особливо як вони відносяться до синхронізації даних в розподілених системах.
Засвоєння принципів взаємопов'язаності кешу може допомогти уникнути помилкових уявлень про багатопоточне програмування. Однак, важливо зазначити, що навіть одноядерні системи можуть бути вразливі до помилок конкурентності, якщо не використовуються відповідні конструкції для синхронізації.
Щодо волатильних змінних у мовах, таких як Java, важливо зрозуміти, що вони гарантують, що оновлення будуть видимі для всіх потоків негайно. Це не обов'язково означає, що вони "запобігають кешуванню спільних даних локально" або змушують їх "читати/записувати безпосередньо в основну пам'ять", як це помилково можуть вважати деякі розробники. Читання волатильних змінних (у Java) часто не відрізняється вартістю від посилання на кеш L1.
Протоколи взаємопов'язаності кешу. Огляд
Апаратні кеші на сучасних процесорах x86 синхронізуються за допомогою складних протоколів, що забезпечують узгодженість між всіма потоками. Один з широко використовуваних протоколів - це протокол MESI (Modified, Exclusive, Shared, Invalid). Стан даних у кеші може перебувати у будь-якому з цих чотирьох станів.
Modified (M): Дані були змінені і відрізняються від основної пам'яті. Exclusive (E): Дані не були змінені і синхронізовані з основною пам'яттю. Shared (S): Дані не були змінені і синхронізовані з іншими даними. Invalid (I): Дані застарілі і не повинні використовуватися.
Протокол MESI забезпечує, що якщо два різні потоки будь-де в системі зчитують дані з однієї і тієї ж адреси пам'яті, вони ніколи одночасно не зчитують різні значення. Ця послідовність зберігається навіть у випадку, коли потоки хочуть записувати до однієї адреси або зчитувати з однієї адреси.
Більш детальний опис роботи протоколу MESI
Давайте розглянемо протокол MESI у прикладі з процесором, що має чотири ядра (кожне з яких оснащене власним кешем L1) та глобальним кешем L2, які приймають участь в записі та зчитуванні даних.
Припустимо, що потік на ядрі-1 бажає записати дані за адресою 0xabcd. Якщо кеш L1-1 вже містить ці дані, то відбувається "попадання в кеш" (cache hit), і запис відбувається безпосередньо. Однак, якщо дані відсутні в L1-1 або знаходяться в стані Shared, кеш L1-1 надсилає запит на отримання власності (Request-For-Ownership) до кешу L2. Це означає, що кеш L2 отримує ексклюзивний доступ до цих даних, і всі інші копії даних в інших кешах стають недійсними.
Кеш L2 відповідає за координацію взаємодії між кешами L1, забезпечуючи когерентність даних і відстежуючи, які кеші мають дійсні копії даних.
Схожий процес відбувається при зчитуванні даних. Якщо дані не присутні в кеші L1, відбувається "промах кешу" (cache miss), і система здійснює пошук даних у кеші L2 або, у разі необхідності, в глобальній пам'яті. Кеш L2 допомагає керувати когерентністю даних між кешами L1.
Цей приклад є спрощеним представленням роботи протоколу MESI. На практиці, існує багато інших сценаріїв та варіацій, що враховують більш складні аспекти роботи процесора та кеш-пам'яті. Також важливо зазначити, що MESI - це лише один з протоколів когерентності кешу, існують також MOESI, MSI, Illinois, та інші.
"L1" вказує на тип кешу - кеш першого рівня. Процесори часто використовують кілька рівнів кешу, зазвичай відмічені як L1, L2, і т.д., де L1 - це найшвидший і найближчий до ядра кеш, а L2, L3 і так далі є дещо повільнішими і “віддаленими“.
"-1" вказує на конкретне ядро процесора. У багатоядерних системах кожне ядро має власний кеш L1, тому "-1" позначає кеш L1 першого ядра. Для інших ядер це могло б бути L1-2, L1-3, L1-4 і так далі, в залежності від номера ядра.
Волатильні змінні та регістри
Незважаючи на узгодженість кешів, нам все ще потрібні конструкції, такі як волатильні змінні, у мовах програмування, таких як Java. Це тому, що дані, які зчитуються до регістрів процесора, не обов'язково синхронізуються з даними у кеші або пам'яті. Компілятори і процесори часто роблять оптимізації з припущенням, що код буде виконуватись в однопотоковому режимі, та можуть переставляти інструкції для покращення продуктивності.
Таким чином, будь-які дані, які можуть бути змінені одночасно декількома потоками (тобто дані, які підлягають умовам гонки), потрібно захищати вручну за допомогою алгоритмів конкурентності та мовових конструкцій. Це може включати використання атомарних операцій, які гарантують, що операція буде виконана повністю без переривань іншими потоками, а також волатильних змінних в Java, які забезпечують, що оновлення будуть видимими для всіх потоків.
https://docs.oracle.com/javase/tutorial/essential/concurrency
https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html
https://docs.oracle.com/javase/specs/jls/se20/jls20.pdf, Див. розділ 17.4, "Memory Model" для подробиць про багатопотоковість в Java.
https://software.rajivprab.com/2018/04/29/myths-programmers-believe-about-cpu-caches/ — В основному, це переклад цієї статті, з доповненням зі вказаних ресурсів
https://www.youtube.com/watch?v=r_ZE1XVT8Ao (картинка з цього відео)