Русский
Русский
English
Статистика
Реклама

Cache

Влияние service workerов на web-приложения

12.08.2020 10:04:09 | Автор: admin

Web-приложения всё больше "затачиваются" под мобильные устройства, а service worker'ы являются фундаментом прогрессивных web-приложений (PWA). При первом ознакомлении с данной технологией может сложиться впечатление, что основной задачей service worker'ов является кэширование контента. И это так. Задача service worker'ов обеспечение функционирования web-приложения в условиях нестабильного или вообще отсутствующего подключения к Сети, что достигается при помощи кэширования данных.


Под катом пара мыслей о том, к каким последствиям для web-приложений привело появление возможности кэшировать данные посредством service worker'ов.


Архитектура PWA


Вот классическая трёхуровневая архитектура web-приложения:


Добавление на клиенте service worker'а и инструментов для сохранения данных (Cache API и IndexedDB) превращают трёхуровневую архитектуру в пятиуровневую:


По сути, при отсутствии соединения с Сетью прогрессивное web-приложение должно работать на клиенте в классическом трёхуровневом режиме:


а когда появляется соединение с Сетью переходить на пятиуровневую:


  1. Presentation (Main Thread): пользовательский интерфейс;
  2. Client Logic (Service Worker): бизнес-логика обработки данных конкретного пользователя с учётом работы в offline & online режимах;
  3. Client Data (Cache API & IndexedDB): хранилища данных конкретного пользователя;
  4. Server Logic (Server): бизнес-логика обработки данных всех пользователей приложения;
  5. Server Data (DB): хранилище данных всех пользователей приложения;

Offline first


В web-разработке популярной является стратегия mobile first. Для PWA есть похожая стратегия offline first. Её суть в том, что приложение изначально разрабатывается для условий автономной работы (классическая трёхуровневая архитектура на клиенте), а затем расширяется до пятиуровневой.


Таким образом, самой первой задачей service worker'а является кэширование данных в объёме, достаточном для автономного функционирования приложения. Следующей организация очереди запросов для обмена данными конкретного пользователя с сервером. Затем мониторинг состояния соединения с сервером (online/offline) и обработка очереди запросов.


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


Типы трафика


Трафик между браузером клиента и сервером можно разделить на две части:



  • статика: контент, общий для всех клиентов (HTML/CSS/JS/images/...);
  • данные (API): контент (как правило, JSON), предназначенный как для всех пользователей (каталог продуктов), так и для конкретного пользователя (корзина покупок);

Для первого типа трафика (статика) браузер предоставляет Cache API простое хранилище "запрос" "ответ"


Для хранения данных (API) IndexedDB (NoSQL хранилище структурированных данных в формате JSON).


Типы хранилищ


Cache


В панели инструментов Chrome'а хранилище находится в Application / Cache / Cache Storage / <имя кэша>, содержимое выглядит примерно так:


Возможности хранилища позволяют, при достаточном воображении, перенести CDN на клиентский уровень, превращая каждый браузер в своего рода однопользовательскую точку распространения контента.


IndexedDB


В панели инструментов Chrome'а хранилища объектов находятся в Application / Storage / IndexedDB / <имя базы> / <имя хранилища объектов>, содержимое выглядит примерно так:


База предоставляет транзакционный доступ к хранилищам для выполнения CRUD-операций, индексацию данных по ключевым полям, версионирование структуры базы. Запросы к IndexedDB выполняются в асинхронном режиме. Объём хранимых данных зависит от многих факторов, но вполне может достигать гигабайтов.


В общем, функционала вполне достаточно для реализации в рамках браузера приложения в классической трёхуровневой архитектуре.


Загрузка файлов service worker'а


При имплементации клиентской бизнес-логики в service worker'е он получается достаточно сложным функциональным блоком. В одном файле его код без средств сборки (типа webpack'а) не разместить. Для загрузки скриптов в область видимости service worker'а существует метод WorkerGlobalScope.importScripts(). Но его особенность в том, что он синхронный. Для service worker'а нет возможности динамической загрузки его компонентов:


