在TIdUDPServer.CloseBinding中可能存在竞态条件:监听线程在CloseBinding关闭Binding之前仍然持有对原始Binding的引用,而CloseBinding会在WaitFor之前将其关闭

编程语言 2026-07-09

我正在调查一个在Delphi 12.2应用中快速 TIdUDPServer.Active := False/True 周期中出现的间歇性AV(访问冲突)。

崩溃发生在UDP关闭路径,位置如下:

TIdUDPServer.CloseBinding
  LListener.Binding.CloseSocket;

我查看了随Delphi 12.2一起提供的Indy源代码:

C:\Program Files (x86)\Embarcadero\Studio\23.0\source\Indy10\Core\IdUDPServer.pas

相关代码具有以下所有权/顺序模式:

procedure TIdUDPServer.CloseBinding;
begin
  LListenerThreads := FListenerThreads.LockList;
  try
    while LListenerThreads.Count > 0 do begin
      LListener := TIdUDPListenerThread(LListenerThreads[0]);

      LListener.Stop;
      LListener.Binding.CloseSocket;
      LListener.WaitFor;
      LListener.Free;
      LListenerThreads.Delete(0);
    end;
  finally
    FListenerThreads.UnlockList;
  end;
end;

监听线程也在它自己的线程中触及同一个绑定:

procedure TIdUDPListenerThread.AfterRun;
begin
  inherited AfterRun;
  FBinding.CloseSocket;
end;

监听器将绑定作为原始对象引用存储:

TIdUDPListenerThread = class(TIdThread)
protected
  FBinding: TIdSocketHandle;

TIdThread.Stop 只设置停止/终止状态。它不会等待线程结束。WaitFor 仅在 LListener.Binding.CloseSocket 之后被调用。

因此所有权模型似乎是:

  • TIdUDPServer.Bindings 拥有 TIdSocketHandle 对象。
  • TIdUDPListenerThread 存储对其中一个对象的非拥有引用 FBinding
  • CloseBinding 只锁定监听器列表,而不锁定绑定的生命周期。
  • TIdSocketHandle.CloseSocket 锁定一个活动对象的套接字句柄字段,但它不会固定 TIdSocketHandle 对象本身的生命周期。
  • 当拥有者线程已经处于 CloseBinding 时,监听线程仍然可能运行 AfterRun 并触及 FBinding

在带有探针的运行中,我看到一个监听器在创建时具有有效的套接字句柄,但在关闭期间它的 FBinding.Handle 在AV发生前变成了一个垃圾值:

bind #0 handle=972
bind #1 handle=1084
listener #0 STARTED
listener #1 STARTED
CloseBinding -> listener #0
AfterRun: listener #0 closes handle=3145776
AV at LListener.Binding.CloseSocket

这看起来不像是在同一个活动对象上进行的无害重复 CloseSocket。如果两个线程都到达同一个活动的 TIdSocketHandleFConnectionHandleTIdSocketHandle.CloseSocket 内部应该对关闭进行序列化。问题似乎在于,监听器的 FBinding 引用在关闭代码使用它时不再是一个连贯的活动绑定对象。

问题如下:

  1. TIdUDPServer.CloseBinding 是否设计为在快速 Active := False / Active := True 周期下也能安全?
  2. CloseBinding 的顺序是否正确?特别是在监听线程仍然可以执行 AfterRun 并使用 FBinding 的情况下,是否应该在 LListener.WaitFor 之前调用 LListener.Binding.CloseSocket
  3. 是否有官方的Indy变通方案?例如:
  4. 避免自动创建的双IPv4/IPv6绑定,显式创建一个绑定;
  5. 激活后切勿清除/修改 Bindings
  6. 外部对 Active 的变更进行序列化;
  7. 以不同顺序修补 CloseBinding 以停止/唤醒/等待。

更新

我进一步调查,原本对 TIdUDPServer.CloseBinding() 的怀疑可能是错误的。

在我的代码中,真正的问题很可能是在一个线程中调用 TIdUDPServer.SendBuffer(),而另一个线程在控制服务器生命周期(Active := False/True、重连/关机)。

在我的应用中,UDP读取路径可以发送数据包,拥有者/运行时线程也可以发送数据包并重新创建UDP服务器。我找不到明确的说法,要求应用对所有 TIdUDPServer 访问进行序列化,或者要求 SendBuffer()Active 之间不能发生竞争。

这个小的重现示例揭示了让我吃惊的部分:

program IndyUdpAvMvp;

{$APPTYPE CONSOLE}

uses
  System.SysUtils,
  System.Classes,
  Winapi.Windows,
  IdGlobal,
  IdSocketHandle,
  IdUDPServer;

type
  TEvents = class
    procedure UDPException(AThread: TIdUDPListenerThread; ABinding: TIdSocketHandle;
      const AMessage: string; const AExceptionClass: TClass);
  end;

  TSender = class(TThread)
  protected
    procedure Execute; override;
  end;

var
  S: TIdUDPServer;
  Events: TEvents;

procedure TEvents.UDPException(AThread: TIdUDPListenerThread; ABinding: TIdSocketHandle;
  const AMessage: string; const AExceptionClass: TClass);
begin
  Writeln('OnUDPException: ', AExceptionClass.ClassName, ': ', AMessage);
end;

procedure TSender.Execute;
var
  B: TIdBytes;
