Является ли потоком назначения указателя метода безопасным?

Пример:

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

type
  TSampleThread = class(TThread)
  private
    FOnNotify: TNotifyEvent;
  protected
    procedure Execute; override;
  public
    property OnNotify: TNotifyEvent read FOnNotify write FOnNotify;
  end;

implementation

procedure TSampleThread.Execute;
begin
  while not Terminated do
  begin
    if Assigned(FOnNotify) then
      FOnNotify(Self); // <- this method can be called anytime
  end;
end;

Тогда предположим, что я хотел бы изменить метод события OnNotify из основного потока в любое время, когда мне нужно. Этот основной поток реализует метод обработчика событий как метод ThreadNotify здесь:

type
  TForm1 = class(TForm)
    Button1: TButton;
    Button2: TButton;
    procedure Button1Click(Sender: TObject);
    procedure Button2Click(Sender: TObject);
  private
    FSampleThread: TSampleThread;
    procedure ThreadNotify(Sender: TObject);
  end;

implementation

procedure TForm1.ThreadNotify(Sender: TObject);
begin
  // do something; unimportant for this example
end;

procedure TForm1.Button1Click(Sender: TObject);
begin
  FSampleThread.OnNotify := nil; // <- can this be changed anytime ?
end;

procedure TForm1.Button2Click(Sender: TObject);
begin
  FSampleThread.OnNotify := ThreadNotify; // <- can this be changed anytime ?
end;

Вопрос:

Можно ли изменить метод, который можно вызвать из рабочего потока из другого контекста потока в любое время? Безопасно ли делать то, что показано в приведенном выше примере?

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

Ответ 1

Нет, это не потокобезопасно, потому что эта операция никогда не будет "атомарной". TNotifyEvent состоит из двух указателей, и эти указатели никогда не будут назначены одновременно: один будет назначен, затем будет назначен другой.

32-разрядный Ассемблер, сгенерированный для назначения TNotifyEvent, состоит из двух различных команд ассемблера, примерно так:

MOV [$00000000], Object
MOV [$00000004], MethodPointer

Если бы это был единственный указатель, тогда у вас были бы некоторые параметры, поскольку эта операция является атомарной: параметры, которые у вас зависят, зависят от того, насколько сильной является модель памяти процессора:

  • Если процессор поддерживает модель "последовательной согласованности", то любое чтение, которое происходит после того, как вы напишете память, увидит новое значение, гарантированное. Если это случай, вы можете просто написать свое значение, нет необходимости в барьерах памяти или использовании методов Interlocked.
  • Если процессор более расслаблен о переупорядочении магазинов и загрузок, вам нужен "барьер памяти". В этом случае самым простым решением является использование InterlockedExchangePointer

К сожалению, я не знаю, насколько сильна модель памяти нынешних процессоров Intel. Там могут возникнуть некоторые косвенные доказательства, которые предполагают некоторый переупорядочивание, рекомендуется использовать Interlocked, но я не видел окончательного заявления Intel, которое говорит то или другое.

Доказательства:

  • Современный процессор использует "предварительную выборку" - это автоматически подразумевает некоторый уровень переупорядочения загрузки/хранения.
  • SSE представила конкретные инструкции для работы с кэшем CPU.

Ответ 2

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

var
  LNotify: TNotifyEvent;
begin
  ...
  LNotify := FOnNotify;
  if Assigned(LNotify) then
    LNotify(Self);
end;