Прежде всего, необходимо знать, что для каждого процесса и потока сохраняется его состояние. У всякого потока имеется собственный программный счетчик и состояние процессора. Это означает, что код каждого потока исполняется независимо. Каждый поток обладает также своим стеком, так что локальные переменные в сущности видны лишь внутри каждого отдельного потока, и для этих переменных не существует вопросов синхронизации . Глобальные же данные программы могут быть общими для нескольких потоков, и для них, таким образом, может появиться проблема синхронизации. Конечно, это не страшно, если переменная глобальна, но используется только одним потоком. Такая же ситуация и для для памяти, распределенной в куче (обычно для объектов): в принципе, любой поток может иметь доступ к конкретному объекту, но если программа написана так, чтобы только у одного потока был указатель на конкретный объект, то только он и может обращаться к этому объекту, и проблемы синхронизации не возникает.
В Delphi есть зарезервированное слово threadvar, что позволяет объявлять "глобальные" переменные, копия которых создается для каждого потока. Эта возможность используется нечасто, поскольку обычно удобнее размещать такие переменные в классе TThread, создавая, таким образом, один экземпляр переменной для каждого созданного потомка TThread.
Атомарность при доступе к общим данным.
Для того, чтобы понять, как заставить потоки работать вместе, необходимо понимать концепцию атомарности. Действие или последовательность действий называются атомарными, если они неделимы. Когда поток выполняет атомарное действие, то все другие потоки видят это действие или как еще не начатое, или как уже завершенное. Невозможно одному потоку застать другой "в действии". Если потоки несинхронизированы, то все действия неатомарные. Давайте рассмотрим простой пример: фрагмент кода.Что может быть проще? К несчастью, даже этот простой код может вызвать проблемы, если два потока используют его для увеличения общей переменной A. Этот единственный оператор Паскаля транслируется в три действия на ассемблерном уровне.
Чтение A из ячейки памяти в регистр процессора.
Увеличение регистра процессора на 1.
Запись содержимого регистра процессора в ячейку памяти A.
Даже на однопроцессорной машине выполнение этого кода несколькими потоками может вызвать проблемы. Причина этого - в scheduling (планировщике действий). Когда есть только один процессор, в любой момент действительно выполняется только один поток, но планировщик Win32 переключается между потоками приблизительно 18 раз в секунду. Планировщик может остановить выполнение одного потока и запустить другой в любое время: вытесняющая многозадачность. Операционная система не ждет разрешения перед остановкой одного потока и запуском другого: переключение может случиться в любой момент. Поскольку переключение может произойти между любыми двумя инструкциями процессора, оно может случиться и произойти в некой точке в середине функции, и даже на полпути выполнения одного конкретного оператора программы. Давайте представим себе, что два потока (X и Y) выполняют код примера на однопроцессорной машине. В удачном случае программа может работать, и действия планировщика могут и не захватить эту критическую точку, что даст ожидаемые результаты: А увеличится на два.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Однако нет никакой гарантии, что все именно так и произойдет. Закон Мерфи гласит,
что может случиться следующее:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
В этом случае А увеличивается не на два, а только на единицу. Конечно, если А является положением индикатора, то это, скорее всего, не проблема, но если А - что-нибудь более важное, подобно счетчику количества элементов в списке, тогда жди беды. Если общая переменная является указателем, то можно наткнуться на самые разные неприятные результаты. Это иногда называют race condition (конфликт, конкуренция потоков).
Дополнительные проблемы с VCL.
VCL не содержит никакой защиты от этих конфликтов. Это означает, что переключение потоков может произойти, когда один или более потоков выполняют код VCL. Большая часть VCL организована в этом отношении достаточно хорошо . К несчастью, компоненты, и, в частности, потомки TControl, содержат различные механизмы, которые не согласуются с переключением потоков. Переключение в неподходящий момент может привести к полному хаосу, искажению счетчиков общих дескрипторов, уничтожению не только данных, но и связей между компонентами.
Даже когда поток не выполняет код VCL, отсутствие синхронизации все равно может вызвать проблемы: недостаточно убедиться, что основной поток VCL остановлен прежде, чем другие потоки что-то модифицируют. Часть кода VCL может все-таки выполняться (например, появление диалогового окна или запись на диск), приостанавливая основной поток. Если другой поток модифицирует разделяемые данные, это может отразиться на основном потоке, так что глобальные данные волшебным образом изменятся в результате вызова диалога или записи в файл. Очевидно, это неприемлемо, и означает, что либо только один поток может выполнять код VCL, либо должен быть найден механизм, который гарантирует, что отдельные потоки не влияют друг на друга.
К счастью для программиста, эта проблема не становится сложнее для машин с более чем одним процессором. Методы синхронизации, обеспечиваемые Delphi и Windows, работают одинаково хорошо независимо от количества процессоров. Разработчикам операционной системы Windows пришлось писать дополнительный код, чтобы справиться со многопроцессорной обработкой: Windows NT 4 сообщает пользователю при загрузке, используется ли одно- или многопроцессорное ядро. Тем не менее, для программиста все это невидимо. Вам не нужно заботиться о том, сколько процессоров есть на машине, так же как и о типе чипсета, использованного на материнской плате.
Решение для Delphi: TThread.Synchronize.
Delphi обеспечивает решение, идеальное для начинающих работать с потоками. Оно простое и решает все вышеуказанные проблемы. У класса TThread есть метод Synchronize. Этот метод принимает как параметр другой метод без параметров, который вы хотите выполнить. Таким обрахом гарантируется, что код метода без параметров будет выполнен в результате синхронизированного вызова, и не будет конфликтов с потоком VCL.
Звучит интригующе? Вполне возможно. Я проиллюстрирую это на примере. Мы изменим нашу программу для простых чисел так, что вместо того, чтобы показывать окно сообщения, она добавит текст, говорящий о том, простое число или нет, в Memo на главной форме. Во-первых, добавим Memo (ResultsMemo) к главной форме пример здесь. Теперь добавим в наш поток новый метод (UpdateResults) , который покажет результаты в Мemo, а вместо вызова ShowMessage вызовем Synchronize, передавая этот метод как параметр. Объявление класса потока и измененные части теперь выглядят так. Заметьте, что UpdateResults имеет доступ и к главной форме, и к строке результата. С точки зрения главного потока VCL, кажется, что главная форма изменяется в ответ на событие. С точки зрения рабочего потока, к строке результата осуществляется доступ во время вызова Synchronize.
В коде, который исполняется при вызове Synchronize, можно делать все то же самое, что и в основном потоке VCL. Кроме того, можно также модифицировать данные, связанные со своим собственным объектом потока, причем безопасно, зная, что выполнение своего потока находится в конкретной точке (точке вызова Synchronize). То, что происходит на самом деле - довольно любопытно, и наилучшим образом иллюстрируется другой диаграммой.
Когда вызывается Synchronize, рабочий поток приостанавливается. На этой стадии основной поток VCL может быть приостановлен в состоянии ожидания (idle), может быть временно приостановлен для операций ввода-вывода, а может и выполняться. Рабочий поток ждет, пока главный не перейдет в состояние ожидания (цикл обработки сообщений). Как только основной поток приостановится, метод без параметров, переданный в Synchronize, выполняется в контексте основного потока VCL . В нашем случае метод без параметров называется UpdateResults и работает он c Memo. Это гарантирует, что никаких конфликтов с основным потоком VCL не произойдет, и в сущности, выполнение этого кода очень похоже на выполнение любого кода Delphi, который срабатывает в ответ на сообщение, посланное приложению. Никаких конфликтов с потоком, вызвавшим Synchronize, не происходит, поскольку он приостановлен в известной безопасной точке (в коде TThread.Synchronize).
Когда это "выполнение кода по доверенности" завершается, основной поток VCL снова свободно может исполнять свои прямые обязанности, а поток, вызвавший Synchronize, продолжает свою работу после возврата из вызова. Таким образом, вызов Synchronize в основном потоке VCL выглядит подобно обработке сообщения, а в счетном потоке - как вызов функции. Код потоков находится в известных точках, и конкуренции нет. Конфликты исключены. Проблема решена.
Синхронизация для не-VCL потоков.
Мой предыдущий пример показывает, как можно создать дополнительный поток, взаимодействующий
с основным потоком VCL. Для этого он заимствует время основного потока VCL.
Но такой подход не сработает при взаимодействии нескольких дополнительных потоков
между собой. Если у вас есть два не-VCL потока, X и Y, то вы не можете вызвать
Synchronize в одном лишь потоке X, и при этом модифицировать данные, хранимые
в Y. Необходимо вызывать Synchronize из обои х потоков при чтении или
записи разделяемых данных. На деле это означает, что данные модифицируются основным
потоком VCL, а все другие потоки синхронизируются с основным каждый раз, когда
им нужен доступ к этим данным. Это выполнимо, но неэффективно, особенно если
основной поток занят: каждый раз, когда двум потокам нужно связаться, они должны
ждать, пока третий не перейдет в режим ожидания. Позже мы увидим, как следует
управлять параллельным выполнением потоков и их прямым взаимодействием.
© Martin Harvey
2000.