C# TCP/IP 多客户端简单聊天
C# TCP/IP simple chat with multiple-clients
我正在学习 c# 套接字编程。所以,我决定做一个TCP聊天,基本思路是A客户端向服务器发送数据,然后服务器广播给所有在线的客户端(在这种情况下所有客户端都在一个字典中)。
当有1个客户端连接时,它正常工作,当有超过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()
传递 2 个参数。
客户:
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();
}
}
从你的问题中不清楚具体你有什么问题。但是,检查代码会发现两个重大问题:
- 您没有以线程安全的方式访问您的字典,这意味着可能向字典添加项目的侦听线程可以在客户端服务线程尝试操作对象的同时对该对象进行操作查字典。但是,添加操作不是原子的。这意味着在添加项目的过程中,字典可能会暂时处于无效状态。这会导致任何试图同时读取它的客户端服务线程出现问题。
- 您的客户端代码尝试处理用户输入并在处理从服务器接收数据的同一线程中写入服务器。这至少会导致几个问题:
- 在用户下次提供一些输入之前,无法从另一个客户端接收数据。
- 因为在单次读取操作中您可能只收到一个字节,所以即使在用户提供输入之后,您仍然可能无法收到之前发送的完整消息。
这是解决这两个问题的代码版本:
服务器代码:
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()
方法指示它已完成发送。为了启动优雅关闭,调用 Shutdown()
并带有 "send" 原因,表明端点已停止发送,但仍将接收。另一个端点一旦完成向第一个端点的发送,就可以调用 Shutdown()
,原因是 "both" 以指示它已完成发送和接收。
代码中仍然存在各种问题。上面只解决了最明显的问题,并将代码带到了一个非常基本的 server/client 架构的工作演示的合理复制品中。
附录:
一些额外的注释来解决评论中的后续问题:
- 客户端在接收线程上调用
Thread.Join()
(即等待该线程退出),以确保在开始正常关闭过程后,它不会真正关闭套接字,直到远程端点响应通过关闭它的末端。
- 使用
o => ReceiveData((TcpClient)o)
作为 ParameterizedThreadStart
委托是我更喜欢强制转换线程参数的习惯用法。它允许线程入口点保持强类型。但是,该代码并不完全是我通常编写的方式;我一直在严格遵守您的原始代码,同时仍然利用这个机会来说明该成语。但实际上,我会使用无参数 ThreadStart
委托的构造函数重载,只让 lambda 表达式捕获必要的方法参数: Thread thread = new Thread(() => ReceiveData(client)); thread.Start();
然后,根本不需要进行强制转换(如果有任何参数是值类型,它们的处理没有任何 boxing/unboxing 开销……在这种情况下通常不是一个关键问题,但仍然让我感觉更好 :))。
- 将这些技术应用于 Windows Forms 项目会增加一些复杂性,这并不奇怪。在非 UI 线程中接收时(无论是专用的每个连接线程,还是使用网络 I/O 的几个异步 API 之一),您将需要返回到 UI 线程与 UI 对象交互时。这里的解决方法和往常一样:最基本的方法是在WPF程序中使用
Control.Invoke()
(或Dispatcher.Invoke()
);一种更复杂(恕我直言,更高级)的方法是对 I/O 使用 async
/await
。如果您正在使用 StreamReader
接收数据,则该对象已经具有可等待的 ReadLineAsync()
和类似的方法。如果直接使用 Socket
,则可以使用 Task.FromAsync()
方法将 BeginReceive()
和 EndReceive()
方法包装在一个 awaitable 中。无论哪种方式,结果都是虽然 I/O 异步发生,但完成仍然在 UI 线程中处理,您可以在其中直接访问 UI 对象。 (在这种方法中,您将等待表示接收代码的任务,而不是使用 Thread.Join()
,以确保您不会过早关闭套接字。)
我正在学习 c# 套接字编程。所以,我决定做一个TCP聊天,基本思路是A客户端向服务器发送数据,然后服务器广播给所有在线的客户端(在这种情况下所有客户端都在一个字典中)。
当有1个客户端连接时,它正常工作,当有超过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()
传递 2 个参数。
客户:
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();
}
}
从你的问题中不清楚具体你有什么问题。但是,检查代码会发现两个重大问题:
- 您没有以线程安全的方式访问您的字典,这意味着可能向字典添加项目的侦听线程可以在客户端服务线程尝试操作对象的同时对该对象进行操作查字典。但是,添加操作不是原子的。这意味着在添加项目的过程中,字典可能会暂时处于无效状态。这会导致任何试图同时读取它的客户端服务线程出现问题。
- 您的客户端代码尝试处理用户输入并在处理从服务器接收数据的同一线程中写入服务器。这至少会导致几个问题:
- 在用户下次提供一些输入之前,无法从另一个客户端接收数据。
- 因为在单次读取操作中您可能只收到一个字节,所以即使在用户提供输入之后,您仍然可能无法收到之前发送的完整消息。
这是解决这两个问题的代码版本:
服务器代码:
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()
方法指示它已完成发送。为了启动优雅关闭,调用Shutdown()
并带有 "send" 原因,表明端点已停止发送,但仍将接收。另一个端点一旦完成向第一个端点的发送,就可以调用Shutdown()
,原因是 "both" 以指示它已完成发送和接收。
代码中仍然存在各种问题。上面只解决了最明显的问题,并将代码带到了一个非常基本的 server/client 架构的工作演示的合理复制品中。
附录:
一些额外的注释来解决评论中的后续问题:
- 客户端在接收线程上调用
Thread.Join()
(即等待该线程退出),以确保在开始正常关闭过程后,它不会真正关闭套接字,直到远程端点响应通过关闭它的末端。 - 使用
o => ReceiveData((TcpClient)o)
作为ParameterizedThreadStart
委托是我更喜欢强制转换线程参数的习惯用法。它允许线程入口点保持强类型。但是,该代码并不完全是我通常编写的方式;我一直在严格遵守您的原始代码,同时仍然利用这个机会来说明该成语。但实际上,我会使用无参数ThreadStart
委托的构造函数重载,只让 lambda 表达式捕获必要的方法参数:Thread thread = new Thread(() => ReceiveData(client)); thread.Start();
然后,根本不需要进行强制转换(如果有任何参数是值类型,它们的处理没有任何 boxing/unboxing 开销……在这种情况下通常不是一个关键问题,但仍然让我感觉更好 :))。 - 将这些技术应用于 Windows Forms 项目会增加一些复杂性,这并不奇怪。在非 UI 线程中接收时(无论是专用的每个连接线程,还是使用网络 I/O 的几个异步 API 之一),您将需要返回到 UI 线程与 UI 对象交互时。这里的解决方法和往常一样:最基本的方法是在WPF程序中使用
Control.Invoke()
(或Dispatcher.Invoke()
);一种更复杂(恕我直言,更高级)的方法是对 I/O 使用async
/await
。如果您正在使用StreamReader
接收数据,则该对象已经具有可等待的ReadLineAsync()
和类似的方法。如果直接使用Socket
,则可以使用Task.FromAsync()
方法将BeginReceive()
和EndReceive()
方法包装在一个 awaitable 中。无论哪种方式,结果都是虽然 I/O 异步发生,但完成仍然在 UI 线程中处理,您可以在其中直接访问 UI 对象。 (在这种方法中,您将等待表示接收代码的任务,而不是使用Thread.Join()
,以确保您不会过早关闭套接字。)