为什么通过UdpClient发送会导致后续接收失败?

Jon*_*eet 106 c# udp udpclient

我正在尝试创建一个 UDP 服务器,它可以向所有向其发送消息的客户端发送消息。真实情况要复杂一些,但最简单的方法是将其想象为一个聊天服务器:之前发送过消息的每个人都会收到其他客户端发送的所有消息。

所有这些都是通过UdpClient单独的进程完成的。(虽然所有网络连接都在同一个系统内,所以我不认为UDP 的不可靠性不是问题。)

服务器代码是这样的循环(稍后提供完整代码):

var udpClient = new UdpClient(9001);
while (true)
{
    var packet = await udpClient.ReceiveAsync();
    // Send to clients who've previously sent messages here
}
Run Code Online (Sandbox Code Playgroud)

客户端代码也很简单 - 同样,这稍微缩写了,但稍后是完整的代码:

var client = new UdpClient();
client.Connect("127.0.0.1", 9001);
await client.SendAsync(Encoding.UTF8.GetBytes("Hello"));
await Task.Delay(TimeSpan.FromSeconds(15));
await client.SendAsync(Encoding.UTF8.GetBytes("Goodbye"));
client.Close();
Run Code Online (Sandbox Code Playgroud)

这一切都工作正常,直到其中一个客户端关闭它UdpClient(或进程退出)。

下次另一个客户端发送消息时,服务器会尝试将其传播到现已关闭的原始客户端。对此的调用SendAsync不会失败 - 但是当服务器循环回到 时ReceiveAsync因异常而失败,并且我还没有找到恢复的方法。

如果我从不向已断开连接的客户端发送消息,我就永远不会发现问题。基于此,我还创建了一个“立即失败”重现,它仅发送到假设没有监听的端点,然后尝试接收。这会失败并出现相同的异常。

例外:

System.Net.Sockets.SocketException (10054): An existing connection was forcibly closed by the remote host.
   at System.Net.Sockets.Socket.AwaitableSocketAsyncEventArgs.CreateException(SocketError error, Boolean forAsyncThrow)
   at System.Net.Sockets.Socket.AwaitableSocketAsyncEventArgs.ReceiveFromAsync(Socket socket, CancellationToken cancellationToken)
   at System.Net.Sockets.Socket.ReceiveFromAsync(Memory`1 buffer, SocketFlags socketFlags, EndPoint remoteEndPoint, CancellationToken cancellationToken)
   at System.Net.Sockets.Socket.ReceiveFromAsync(ArraySegment`1 buffer, SocketFlags socketFlags, EndPoint remoteEndPoint)
   at System.Net.Sockets.UdpClient.ReceiveAsync()
   at Program.<Main>$(String[] args) in [...]
Run Code Online (Sandbox Code Playgroud)

环境:

  • Windows 11、x64
  • .NET 6

这是预期的行为吗?我UdpClient在服务器端使用不正确吗?我对客户端在关闭后没有收到消息UdpClient(这是预期的)感到满意,并且在“完整”应用程序中,我将整理我的内部状态以跟踪“活动”客户端(已发送数据包的客户端)最近),但我不希望一个客户端关闭 aUdpClient来关闭整个服务器。在一个控制台中运行服务器,在另一个控制台中运行客户端。一旦客户端完成一次,再次运行它(以便它尝试发送到现已失效的原始客户端)。

默认 .NET 6 控制台应用程序项目模板适用于所有项目。

重现代码

首先是最简单的示例 - 但如果您想运行服务器和客户端,它们会在后面显示。

故意破坏服务器(立即失败)

基于确实是发送导致问题的假设,只需几行即可轻松重现:

System.Net.Sockets.SocketException (10054): An existing connection was forcibly closed by the remote host.
   at System.Net.Sockets.Socket.AwaitableSocketAsyncEventArgs.CreateException(SocketError error, Boolean forAsyncThrow)
   at System.Net.Sockets.Socket.AwaitableSocketAsyncEventArgs.ReceiveFromAsync(Socket socket, CancellationToken cancellationToken)
   at System.Net.Sockets.Socket.ReceiveFromAsync(Memory`1 buffer, SocketFlags socketFlags, EndPoint remoteEndPoint, CancellationToken cancellationToken)
   at System.Net.Sockets.Socket.ReceiveFromAsync(ArraySegment`1 buffer, SocketFlags socketFlags, EndPoint remoteEndPoint)
   at System.Net.Sockets.UdpClient.ReceiveAsync()
   at Program.<Main>$(String[] args) in [...]
