C# TCP/IP简单多客户端聊天

10
我正在学习C#套接字编程。因此,我决定制作一个TCP聊天,基本思路是客户端A发送数据到服务器,然后服务器将其广播给所有在线客户端(在这种情况下,所有客户端都在一个字典中)。
当只有1个客户端连接时,它按预期工作,但当有多个客户端连接时就会出现问题。 服务器:
class Program
{
    static void Main(string[] args)
    {
        Dictionary<int,TcpClient> list_clients = new Dictionary<int,TcpClient> ();

        int count = 1;


        TcpListener ServerSocket = new TcpListener(IPAddress.Any, 5000);
        ServerSocket.Start();

        while (true)
        {
            TcpClient client = ServerSocket.AcceptTcpClient();
            list_clients.Add(count, client);
            Console.WriteLine("Someone connected!!");
            count++;
            Box box = new Box(client, list_clients);

            Thread t = new Thread(handle_clients);
            t.Start(box);
        }

    }

    public static void handle_clients(object o)
    {
        Box box = (Box)o;
        Dictionary<int, TcpClient> list_connections = box.list;

        while (true)
        {
            NetworkStream stream = box.c.GetStream();
            byte[] buffer = new byte[1024];
            int byte_count = stream.Read(buffer, 0, buffer.Length);
            byte[] formated = new Byte[byte_count];
            //handle  the null characteres in the byte array
            Array.Copy(buffer, formated, byte_count);
            string data = Encoding.ASCII.GetString(formated);
            broadcast(list_connections, data);
            Console.WriteLine(data);

        } 
    }

    public static void broadcast(Dictionary<int,TcpClient> conexoes, string data)
    {
        foreach(TcpClient c in conexoes.Values)
        {
            NetworkStream stream = c.GetStream();

            byte[] buffer = Encoding.ASCII.GetBytes(data);
            stream.Write(buffer,0, buffer.Length);
        }
    }

}
class Box
{
    public TcpClient c;
     public Dictionary<int, TcpClient> list;

    public Box(TcpClient c, Dictionary<int, TcpClient> list)
    {
        this.c = c;
        this.list = list;
    }

}

我创建了这个盒子,这样我就可以为Thread.start()传递两个参数。

客户端:

class Program
{
    static void Main(string[] args)
    {
        IPAddress ip = IPAddress.Parse("127.0.0.1");
        int port = 5000;
        TcpClient client = new TcpClient();
        client.Connect(ip, port);
        Console.WriteLine("client connected!!");
        NetworkStream ns = client.GetStream();

        string s;
        while (true)
        {
             s = Console.ReadLine();
            byte[] buffer = Encoding.ASCII.GetBytes(s);
            ns.Write(buffer, 0, buffer.Length);
            byte[] receivedBytes = new byte[1024];
            int byte_count = ns.Read(receivedBytes, 0, receivedBytes.Length);
            byte[] formated = new byte[byte_count];
            //handle  the null characteres in the byte array
            Array.Copy(receivedBytes, formated, byte_count); 
            string data = Encoding.ASCII.GetString(formated);
            Console.WriteLine(data);
        }
        ns.Close();
        client.Close();
        Console.WriteLine("disconnect from server!!");
        Console.ReadKey();        
    }
}

