Мельком пробежал статью Синхронизация операций в .NET на примерах / Хабр, после чего захотелось поделиться с пользователями Хабра некоторыми мыслями насчет синхронизации доступа к объектам в различных языках программирования.


Если честно, то большая часть моей статьи уже давно лежала в черновиках, но все не доходили руки её доработать, а тут такой хороший повод поделиться своими размышлениями на эту тему, оставалось просто дописать эту вводную часть :-)


Какие объекты синхронизации доступа бывают?


Очень часто для реализации различных оптимизирующих алгоритмов требуются объекты синхронизации доступа к данных. Например, при параллельном выполнении нескольких вычислительных операций на разных ядрах CPU (или GPU) потом требуется объединить результаты, что по определению требует использования объекта синхронизации.


Придумано очень много разных объектов синхронизации, которые отличаются назначением и вариантами реализации:


  • Event
  • Mutex
  • Shared mutex
  • Recursive mutex
  • Semaphore
  • Waitable timer
  • Critical section
  • Interlocked Variable Access и т.д.

Но всех их объединяет одно, все они могут быть реализованы с помощью концепции Мьютекса (англ. mutex, от mutual exclusion — «взаимное исключение»)


Внутренние и внешние объекты синхронизации


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


Разница между ними, а так же плюсы и минусы очевидны. Объекты синхронизации внутри одного приложения работают гораздо быстрее аналогичных объектов из ядра ОС, тогда как только последние обеспечивают возможность синхронизации доступа между несколькими приложениями.


Возможность рекурсивной блокировки


Вторым важным свойством для классификации объектов синхронизации я бы выделил возможность рекурсивной блокировки (recursive mutex). Рекурсивный мьютекс — это особый тип мьютекса, которое может блокироваться несколько раз одним и тем же процессом/потоком, не вызывая взаимной блокировки, т.е. один и тот же поток может захватывать данный тип мьютексов несколько раз.


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


Раздельный уровень доступа


Третьим по счету (но не по важности) свойством для классификации объектов синхронизации я бы выделил возможность захвата блокировки с раздельным уровнем доступа (shared mutex). В отличие от обычных мьютексов с монопольным доступом, у shared mutex есть два уровня доступа: общий — несколько потоков могут совместно владеть одним и тем же мьютексом и эксклюзивный — только один поток может владеть мьютексом.


Shared mutex чаще всего используются для управления доступом к ресурсам с возможностью одновременного чтения/модификации при наличии сразу нескольких читателей. Общий доступ требуется для читателей, а монопольный доступ необходим для изменения объекта.


Где начало проблем при синхронизации доступа?


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


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


Так как если порядок обращения к разделяемым данным детерминирован и определен заранее, то доступ к таким данным не требует объектов синхронизации.


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


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


Причем тут серебро?


У меня отложилась в памяти пара статей про управление памятью в языке программирования Аргентум. Я не буду пересказывать особенности этого языка, предоставив слово его автору kotan-11 в публикациях Управление временем жизни объектов: почему это важно и почему для этого пришлось создать новый язык «Аргентум» и Реализация ссылочной модели в языке программирования Аргентум.


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


Причем тут Rust?


А ещё при изучении Rust я вдохновился идеей управления памятью на основе владения — где каждое значение в памяти должно иметь только одну переменную-владельца, и когда владелец уходит из области выполнения, память сразу же освобождается. Фактически, это реализация подсчёта ссылок, только на этапе компиляции.


Но к сожалению, разработчики Rust остановились на пол пути в реализации полного контроля над управлением памятью. Мне кажется, что более правильным было бы реализовать на уровне языка не только контроль "владения" объектами, а полное управление памятью, в том числе и управлением ссылками на объекты внутри приложения.


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


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


И вот благодаря этому родилась концепция управление временем жизни объектов с одновременным контролем совместного доступа.


Термины и понятия