Run Code Online (Sandbox Code Playgroud)

服务器

using System.Net;
using System.Net.Sockets;

var badEndpoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 12346);
var udpClient = new UdpClient(12345);
await udpClient.SendAsync(new byte[10], badEndpoint);
await udpClient.ReceiveAsync();
Run Code Online (Sandbox Code Playgroud)

客户

(我之前有一个循环实际上接收服务器发送的数据包,但这似乎没有任何区别,因此为了简单起见,我将其删除。)

using System.Net;
using System.Net.Sockets;
using System.Text;

var udpClient = new UdpClient(9001);
var endpoints = new HashSet<IPEndPoint>();
try
{
    while (true)
    {
        Log($"{DateTime.UtcNow:HH:mm:ss.fff}: Waiting to receive packet");
        var packet = await udpClient.ReceiveAsync();
        var buffer = packet.Buffer;
        var clientEndpoint = packet.RemoteEndPoint;
        endpoints.Add(clientEndpoint);
        Log($"Received {buffer.Length} bytes from {clientEndpoint}: {Encoding.UTF8.GetString(buffer)}");
        foreach (var otherEndpoint in endpoints)
        {
            if (!otherEndpoint.Equals(clientEndpoint))
            {
                await udpClient.SendAsync(buffer, otherEndpoint);
            }
        }
    }
}
catch (Exception e)
{
    Log($"Failed: {e}");
}

void Log(string message) =>
    Console.WriteLine($"{DateTime.UtcNow:HH:mm:ss.fff}: {message}");
Run Code Online (Sandbox Code Playgroud)

Kaz*_*zar 145

您可能知道,如果主机收到当前未绑定的 UDP 端口的数据包,它可能会发回 ICMP“端口不可达”消息。它是否这样做取决于防火墙、私有/公共设置等。但是,在本地主机上,它几乎总是会发回此数据包。

在您的服务器代码中,您正在调用SendAsync旧客户端,这会提示这些“端口无法访问”消息。

现在,在 Windows 上(并且仅在 Windows 上),默认情况下,收到的 ICMP 端口不可达消息将关闭发送该消息的 UDP 套接字;因此,下次尝试在套接字上接收时,它将抛出异常,因为套接字已被操作系统关闭。

显然,这会导致此处的多客户端、单服务器套接字设置令人头痛,但幸运的是有一个修复:

您需要利用不经常需要的SIO_UDP_CONNRESET Winsock 控制代码,它会关闭自动关闭套接字的内置行为。

我不相信这个 ioctl 代码在 dotnetIoControlCodes类型中可用,但您可以自己定义它。如果将以下代码放在服务器重现的顶部,则不再引发错误。

const uint IOC_IN = 0x80000000U;
const uint IOC_VENDOR = 0x18000000U;

/// <summary>
/// Controls whether UDP PORT_UNREACHABLE messages are reported. 
/// </summary>
const int SIO_UDP_CONNRESET = unchecked((int)(IOC_IN | IOC_VENDOR | 12));

var udpClient = new UdpClient(9001);
udpClient.Client.IOControl(SIO_UDP_CONNRESET, new byte[] { 0x00 }, null);
Run Code Online (Sandbox Code Playgroud)

请注意,此 ioctl 代码在 Windows(XP 及更高版本)上受支持,在 Linux 上不受支持,因为它是由 Winsock 扩展提供的。当然,由于所描述的行为只是 Windows 上的默认行为,因此这种遗漏并不是重大损失。如果您尝试创建跨平台库,则应将其作为特定于 Windows 的代码进行封锁。

  • 工作完美 - 我*永远不会*自己解决这个问题。太感谢了! (22认同)
  • 请注意,Linux 上不支持 IOControl 调用,以防万一您想制作可移植库。 (9认同)
  • 非常好的解释,除了没有这种“自动关闭套接字的内置行为”。Windows 不会关闭套接字。引起 SocketException 的原因有很多,而不仅仅是关闭套接字。如果 .NET 在看到此错误时关闭套接字,则这是 .NET 错误,而不是 Windows 错误。 (4认同)
  • 为什么要使用“unchecked”和 uint/int 混合?`IOC_IN | 有问题吗?IOC_供应商 | 12U`? (3认同)