2
你能描述一下当有多个客户端连接时会出现什么问题吗?目前我们只知道它“不像预期那样工作”。 - C.Evenhuis
预期的结果是,如果我向服务器发送“123”,那么服务器将把此“123”发送给所有客户端。当只有一个客户端时,它可以正常工作,但当有多个客户端时,它会出现错误,说实话我不知道原因,也许我没有以正确的方式处理线程。 - Paulo Afonso Pinheiro
不清楚你在问什么。你的问题陈述太模糊了。你的代码有许多可以改进的地方。我看到最显著的缺陷在于客户端代码,因为你只执行了一次读取操作,就去获取更多来自用户的输入。但是TCP不能保证你会从任何已完成的读取操作中获得超过一个字节的数据。所以如果你的问题是客户端接收到部分消息,那就是原因。请改进问题。请参见[ask],以及该页面底部的相关链接,以获取建议。 - Peter Duniho
如果你真的想学习网络编程,你应该从仔细和彻底阅读Winsock程序员常见问题解答开始。它不是针对.NET特定的,但.NET API(事实上,大多数主流的低级网络API)都是基于套接字构建的,在任何情况下,该指南中的大部分建议都与协议有关,而不是API。还有许多其他有用的资源也值得一读,但那就是你应该开始的地方。 - Peter Duniho
“it just gets buggy” - 只是变得很有 Bug。另一个问题是代码中使用的字典类型不是线程安全的,因此如果监听线程在另一线程正试图读取字典以发送消息时修改了字典,正在读取的线程将看到字典处于无效、破坏的状态。 - Peter Duniho
1个回答

23
从您的问题中并不清楚您具体遇到了哪些问题。然而,代码检查发现两个重要问题:
1. 您没有以线程安全的方式访问字典,这意味着监听线程可能会在客户端服务线程试图同时检查字典的对象时对其进行操作。但是,添加操作不是原子性的。这意味着在添加项目的过程中,字典可能暂时处于无效状态。这将为任何正在尝试并发读取它的客户端服务线程带来问题。
2. 您的客户端代码试图在处理从服务器接收数据的同时处理用户输入并向服务器写入。这可能会导致至少几个问题:
- 在用户提供一些输入之前,不可能从另一个客户端接收数据。 - 因为您可能仅通过单个读取操作接收到最少的一个字节,即使在用户提供输入后,您仍可能未接收到先前发送的完整消息。
以下是解决这两个问题的代码版本:
服务器代码:
class Program
{
    static readonly object _lock = new object();
    static readonly Dictionary<int, TcpClient> list_clients = new Dictionary<int, TcpClient>();

    static void Main(string[] args)
    {
        int count = 1;

        TcpListener ServerSocket = new TcpListener(IPAddress.Any, 5000);
        ServerSocket.Start();

        while (true)
        {
            TcpClient client = ServerSocket.AcceptTcpClient();
            lock (_lock) list_clients.Add(count, client);
            Console.WriteLine("Someone connected!!");

            Thread t = new Thread(handle_clients);
            t.Start(count);
            count++;
        }
    }

    public static void handle_clients(object o)
    {
        int id = (int)o;
        TcpClient client;

        lock (_lock) client = list_clients[id];

        while (true)
        {
            NetworkStream stream = client.GetStream();
            byte[] buffer = new byte[1024];
            int byte_count = stream.Read(buffer, 0, buffer.Length);

            if (byte_count == 0)
            {
                break;
            }

            string data = Encoding.ASCII.GetString(buffer, 0, byte_count);
            broadcast(data);
            Console.WriteLine(data);
        }

        lock (_lock) list_clients.Remove(id);
        client.Client.Shutdown(SocketShutdown.Both);
        client.Close();
    }

    public static void broadcast(string data)
    {
        byte[] buffer = Encoding.ASCII.GetBytes(data + Environment.NewLine);

        lock (_lock)
        {
            foreach (TcpClient c in list_clients.Values)
            {
                NetworkStream stream = c.GetStream();

                stream.Write(buffer, 0, buffer.Length);
            }
        }
    }
}

客户端代码:

class Program
{
    static void Main(string[] args)
    {
        IPAddress ip = IPAddress.Parse("127.0.0.1");
        int port = 5000;
        TcpClient client = new TcpClient();
        client.Connect(ip, port);
        Console.WriteLine("client connected!!");
        NetworkStream ns = client.GetStream();
        Thread thread = new Thread(o => ReceiveData((TcpClient)o));

        thread.Start(client);

        string s;
        while (!string.IsNullOrEmpty((s = Console.ReadLine())))
        {
            byte[] buffer = Encoding.ASCII.GetBytes(s);
            ns.Write(buffer, 0, buffer.Length);
        }

        client.Client.Shutdown(SocketShutdown.Send);
        thread.Join();
        ns.Close();
        client.Close();
        Console.WriteLine("disconnect from server!!");
        Console.ReadKey();
    }