Итак, основные понятия и допущения для концепции управления объектами:


  • Любой объект — это ссылка на область памяти с данными.


  • Ссылки на объекты могут быть двух видов:


    • Сильные/Владеющие ссылки (аналог shared_ptr из С++), а фактические, это переменная которая хранит значение объекта.
    • Слабые/Не владеющие ссылки (аналог weak_ptr из С++) — указатели на другие объекты которые перед использованием требуют обязательного захвата (т.е. преобразования в сильную ссылку).

  • Переменные — владельцы объектов (в них хранятся ссылки) могут быть двух видов:


    • локальные (контролируемые) — их область жизни строго ограничена правилами синтаксиса языка и управляется компилятором (аргументы функций, переменные внутри блоков кода или функций и т.д.).
    • не контролируемые — глобальные или статические переменные, динамически создаваемые объекты, время жизни которых компилятор не контролирует.

  • Когда локальная переменная уходит из области видимости — уменьшается счетчик ссылок, а при достижении нуля — память объекта освобождается.


  • Объект может иметь только одну не контролируемую переменную с сильной ссылкой и произвольное количество любых типов ссылок в локальных (контролируемых) переменных.


  • Для не контролируемые переменных разрешается делать только слабые ссылки, которые перед использованием требуется захватить, например в локальную (контролируемую) переменную.


  • Управление временем жизни объекта включает в себя не только управлением памятью объекта, но и создание механизма синхронизации доступа (при необходимости), который работает автоматически при захвате и освобождении ссылок


  • При определении переменной (объекта) сразу декларируются возможные способы получения ссылок на данный объект, которые могут быть:


    • без создания ссылок, т.е. компилятор не даст создать ссылку на данную переменную, а совместный доступ к такой переменой будет не возможен
    • создание ссылки только в текущем потоке. Компилятору при генерации машинного кода не нужно создавать объект синхронизации доступа к переменной. Но к такой переменной нельзя получить доступ из других потоков (т.е. переменная не видна из другого модуля, а ссылку на такой объект нельзя вернуть как результат выполнения функции).
    • разрешено создавать ссылки с монопольным доступом. Компилятор автоматически создает не рекурсивный мьютекс для синхронизации доступа к переменной.
    • разрешено создавать ссылки с рекурсивным доступом. Компилятор автоматически создает рекурсивный мьютекс (его можно захватывать несколько раз)

  • Все виды ссылок могут быть константными, т.е. только для чтения (и в случае не изменяемых объектов, таким ссылкам мьютекс не потребуется).


  • Захват слабой ссылки обеспечивается с помощью специальных синтаксических конструкций с сохранением результата в локальную (контролируемую) переменную. Такое использование логики захвата объекта на уровне синтаксиса языка гарантирует последующее автоматическое освобождение временной переменной, что равнозначно отсутствию циклических ссылок.



Гипотетический пример кода на абстрактном языке:


Условные символьные обозначения:


  • & — однопоточная ссылка без блокировки доступа
  • && — мнопоточная ссылка с монопольной блокировкой доступа
  • &* — мнопоточная ссылка с рекурсивной блокировкой доступа
  • ^ (&^, &&^ или &*^) — суффикс для указания ссылки на константный объект, т.е. захват такой ссылки возможен только для чтения
  • * — Захват ссылки для монопольного доступа к объекту
  • *^ — Захват ссылки только для чтения

Пример создания переменных:


# Обычная переменная - владелец объекта без раздельного доступа 
val := 123; 
# и без возможности создания ссылки на объект т.е. 
val2 := &val; # Ошибка !!!!

# Переменная - владелец объекта с возможностью создания ссылки 
# для использования в текущем потоке т.е. 
& ref := 123;

ref2 := &ref; # ОК, но только в рамках одного потока !!!!

# Переменная - владелец объекта с возможностью создать ссылку
# и с синхронизацией доступа из разных потоков приложения 
# (с межпотоковой синхронизацией) т.е. 
&& ref_mt := 123;
ref_mt2 := &&ref_mt; # ОК !!!!

