第一部分:ClientSocket.cs (客户端封装)
这个类代表“一个具体的连接”。服务器每接受一个客户端,就会 new 一个这个类的对象。
using System.Net.Sockets;
using System.Text;
using System.Threading;
namespace Lesson19
{
/// <summary>
/// ClientSocket 类:封装单个客户端连接的所有逻辑
/// 职责:维护自己的Socket,负责接收自己的消息,发送消息给自己,处理断开。
/// </summary>
class ClientSocket
{
// 静态变量,所有 ClientSocket 对象共享这一个计数器,用于生成唯一的 ID
// 使用 volatile 确保多线程可见性,但在自增时最好用 Interlocked (见下文分析)
private static int CLIENT_BEGIN_ID = 1;
public int clientID; // 当前客户端的唯一标识
public Socket socket; // 当前客户端对应的网络套接字
/// <summary>
/// 构造函数:当有新客户连接时调用
/// </summary>
/// <param name="socket">服务端 Accept 得到的新 Socket</param>
public ClientSocket(Socket socket)
{
// 【注意】这里在多线程下不是绝对安全的,建议改为 Interlocked.Increment
this.clientID = CLIENT_BEGIN_ID;
this.socket = socket;
CLIENT_BEGIN_ID++;
}
// 属性:快速判断连接是否还活着
public bool Connected => this.socket.Connected;
/// <summary>
/// 关闭连接:释放资源
/// </summary>
public void Close()
{
if (socket != null)
{
try
{
// 优雅地关闭双向通信
socket.Shutdown(SocketShutdown.Both);
socket.Close();
}
catch { /* 忽略关闭时的异常 */ }
socket = null;
}
}
/// <summary>
/// 发送消息:向当前客户端发送字符串
/// </summary>
public void Send(string info)
{
if (socket != null && socket.Connected)
{
try
{
// 将字符串转为字节数组发送
byte[] data = Encoding.UTF8.GetBytes(info);
socket.Send(data);
}
catch (Exception e)
{
Console.WriteLine($"客户端 {clientID} 发送失败: {e.Message}");
Close(); // 发送失败通常意味着连接已断,主动关闭
}
}
}
/// <summary>
/// 接收消息 (非阻塞轮询版)
/// 【缺陷分析】:此方法只检查一次,如果没数据就直接返回。
/// 需要外部有一个死循环不断调用它,或者改用阻塞式接收 (StartReceiveLoop)。
/// </summary>
public void Receive()
{
if (socket == null || !socket.Connected) return;
byte[] result = new byte[1024];
try
{
// Available > 0 表示缓冲区里有数据可读
if (socket.Available > 0)
{
int receiveNum = socket.Receive(result);
// 将处理逻辑丢到线程池,避免阻塞当前的接收检查线程
ThreadPool.QueueUserWorkItem(MsgHandle, Encoding.UTF8.GetString(result, 0, receiveNum));
}
}
catch (Exception e)
{
Console.WriteLine($"客户端 {clientID} 收消息出错: " + e.Message);
Close();
}
}
/// <summary>
/// 消息处理回调:在线程池中执行
/// </summary>
private void MsgHandle(Object obj)
{
string str = obj as string;
// 【修复】原代码用了两个 {0},导致消息内容没显示。这里改为 {0}端点 {1}消息
Console.WriteLine("收到客户端[{0}] (ID:{1}) 发来的消息:{2}",
this.socket.RemoteEndPoint,
this.clientID,
str);
}
}
}
代码分析 & 改进建议
- 当前的 Receive() 是非阻塞的。它依赖外部有一个死循环不停地调用它。
- 缺点:如果外部循环不够快,会漏消息;如果外部循环太快且没有 Sleep,会占用 100% CPU。
- 最佳实践:通常我们会在这个类里写一个 StartReceiveLoop() 方法,内部是一个 while(true) 的死循环,直接调用阻塞式的 socket.Receive()。这样每个客户端在自己的线程里跑,不需要服务器统一轮询。
📄 第二部分:ServerSocket.cs (服务端封装)
这个类代表“服务器管理者”。它负责监听端口、管理所有 ClientSocket 对象、处理广播。
using System.Net;
using System.Net.Sockets;
using System.Collections.Generic;
using System.Threading;
namespace Lesson19
{
/// <summary>
/// ServerSocket 类:服务器核心管理类
/// 职责:监听端口、接受新连接、维护客户端列表、广播消息。
/// </summary>
class ServerSocket
{
public Socket socket; // 监听用的主 Socket
// 字典存储所有在线客户端:Key=ID, Value=ClientSocket对象
// 【关键】Dictionary 不是线程安全的,多线程访问必须加锁!
Dictionary<int, ClientSocket> clientDic = new Dictionary<int, ClientSocket>();
static bool isClose = false; // 控制服务器停止的标志位
/// <summary>
/// 启动服务器
/// </summary>
public void Start(string ip, int port, int num)
{
// 1. 初始化监听 Socket
socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
IPEndPoint ipPoint = new IPEndPoint(IPAddress.Parse(ip), port);
socket.Bind(ipPoint); // 绑定 IP 和端口
socket.Listen(num); // 开始监听, backlog 为 num
Console.WriteLine($"服务器已启动,监听 {ip}:{port}");
// 2. 将“接受连接”任务放入线程池 (或新建独立线程)
ThreadPool.QueueUserWorkItem(Accept);
// 3. 将“轮询接收消息”任务放入线程池
// 【注意】这种轮询模式效率较低,见下方分析
ThreadPool.QueueUserWorkItem(Receive);
}
/// <summary>
/// 关闭服务器
/// </summary>
public void Close()
{
isClose = true;
// 关闭所有客户端连接
foreach(ClientSocket client in clientDic.Values)
{
client.Close();
}
// 关闭主监听 Socket
if(socket != null)
{
socket.Shutdown(SocketShutdown.Both);
socket.Close();
socket = null;
}
clientDic.Clear();
Console.WriteLine("服务器已关闭。");
}
/// <summary>
/// 接受客户端连入 (运行在后台线程)
/// </summary>
private void Accept(Object obj)
{
while (!isClose)
{
try
{
// 【阻塞】这里会卡住,直到有客户端连入
Socket clientSocket = socket.Accept();
// 实例化一个新的客户端对象
ClientSocket client = new ClientSocket(clientSocket);
// 发送欢迎语
client.Send("欢迎连入服务器");
// 【危险】直接 Add 到字典,没有加锁 (lock)
// 如果此时 Receive 线程正在遍历字典,会抛出异常导致崩溃
clientDic.Add(client.clientID, client);
Console.WriteLine($"新客户连接:ID={client.clientID}");
}
catch(Exception e)
{
if(!isClose)
Console.WriteLine("客户端连入报错:" + e.Message);
}
}
}
/// <summary>
/// 轮询接收所有客户端消息 (运行在后台线程)
/// </summary>
private void Receive(Object obj)
{
while (!isClose)
{
// 只有当有客户端时才处理
if (clientDic.Count > 0)
{
// 【危险】直接遍历字典,没有加锁
// 如果此时 Accept 线程正在 Add 新客户端,会抛出 "Collection was modified" 异常
foreach(ClientSocket client in clientDic.Values)
{
// 调用单个客户端的 Receive 方法 (非阻塞检查)
client.Receive();
}
}
// 【严重问题】这里没有 Thread.Sleep,会导致 CPU 占用率 100% (忙等待)
}
}
/// <summary>
/// 广播消息:发给所有人
/// </summary>
public void Broadcast(string str)
{
if (clientDic.Count > 0)
{
// 【危险】同样没有加锁
foreach(ClientSocket client in clientDic.Values)
{
client.Send(str);
}
}
}
}
}
代码分析 & 致命隐患
- clientDic 被三个地方同时操作:Accept (添加), Receive (遍历), Broadcast (遍历), Close (清空)。
- C# 的 Dictionary 不是线程安全的。如果在 foreach 遍历时有另一个线程执行 Add,程序会直接崩溃 (InvalidOperationException)。
- 修复方案:必须在操作 clientDic 前后加上 lock (lockObj) { … }。
- Receive 方法中的 while(!isClose) 循环里没有 Thread.Sleep。
- 这意味着即使没有客户端发消息,这个线程也会以每秒几千万次的速度空转检查,吃掉一个完整的 CPU 核心。
- 修复方案:在循环末尾加 Thread.Sleep(10),或者改用每个客户端独立线程的阻塞式接收模型。
- 服务器强行统一管理所有客户端的接收。如果客户端数量达到几千,这个统一的 Receive 循环会成为瓶颈,导致消息延迟。
- 优化方向:在 Accept 成功后,直接为新的 ClientSocket 启动一个专属线程去跑接收循环,移除全局的 Receive 方法。
📄 第三部分:Program.cs (主程序入口)
这是程序的入口点,负责启动服务器并处理管理员的控制台指令。
using System;
namespace Lesson19
{
class Program
{
// 主入口函数
static void Main(string[] args)
{
// 启动逻辑封装在 Start 方法中
Start();
}
public static void Start()
{
// 1. 实例化服务器对象
ServerSocket serverSocket = new ServerSocket();
// 2. 启动服务器 (监听 127.0.0.1:8080,最大排队 10 人)
// 注意:Start 方法内部已经开启了后台线程,所以这里不会卡住,代码会继续往下执行
serverSocket.Start("127.0.0.1", 8080, 10);
Console.WriteLine("服务器运行中… 输入 'B:消息' 广播,输入 'quit' 退出");
// 3. 主循环:无限等待用户输入指令
while (true)
{
string input = Console.ReadLine();
// 指令 1: 退出
if (input == "quit")
{
serverSocket.Close(); // 关闭服务器资源
// 【重要】必须 break 跳出循环,否则程序还会卡在 ReadLine
break;
}
// 指令 2: 广播 (格式 B:内容)
else if (input.Length >= 2 && input.Substring(0, 2) == "B:")
{
// 截取 "B:" 后面的内容
string msg = input.Substring(2);
serverSocket.Broadcast(msg);
Console.WriteLine($"[广播] 已发送:{msg}");
}
else
{
Console.WriteLine("未知指令。格式示例:B:大家好");
}
}
Console.WriteLine("程序已结束。");
}
}
}