begin
  SetLength(B, 1);
  Sleep(50);
  If not Terminated then
    S.SendBuffer('127.0.0.1', 54000, Id_IPv4, B);
end;

var
  T: TSender;

begin
  Events := TEvents.Create;
  S := TIdUDPServer.Create(nil);
  try
    S.DefaultPort := 54000;
    S.OnUDPException := Events.UDPException;
    S.Active := true;
    T := TSender.Create(false);
    try
      S.Active := false;
      T.WaitFor;
    finally
      T.Free;
    end;
    S.Bindings.ClearAndResetID;
    S.Binding.CloseSocket;
  finally
    S.Free;
    Events.Free;
  end;
end.

在我的机器上,这会引发:

Exception EAccessViolation in module IndyUdpAvMvp.exe ...
Read of address 0000000000000000

OnUDPException 未被调用。

我的当前理解是:

  • TIdUDPServer.SendBuffer() 不只是一个被动发送操作。它会经过 Binding,对于 TIdUDPServer 访问 Binding 可能会分配并绑定套接字、启动监听线程。
  • 因此,在 Active := False 之后或期间调用 SendBuffer() 可能会再次实际激活服务器。
  • 所以在我这边的正确修复是将 SendBuffer() 与服务器生命周期变化进行序列化,或在后期发送路径中避免基础 TIdUDPServer.SendBuffer(),仅通过已存在的活动绑定发送。

这个理解正确吗?换句话说,TIdUDPServer.SendBuffer() 是否应被视为影响生命周期的操作,不应与 Active 的变更竞争?

解决方案

TIdThread.Stop 只设置停止/终止状态。它不会等待线程结束。WaitFor 仅在 LListener.Binding.CloseSocket 之后被调用。

正确。监听线程可能处于等待下一个客户端数据包到达的阻塞套接字状态。线程收到停止信号后,直接关闭套接字以取消任何挂起的套接字操作,使线程有机会完成终止。

TIdUDPServer.Bindings 拥有 TIdSocketHandle 对象。

是的。

TIdUDPListenerThread 存储对其中一个对象的非拥有引用 FBinding

是的。

CloseBinding 只锁定监听器列表,而不锁定绑定的生命周期。

是的。因为它只是遍历绑定对象的列表,不会影响它们的生命周期。此外,遍历本身也发生在列表锁之内,因此在遍历期间生命周期实际上会被延长,因为在锁定状态下不能添加/删除项。

TIdSocketHandle.CloseSocket 锁定一个活动对象的套接字句柄字段,但它不固定 TIdSocketHandle 对象本身的生命周期。

是的,因为它不需要(顺便说一句,Delphi/Pascal里本来就没有对象生命周期的固定机制)。假设在 CloseSocket 仍在其上运行时,绑定对象不会被从内存中释放。

监听线程仍然可能在拥有者线程已经处于 CloseBinding 时运行 AfterRun 并触及 FBinding

这是可以的。CloseSocket() 对底层套接字字段有内部线程锁。

在带有探针的运行中,我看到一个监听器在创建时具有有效的套接字句柄,但在关闭期间它的 FBinding.Handle 在AV发生前变成了垃圾值:

这在你描述的步骤下不应该发生。某一个线程会调用 CloseSocket,进入锁,关闭套接字并将其设为 INVALID_SOCKET,然后离开锁。另一线程会调用 CloseSocket(),进入锁,看到 INVALID_SOCKET 并不执行任何操作,随后离开锁。

你描述的情况更像是在UDP服务器之外发生了可能的内存损坏,服务器只是受害者。

如果两个线程都到达同一个活动的 TIdSocketHandleFConnectionHandle 内部的 TIdSocketHandle.CloseSocket 应该对关闭进行序列化。

是的。

问题似乎在于监听器的 FBinding 引用在关闭代码使用它时不再是一个连贯的活绑定对象。

这不应当,因为在服务器关机时 TIdUDPServer.Bindings 对象不会被释放。Bindings 必须比监听器存在得更久。这也是监听器使用非拥有引用的原因。

Is TIdUDPServer.CloseBinding intended to be safe under rapid Active := False / Active := True cycles?

是的。

Is the ordering in CloseBinding correct?

是的。

In particular, should it call LListener.Binding.CloseSocket before LListener.WaitFor No, 否则在监听器阻塞在套接字操作上时可能发生死锁。WaitFor 等待监听线程完全终止,但监听线程在其当前的套接字操作循环结束前不会看到停止请求。这也是在 WaitFor 之前先关闭套接字的原因。这样可以给套接字操作一个取消/失败的机会,使监听线程能够自行停止。

while the listener thread can still execute AfterRun and use FBinding?

这样做是完全可以的。

Is there an official Indy workaround?

没有,因为没有需要变通的地方。这么多年都运行良好。你需要更深入地调试你的问题。很可能不是Indy本身的问题。

avoid auto-created dual IPv4/IPv6 bindings and explicitly create one binding;

这与关机处理无关。

never clear/change Bindings after activation;

激活时不能改变 Bindings。但在停用后再修改是安全的。

serialize Active changes externally;

无论如何你都应该这样做。不能让多个线程同时激活/停用服务器。

patch CloseBinding to stop/wake/wait in a different order.

顺序本身不是问题。

站内所有文章版权归属LeftHeroAI导航站,无授权禁止任何主体转载、抄袭、复制内容,亦不得私自架设镜像站点。一经侵权,本站将通过法律途径追责。

相关文章