# Переменная - владелец объекта с возможностью создать ссылку
# и с синхронным доступом из разных потоков приложения с рекурсивной блокировкой
&* ref_mult := 123;
ref_mult2 := &* ref_mult; # ОК !!!!

Или как-то так:


    # Обычная переменная без раздельного доступа 
    # и без возможности создания ссылки на объект 
    let val := 123; 
    let val_err := &val; # Ошибка !!!!

    # Переменная - владелец объекта с возможностью раздельного доступа
    # и с возможностью создать ссылку в текущем потоке 
    let & ref := 123; 
    let ref2 := &ref; # ОК, но только в рамках одного потока без мьютекса !!!!

    # Переменная с возможностью создать ссылку 
    # с доступом из разных потоков приложения и монопольной блокировкой
    let && ref_mt := Dict(field1 = 123, filed2 = 456); 
    let ref_mt2 := &&ref_mt; # ОК

    # Захват слабой ссылки выполняется отдельным оператором
    print(*ref_mt2.field1);
    *ref_mt2.field2 = 42;

    def func_name(val,  &ref){
        # Контролируемые переменные
        let dup = val; # Дубль сильной ссылки с инкрементом счетчика
        let local = *ref; # Захват слабой ссылки с инкрементом счетчика
        # При выходе из функции будет декремент счетчика ссылок
    }

    func_name(ref, &ref);

Зачем такие сложности?


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


И весь анализ происходит во время компиляции исходного кода приложения, чтобы во время выполнения программы все летало :-)!


З.Ы.


С наступающим Новым Годом!

Комментарии (8)


  1. rsashka Автор
    01.01.2024 13:54

    нагромождение правил

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

    Или я чего-то не понимаю в изложенных концептах?

    Возможно, либо я.

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

    Если компилятор на уровне синтаксиса языка будет понимать нужен ли контроль доступа к объекту, то он автоматически может выбрать оптимальный вариант реализации объекта синхронизации. Кроме этого компилятор так же автоматически будет добавлять необходимую логику для работы с ним. Ну или не добавлять, если объект синхронизации доступа не требуется (например, для ссылок readonly на константный объект).

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


  1. voidptr0
    01.01.2024 13:54
    +1

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

    Мне тяжело понять && при написании, а при прочтении чужого - я, наверное, сойду с ума.


    1. rsashka Автор
      01.01.2024 13:54

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


  1. VladimirFarshatov
    01.01.2024 13:54
    +1

    Почему нет ни слова про Хоаровское взаимодействие процессов, исключающее необходимость применения мьютексов и семафоров? Реализовано ещё в Ада, есть вариант в Go.. очень хотелось прочитать..


    1. rsashka Автор
      01.01.2024 13:54
      +3

      Если речь идет о том, что "Задачи в языке Ада основываются на хоаровском предположении о том, что параллельные процессы должны связываться через команды ввода-вывода.", то такой тип взаимодействия не покрывает все требуемые сценарии использования объектов синхронизации.

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


      1. VladimirFarshatov
        01.01.2024 13:54

        Вы читали самого Хоара или Википедию? Не требуется там ничего лишнего. Насколько помню, было доказано математически.


        1. rsashka Автор
          01.01.2024 13:54
          +1

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


  1. Miiao
    01.01.2024 13:54

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


    1. rsashka Автор
      01.01.2024 13:54

      нагромождение правил

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

      Или я чего-то не понимаю в изложенных концептах?

      Возможно, либо я.

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

      Если компилятор на уровне синтаксиса языка будет понимать нужен ли контроль доступа к объекту, то он автоматически может выбрать оптимальный вариант реализации объекта синхронизации. Кроме этого компилятор так же автоматически будет добавлять необходимую логику для работы с ним. Ну или не добавлять, если объект синхронизации доступа не требуется (например, для ссылок readonly на константный объект).

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