import('/modules/my-module.js')  .then((module) => {    // Do something with the module.  });

Все скрипты, которые могут понадобиться service worker'у, должны загружаться при его регистрации. Что и понятно service worker должен обеспечивать работоспособность web-приложения в offline-режиме, а для этого он сначала должен обеспечить свою собственную работоспособность.


Резюме


Добавление service worker'а (и IndexedDB) даёт возможность создавать браузерные приложения (PWA в режиме offline) по классической трёхуровневой схеме (интерфейс логика данные). Online-режим в PWA добавляет к трёхуровневой схеме ещё и "межсерверные" взаимодействия: Client Logic/Data Server Logic/Data. Бизнес-логика web-приложения распадается на две части: для отдельного пользователя и для совокупности всех пользователей, во многом совпадающие, но имеющие различия (например, ACL имеет смысл встраивать только в серверную сторону).


Синхронная загрузка скриптов в service worker'е ограничивает возможности разработчиков в выборе инструментария для реализации бизнес-логики в самом service worker'е (например, слабо поддерживается ES6 import), поэтому есть смысл оставлять за service worker'ами только лишь функции кэширования статики, а всю клиентскую бизнес-логику выводить в Main Thread (включая обработку данных и очередей запросов).


Поэтому схема архитектуры прогрессивного web-приложения, на мой взгляд, лучше выглядит в таком виде:


Service worker обрабатывает и кэширует только статику. Клиентская логика отрабатывает в основном потоке браузерного приложения, взаимодействует с хранилищем данных и сама контролирует переход из online в offline и обратно (т.е. обмен данными с серверной частью приложения).


Возможно, кому-то изложенное выше покажется несколько запутанным, но до написания этой статьи моё понимание роли service worker'ов в прогрессивных web-приложениях было ещё запутаннее. Буду признателен за комментарии, ещё больше проясняющие задачи service worker'ов и способы их использования.


Ссылки


Подробнее..

Перевод Рецепты по приготовлению оффлайн-приложений

03.09.2020 14:17:53 | Автор: admin


Доброго времени суток, друзья!

Представляю вашему вниманию перевод замечательной статьи Джейка Арчибальда Offline Cookbook, посвященной различным вариантам использования сервис-воркера (ServiceWorker API, далее по тексту просто воркер) и интерфейса кэширования (Cache API).

Предполагается, что вы знакомы с основами названных технологий, потому что кода будет много, а слов мало.

Если не знакомы, то начните с MDN, а затем возвращайтесь. Вот еще неплохая статья про сервис-воркеры специально для визуалов.

Без дальнейших предисловий.

В какой момент сохранять ресурсы?


Воркер позволяет обрабатывать запросы независимо от кэша, поэтому будем рассматривать их по-отдельности.

Первый вопрос: когда следует кэшировать ресурсы?

При установке как зависимость



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

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

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

self.addEventListener('install', event => {    event.waitUntil(        caches.open('mysite-static-v3')            .then(cache => cache.addAll([                '/css/whatever-v3.css',                '/css/imgs/sprites-v6.png',                '/css/fonts/whatever-v8.woff',                '/js/all-min-v4.js'                // и т.д.            ]))    )})

event.waitUntil принимает промис для определения продолжительности и результата установки. Если промис будет отклонен, воркер не будет установлен. caches.open и cache.addAll возвращают промисы. Если один из ресурсов не будет получен,
вызов cache.addAll будет отклонен.

При установке не как зависимость



Это похоже на предыдущий пример, но в данном случае мы не ожидаем завершения установки, поэтому это не приведет к ее отмене.

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

self.addEventListener('install', event => {    event.waitUntil(        caches.open('mygame-core-v1')            .then(cache => {                cache.addAll(                    // уровни 11-20                )                return cache.addAll(                    // ключевые ресурсы и уровни 1-10                )            })    )})

Мы не передаем промис cache.addAll в event.waitUntil для уровней 11-20, так что если он будет отклонен, то игра все равно будет работать оффлайн. Разумеется, вам следует позаботиться о решении возможных проблем с кэшированием первых уровней и, например, повторить попытку кэширования в случае провала.

Воркер может быть остановлен после обработки событий до того, как уровни 11-20 будут кэшированы. Это означает, что данные уровни не будут сохранены. В будущем в воркер планируется добавить интерфейс фоновой загрузки для решения названной проблемы, а также для загрузки больших файлов, таких как фильмы.

Прим. пер.: данный интерфейс был реализован в конце 2018 года и получил название Background Fetch, но пока работает только в Хроме и Опере (68% по данным CanIUse).

При активации



Подходит для удаления старого кэша и миграции.

После установки нового воркера и остановки старого, новый воркер активируется и мы получаем событие activate. Это отличная возможность для замены ресурсов и удаления старого кэша.

self.addEventListener('activate', event => {    event.waitUntil(        caches.keys()            .then(cacheNames => Promise.all(                cacheNames.filter(cacheName => {                    // если true, значит, мы хотим удалить данный кэш,                    // но помните, что он используется во всем источнике                }).map(cacheName => caches.delete(cacheName))            ))    )})

Во время активации другие события, такие как fetch помещаются в очередь, так что долгая активация теоретически может заблокировать страницу. Так что используйте эту стадию только для того, что не можете сделать при работе старого воркера.

При возникновении пользовательского события



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

Предоставьте пользователю кнопку Прочитать позже или Сохранить. При нажатии кнопки получите ресурс и запишите его в кэш.

document.querySelector('.cache-article').addEventListener('click', event => {    event.preventDefault()    const id = event.target.dataset.id    caches.open(`mysite-article ${id}`)        .then(cache => fetch(`/get-article-urls?id=${id}`)            .then(response => {                // get-article-urls возвращает массив в формате JSON                // с URL для данной статьи                return response.json()            }).then(urls => cache.addAll(urls)))})

Интерфейс кэширования доступен на странице, как и сам воркер, поэтому нам не нужно вызывать последний для сохранения ресурсов.

Во время получения ответа



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

Если запрашиваемого ресурса нет в кэше, получаем его из сети, отправляем клиенту и записываем в кэш.

Если вы запрашиваете несколько URL, таких как пути к аватарам, убедитесь, что это не приведет к переполнению хранилища источника (origin протокол, хост и порт) если пользователю потребуется освободить место на диске, вы не должны оказаться первыми. Позаботьтесь об удалении ненужных ресурсов.

self.addEventListener('fetch', event => {    event.respondWith(        caches.open('mysite-dynamic')            .then(cache => cache.match(event.request)                .then(response => response || fetch(event.request)                    .then(response => {                        cache.put(event.request, response.clone())                        return response                    })))    )})

Для эффективного использования памяти мы читаем тело ответа только один раз. В приведенном примере метод clone используется для создания копии ответа. Это делается для того, чтобы одновременно отправить ответ клиенту и записать его в кэш.

Во время проверки на новизну



Подходит для обновления ресурсов, не требующих последних версий. Это может относиться и к аватарам.

Если ресурс имеется в кэше, используем его, но получаем обновление при следующем запросе.

self.addEventListener('fetch', event => {    event.respondWith(        caches.open('mysite-dynamic')            .then(cache => cache.match(event.request)                .then(response => {                    const fetchPromise = fetch(event.request)                        .then(networkResponse => {                            cache.put(event.request, networkResponse.clone())                            return networkResponse                        })                        return response || fetchPromise                    }))    )})

При получении пуш-уведомления



Интерфейс уведомлений (Push API) это абстракция над воркером. Она позволяет воркеру запускаться в ответ на сообщение от операционной системы. Причем, это происходит независимо от пользователя (при закрытой вкладке браузера). Страница, как правило, отправляет пользователю запрос на предоставление разрешения для совершения определенных действий.

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

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

Без подключения к сети Twitter не предоставляет контент, связанный с уведомлением. Тем не менее, клик по уведомлению приводит к его удалению. Не делайте так!

Следующий код обновляет кэш перед отправкой уведомления:

self.addEventListener('push', event => {    if (event.data.text() === 'new-email') {        event.waitUntil(            caches.open('mysite-dynamic')                .then(cache => fetch('/inbox.json')                    .then(response => {                        cache.put('/inbox.json', response.clone())                        return response.json()                    })).then(emails => {                        registration.showNotification('New email', {                            body: `From ${emails[0].from.name}`,                            tag: 'new-email'                        })                    })        )    }})self.addEventListener('notificationclick', event => {    if (event.notification.tag === 'new-email') {        // предположим, что все ресурсы, необходимые для рендеринга /inbox/ были кэшированы,        // например, при установке воркера        new WindowClient('/inbox/')    }})

При фоновой синхронизации



Фоновая синхронизация (Background Sync) еще одна абстракция над воркером. Она позволяет запрашивать разовую или периодическую фоновую синхронизацию данных. Это также не зависит от пользователя. Однако, ему также оправляется запрос на разрешение.

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

self.addEventListener('sync', event => {    if (event.id === 'update-leaderboard') {        event.waitUntil(            caches.open('mygame-dynamic')                .then(cache => cache.add('/leaderboard.json'))        )    }})

Сохранение кэша


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

Размеры хранилищ не являются фиксированными и зависят от устройства, а также от условий хранения ресурсов. Это можно проверить так:

navigator.storageQuota.queryInfo('temporary').then(info => {    console.log(info.quota)    // результат: <квота в байтах>    console.log(info.usage)    // результат <размер хранящихся данных в байтах>})

Когда размер того или иного хранилища достигает лимита, это хранилище очищается по определенным правилам, которые в настоящее время нельзя изменить.

Чтобы решить эту проблему был предложен интерфейс отправки запроса на разрешение (requestPersistent):

navigator.storage.requestPersistent().then(granted => {    if (granted) {        // ура, данные сохранятся за счет увеличения размера хранилища    }})

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

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

Ответы на запросы


Неважно, сколько ресурсов вы храните в кэше, воркер не будет его использовать, пока вы не скажете ему когда и что именно использовать. Вот несколько шаблонов для обработки запросов.

Только кэш



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

self.addEventListener('fetch', event => {    // если не будет найдено совпадение,    // ответ будет похож на ошибку соединения    event.respondWith(caches.match(event.request))})

Только сеть



Подходит для ресурсов, которые не могут быть записаны в кэш, например, данные аналитики или не GET-запросы.

self.addEventListener('fetch', event => {    event.respondWith(fetch(event.request))    // или просто не вызывайте event.respondWith    // это приведет к стандартному поведению браузера})

Сначала кэш, затем, при неудаче, сеть



Подходит для обработки большинства запросов в оффлайн-приложениях.

self.addEventListener('fetch', event => {    event.respondWith(        caches.match(event.request)            .then(response => response || fetch(event.request))    )})

Сохраненные ресурсы возвращаются из кэша, несохраненные из сети.

Кто успел, тот и съел



Подходит для небольших ресурсов в погоне за повышением производительности устройств с маленьким объемом памяти.

Сочетание старого жесткого диска, антивируса и быстрого соединения с Интернетом может привести к тому, что получение данных из сети окажется быстрее, чем получение данных из кэша. Тем не менее, получение данных из сети в то время, как эти данные хранятся в устройстве пользователя это пустая трата ресурсов.

// Promise.race нам не подойдет, поскольку он отклоняется// при отклонении любого из переданных ему промисов.// Напишем собственную функциюconst promiseAny = promises => new Promise((resolve, reject) => {    // все promises должны быть промисами    promises = promises.map(p => Promise.resolve(p))    // выполняем текущий промис, как только он разрешается    promises.forEach(p => p.then(resolve))    // если все промисы были отклонены, останавливаем выполнение функции    promises.reduce((a, b) => a.catch(() => b))        .catch(() => reject(Error('Все промисы отклонены')))})self.addEventListener('fetch', event => {    event.respondWith(        promiseAny([            caches.match(event.request),            fetch(event.request)        ])    )})

Прим. пер.: сейчас для этой цели можно использовать Promise.allSettled, но его поддержка браузерами составляет 80%: -20% пользователей это, пожалуй, слишком.

Сначала сеть, затем, при неудаче, кэш



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

Это означает, что вы предоставляете онлайн-пользователям новый контент, а оффлайн-пользователям старый. Если запрос ресурса из сети завершился успешно, вероятно, следует обновить кэш.

У данного подхода есть один недостаток. Если у пользователя проблемы с соединением или оно медленное, он вынужден ждать завершения или провала запроса вместо того, чтобы моментально получить контент из кэша. Это ожидание может быть очень долгим, что приведет к ужасному пользовательскому опыту.

self.addEventListener('fetch', event => {    event.respondWith(        fetch(event.request).catch(() => caches.match(event.request))    )})

Сначала кэш, затем сеть



Подходит для часто обновляемых ресурсов.

Это требует от страницы отправки двух запросов: одного для кэша, другого для сети. Идея состоит в том, чтобы вернуть данные из кэша, а затем обновить их при получении данных из сети.

Порой вы можете заменить текущие данные при получении новых (например, рейтинг игроков), но это проблематично сделать для больших частей контента. Это может привести к исчезновению того, что пользователь в данный момент читает или с чем взаимодействует.

Twitter добавляет новый контент над существующим, сохраняя прокрутку: пользователь видит уведомление о новых твитах в верхней части экрана. Это возможно благодаря линейному порядку контента. Я скопировал этот шаблон для максимально быстрого отображения контента из кэша и добавления нового контента при его получении из сети.

Код на странице:

const networkDataReceived = falsestartSpinner()// получаем свежие данныеconst networkUpdate = fetch('/data.json')    .then(response => response.json())        .then(data => {            networkDataReceived = true            updatePage(data)        })// получаем сохраненные данныеcaches.match('/data.json')    .then(response => {        if (!response) throw Error('Данные отсутствуют')        return response.json()    }).then(data => {        // не перезаписывайте новые данные из сети        if (!networkDataReceived) {            updatePage(data)        }    }).catch(() => {        // мы не получили данные из кэша, сеть - наша последняя надежда        return networkUpdate    }).catch(showErrorMessage).then(stopSpinner)

Код воркера:

Мы обращаемся к сети и обновляем кэш.

self.addEventListener('fetch', event => {    event.respondWith(        caches.open('mysite-dynamic')            .then(cache => fetch(event.request)                .then(response => {                    cache.put(event.request, response.clone())                    return response                }))    )})

Подстраховка



Если попытки получить ресурс из кэша и сети провалились, должен быть запасной вариант.

Подходит для местозаполнителей (замена изображений пустышками), неудачных POST-запросов, страниц Недоступно при отсутствии подключения к сети.

self.addEventListener('fetch', event => {    event.respondWith(        // пробуем получить ресурс из кэша        // если не получилось, обращаемся к сети        caches.match(event.request)            .then(response => response || fetch(event.request))            .catch(() => {                // если оба запроса провалились, используем страховку                return caches.match('/offline.html')                // у вас может быть несколько запасных вариантов                // в зависимости от URL или заголовков запроса            })    )})

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

Создание разметки на стороне воркера



Подходит для страниц, которые рендерятся на стороне сервера и не могут быть сохранены в кэше.

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

import './templating-engine.js'self.addEventListener('fetch', event => {    const requestURL = new URL(event.request.url)    event.respondWith(        Promise.all([            caches.match('/article-template.html')                .then(response => response.text()),            caches.match(`${requestURL.path}.json`)                .then(response => response.json())        ]).then(responses => {            const template = responses[0]            const data = responses[1]            return new Response(renderTemplate(template, data), {                headers: {                    'Content-Type': 'text/html'                }            })        })    )})

Все вместе

Вам не обязательно ограничиваться одним шаблоном. Скорее всего, вам придется их комбинировать в зависимости от запроса. Например, в trained-to-thrill используется следующее:

  • Кэширование при установке воркера для постоянных элементов пользовательского интерфейса
  • Кэширование при получении ответа от сервера для изображений и данных Flickr
  • Получение данных из кэша, а при неудаче из сети для большинства запросов
  • Получение ресурсов из кэша, а затем из сети для результатов поиска Flick

Просто смотрите на запрос и решайте, что с ним делать:

self.addEventListener('fetch', event => {    // разбираем URL    const requestURL = new URL(event.request.url)    // обрабатываем запросы к определенному хосту особым образом    if (requestURL.hostname === 'api.example.com') {        event.respondWith(/* определенная комбинация шаблонов */)        return    }    // маршрутизация для относительных путей    if (requestURL.origin === location.origin) {        // обработка различных путей        if (/^\/article\//.test(requestURL.pathname)) {            event.respondWith(/* другая комбинация шаблонов */)            return        }        if (/\.webp$/.test(requestURL.pathname)) {            event.respondWith(/* другая комбинация шаблонов */)            return        }        if (request.method == 'POST') {            event.respondWith(/*  другая комбинация шаблонов */)            return        }        if (/cheese/.test(requestURL.pathname)) {            event.respondWith(                // прим. пер.: не смог перевести - Вопиющая ошибка сыра?                new Response('Flagrant cheese error', {                // такого статуса не существует                status: 512                })            )            return        }    }    // общий паттерн    event.respondWith(        caches.match(event.request)            .then(response => response || fetch(event.request))    )})

Надеюсь, статья была вам полезной. Благодарю за внимание.
Подробнее..

О кэшах в микроконтроллерах ARM

04.11.2020 18:10:50 | Автор: admin
image Привет!

В предыдущей статье мы для ускорения графики на микроконтроллере применяли процессорный кэш. При этом мы использовали режим write-through. Тогда мы писали о некоторых преимуществах и недостатках связанных с write-through режимом, но это был лишь беглый обзор. В этой статье я, как и обещал, хочу подробней рассмотреть типы кэшей на ARM микроконтроллерах, а также сравнить их. Конечно все это будет рассмотрено с точки зрения программиста, и вдаваться в детали работы контроллера памяти в данной статье мы не планируем.

Начну с того на чем остановился в предыдущей статье, а именно, на разнице между write-back и write-through режимами, поскольку именно эти два режима чаще всего используются. Если кратко, то:
  • Write-back. Данные по записи подают только в кэш. Реальная запись в память откладывается до тех пор пока кэш не переполнится и не потребуется место для новых данных.
  • Write-through. Запись происходит одновременно и в кэш и в память.

Write-through


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

Конечно кажется будто это должно сильно сказаться на производительности, но сам STM в этом документе говорит, что это не так:
Write-through: triggers a write to the memory as soon as the contents on the cache line are written to. This is safer for the data coherency, but it requires more bus accesses. In practice, the write to the memory is done in the background and has a little effect unless the same cache set is being accessed repeatedly and very quickly. It is always a tradeoff.

То есть, изначально мы предполагали, что раз запись происходит в память, то на операциях записи производительность будет примерно такой же как и совсем без кэша, а основной выигрыш происходит за счет повторных чтений. Однако, STM это опровергает, говорится что данные в в память попадают в фоне, поэтому производительность на записи практически такая же как и в режиме write-back. Это, в частности, может зависеть от внутренних буферов контроллера памяти (FMC).

Минусы режима write-through:
  • При последовательном и быстром доступе в одну и ту же память производительность может снижаться. В режиме write-back последовательные частые доступы к одной памяти будут, наоборот, являться плюсом.
  • Как и в случае с write-back все равно нужно делать cache invalidate после окончания DMA операций.
  • Баг Data corruption in a sequence of Write-Through stores and loads в некоторых версиях Cortex-M7. Нам указал на него один из разработчиков LVGL.

Write-back


Как уже говорилось выше, в этом режиме (в отличие от write-through) данные в общем случае не попадают в память по записи, а попадают только в кэш. Как и у write-through, у этой стратегии имеются два под варианта 1) write allocate, 2) no write allocate. Об этих вариантах мы поговорим дальше.

Write Allocate


Как правило, в кэшах всегда используется read allocate то есть по промаху (cache miss) на чтение данные забираются из памяти и размещаются в кэше. Аналогично, при промахе на запись данные могут подгружаться в кэш (write allocate) или не подгружаться (no write allocate). Обычно на практике используются сочетания write-back write allocate или write-through no write allocate. Далее в тестах мы попробуем чуть более детально проверить в каких ситуациях использовать write allocate, а в каких no write allocate.

MPU


Прежде чем переходить к практической части нам необходимо разобраться как же задавать параметры региона памяти. Для выбора режима кэша (или его отключения) для определенного региона памяти в архитектуре ARMv7-M используется MPU (Memory Protection Unit).

В контроллере MPU поддерживается задание регионов памяти. Конкретно в архитектуре ARMV7-M может быть до 16 регионов. Для этих регионов можно независимо устанавливать: стартовый адрес, размер, права доступа (read/write/execute и т.д.), атрибуты TEX, cacheable, bufferable, shareable, а так же и другие параметры. С помощью такого механизма, в частности, можно добиться любого типа кэширования для определенного региона. Например, мы можем избавиться от необходимости вызывать cache_clean/cache_invalidate просто выделив регион памяти под все операции DMA и пометив эту память как не кэшируемую.

Нужно отметить важный момент при работе с MPU:
The base address, size and attributes of a region are all configurable, with the general rule that all regions are naturally aligned. This can be stated as:
RegionBaseAddress[(N-1):0] = 0, where N is log2(SizeofRegion_in_bytes)


Иными словами, стартовый адрес региона памяти должен быть выровнен на его собственный размер. Если у вас, к примеру, регион 16 Кб, то выравнивать нужно на 16 Кб. Если регион памяти 64 Кб, то выравниваем на 64 Кб. И так далее. Если этого не сделать, то MPU может автоматически обрезать регион под размер соответствующий его стартовому адресу (проверено на практике).

Кстати, в STM32Cube есть несколько ошибок. Например:
  MPU_InitStruct.BaseAddress = 0x20010000;  MPU_InitStruct.Size = MPU_REGION_SIZE_256KB;


Видно, что стартовый адрес выровнен на 64 Кб. А размер региона хотим 256 Кб. В этом случае придется создавать 3 региона: первый 64 Кб, второй 128 Кб, и третий 64 Кб.

Задавать нужно только регионы с отличными от стандартных свойствами. Дело в том, что атрибуты всех памятей при включении кэша процессора описаны в архитектуре ARM. Есть стандартный набор свойств (к примеру, поэтому память SRAM STM32F7 имеет режим write-back write-allocate по умолчанию), Поэтому если вам понадобится не стандартный режим для какой-то из памятей, то нужно будет задать его свойства через MPU. При этом внутри региона можно задать подрегион со своими свойствами, Выделив внутри этого региона еще один с большим приоритетом с требуемыми свойствами.

TCM


Как следует из документации (раздел 2.3 Embedded SRAM), первые 64 Кб SRAM в STM32F7 некэшируемые. В самой архитектуре ARMv7-M по адресу 0x20000000 находится память SRAM. TCM тоже относится к SRAM, но находится на другой шине относительно остальных памятей (SRAM1 и SRAM2), и располагается ближе к процессору. Из-за этого данная память очень быстрая, по сути дела, имеет такую же скорость как и кэш. И из за этого кэширование не нужно, и этот регион не возможно сделать кэшируемым. По сути TCM это еще один такой вот кэш.

Instruction cache


Стоит отметить, что все рассмотренное выше относится к кэшу данных (D-Cache). Но кроме кэша данных в ARMv7-M предусмотрен и кэш инструкций Instruction cache (I-Cache). I-Cache позволяет перенести часть исполняемых (и следующих) инструкций в кэш, что может значительно ускорить работу программы. Особенно, в тех случаях, когда код находится в более медленной памяти чем FLASH, к примеру, QSPI.

Чтобы уменьшить непредсказуемость в тестах с кэшем ниже, мы намеренно отключим I-Cache и будем думать исключительно о данных.

При этом хочу отметить, что включается I-Cache достаточно просто и не требует никаких дополнительных действий со стороны MPU в отличие от D-Cache.

Синтетические тесты


После обсуждения теоретической части, давайте перейдем к тестам, чтобы лучше понять разницу и сферы применимости той или иной модели. Как я и говорил выше, отключаем I-Cache и работаем только с D-Cache. Так же я намеренно компилирую с -O0, чтобы циклы в тестах не оптимизировались. Тестировать будем через внешнюю память SDRAM. С помощью MPU я разметил регион 64 Кб, и будем выставлять этому региону нужные нам атрибуты.

Так как тесты с кэшами очень капризные и находятся под влиянием всего и вся в системе сделаем код линейным и непрерывным. Для этого отключаем прерывания. Так же, замерять время будем не таймерами, а DWT (Data Watchpoint and Trace unit), в котором есть 32 битный счетчик процессорных тактов. На его основе (на просторах интернета) люди делают микросекундные задержки в драйверах. Счетчик довольно быстро переполняется на системной частоте 216 МГц, но до 20 секунд померить можно. Просто будем об этом помнить, и сделаем тесты в этом временном интервале, предварительно обнуляя счетчик тактов перед стартом.

Non-cacheable memory VS. write-back


Итак, начнем с совсем простых тестов.

Просто последовательно пишем в память.
    dst = (uint8_t *) DATA_ADDR;    for (i = 0; i < ITERS * 8; i++) {        for (j = 0; j < DATA_LEN; j++) {            *dst = VALUE;            dst++;        }        dst -= DATA_LEN;    }


Так же последовательно пишем в память, но не по одному байту за раз, а немного развернем циклы.
    for (i = 0; i < ITERS * BLOCKS * 8; i++) {        for (j = 0; j < BLOCK_LEN; j++) {            *dst = VALUE;            *dst = VALUE;            *dst = VALUE;            *dst = VALUE;            dst++;        }        dst -= BLOCK_LEN;    }

Так же последовательно пишем в память, но теперь еще и чтение добавим.
    for (i = 0; i < ITERS * BLOCKS * 8; i++) {        dst = (uint8_t *) DATA_ADDR;        for (j = 0; j < BLOCK_LEN; j++) {            val = VALUE;            *dst = val;            val = *dst;            dst++;        }    }


Если запустить все эти три теста, то они дадут абсолютно одинаковый результат какой бы режим вы не выбрали:
mode: nc, iters=100, data len=65536, addr=0x60100000
Test1 (Sequential write):
0s 728ms
Test2 (Sequential write with 4 writes per one iteration):
7s 43ms
Test3 (Sequential read/write):
1s 216ms


И это резонно, SDRAM не такая уж и медленная, особенно если учесть внутренние буферы FMC, через который она подключена. Тем не менее, я ожидал небольшой вариации в цифрах, но оказалось что ее на этих тестах нет. Ну что же, будем думать дальше.

Давайте попробуем подпортить жизнь SDRAM смешивая чтения и записи. Для этого развернем циклы добавим такую распространенную на практике вещь как инкремент элемента массива:
    for (i = 0; i < ITERS * BLOCKS; i++) {        for (j = 0; j < BLOCK_LEN; j++) {            // 16 lines            arr[i]++;            arr[i]++;***            arr[i]++;        }    }

Результат:
Не кэшируемая память: 4s 743ms
Write-back: : 4s 187ms

Уже лучше с кэшем оказалось на пол секунды быстрей. Давайте попробуем еще усложнить тест добавим доступ по разреженным индексам. К примеру, с одним индексом:
    for (i = 0; i < ITERS * BLOCKS; i++) {        for (j = 0; j < BLOCK_LEN; j++) {            arr[i + 0 ]++;            ***            arr[i + 3 ]++;            arr[i + 4 ]++;            arr[i + 100]++;            arr[i + 6 ]++;            arr[i + 7 ]++;            ***            arr[i + 15]++;        }    }

Результат:
Не кэшируемая память: 11s 371ms
Write-back: : 4s 551ms

Теперь разница с кэшем стала более чем заметна! И в довершение введем второй такой индекс:
    for (i = 0; i < ITERS * BLOCKS; i++) {        for (j = 0; j < BLOCK_LEN; j++) {            arr[i + 0 ]++;            ***            arr[i + 4 ]++;            arr[i + 100]++;            arr[i + 6 ]++;            ***            arr[i + 9 ]++;            arr[i + 200]++;            arr[i + 11]++;            arr[i + 12]++;            ***            arr[i + 15]++;        }    }

Результат:
Не кэшируемая память: 12s 62ms
Write-back: : 4s 551ms

Видим как время для не кэшируемой памяти подросло еще почти на секунду, в то время как для кэша осталось прежним.

Write allocate VS. no write allocate


Теперь давайте разберемся с режимом write allocate. Тут еще сложней увидеть разницу, т.к. если в ситуации между не кэшируемой памятью и write-back становятся хорошо видны уже начиная с 4-го теста, то различия между write allocate и no write allocate до сих пор тестами не вскрылись. Давайте подумаем когда write allocate будет быстрей? Например, когда у вас есть много записей в последовательные ячейки памяти, а чтений из этих ячеек памяти мало. В этом случае в режиме no write allocate будем получать постоянные промахи, и подгружаться по чтению в кэш будут совсем не те элементы. Давайте смоделируем такую ситуацию:
    for (i = 0; i < ITERS * BLOCKS; i++) {        for (j = 0; j < BLOCK_LEN; j++) {            arr[j + 0 ]  = VALUE;            ***            arr[j + 7 ]  = VALUE;            arr[j + 8 ]  = arr[i % 1024 + (j % 256) * 128];            arr[j + 9 ]  = VALUE;            ***            arr[j + 15 ]  = VALUE;        }    }

Здесь в 15 из 16 записей выставляется константа VALUE, в то время как чтение осуществляется из разных (и не связанных с записью) элементов arr[i % 1024 + (j % 256) * 128]. Получается, что при стратегии no write allocate только эти элементы и будут загружаться в кэш. Причина по которой используется такая индексация (i % 1024 + (j % 256) * 128) ухудшение скорости FMC/SDRAM. Так как обращения к памяти по существенно различным (не последовательным) адресам, могут существенно сказываться на скорости работы.

Результат:
Write-back : 4s 720ms
Write-back no write allocate: : 4s 888ms

Наконец-то получили разницу, пусть и не настолько заметную, но уже видимую. То есть наша гипотеза подтвердилась.

И наконец, самый сложный, на мой взгляд, случай. Хотим понять когда no write allocate лучше чем write allocate. Первый лучше если мы часто обращаемся к адресам, с которыми в ближайшее время работать не будем. Такие данные, не нужно заносить в кэш.

В следующем тесте в случае write allocate данные будут заполняться по чтению и по записи. Я сделал массив arr2 на 64 Кб, поэтому кэш будет сбрасываться, чтобы подкачать новые данные. В случае же с no write allocate я сделал массив arr на 4096 байт, и только он попадет в кэш, а значит данные кэша сбрасываться в память не будут. За счет этого и попробуем получить хотя бы небольшой выигрыш.
    arr = (uint8_t *) DATA_ADDR;    arr2 = arr;    for (i = 0; i < ITERS * BLOCKS; i++) {        for (j = 0; j < BLOCK_LEN; j++) {            arr2[i * BLOCK_LEN            ] = arr[j + 0 ];            arr2[i * BLOCK_LEN + j*32 + 1 ] = arr[j + 1 ];            arr2[i * BLOCK_LEN + j*64 + 2 ] = arr[j + 2 ];            arr2[i * BLOCK_LEN + j*128 + 3] = arr[j + 3 ];            arr2[i * BLOCK_LEN + j*32 + 4 ] = arr[j + 4 ];            ***            arr2[i * BLOCK_LEN + j*32 + 15] = arr[j + 15 ];        }    }

Результат:
Write-back : 7s 601ms
Write-back no write allocate: : 7s 599ms

Видно, что write-back write allocate режим чуть-чуть быстрей. Но главное, что быстрей :) Лучшей демонстрации у меня добиться не получилось, но я уверен, что есть практические ситуации, когда разница более ощутима. Читатели могут предложить свои варианты!

Практические примеры


Давайте перейдем от синтетических примеров к реальным.

ping


Один из самых простых это ping. Его легко запустить, а время можно смотреть прямо на хосте. Embox был собран с оптимизацией -O2. Сразу приведу результаты:

Не кэшируемая память : ~0.246 c
Write-back : ~0.140 c


OpenCV


Еще одним примером реальной задачи на которой мы хотели попробовать работу подсистемы cache это OpenCV на STM32F7. В той статье было показано, что запустить вполне реально, но производительность была довольно низкая. Мы используем для демонстрации стандартный пример, который выделяет границы на основе фильтра Canny. Давайте измерим время работы с кешами (и D-cache и I-cache) и без.
   gettimeofday(&tv_start, NULL);    cedge.create(image.size(), image.type());    cvtColor(image, gray, COLOR_BGR2GRAY);    blur(gray, edge, Size(3,3));    Canny(edge, edge, edgeThresh, edgeThresh*3, 3);    cedge = Scalar::all(0);    image.copyTo(cedge, edge);    gettimeofday(&tv_cur, NULL);    timersub(&tv_cur, &tv_start, &tv_cur);

Без кэша:

> edges fruits.png 20
Processing time 0s 926ms
Framebuffer: 800x480 32bpp
Image: 512x269; Threshold=20

С кэшем:

> edges fruits.png 20
Processing time 0s 134ms
Framebuffer: 800x480 32bpp
Image: 512x269; Threshold=20

То есть, 926ms и 134ms ускорение почти в 7 раз.

На самом деле у нас достаточно часто спрашивают про OpenCV на STM32, в частности какая производительность. Получается FPS конечно не высокий, но 5 кадров в секунду, вполне реально получить.

Не кэшируемая или кэшируемая память, но с cache invalidate


В реальных устройствах повсеместно используется DMA, естественно с ним связаны трудности, ведь нужно синхронизировать память даже для режима write-through. Возникает естественное желание просто выделить кусок памяти который будет не кэшируемый и использовать его при работе с DMA. Немного отвлекусь. В Linux это делается функцию через dma_coherent_alloc(). И да, это очень эффективный метод, например, когда идет работа с сетевыми пакетами в ОС, пользовательские данные проходят большой этап обработки прежде чем дойти до драйвера, а в драйвере подготовленные данные со всем шапками копируются в буферы, которые используют не кэшируемую память.

А есть случаи когда в драйвере с DMA более предпочтителен clean/invalidate? Да, есть. К примеру, видеопамять, которая нас и побудила более подробно разобраться с работой cache (). В режиме двойной буферизации у системы есть два буфера, в которые она поочередно рисует, а потом отдает видеоконтроллеру. Если делать такую память не кэшируемой, то случится падение в производительности. Поэтому лучше сделать clean перед тем как отдать буфер в видеоконтроллер.

Заключение


Мы немного разобрались с разными вида кэшей в ARMv7m: write-back, write-through, а также настроек write allocate и no write allocate. Построили синтетические тесты, в которых попытались выяснить когда один режим лучше другого, а также рассмотрели практические примеры с ping и OpenCV. В Embox мы еще только занимаемся данной тематикой, поэтому соответствующая подсистема пока прорабатывается. Хотя достоинства в использовании кэшей определенно заметны.

Все примеры можно посмотреть и воспроизвести собрав Embox из открытого репозитория.

P.S.
Если вам интересна тема системного программирования и OSDev, то уже завтра будет проходить конференция OS Day! В этом году он проходит в онлайне, так что желающие не пропустите! :) Embox выступает завтра в 12.00
Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru