Управление памятью и разделяемыми ресурсами без ошибок

rcysnjlceumhoswiepxmkqjciqu.jpeg

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

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


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

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

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


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


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

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

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


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

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

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


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

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

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


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

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

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

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

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

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


Причем тут Rust?

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

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

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

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


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

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

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

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


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

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


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


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


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

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


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

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


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


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


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


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


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

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


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



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


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


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

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

# Обычная переменная - владелец объекта без раздельного доступа 
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);


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

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


З.Ы.

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

© Habrahabr.ru