Pull to refresh

Comments 67

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

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

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

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

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

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

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

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

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

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

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

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

Так что ситуация когда деструктор вызывается параллельно с другими методами объекта — это просто потоконебезопасный код (внешний по отношению к объекту) и синхронизацией внутри объекта это нельзя устранить…
да, вы правы. лучше такими костылями не пользоваться
Тут имелась ввиду не синхронизация в деструкторе, а синхронизация деструктора и других методов объекта. В деструкторе вообще нет смысл вызывать синхронизацию, она должна быть снаружи, и суть данной синхронизации заключается как раз в том, что вы сказали. Пока хоть кто-то выполняет метод объекта — удалять объект нельзя. В случае работы с интерфейсами — да можно произвести захват ссылки, это и будет решением синхронизации. Но есть ведь еще не интерфейсы, а объекты, которые удаляют по obj.Free. В этом случае нужно другое решение.
UFO just landed and posted this here
Не не не. Вход в метод obj.DoSomething() не увеличивает счетчик ссылок. Поэтому несмотря на то, что ссылка через self доступна, она никак не защитит от уничтожения объекта. Тут надо явно копировать ссылку в переменную до вызова метода.
UFO just landed and posted this here
На счет синхронизации — она ведь не нужна если у вас нет изменяемых состояний. Есть неплохая статья как с ними бороться. Мне например избавление от изменяемого состояния видеться куда более простым способом писать (а потом и читать) код.
От изменяемого состояния легко избавляться на легковесных объектах.
На тяжелых, конструируемых объектах это бывает необоснованно.
Почитайте про CSP. На мой взгляд — это единственный разумный (безопасный, надёжный и простой) способ работать с потоками.

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

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

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

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

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

Articles