    static void ReceiveData(TcpClient client)
    {
        NetworkStream ns = client.GetStream();
        byte[] receivedBytes = new byte[1024];
        int byte_count;

        while ((byte_count = ns.Read(receivedBytes, 0, receivedBytes.Length)) > 0)
        {
            Console.Write(Encoding.ASCII.GetString(receivedBytes, 0, byte_count));
        }
    }
}

注:
  • 此版本使用lock语句确保线程对list_clients对象的独占访问。
  • 在广播消息期间,必须维护锁定状态,以确保在枚举集合时不会删除任何客户端,并且在一个线程试图在套接字上发送时,另一个线程不会关闭客户端。
  • 在此版本中,不需要Box对象。集合本身由所有执行方法都可以访问的静态字段引用,并且分配给每个客户端的int值作为线程参数传递,因此线程可以查找相应的客户端对象。
  • 服务器和客户端都会监视并处理完成字节数为0的读取操作。这是指示远程端点已完成发送的标准套接字信号。端点使用Shutdown()方法指示它已完成发送。为启动优雅的关闭,使用“send”原因调用Shutdown(),表示端点已停止发送,但仍将接收。一旦第一个端点完成向第二个端点发送,就可以使用“both”原因调用Shutdown()来指示它已完成发送和接收。

代码中仍存在各种问题。上述内容只解决了最明显的问题,并将代码带到了一个合理的工作演示的基本服务器/客户端架构。


补充说明:

以下是针对评论中后续问题的一些附加说明:

  • 客户端在接收线程上调用 Thread.Join() (即等待该线程退出),以确保在启动优雅关闭进程后,直到远程端点通过关闭其端口响应后,它才会实际关闭套接字。
  • 使用 o => ReceiveData((TcpClient)o) 作为 ParameterizedThreadStart 委托是一种我更喜欢的惯用法,可避免对线程参数进行转换。它使线程入口点保持强类型。尽管如此,那段代码并不是我通常会写的样子;我只是紧密地跟随您的原始代码,同时利用这个机会说明这种惯用法。但实际上,我会使用构造函数重载,使用无参数的 ThreadStart 委托,只需让 Lambda 表达式捕获所需的方法参数:Thread thread = new Thread(() => ReceiveData(client)); thread.Start(); 然后,不需要进行任何转换(如果有任何参数是值类型,则可以处理它们,而不会产生任何装箱/取消装箱开销...在这种情况下通常不是关键问题,但仍然让我感觉好多了 :))。
  • 将这些技术应用于 Windows Forms 项目会增加一些复杂性,这并不奇怪。当在非 UI 线程中接收(无论是使用专用的每连接线程还是使用几个异步网络 I/O API 之一),您需要返回到 UI 线程以与 UI 对象交互。解决方案与通常相同:最基本的方法是使用 Control.Invoke()(或在 WPF 程序中使用 Dispatcher.Invoke() );更复杂(我认为更好)的方法是使用 async/await 进行 I/O。如果正在使用 StreamReader 接收数据,则该对象已经具有可等待的 ReadLineAsync() 和类似的方法。如果直接使用 Socket,则可以使用 Task.FromAsync() 方法将 BeginReceive()EndReceive() 方法包装在一个可等待的方法中。无论哪种方式,结果是虽然 I/O 发生异步,但完成仍在 UI 线程中处理,您可以直接访问您的 UI 对象。(在此方法中,您将等待表示接收代码的任务,而不是使用 Thread.Join(),以确保不会过早地关闭套接字。)

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