Может ли быть так, что в большинстве популярных языков отсутствует самый эффективный механизм синхронизации? Что инженеры Microsoft, Oracle и мн. др., не говоря уже об остальных, вплоть до 2024 года так и не догадались, как же эффективнее всего синхронизировать доступ к данным? А все что знает абсолютное большинство программистов, в том числе топовых IT компаний (за исключением редких разработчиков платформ Apple) о синхронизации - ошибочно? Сегодня попробуем разобраться.
Эта статья подразумевает, что вы уже имеете базовое представление о механизмах синхронизации. Код написан на C#, но конкретный язык особого значения не имеет.
Вступление
На C# я уже давно не пишу, но около 12 лет назад я начал заниматься кроссплатформенной мобильной разработкой на Xamarin. В задачах часто требовалось реализовывать всевозможные синхронизации доступа к состоянию из главного и фоновых потоков, а также синхронизацию работы с базой SQLite. На тот момент в Xamarin были доступны как инструменты из нативной разработки - например Grand Central Dispatch с параллельными и последовательными очередями и NSOperationQueue на их основе, так и из языка C# - Monitor, SpinLock, Mutex, Semaphore, TPL Dataflow, Thread, потокобезопасные коллекции, RxUI и многое другое. Имея такое огромное разнообразие, разработчики использовали “то, что могли” и что быстрее находилось в поисковике и на StackOverflow: блокировка примитивами всех вызовов из главного потока или из пула потоков, создание отдельных потоков, синхронизация главным потоком, да и весь остальной зоопарк, что я перечислил ранее. Все это периодически сопровождалось ошибками типа заморозки пользовательского интерфейса, состояния гонки, взаимной блокировки.
Но все таки я любил докапываться до сути и находить лучшее решение, и довольно быстро пришел к выводу, что:
Любые длительные блокировки потоков - плохо, так как либо расходуют процессорное время на ожидание очереди, либо приводят к переключениям контекста и дополнительному созданию потоков. А блокировка главного потока приложения, отвечающего за рендеринг и обработку нажатий пользователя - это очень плохо, и ведет к “заморозке” отрисовки интерфейса и обработки нажатий.
Потоков в приложении в идеале должно быть не более чем ядер процессора, но не менее двух - максимально приоритетный для отрисовки и обработки нажатий, другие - для всего остального. Тогда будет минимум переключений контекста и использования памяти.
Но с блокирующими операциями такое не пройдет - во время полноценных блокировок (Mutex) пул потоков вынужден создавать дополнительные потоки, чтобы ядра процессора не простаивали, а во время циклических блокировок (SpinLock) процессор попусту гоняет циклы. И если для операций ввода-вывода решение очевидно - использовать неблокирующие альтернативы, то все стандартные механизмы синхронизации популярных языков типа C# блокируют ожидающий поток на время ожидания очереди выполнения операции, которое может длится довольно долго - часто даже дольше чем сама операция.
Знакомство с GCD
Разбираясь в возможностях библиотеки GCD от Apple, и разрабатывая на Xamarin, параллельные очереди не показались чем то необходимым для использования - по сути это тот же ThreadPool либо TaskPool на C#. Но вот последовательные очереди меня заинтересовали - они легковесны, следуют принципу «первым пришёл — первым ушёл» (FIFO), не блокируют поток вызова на время ожидания выполнения операции. Выполняют операцию также в другом потоке, при этом не создают потоки на каждую операцию, а используют либо имеющийся пул, либо свой собственный поток в случае очереди главной очереди.
Логично сделать вывод, что на практике в большинстве случаев лучшим решением для синхронизации являются именно последовательные очереди, так как следуют ранее перечисленным требованиям "лучшего решения". Исключения могут составить лишь самые короткие операции, но даже для них, в абсолютном большинстве случаев, при разработке с уже имеющейся очередью главного потока (например под iOS), не имеет никакого смысла использовать более низкоуровневые, и соответственно более сложные и багоемкие конструкции, о которых могут не знать другие разработчики в команде - операцию в наносекунду лучше кинуть в эту самую очередь главного потока, и это никак не отразится на кадрах в секунду. Причем практика показывает, что даже те, кто думает что знают о примитивах синхронизации, часто заблуждаются и выдают некачественный, багоемкий и медленный код. А баги в “мнотопоточке” часто очень нестабильны и тяжелы в воспроизведении и исправлении. Также это хорошо сочетается с общепринятым правилом всегда вызывать методы обратного вызова (callback) в главном потоке, что позволяет не гадать в каком потоке ты сейчас находишься, и не думать лишний раз о синхронизациях. Кстати, в Xamarin для async/await при вызове из главного потока это работало по умолчанию, что довольно удобно.
Реальные примеры проблем с блокировками потоков и синхронизацией
Несколько примеров ошибки блокировки потоков, отразившихся на качество продукта, из реальной жизни:
Одно из самых популярных приложений каршеринга в России - годами главный поток постоянно блокировался, и только относительно недавно они наконец смогли решить проблему. Приложение работало настолько отвратительно, что пользователей пришлось удерживать самой низкой ценой на рынке, за что, впрочем, хочу высказать коллегам “руки-не-из-того-места” благодарность