在TIdUDPServer.CloseBinding中可能存在竞态条件:监听线程在CloseBinding关闭Binding之前仍然持有对原始Binding的引用,而CloseBinding会在WaitFor之前将其关闭
我正在调查一个在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。如果两个线程都到达同一个活动的 TIdSocketHandle,FConnectionHandle 在 TIdSocketHandle.CloseSocket 内部应该对关闭进行序列化。问题似乎在于,监听器的 FBinding 引用在关闭代码使用它时不再是一个连贯的活动绑定对象。
问题如下:
TIdUDPServer.CloseBinding是否设计为在快速Active := False/Active := True周期下也能安全?CloseBinding的顺序是否正确?特别是在监听线程仍然可以执行AfterRun并使用FBinding的情况下,是否应该在LListener.WaitFor之前调用LListener.Binding.CloseSocket?- 是否有官方的Indy变通方案?例如:
- 避免自动创建的双IPv4/IPv6绑定,显式创建一个绑定;
- 激活后切勿清除/修改
Bindings; - 外部对
Active的变更进行序列化; - 以不同顺序修补
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服务器之外发生了可能的内存损坏,服务器只是受害者。
如果两个线程都到达同一个活动的
TIdSocketHandle,FConnectionHandle内部的TIdSocketHandle.CloseSocket应该对关闭进行序列化。
是的。
问题似乎在于监听器的
FBinding引用在关闭代码使用它时不再是一个连贯的活绑定对象。
这不应当,因为在服务器关机时 TIdUDPServer.Bindings 对象不会被释放。Bindings 必须比监听器存在得更久。这也是监听器使用非拥有引用的原因。
Is
TIdUDPServer.CloseBindingintended to be safe under rapidActive := False/Active := Truecycles?
是的。
Is the ordering in
CloseBindingcorrect?
是的。
In particular, should it call
LListener.Binding.CloseSocketbeforeLListener.WaitForNo, 否则在监听器阻塞在套接字操作上时可能发生死锁。WaitFor等待监听线程完全终止,但监听线程在其当前的套接字操作循环结束前不会看到停止请求。这也是在WaitFor之前先关闭套接字的原因。这样可以给套接字操作一个取消/失败的机会,使监听线程能够自行停止。while the listener thread can still execute
AfterRunand useFBinding?
这样做是完全可以的。
Is there an official Indy workaround?
没有,因为没有需要变通的地方。这么多年都运行良好。你需要更深入地调试你的问题。很可能不是Indy本身的问题。
avoid auto-created dual IPv4/IPv6 bindings and explicitly create one binding;
这与关机处理无关。
never clear/change
Bindingsafter activation;
激活时不能改变 Bindings。但在停用后再修改是安全的。
serialize
Activechanges externally;
无论如何你都应该这样做。不能让多个线程同时激活/停用服务器。
patch
CloseBindingto stop/wake/wait in a different order.
顺序本身不是问题。