Моя «парадигма» работы с потоками

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

    Поскольку ошибки, связанные с синхронизацией потоков крайне сложно отлаживать, то самым эффективным способом тут является предупреждение этих самых ошибок. Для этого используются различные парадигмы программирования на разных уровнях абстракции. Нижним уровнем абстракции будем считать работу с объектами синхронизации (критические секции, мьютексы, семафоры). Верхним — такие парадигмы программирования, как Futures and promises, STM (software transactional memory), обмен асинхронными сообщениями и т.п. Верхний уровень абстракции зачастую всегда основан на нижнем.

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

    Потокобезопасный объект


    Первое правило — между потоками работать только с потокобезопасными объектами. Это самое простое, логичное и понятное правило. Однако даже тут есть некоторые особенности. Объект должен быть целиком потокобезопасный, а это значит что все public методы (кроме конструктора и деструктора) нужно синхронизировать. Конструкторы и деструкторы в свою очередь должны быть всегда синхронизированы снаружи объекта. Одна из ошибок на ранних этапах работы с потоками — я забывал о синхронизации конструкторов и деструкторов. И если в делфи с конструктором проблем нет (мы получаем указатель на объект только когда конструктор уже отработал), то с деструктором надо быть внимательным. Синхронизация деструкторов — очень скользкая тема, и я не могу дать каких-либо указаний, как лучше её реализовывать (я не гений многопоточного программирования, а только учусь ;) ). Сам я стараюсь проводить такую синхронизацию через деструктор класса TThread, но это справедливо только для объектов, которые существуют всю жизнь потока.

    Блокировки


    Описание

    Другая распространенная проблема — это взаимные блокировки (deadlock-и). Несмотря на то, что это наиболее распространенная проблема, возникающая при синхронизации — тут есть одно не очевидное правило. Если поток единовременно выполняет не больше одной синхронизации, то никаких дедлоков не будет. Здесь под словом синхронизация — я имею ввиду как блокировку ресурса, так и ожидание какого-либо ресурса. Таким образом остановка на мьютексе, закрытие мьютекса, вход в семафор, вход критическую секцию, или отправка сообщения (SendMessage) — это все синхронизации. И в самом деле, если поток А ожидает ресурс, и при этом он не заблокировал ни один ресурс, то его в свою очередь никто не ожидает, а значит взаимной блокировки быть не может.

    Примеры

    Понимание и строгое выполнение данного условия — ключ к отсутствию deadlock-ов. Давайте рассмотрим на примере, о чем я говорю. Допустим у нас есть некоторый класс:
    TMyObj = class
    private
      FCS: TCriticalSection;
      FA: Integer;
      FB: Integer;
    public
      property A: Integer read GetA write SetA;
      property B: Integer read GetB write SetB;
      function DoSomething: Integer;
      //...другие методы
    end;
    

    Следуя тому, что у нас должен быть потокобезопасный объект — я реализовал свойства A и B через геттеры и сеттеры с критической секцией:
    function TMyObj.GetA: Integer;
    begin
      FCS.Enter;
      try
        Result := FA;
      finally
        FCS.Leave;
      end;
    end;
    
    function TMyObj.GetB: Integer;
    begin
      FCS.Enter;
      try
        Result := FB;
      finally
        FCS.Leave;
      end;
    end;
    
    procedure TMyObj.SetA(const Value: Integer);
    begin
      FCS.Enter;
      try
        FA := Value;
      finally
        FCS.Leave;
      end;
    end;
    
    procedure TMyObj.SetB(const Value: Integer);
    begin
      FCS.Enter;
      try
        FB := Value;
      finally
        FCS.Leave;
      end;
    end;
    

    Допустим функция DoSomething у нас работает с A и B как-то так:
    function TMyObj.DoSomething: Integer;
    begin
      Result := SendMessage(SomeHandle, WM_MYMESSAGE, A mod 3, B mod 4);
    end;
    

    Эй, но мы ведь используем одну критическую секцию для A и для B, скажет неискушенный писатель. И сразу же «оптимизирует» этот кусок:
    function TMyObj.DoSomething: Integer;
    begin
      FCS.Enter;
      try
        Result := SendMessage(SomeHandle, WM_MYMESSAGE, FA mod 3, FB mod 4);
      finally
        FCS.Leave;
      end;
    end;
    

    И это будет ошибка. Теперь, если мы в обработчике WM_MYMESSAGE попытаемся обратиться к полю A или B — мы получим дедлок. Данный дедлок — очевиден, так как объем кода маленький, данные простые. Но оно становится не тривиальным, когда когда код огромен, появляется куча связей и зависимостей. Согласно правилу — работать только с одной синхронизацией одновременно, вышеописанный код можно «оптимизировать» так:
    function TMyObj.DoSomething: Integer;
    var k, n: Integer;
    begin
      FCS.Enter;
      try
        k := FA mod 3;
        n := FB mod 4;
      finally
        FCS.Leave;
      end;
      Result := SendMessage(SomeHandle, WM_MYMESSAGE, k, n);
    end;
    

    Поэтому всегда, прежде чем вызвать новую синхронизацию — нужно освободить другие объекты синхронизации. Код в духе:
    FCS1.Enter;
    try
      //bla bla bla
      FCS2.Enter;
      try
        //bla bla bla
      finally
        FCS2.Leave;
      end;
      //bla bla bla
    finally
      FCS1.Leave;
    end;
    

    В большинстве случаев можно считать многопоточным быдлокодом. Я думаю вы уже представляете как надо его переписать:
    FCS1.Enter;
    try
      //bla bla bla
      //bla bla bla
      //сохраняем в стеке/куче данные, которые нам понадобятся при работе внутри FCS2 
    finally
      FCS1.Leave;
    end;
    
    FCS2.Enter;
    try
      //используем данные из стека/кучи
      //bla bla bla
    finally
      FCS2.Leave;
    end;
    

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

    Диагностика

    На уровне компилирования такое диагностировать не получится. Однако можно провести диагностику в реалтайме. Для этого нам надо хранить текущий объект синхронизации для каждого потока. Вот пример реализации средства диагностики на Delphi.
    procedure InitSyncObject;
    procedure PushSyncObject(handle: Cardinal); overload;
    procedure PushSyncObject(obj: TObject); overload;
    procedure PopSyncObject;
    
    implementation
    
    threadvar syncobj: Cardinal;
              synccnt: Cardinal;
    
    procedure InitSyncObject;
    begin
      syncobj := 0;
      synccnt := 0;
    end;
    
    procedure PushSyncObject(handle: Cardinal);
    begin
      if handle = 0 then
        raise EProgrammerNotFound.Create('Нельзя захватить несуществующий объект');
    
      if (syncobj <> 0) and (handle <> syncobj)  then
        raise EProgrammerNotFound.Create('Попытка воспользоваться двумя объектами синхронизации преследуется по закону');
    
      syncobj := handle;
      inc(synccnt);
    end;
    
    procedure PushSyncObject(obj: TObject);
    begin
      PushSyncObject(Cardinal(obj));
    end;
    
    procedure PopSyncObject;
    begin
      if (syncobj = 0) or (synccnt = 0) then
        raise EProgrammerNotFound.Create('Слишком много освобождений объекта');
      Dec(synccnt);
      if synccnt = 0 then syncobj := 0;
    end;
    

    Вызываем InitSyncObject когда стартуем новый поток.
    Перед захватом объекта синхронизации вызываем PushThreadObject, после освобождения объекта синхронизации вызываем PopThreadObject.
    Для удобства использования данных функций рекомендую скопировать код модуля SyncObjs.pas в новый, скажем SyncObjsDbg.pas. В нем есть базовый класс объекта синхронизации:
      TSynchroObject = class(TObject)
      public
        procedure Acquire; virtual;
        procedure Release; virtual;
      end;
    

    В Acquire добавить вызов PushSyncObject(Self), а в Release PopSyncObject. Так же не забываем обрамить в эти функции WaitFor методы у THandleObject. Кроме того, если используем метод TThread.Synchronize то до вызова сохраняем объект TThread, а после извлекаем его (PopSyncObject), если используем API функции SendMessage или WaitFor функции, то до вызова сохраняем хендл (PushSyncObject), после — извлекаем (PopSyncObject).
    Вот и все, теперь при попытке захватить второй объект синхронизации — будет возникать исключение, а модули (SyncObjs/SyncObjsDbg) можно менять через дефайны.

    Плохой код

    В качестве примера плохого кода возьмем… класс TThreadList из модуля Classes.pas
      TThreadList = class
      private
        FList: TList;
        FLock: TRTLCriticalSection;
        FDuplicates: TDuplicates;
      public
        constructor Create;
        destructor Destroy; override;
        procedure Add(Item: Pointer);
        procedure Clear;
        function LockList: TList;
        procedure Remove(Item: Pointer); inline;
        procedure RemoveItem(Item: Pointer; Direction: TList.TDirection);
        procedure UnlockList; inline;
        property Duplicates: TDuplicates read FDuplicates write FDuplicates;
      end;
    

    Казалось бы, потокобезопасный класс, с доступом через критическую секцию, что в нем плохого? А плохо то, что у нас доступны методы LockList и UnlockList. Если между парой вызовов LockList и UnlockList у нас будет синхронизация — то мы нарушаем вышеописанное правило. Поэтому выносить пару функций Lock/Unlock в паблик — не есть хорошо, и такие функции нужно использовать крайне осторожно.

    К слову, различные API от Microsoft часто возвращают Enum интерфейсы, вот например. Зачем они это делают? Ведь гораздо удобнее получить количество скажем через функцию Count, а потом в цикле через функцию GetItem по индексу получать элемент. Но в этом случае им бы пришлось вынести еще пару функций Lock/Unlock, чтобы никто не мог изменить список, пока вы в цикле работаете. Кроме того, если вы между Lock/Unlock вдруг вызовете такую API функцию, которая выполняет внутреннюю синхронизацию — вы запросто можете получить дедлок. Поэтому все и сделано через Enum интерфейсы. При получении такого интерфейса формируется список объектов, и счетчик ссылок их увеличивается. Это значит что ни один объект в Enum интерфейсе не будет уничтожен пока как минимум энум интерфейс существует, и пока вы работаете с Enum — ко внутреннему списку все имеют доступ, и этот список может даже изменяться.

    Наверное хватит


    Нажал я кнопку предпросмотр, увидел получившийся объем, и понял что пока хватит. В следующей статье я хотел бы рассказать про класс делфи TThread, и показать правила, которым я следую при создании и работе с потоками.
    Метки:
    Поделиться публикацией
    Реклама помогает поддерживать и развивать наши сервисы

    Подробнее
    Реклама
    Комментарии 67
    • 0
      Я понимаю, что статья может не нравится. Поэтому прошу, прежде чем кидать минус (тем более в карму) хотя бы в общих чертах объяснить, что именно плохо, чтобы я мог проанализировать свои ошибки, и не совершать их в дальнейшем.
      Спасибо.
      • +6
        Код на Delphi для многих — красная тряпка, стоит учитывать это.
        • +8
          Печально конечно, если так. Я лично в равной степени уважаю все языки программирования, и я бы конечно же описал статью на С++, т.к. большинству понятнее читать на нем, но к сожалению у меня мало практики работы с этим языком (знание тонкостей стандартных классов и т.п.), и дабы не допустить ляп — я описал на том языке, с которым каждый день работаю.
          • +4
            Раньше тоже считал Delphi — недоязыком для блАндинок, пока не пришлось писать и поддерживать проекты написанные на нём некоторое время. И теперь после 3,5 лет, когда я набрал опыта, почитал книг, мне стал это язык нравиться. А с введением helpers for simple types в XE3, ещё больше располагает к себе. Есть свои неудобства (больше всего бесит объявление переменных, отсутствие «сопряжения» с модулями на других языках ;), но очень гибкий и мощный язык для enterprise — программирования.
            • +1
              В стандартных UI-компонентах Delphi очень плохо с инкапсуляцией.
              Вплоть до того, что можно штатным, нормальным использованием интерфейса класса получить Exception. (!)
              IMHO, в C# и Java с этим гораздо лучше.
              • 0
                Ну, я и не говорю что Delphi — образцовый язык, кое в чём c# тоже больше нравится, пару проектов на нём писал. Но и на Delphi при правильном подходе можно достаточно продуктивно работать в удовольствие.
                • 0
                  А причем тут эксепшн и инкапсуляция? И вообще исключения — могут быть вполне себе штатной ситуацией, которые нужно обрабатывать, например библиотека Indy активно использует исключения.
          • +1
            Не читал всю статью (и не оценивал её), но у Вас как минимум утверждение «Объект должен быть целиком потокобезопасный, а это значит что все public методы (кроме конструктора и деструктора) нужно синхронизировать.» неверно. Кроме алгоритмов блокирующих, которые требуют безусловной синхронизации, есть ещё неблокирующие алгоритмы и immutable объекты, которые так же потокобезопасны.
            • 0
              Этой фразой я хотел подчеркнуть, что нельзя оставлять половину методов объекта не потокобезопасными. Пожалуй не совсем точно выразился
          • +6
            «одно не очевидное правило. Если поток единовременно выполняет не больше одной синхронизации, то никаких дедлоков не будет»
            Действительно, правило неочевидное. Очевидное правило выглядит так:
            — дедлок — это цикл на графе ресусов, чтобы не попадать в дедлок, надо избегать циклов.
            Для этого:
            — все блокируемые ресурсы организуются в нециклический ориентированный граф. Иными словами, определяется частичный порядок на множестве ресурсов.
            — блокировка нескольких ресурсов допускается только в последовательности, сохраняющей этот порядок.
            • 0
              Спасибо за очевидное правило. К сожалению слишком мало находил материалов по борьбе с дедлоками, и такого определения не встречал. Получается что мой случай — частный случай графа без циклов. С другой стороны если оперировать вашим вариантом, то непонятно как диагностировать такие проблемы, потому что пока блокировку не схватишь — не поймешь этого.
              • +2
                Неужели трудно было найти, например, en.wikipedia.org/wiki/Deadlock_prevention_algorithms?
                И что вы собрались диагностировать, если дедлков гарантированно не будет возникать?
                • 0
                  Спасибо за ссылку. Как бы объяснить, суть в том, что когда архитектура проекта разрастается, проект развивается, какой-то код меняется и т.п. становится сложно уследить за дедлоками. Поэтому я стараюсь писать потокобезопасные классы так, что как бы этот класс не использовали — дедлока гарантированно не будет, а когда у класса есть коллбек евенты — это особо актуально. С практикой — я пришел к выводу, что предупредить дедлоки можно только так, как я описал в статье. Граф это конечно хорошо, и он правильно описывает ситуацию с дедлоками, но непонятно, как в условиях изменяющегося кода предупреждать их?
                  • 0
                    Любое изменение кода со сложным управлением многопоточностью должно проводиться под микроскопом.
                    Желательно с формальным доказательством а-ля happens-before.
                    • 0
                      Кстати, поскольку проблемы с многопоточностью идут от разделяемых данных, часто используют концепцию очередей, когда данные одного потока «привязываются» к событию в очереди, и потом поток-получатель события получается эти данные.
                      Существует множество хороших решений для реализации очередей, так что можно просто использовать готовое решение, вся сложность многопоточных блокировок спрячется под капот, и останется только следовать event-oriented парадигме.
                • 0
                  Хм… поторопился с «не встречал». Встречал конечно же, но как построить такой граф по коду — я не знаю. Блокировку становится видно только когда она уже случается. В связи с этим я пришел к выше описываемому подходу, который на практике помогает мне избегать блокировок.
                  • +1
                    Существует такой подход к постоению графа: каждому ресурсу назначается уровень, от низкого к высокому.
                    Для гарантии от дедлоков требуется не совершать блокировку более высокого уровня, если действует блокировка того же уровня или ниже.

                    Например, объект низкого уровня — персонаж (в игре).
                    Объект высокого уровня — магазин.

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

                    При таком подходе легко сделать проверки в runtime: у каждой критической секции назначаем level и при входе в новую блокировку level может только понижаться, повышение или вход в блокировку с тем же level запрещено (возможен deadlock).
                    • 0
                      Ух ты, спасибо за дельные мысли. Обязательно опробую такой подход на практике.
                    • +1
                      У нас (MariaDB) есть встроенный дедлок-детектор. Включается только при компиляции в отладочном режиме. Там — именно — каждый поток хранит список взятых мьютексов и при каждом pthread_mutex_lock в глобальной хэш-таблице отмечается, какие мьютексы уже были взяты на момент взятия данного мьютекса. То есть динамически строится граф зависимостей, и если в нем обнаруживается цикл — выдается ошибка, мол, нарушение порядка взятия мьютексов.
                      • 0
                        Найти блокировку когда она уже случилась — не сильная проблема. Тут проблема определить, когда существует опасность возникновения блокировки.
                        • 0
                          Так он не блокировки находит, а нарушение порядка взятия мьютексов. Если в одном месте кода мы берем мьютекс A, и через некоторое время мьютекс B. А где-то совсем в другом и в другое время мьютекс B и под ним мьютекс А — это вовсе не блокировка. Это будет блокировка только если мы запустим два потока, и первый будет выполнять то первое место кода, а второй — второе, и первый возьмет мьютекс А, но не успеет взять B, а второй возьмет B, вот тогда это будет блокировка. Но это может быть очень маловероятное событие, и программа может работать годами и изредка зависать, и вы так и не поймете почему.

                          А наш детектор сразу скажет, что мол, в графе зависимостей есть цикл, и возможен дедлок.
                          • 0
                            Тогда я не представляю как строить такой граф. Можете показать на примере?
                            • +2
                              в лоб, без всяких хитростей. Есть обертка над pthread_mutex_lock, то есть при каждой попытке взять мьютекс вызывается наш код. Есть thread-local storage, там эта обертка запоминает каждый взятый мьютекс. И есть глобальная хэш-таблица. Там при каждом взятии мьютекса запоминается, что когда брали мьютекс X, мьютексы A,B,C,D уже были взяты. Получается граф зависимостей, типа «A,B,C,D перед X». Ну и обычным рекурсивным поиском ищем циклы — если, например, есть «Y перед C» и «X перед Y», то получится цикл.

                              Кстати, если нарушение порядка взятия двух мьютексов еще можно как-то отследить в голове, то циклы из трех-четырех уже практически нереально заметить просто читая код, нужно что-то вроде поиска циклов в графе.
                              • 0
                                Спасибо за разъяснения. Делаю вывод, чот стоило писать статью, чтобы как минимум узнать новые подходы ;)
                                • 0
                                  Как идентифицировать мутексы в глобальном хеше?
                                  По адресу не очень надёжно, ведь mutex может входить в состав объекта, при удалении объекта и создании нового адрес нового мутекса может совпасть с адресом старого, хотя тип объекта-контейнера уже другой
                                  • 0
                                    Ну, у нас все pthread_mutex_… функции в обертках. Для диагностики запоминается в каком месте какой мутекс инициализировали, или где его брали. Имена, опять же, даются, но если мутекс в составе объекта это не поможет. Нам хватает. Хотя такая проблема с адресом теоретически быть может (не в MariaDB, а вообще). Тогда можно, например, присваивать всем мутексам уникальные номера, просто, по порядку. И по ним идентифицировать.

                                    Это по-любому не очень дешевая процедура, графы строить и циклы искать, поэтому включаем ее только на время отладки и тестов.
                                    • 0
                                      «в каком месте» — это использование макросов __FILE__ и __LINE__?
                              • +1
                                В Linux Kernel это есть, там посмотрите.
                                • 0
                                  Легко сказать, да не каждый с лету осилит :-)
                                  Да и времени всегда не хватает на исследования, увы.
                    • +1
                      А зачем синхронизировать деструкторы? Я не знаток Делфи, но насколько я понимаю роль деструкторов в C++, деструктор должен вызываться только тогда, когда на объект не осталось больше ссылок. Синхронизация не защитит от обращения к разрушенному объекту, а обращение к разрушенному объекту из любого потока — это баг.
                      • 0
                        Допустим поток Б зашел и выполняет метод объекта obj.DoSomething. Поток А хочет уничтожить объект, и обнуляет ссылку на объект у себя, и у потока Б. Ссылок на объект не осталось, поэтому уничтожаем объект. Но код в obj.DoSomething при этом еще не успел отработать.
                        • 0
                          Э а то что поток А удаляет ссылку на объект в потоке B это не нарушение инкапсуляции? И еще вопрос — деструктор в Делфи вызывается когда ссылок на объект не осталось или когда вызван специальный оператор (аналог delete в С++)
                          • +1
                            Вас ничего не смущает? Если у нас два потока, параллельно и несинхронизированно выполняют код
                            1. obj := nil;
                            2. obj.DoSomething();
                            то проблема гораздо прозаичнее, чем синхронизация деструктора или нюансы удаления объекта при обнуления ссылки (хотя эти два момента стоят отдельного рассмотрения)
                            • +1
                              Это невалидный код. И синхронизация деструктора не сделает его валидным — она просто перенесет проблему из деструктора за его пределы.

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

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

                              Поэтому синхронизация в деструкторе бессмысленна.
                              • 0
                                >> синхронизация в деструкторе бессмысленна

                                не аксиома. например, был такой случай: у объекта есть подписчики, с которыми он взаимодействует. подписчики пинают методы объекта в своих потоках, а вход в каждый метод защищён блокировкой.

                                в деструкторе тоже нужно взять блокировку (дождаться, когда подписчик завершит метод объекта, если он сейчас его выполняет). в заблокированном режиме всем своим подписчикам сказать «до свидания, я умираю, отписывайтесь». после чего снять блокировку
                                • +1
                                  Если есть внешние ссылки на объект то для него не должен вызываться деструктор.
                                  Деструктор вызывается только после того как последняя ссылка удалена.
                                  Есть еще слабые ссылки, но там по определению сначала делается попытка создать сильную ссылку и она не удастся если объект в состоянии когда возможен вызов деструктора.

                                  Так что ситуация когда деструктор вызывается параллельно с другими методами объекта — это просто потоконебезопасный код (внешний по отношению к объекту) и синхронизацией внутри объекта это нельзя устранить…
                                  • 0
                                    да, вы правы. лучше такими костылями не пользоваться
                                • 0
                                  Тут имелась ввиду не синхронизация в деструкторе, а синхронизация деструктора и других методов объекта. В деструкторе вообще нет смысл вызывать синхронизацию, она должна быть снаружи, и суть данной синхронизации заключается как раз в том, что вы сказали. Пока хоть кто-то выполняет метод объекта — удалять объект нельзя. В случае работы с интерфейсами — да можно произвести захват ссылки, это и будет решением синхронизации. Но есть ведь еще не интерфейсы, а объекты, которые удаляют по obj.Free. В этом случае нужно другое решение.
                                • 0
                                  Если поток Б выполняет метод obj.DoSomething() то у него всегда есть ссылка на obj — это указатель this/self, который передается через регистры или стек — эту ссылку забрать из другого потока не получится (ну разве что убив поток средствами ОС).
                                  • 0
                                    Не не не. Вход в метод obj.DoSomething() не увеличивает счетчик ссылок. Поэтому несмотря на то, что ссылка через self доступна, она никак не защитит от уничтожения объекта. Тут надо явно копировать ссылку в переменную до вызова метода.
                                    • +1
                                      Печально. Я бы сказал что это является ошибкой в проектировании языка/рантайма, но возможно за этим стоит какой-то технический компромисс.
                              • +1
                                На счет синхронизации — она ведь не нужна если у вас нет изменяемых состояний. Есть неплохая статья как с ними бороться. Мне например избавление от изменяемого состояния видеться куда более простым способом писать (а потом и читать) код.
                                • 0
                                  От изменяемого состояния легко избавляться на легковесных объектах.
                                  На тяжелых, конструируемых объектах это бывает необоснованно.
                                • +1
                                  В тему параллельной работы в Delphi «порекламирую» бесплатную OmniThreadLibrary.
                                  • 0
                                    Почитайте про CSP. На мой взгляд — это единственный разумный (безопасный, надёжный и простой) способ работать с потоками.

                                    Если вкратце описать его с практической точки зрения, то выглядит это примерно так. Не используется никаких низкоуровневых примитивов для синхронизации потоков (блокировки/семафоры/мьютексы/etc.). Не используется прямой доступ из нескольких потоков к общим структурам данных (ну, не то чтобы совсем категорически не используется, но это однозначно не основной способ работы с общими данными и применяется он редко и в однозначно простых ситуациях когда можно безопасно работать с данными без блокировок). Вместо всего этого используются каналы для коммуникации между потоками: один поток может отправить «сообщение» другому потоку, при этом отправитель обычно (бывают ещё буферизированные каналы, но это частный случай) блокируется пока получатель не примет это сообщение. Разумеется, внутри сами каналы реализованы через те самые низкоуровневые примитивы, но в своём приложении программист пользуется только каналами.

                                    Например, если требуется обеспечить общий доступ (в т.ч. на запись) к общей структуре данных, то запускается отдельный поток, который единственный работает с этой структурой данных, а все остальные потоки при необходимости считать или изменить данные в этой структуре посылают эти запросы через канал(ы) в этот выделенный поток-менеджер данной структуры. В этом случае код получается очень простым и безопасным. К сожалению, такой стиль работы в качестве побочного эффекта подразумевает возможность использовать очень большое количество нитей, что в свою очередь налагает требования на использование очень маленького (и обычно динамически растущего) стека, что поддерживается в очень небольшом количестве языков программирования (Go, Limbo, Stackless Python, etc.).
                                    • 0
                                      Разумеется это хороший и разумный способ. И я упомянул о подобном в статье:
                                      Нижним уровнем абстракции будем считать работу с объектами синхронизации (критические секции, мьютексы, семафоры). Верхним — такие парадигмы программирования, как Futures and promises, STM (software transactional memory), обмен асинхронными сообщениями и т.п. Верхний уровень абстракции зачастую всегда основан на нижнем.

                                      Всегда стараюсь уходить от примитивов синхронизации
                                      • 0
                                        Для решения проблемы выделенных нитей придуманы пулы потоков :-)
                                        • 0
                                          Плюс планировщики потоков.
                                          Без этого комментария мысль была неполной.
                                      • 0
                                        Действительно ли в первом примере, где блокировка захватывается геттером и сеттером будет деадлок. На сколько я помню вложенность блокировок никто не отменял, и внутренний счетчик бокировки FSC просто увеличится на единицу, при очередном входе в блок, а при освобождении, в обратную сторону
                                        • 0
                                          Если бы у автора были разные критические секции на каждое поле, то был бы deadlock. С одной и той же секцией дедлока не будет — reentrancy в критических секциях допускается. Правда, мне не кажется хорошим вариантом защищать обращение к каждому полю, т.к. в вызове Result := SendMessage(SomeHandle, WM_MYMESSAGE, A mod 3, B mod 4) может возникнуть ситуация, когда сначала первый поток вычисляет поле A, потом другой поток меняет поле B и лишь затем первый поток получает значение B. Т.е. для первого потока значения полей A и B могут оказаться несогласованными (если такая согласованность для объекта вообще требуется). ИМХО, логичнее было реализовать метод Lock у самого объекта (или использовать TMonitor.Enter), чтобы можно было блокировать весь объект целиком:
                                          TMonitor.Enter(Obj); // или Obj.Lock
                                          try
                                          Result := SendMessage(SomeHandle, WM_MYMESSAGE, A mod 3, B mod 4)
                                          finally
                                          TMonitor.Exit(Obj); // или Obj.Unlock
                                          end;

                                          А вообще, в случае c getter'ами и setter'ами напрашивается MREWS (multi read exclusive write synchronizer), т.к. непонятно, нафига блокировать объект для всех потоков на чтение.

                                          И опять же, юзайте OmniThreadLibrary, жизнь будет намного проще.
                                          • +1
                                            Вы привели классический пример потенциально возможного deadlock-а. Ваш вариант по сути ничем не отличается от:
                                            function TMyObj.DoSomething: Integer;
                                            begin
                                              FCS.Enter;
                                              try
                                                Result := SendMessage(SomeHandle, WM_MYMESSAGE, FA mod 3, FB mod 4);
                                              finally
                                                FCS.Leave;
                                              end;
                                            end;
                                            

                                            Дедлок гарантированно будет, если вы в обработчике сообщения WM_MYMESSAGE попытаетесь изменить значения свойства A или B.
                                          • 0
                                            Если вы про этот вариант:
                                            function TMyObj.DoSomething: Integer;
                                            begin
                                              FCS.Enter;
                                              try
                                                Result := SendMessage(SomeHandle, WM_MYMESSAGE, FA mod 3, FB mod 4);
                                              finally
                                                FCS.Leave;
                                              end;
                                            end;
                                            

                                            то будет. SendMessage ждет обработки сообщения, а значит выхода из критической секции не будет до тех пор, пока мы не обработаем сообщение. В свою очередь в обработчике если мы обратимся к свойству A или B — код остановится на входе в критическую секцию и будет ждать освобождения её, которого не будет.
                                            • 0
                                              С критикой согласен. Про reentrancy я ляпнул не в тему — она допускается для критической секции в рамках одного и того же потока. А про SendMessage — перепутал с PostMessage :) Так что ваш пример верный. Но я бы все-таки стал блокировать объект целиком, а не по отдельным полям (конечно, это зависит от задачи).
                                              • 0
                                                Вот посути объекта целиком:
                                                function TMyObj.DoSomething: Integer;
                                                var k, n: Integer;
                                                begin
                                                  FCS.Enter;
                                                  try
                                                    k := FA mod 3;
                                                    n := FB mod 4;
                                                  finally
                                                    FCS.Leave;
                                                  end;
                                                  Result := SendMessage(SomeHandle, WM_MYMESSAGE, k, n);
                                                end;
                                                

                                                А выносить наружу Lock/Unlock я бы стал с особой осторожностью.
                                                • 0
                                                  Слово «блокировка» пропустил, а отредактировать комментарий нельзя. :(
                                                  Читать:
                                                  «Вот посути блокировка объекта целиком:»
                                                  • 0
                                                    Ага, я понял. Про ваши опасения Lock..Unlock кажется тоже понял — вас пугает Syncronize. Если его не использовать, то все ОК
                                                    • 0
                                                      Нет, не обязательно Syncronize, просто на Syncronize/SendMessage это проще показать.
                                              • 0
                                                Кстати что касается синхронизации (если вы про Synchronize), то например автор OmniThreadLibrary выступил с резкой критикой этого подхода и вообще отказался делать блокирующий вызов Synchronize (реализовать его можно, но ручками). Т.е. у него вместо Synchronize используется Queue (доп. поток ставит в очередь сообщение для основного потока и продолжает работу, т.е. не ждет пока главный поток обработает это сообщение). При этом он пошел еще дальше и реализовал Invoke (вызов из любого потока метода в контексте любого другого потока) (этот вызов также неблокирующий, т.е. если нужна блокировка, то надо ее запрограммировать самому, через Event например).
                                            • 0
                                              парадигма — громкое слово
                                              • 0
                                                если поток А ожидает ресурс, и при этом он не заблокировал ни один ресурс, то его в свою очередь никто не ожидает, а значит взаимной блокировки быть не может.
                                                В таком виде можно писать «Hello world'ы», но к сожалению редко что-то серьезнее. Ваш этот частный случай подразумевает, что все потоки делают разные действия, что на практике как раз очень тяжело добиться — распараллеливая потоки, где-то всегда получим пересечения (читать/писать файл/сокет, обращения к очередям, банкам данных, да мало ли что). И проблема здесь гораздно глубже — не раз ловил deadlock, казалось бы на совсем асинхронных кусках, совсем вроде без синхронизации. И deadlock на два треда ловится на раз-два-три — самый противный deadlock это что-то вида:
                                                A ждет mutex(B)
                                                B ждет database.rowlock(C)
                                                C ждет file.lock(D)
                                                D ждет .... 
                                                  и где-то тут
                                                Z ждет mutex(A)
                                                
                                                И совсем класно, когда все это не только многопоточно, но и многопроцессно…
                                                • 0
                                                  Перечитайте еще раз процитированное предложение. Ваш пример:
                                                  A ждет mutex(B) B ждет database.rowlock(C) C ждет file.lock(D) D ждет .... и где-то тут Z ждет mutex(A)
                                                  не удовлетворяет ему. Если поток A ждет мьютекса потока B, то поток B не должне никого ждать. Согласно моему принципу — если мы отправляем в ожидание поток B, то мы должны освободить его мьютексы.
                                                  • 0
                                                    Согласно моему принципу
                                                    Так я об этом и говорю — на практике (если не «Hello world») практически не могу себе представить — вплоть до потери смысла многопоточности (потоки распараллеливаются, т.е. все делается в результате однопоточно).
                                                    • 0
                                                      Не уловил смысла фразы:
                                                      потоки распараллеливаются, т.е. все делается в результате однопоточно

                                                      и где вы видите однопоточность?
                                                      • 0
                                                        Вот это вот каким образом вы хотите гарантировать?
                                                        Согласно моему принципу — если мы отправляем в ожидание поток B, то мы должны освободить его мьютексы.
                                                        А тут надо еще добавить «закоммитить все транзакции, закрыть все блокирующие statements, file locks и т.д.».
                                                        Deadlock prevention достигается совсем другими способами, например try_lock(50ms), если не могу (блокирован) делай что-то другое (например из очереди задач) — т.е. наши потоки вообще почти не «спят» (кроме idle) и соответственно не блокируются в deadlock.
                                                        • 0
                                                          С try_lock не могу согласиться.
                                                          1. А если у нас это ресурс, который не создаст взаимной блокировки, просто ресурс за который конкурируют несколько потоков. Получается что если поток захватил ресурс и за 50мс его не отдал, то все, делай что хочешь?
                                                          2. Большинство алгоритмов линейны, и потоку просто нечего делать, если данный ресурс на был получен. Пример: N потоков выбирают задачи из одного списка, и выполняют их. Чтобы выбрать задачу из списка — нужно заблокировать список на момент выбора. И что делать, если я не смог получить задачу через try_lock? Вызвать try_lock еще раз? И чем тогда это будет отличаться от просто запирания ресурса?

                                                          Попытка прикрутить сюда try_lock выглядит как какой-то костыль, чтобы хоть как-то решить проблему дедлоков.
                                                          • 0
                                                            Чтобы выбрать задачу из списка — нужно заблокировать список на момент выбора.
                                                            Ну это за уши притянуто…
                                                            Этот пример вообще не может создать deadlock (если вы только забираете/удаляете задачу в мютексе, а исполняете ее за ним).
                                                            Где я говорил, что нужно везде сувать try_lock? Тем более я подчеркнул, что это только один из способов. Мы ведь все-таки про Парадигму говорим.

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