Моя "парадигма" работы с потокамиИсточник: habrahabr MrShoor
Когда я учился писать многопоточные приложения - я перечитал кучу литературы и справочной информации по этой области. Но между теорией и практикой - огромная пропасть. Я набил кучу шишек, и до сих пор иногда получаю по голове от собственных потоков. Для себя я выработал набор некоторых правил, которым стараюсь строго следовать, и это значительно помогает мне в написании многопоточного кода. Поскольку ошибки, связанные с синхронизацией потоков крайне сложно отлаживать, то самым эффективным способом тут является предупреждение этих самых ошибок. Для этого используются различные парадигмы программирования на разных уровнях абстракции. Нижним уровнем абстракции будем считать работу с объектами синхронизации (критические секции, мьютексы, семафоры). Верхним - такие парадигмы программирования, как Futures and promises, STM (software transactional memory), обмен асинхронными сообщениями и т.п. Верхний уровень абстракции зачастую всегда основан на нижнем. В данной статье я поделюсь своим стилем написания кода на нижнем уровне абстракции. Поскольку я дельфист, то все примеры будут на Delphi, однако все нижесказанное справедливо и для других языков программирования (позволяющих работать с объектами синхронизации конечно) Потокобезопасный объектПервое правило - между потоками работать только с потокобезопасными объектами. Это самое простое, логичное и понятное правило. Однако даже тут есть некоторые особенности. Объект должен быть целиком потокобезопасный, а это значит что все public методы (кроме конструктора и деструктора) нужно синхронизировать. Конструкторы и деструкторы в свою очередь должны быть всегда синхронизированы снаружи объекта. Одна из ошибок на ранних этапах работы с потоками - я забывал о синхронизации конструкторов и деструкторов. И если в делфи с конструктором проблем нет (мы получаем указатель на объект только когда конструктор уже отработал), то с деструктором надо быть внимательным. Синхронизация деструкторов - очень скользкая тема, и я не могу дать каких-либо указаний, как лучше её реализовывать (я не гений многопоточного программирования, а только учусь ;) ). Сам я стараюсь проводить такую синхронизацию через деструктор класса TThread, но это справедливо только для объектов, которые существуют всю жизнь потока.
Блокировки
ОписаниеДругая распространенная проблема - это взаимные блокировки (deadlock-и). Несмотря на то, что это наиболее распространенная проблема, возникающая при синхронизации - тут есть одно не очевидное правило. Если поток единовременно выполняет не больше одной синхронизации, то никаких дедлоков не будет. Здесь под словом синхронизация - я имею ввиду как блокировку ресурса, так и ожидание какого-либо ресурса. Таким образом остановка на мьютексе, закрытие мьютекса, вход в семафор, вход критическую секцию, или отправка сообщения (SendMessage) - это все синхронизации. И в самом деле, если поток А ожидает ресурс, и при этом он не заблокировал ни один ресурс, то его в свою очередь никто не ожидает, а значит взаимной блокировки быть не может.
ПримерыПонимание и строгое выполнение данного условия - ключ к отсутствию deadlock-ов. Давайте рассмотрим на примере, о чем я говорю. Допустим у нас есть некоторый класс:
Следуя тому, что у нас должен быть потокобезопасный объект - я реализовал свойства A и B через геттеры и сеттеры с критической секцией:
Допустим функция DoSomething у нас работает с A и B как-то так:
Эй, но мы ведь используем одну критическую секцию для A и для B, скажет неискушенный писатель. И сразу же "оптимизирует" этот кусок:
И это будет ошибка. Теперь, если мы в обработчике WM_MYMESSAGE попытаемся обратиться к полю A или B - мы получим дедлок. Данный дедлок - очевиден, так как объем кода маленький, данные простые. Но оно становится не тривиальным, когда когда код огромен, появляется куча связей и зависимостей. Согласно правилу - работать только с одной синхронизацией одновременно, вышеописанный код можно "оптимизировать" так:
Поэтому всегда, прежде чем вызвать новую синхронизацию - нужно освободить другие объекты синхронизации. Код в духе:
В большинстве случаев можно считать многопоточным быдлокодом. Я думаю вы уже представляете как надо его переписать:
По данному подходу видно, что нам приходится копировать данные, что может отразиться на производительности. Однако в большинстве случаев объемы данных не велики, и мы можем позволить копировать их.
ДиагностикаНа уровне компилирования такое диагностировать не получится. Однако можно провести диагностику в реалтайме. Для этого нам надо хранить текущий объект синхронизации для каждого потока. Вот пример реализации средства диагностики на Delphi.
Вызываем InitSyncObject когда стартуем новый поток.
В Acquire добавить вызов PushSyncObject(Self), а в Release PopSyncObject. Так же не забываем обрамить в эти функции WaitFor методы у THandleObject. Кроме того, если используем метод TThread.Synchronize то до вызова сохраняем объект TThread, а после извлекаем его (PopSyncObject), если используем API функции SendMessage или WaitFor функции, то до вызова сохраняем хендл (PushSyncObject), после - извлекаем (PopSyncObject).
Плохой кодВ качестве примера плохого кода возьмем… класс TThreadList из модуля Classes.pas
Казалось бы, потокобезопасный класс, с доступом через критическую секцию, что в нем плохого? А плохо то, что у нас доступны методы LockList и UnlockList. Если между парой вызовов LockList и UnlockList у нас будет синхронизация - то мы нарушаем вышеописанное правило. Поэтому выносить пару функций Lock/Unlock в паблик - не есть хорошо, и такие функции нужно использовать крайне осторожно. К слову, различные API от Microsoft часто возвращают Enum интерфейсы, вот например. Зачем они это делают? Ведь гораздо удобнее получить количество скажем через функцию Count, а потом в цикле через функцию GetItem по индексу получать элемент. Но в этом случае им бы пришлось вынести еще пару функций Lock/Unlock, чтобы никто не мог изменить список, пока вы в цикле работаете. Кроме того, если вы между Lock/Unlock вдруг вызовете такую API функцию, которая выполняет внутреннюю синхронизацию - вы запросто можете получить дедлок. Поэтому все и сделано через Enum интерфейсы. При получении такого интерфейса формируется список объектов, и счетчик ссылок их увеличивается. Это значит что ни один объект в Enum интерфейсе не будет уничтожен пока как минимум энум интерфейс существует, и пока вы работаете с Enum - ко внутреннему списку все имеют доступ, и этот список может даже изменяться.
Наверное хватитНажал я кнопку предпросмотр, увидел получившийся объем, и понял что пока хватит. В следующей статье я хотел бы рассказать про класс делфи TThread, и показать правила, которым я следую при создании и работе с потоками. |