Заметки по планированию архитектуры.
В больших классах частая проблема - множество методов, которые имеют доступ ко всем полям класса.
В чем тут проблема:
- Читаемость - непонятно что где меняется.
- Синхронизация - если поля класса защищены разными примитивами синхронизации, то очень сложно отследить где что используется и была ли синхронизация перед использованием.
Возможные решения:
- Использовать статические функции. У них остается доступ к приватным типам класса, но нет доступа к полям, для этого их нужно явно передавать, что сразу же улучшает читаемость кода.
- Константные методы. Они могут читать все поля, но меняют только те, что передаются в виде параметров.
- Приватные классы. В них хранятся только нужные данные и только их логика, но это работает только в редких случаях, так как часто данные нужны везде.
class Obj
{
ReadOnly r;
Mutable m;
static void StaticFn (const ReadOnly &, Mutable &);
void ConstMethod (Mutable &) const;
};
Если в классе используется mutex или другой примитив синхронизации, то он должен использоваться для всех полей, кроме константных. Иначе это выглядит как ошибка, когда часть методов не используют синхронизацию.
Вместо нескольких примитивов синхронизации внутри одного класса лучше использовать Synchronized тип, это аналог boost::synchronized_value.
Главное - алгоритмы и архитектура должны изначально разрабатываться под асинхронность.
В многопоточном коде легко может произойти худший случай, когда например много потоков запускают тяжелые задачи с большим потреблением памяти и ОС приходится постоянно гонять данные между ОЗУ и виртуальной памятью на диске.
То же самое со всеми остальными узкими местами: легко перегружается сеть из-за множества соединений, начинает дергаться картинка, когда потоки перегружают PCI-E шину пытаясь загрузить текстуры одновременно, перегружается диск, память, общие кэши и тд.
Поэтому обязательно нужен планировщик, который будет распределять ограниченные ресурсы между потоками и не позволять перегружать системы из-за чего снижается производительность.
Например загрузка данных с диска на видеокарту.
Скорость чтения с диска HDD: ~100Мб/с, SSD: ~500Мб/с, копирование внутри ОЗУ DDR4 3200: 25.6Гб/с, скорость передачи данных по PCI-E: Gen3 x16: 15.75 Гб/с, Gen4 x16: 31.5 Гб/с.
Если данные занимают несколько Гб, то мы не можем загрузить все с диска в ОЗУ и затем уже передать на видеокарту, это отберет слишком много памяти у других операций. Тогда нужно использовать фиксированный объем ОЗУ, чтобы читать часть с диска, загружать ее на видеокарту и запускать заново. При этом другие операции могут занимать всю фиксированную часть ОЗУ, поэтому наш алгоритм должен сначала пытаться захватить нужный блок памяти, а затем производить работу с ним.
Чтобы не блокировать шину PCI-E передачей больших объемов данных, требуется передавать их малыми частями каждый кадр. Также захватывается блок видимой для видеокарты ОЗУ, в нее копируются данные полученные с диска (ОЗУ -> ОЗУ), потом на видеокарте идет копирование из видимой ОЗУ в память видеокарты.
Поэтому асинхронный алгоритм сильно отличается от синхронного, но правильно написанный алгоритм позволяет распараллелить работу, тогда как синхронный алгоритм упрется в объем ОЗУ, пропускную способность диска или шины PCI-E.
Требуется следить за выделением памяти.
Всегда может возникнуть ситуация, когда один алгоритм запущен множество раз параллельно и требуется выделить слишком много памяти.
Самое тривиальное это разделение на высокопроизводительные потоки и фоновые потоки. Для этого и ЦП делают с разными типами ядер: высокопроизводительными и энергоэффективными, в ОС есть приоритеты потоков и тд.
Кроме этого некоторые библиотеки привязаны к одному конкретному потоку, что сильно усложняет планирование задач, а таких библиотек достаточно много:
- ОС (WinAPI)
- OpenGL
- И местами даже Vulkan и DX12 там где это касается ОС.
- UI поток (Android, MacOS).
В WinAPI окно и все что с ним связано требуется выполнять в том же потоке, в котором окно было создано. Таким образом снова появляется главный поток (main/UI). Кроме этого в Vulkan под Windows хоть и разрешено вызывать vkQueuePresent() из любого потока, но если это происходит не в потоке окна, то получаются дополнительные расходы на внутреннюю синхронизацию:
In a multithreaded environment, calling SendMessage from a thread that is not the thread associated with pCreateInfo::hwnd will block until the application has processed the window message.
В DX12 это явно запрещено, чтобы не было скрытых синхронизаций.
В Android про это не пишут, но использование свопчейна из разных потоков приводит к долгим синхронизациям внутри драйвера.
В MacOS и Android вся работа с UI возможна только из UI потока, это касается и диалоговых окон, которые в WinAPI можно создать из любого потока, чтоб удобно использовать для отладки.
В Android еще есть запрет на долгую приостановку UI потока, 5-10 секунд без передачи управления ОС и весь процесс прибивается, то есть в UI потоке нельзя выполнять очень долгие задачи.
Текущая реализация C++ не позволяет писать безопасный код с использованием исключений.
Проблемы:
- Приходится вручную отслеживать какая функция бросает исключения. Компилятор выдает ошибку, если функция с
noexcept
бросает исключение, которое не перехватывается, но это работает только на явный вызовthrow
внутри функции, а используемые функции могут кидать исключения и компилятор никак это не проверяет. Пока не будет предупреждений на использование не-noexcept
функций внутриnoexcept
функций исключения будут опасны. - Концепция исключений предполагает, что после бросания исключений объект возвращается в первоначальное состояние, никаких частичных изменений, либо все, либо ничего, это часто требует выделение дополнительной памяти.
- Если отказаться от концепции "все или ничего" и, например, требовать пересоздавать объект при ошибке, то сложно отловить какой объект кинул исключение, следовательно отказаться от практики "все или ничего" непросто.
- Бросание исключений в конструкторах, особенно в move-конструкторах, приводит к выделению дополнительной памяти, как описано выше.
- В ObjC бросание исключения внутри
autoreleasepool
приводит к утечке памяти. - Сложно писать код, который требует соблюдения правил и компилятор в этом никак не помогает, поэтому поддержка исключений усложняет код и увеличивает время разработки.
- В C++ часть функций возвращает коды ошибок, а часть - кидают исключения, где-то нет проверок вообще, например нулевые указатели и выход за пределы массива. В отличие от Java, где везде используются исключения. Таким образом проще всегда не использовать исключения, чем комбинировать оба варианта.
Преимущества:
- Хорошо подходят для функций, вызываемых из скриптов, тогда в случае бросания исключения скрипт завершается и выдает ошибку из исключения.
- Неплохо подходит для десериализации, где из-за порчи данных может произойти попытка выделения большого объема памяти.
В большинстве случаев удобнее использовать коды ошибок и атрибут [[nodiscard]]
, что не позволит пользователю проигнорировать возвращаемое значение.
Обычно достаточно возвращать bool
- успешно отработала функция или нет. Ошибки должны обрабатываться внутри функции, а пользователь может передать флаги и функторы (std::function), которые будут вызваны для обработки или исправления ошибки.
Почему плохо возвращать enum
с кодами ошибок - на каждый вызов функции от пользователя требуется обработать все возможные ошибки, это сильно увеличивает объем кода, это требует заново читать документацию и тд, тогда как чаще всего пользователю нужно получить ответ успешно ли отработала функция или нет.
Кроме сложностей для пользователей есть и сложность для разработчиков, так как надо сопоставить каждую ошибку с определенным кодом, при этом, чем чаще используется один и тот же аргумент, тем сложнее пользователю найти причину ошибки. Например коды E_INVALIDARG и GL_INVALID_VALUE возвращаются во множестве случаев.
Например в SDL3 перешли с int
, где отрицательные значения содержат код ошибки, на SDL_bool
.