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

106

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

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

服务器代码是一个像这样的循环(完整代码稍后):

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

客户端代码也很简单-同样,这略有缩写,但稍后会提供完整代码:

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();

这都可以正常工作,直到其中一个客户端关闭了它的 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 [...]

环境:

  • Windows 11,x64
  • .NET 6

这是预期的行为吗?我在服务器端使用 UdpClient 有问题吗?如果客户端关闭了他们的 UdpClient,那么不希望他们继续接收消息(这是可以预料的),而且在“完整”的应用程序中,我会清理内部状态以跟踪“活动”客户端(最近发送过数据包的客户端),但我不希望一个客户端关闭一个 UdpClient 就把整个服务器都搞垮。请在控制台中运行服务器和客户端。一旦客户端完成,再次运行它(因此它将尝试发送到现在不存在的原始客户端)。

默认的.NET 6控制台应用程序项目模板对所有项目都很好。

复现代码

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

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

基于假设它确实是发送造成了问题,只需要几行代码就可以轻松重现:

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();

服务器

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}");

客户端

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

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

Guid clientId = Guid.NewGuid();
var client = new UdpClient();
Log("Connecting UdpClient");
client.Connect("127.0.0.1", 9001);
await client.SendAsync(Encoding.UTF8.GetBytes($"Hello from {clientId}"));
await Task.Delay(TimeSpan.FromSeconds(15));
await client.SendAsync(Encoding.UTF8.GetBytes($"Goodbye from {clientId}"));
client.Close();
Log("UdpClient closed");

void Log(string message) =>
    Console.WriteLine($"{DateTime.UtcNow:HH:mm:ss.fff}: {message}");
2个回答

145

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

在服务器代码中,您正在调用SendAsync来向旧客户端发送数据,这会引发这些“端口不可达”的消息。

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

显然,这在您这里的多客户端、单服务器套接字设置中造成了麻烦,但幸运的是,有一种解决方法:

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

我不认为这个ioctl代码在dotnet的IoControlCodes类型中可用,但您可以自己定义它。如果将以下代码放在服务器示例的顶部,则不会再引发错误。

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);

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


22
非常完美地解决了问题,如果没有你的帮助,我永远不会自己解决。非常感谢你! - Jon Skeet
9
请注意,IOControl调用在Linux上不受支持,以防您想制作可移植库。 - jmik
3
为什么要使用 unchecked 关键字并混合使用 uint/int 型变量?这里有什么问题吗:IOC_IN | IOC_VENDOR | 12U - user2357112
4
很好的解释,除了没有“自动关闭套接字的内置行为”之外。Windows不会关闭套接字。SocketException有许多原因,不仅是关闭套接字。如果.NET在遇到此错误时关闭套接字,则这是.NET bug而不是Windows bug。 - Ben Voigt
我的一个朋友向我展示了这篇文章,因为我正在处理UDP,问题和答案非常有价值!感谢你们两个提供这些信息。 - Stephan Møller
显示剩余2条评论

2
由于其他人在这里的工作,我编写了一个小的udpSocket创建代码,它将处理封装的windows / if not逻辑。
using System.Net;
using System.Net.Sockets;
using System.Runtime.InteropServices;

public static class UdpSocketUtils
{
    public static Socket CreateUdpSocket(IPEndPoint localEndpoint)
    {
        var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
        socket.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.ReuseAddress, true);
        socket.Bind(localEndpoint);

        if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
        {
            // Due to this issue: https://dev59.com/ZVEG5IYBdhLWcg3wHURc
            // .. the following needs to be done on windows
            const uint IOC_IN = 0x80000000U;
            const uint IOC_VENDOR = 0x18000000U;
            const int SIO_UDP_CONNRESET = unchecked((int)(IOC_IN | IOC_VENDOR | 12));
            socket.IOControl(SIO_UDP_CONNRESET, new byte[] { 0x00 }, null);
        }
        return socket;
    }
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接