作业五:基于Select模型的通信程序设计

1. 实验思路

1.1 实验内容

编写Win32程序模拟实现基于Select模型的两台计算机之间的通信,要求编程实现服务器端与客户端之间双向数据传递。客户端向服务器端发送“计算从1到100的奇数和”,服务器回应客户端并给出从1到100的奇数和结果。

1.2 设计思路

实验整体设计目标:

  1. 服务端:创建监听 socket,绑定端口 8888;使用 select 轮询检测所有 socket 状态(是否有新连接、是否收到消息);收到计算指令后完成运算,把结果发回对应客户端。
  2. 客户端:创建 TCP 套接字,主动连接127.0.0.1:8888,连接成功后自动发送计算指令,等待并打印服务端返回的计算结果。
  3. 通信约定:客户端固定下发指令字符串“计算从1到100的奇数和”,服务端识别该指令,执行求和逻辑,将结果回传给发起请求的客户端。

1.3 实现原理

  1. Winsock 初始化:调用WSAStartup()加载 Winsock 库,程序结束使用WSACleanup()释放资源。
  2. TCP 套接字基础流程:
    • 服务端:socket()创建监听套接字 → bind()绑定 IP 端口 → listen()开启监听
    • 客户端:socket()创建套接字 → connect()向服务端发起 TCP 三次握手连接
  3. Select 多路复用核心原理
    Select 维护一个套接字集合(fd_set),把监听 socket、所有已经接入的客户端 socket 全部放进集合;调用select()函数阻塞等待。
    当集合中任意一个 socket 事件就绪(监听 socket 有新连接、客户端 socket 有数据可读),select 函数返回,遍历集合判断哪一个 socket 就绪,再执行对应的accept()或者recv()接收数据操作。核心特点:单线程管理多个 socket,靠内核通知就绪事件,避免多线程带来的线程竞争、资源开销问题;属于同步 IO 多路复用。
  4. 数据收发:使用send()/recv()完成 TCP 字节流传输;服务端解析收到的字符串指令,匹配后执行求和算法,再通过对应 socket 发送计算结果。

2. 解答思路(关键代码解析)

(1)fd_set 集合与 select 主循环

fd_set readSet;
while (true)
{
    FD_ZERO(&readSet);
    FD_SET(listenSock, &readSet);
    if (clientSock != INVALID_SOCKET)
    {
        FD_SET(clientSock, &readSet);
    }
    int ret = select(0, &readSet, nullptr, nullptr, nullptr);
}
  • FD_ZERO(&readSet):每次循环清空可读集合,fd_set在 select 返回后会被内核修改,不能复用,必须每次重建集合(这是 Select 最容易踩的坑)
  • FD_SET(listenSock, &readSet):把监听套接字加入可读集合,监听是否有新连接到来
  • 如果当前已有客户端连接(clientSock有效),再把这个唯一的客户端套接字加入集合,监听它是否发来数据
  • select(0, &readSet, nullptr, nullptr, nullptr):阻塞等待,直到集合里任意一个 socket 产生可读事件,函数返回就绪的 socket 数量

本实现特点:只用单个变量clientSock保存客户端套接字,同一时间最多维护一个客户端连接,后续新的客户端接入会直接被拒绝断开。

(2)监听 socket 就绪:accept 接收新客户端

if (FD_ISSET(listenSock, &readSet))
{
    sockaddr_in clientAddr;
    int addrLen = sizeof(clientAddr);
    SOCKET newClient = accept(listenSock, (sockaddr*)&clientAddr, &addrLen);
    if (newClient == INVALID_SOCKET)
    {
        continue;
    }
    if (clientSock == INVALID_SOCKET)
    {
        clientSock = newClient;
        char ipStr[INET_ADDRSTRLEN];
        inet_ntop(AF_INET, &clientAddr.sin_addr, ipStr, INET_ADDRSTRLEN);
        std::cout << "客户端连接成功!IP:" << ipStr << "\n";
    }
    else
    {
        // 已经存在一个在线客户端,拒绝新连接
        closesocket(newClient);
    }
}
  • FD_ISSET(listenSock, &readSet):判断就绪的是不是监听 socket,如果是,代表有新 TCP 连接待接收
  • accept():取出这个新连接,返回新的客户端套接字
  • 判断:如果目前没有客户端在线,就把新连接赋值给clientSock;如果已经有客户端在线,直接关闭新连接,限制单客户端

(3)客户端 socket 就绪:recv 接收指令、计算、send 回发结果

if (clientSock != INVALID_SOCKET && FD_ISSET(clientSock, &readSet))
{
    ZeroMemory(buf, BUF_SIZE);
    int recvLen = recv(clientSock, buf, BUF_SIZE - 1, 0);
    if (recvLen <= 0)
    {
        std::cout << "客户端断开连接\n";
        closesocket(clientSock);
        clientSock = INVALID_SOCKET;
        continue;
    }
    std::cout << "收到客户端消息:" << buf << "\n";
    std::string recvMsg(buf);
    if (recvMsg == "计算从1到100的奇数和")
    {
        int sum = CalcOddSum();
        std::string resp = "1到100的奇数和结果 = " + std::to_string(sum);
        send(clientSock, resp.c_str(), (int)resp.size(), 0);

        std::cout << "已返回结果给客户端:" << resp << "\n";
    }
    else
    {
        std::string tip = "收到未知指令";
        send(clientSock, tip.c_str(), (int)tip.size(), 0);
    }
}
  • FD_ISSET(clientSock, &readSet):客户端 socket 可读,代表客户端发来数据
  • ZeroMemory清空缓冲区,防止上一次残留数据干扰
  • recv()读取字节流:返回值<=0代表连接断开 / 异常,关闭套接字,把clientSock置为无效,服务端回到等待新客户端的状态
  • 字符串匹配:识别指令计算从1到100的奇数和,调用CalcOddSum()求和,构造响应字符串,通过send()原路返回给客户端;收到其他指令返回提示信息

(4)CalcOddSum 业务计算函数

int CalcOddSum()
{
    int sum = 0;
    for (int i = 1; i <= 100; i += 2)
    {
        sum += i;
    }
    return sum;
}

业务函数,遍历 1~100,累加所有奇数,返回求和结果(结果固定为 2500)。

(5)Winsock 初始化、bind、listen、端口复用

BOOL opt = TRUE;
setsockopt(listenSock, SOL_SOCKET, SO_REUSEADDR, (const char*)&opt, sizeof(opt));
  • SO_REUSEADDR:端口复用选项,程序重启后可以立刻重新绑定 8888 端口,避免 “端口被占用” 报错,调试时非常实用。
  • WSAStartup:加载 Winsock2 库;WSACleanup()程序结束释放资源。
  • bind绑定本机任意网卡INADDR_ANY+8888 端口;listen开启监听。

(6)客户端关键代码

inet_pton(AF_INET, "127.0.0.1", &serverAddr.sin_addr);
serverAddr.sin_port = htons(PORT);
if (connect(sock, (sockaddr*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR)
{
    std::cout << "连接服务器失败!检查IP和端口\n";
    closesocket(sock);
    WSACleanup();
    return -1;
}
std::string sendStr = "计算从1到100的奇数和";
send(sock, sendStr.c_str(), (int)sendStr.size(), 0);
  • htons:主机字节序转网络大端字节序;
  • connect发起 TCP 连接;连接成功后发送预设求和指令。
fd_set readSet;
char buf[BUF_SIZE] = { 0 };
timeval timeout;
while (true)
{
    FD_ZERO(&readSet);
    FD_SET(sock, &readSet);
    // 设置超时5秒,防止select永久阻塞
    timeout.tv_sec = 5;
    timeout.tv_usec = 0;
    int ret = select(0, &readSet, nullptr, nullptr, &timeout);
    if (ret == 0)
    {
        std::cout << "等待服务端回复超时\n";
        break;
    }
    if (ret < 0)
    {
        std::cout << "select错误\n";
        break;
    }
    if (FD_ISSET(sock, &readSet))
    {
        ZeroMemory(buf, BUF_SIZE);
        int recvLen = recv(sock, buf, BUF_SIZE - 1, 0);
        if (recvLen <= 0)
        {
            std::cout << "服务器断开\n";
            break;
        }
        std::cout << "收到服务器回复:" << buf << "\n";
        break;
    }
}
  • 服务端 select 用于同时监听新连接和客户端消息两类事件;客户端 select 仅监听唯一一条通信 socket,核心目的是增加 IO 超时保护。
  • 客户端这里使用 select+timeval实现超时控制,不再使用无期限阻塞recv;超时时间设置为 5 秒;
  • ret == 0:代表 5 秒内没有收到服务端数据,判定超时退出;
  • ret < 0:select 调用出错;
  • 套接字就绪后读取返回结果,打印之后直接退出循环,本次通信结束。

3. 实验运行结果

  1. 启动顺序:先运行 Select 模型服务端程序,控制台输出:【服务端启动成功】端口:8888,等待客户端连接...,服务端进入 select 循环,等待事件就绪。
  2. 运行客户端程序:客户端成功和 127.0.0.1:8888 建立 TCP 连接,打印【客户端连接服务器成功】,自动发送指令:计算从1到100的奇数和,随后进入 select 循环等待服务端应答,最长等待 5 秒。
  3. 服务端 select 捕获客户端 socket 可读事件,调用 recv 拿到指令,识别指令后进行求和计算(1~100 奇数和为 2500),将结果字符串发回客户端。
  4. 客户端 select 检测到 socket 可读,recv 收到结果,控制台打印服务端返回的计算结果,通信结束。
  5. 异常场景测试:如果服务端 5 秒内没有返回数据,客户端输出等待服务端回复超时并退出;
  6. 拓展测试:在已有一个客户端连接的情况下,再启动第二个客户端,第二个客户端会直接连接被断开,本简化版本同一时刻仅支持一个客户端接入。

image
image

4. 实验过程中遇到的问题与解决方式

问题 1:编译报错 LNK2019 无法解析外部符号 WinMain

原因:VS 项目创建时误设置为【Windows 窗口程序】,程序入口需要 WinMain;而我们写的是控制台程序,入口是 main 函数。
解决:右键项目 → 属性 → 链接器 → 系统 → 子系统,修改为 控制台 (/SUBSYSTEM:CONSOLE),重新生成项目。

问题 2:客户端 connect 连接失败

排查点:①服务端没有提前启动;②端口被占用;③防火墙拦截 8888 端口;④IP 地址写错;⑤Winsock 库忘记初始化。
解决:保证先启动服务端;更换端口;放行防火墙;确认 IP 为 127.0.0.1,检查 WSAStartup 代码。

问题 3:select 集合处理错误,事件无法正常捕获

原因:fd_set 是内核使用的临时拷贝,select 返回之后集合内容会被改写;如果只在循环外部添加一次 socket,不每次循环 FD_ZERO 重建集合,后续无法捕获 IO 事件。
解决:每次循环先 FD_ZERO 清空集合,再把监听 socket、存活客户端 socket 重新 FD_SET 加入集合,再调用 select。

5 组员实验心得

成员 1(负责服务端 Select 模型核心代码编写,多路复用逻辑调试)

本次实验主要负责服务端 Select 模型主体代码的编写与调试。之前只写过单连接阻塞 TCP 服务端,第一次接触 IO 多路复用,一开始很难理解 fd_set 集合的本质,踩坑最多的地方就是 select 每次循环都要重建集合,一开始只添加一次 socket,导致后续客户端无法通信。通过不断调试,我理解了 select 只是通知 “集合中有 socket 就绪”,不会帮我们维护客户端列表。这次实验让我搞懂多路复用不是异步 IO,依然是同步等待事件,只是用一个等待点管理多个通道,也体会到 IO 多路复用在网络编程里的价值,对后续学习 epoll、IOCP 打下基础。
本次实现的是简化版 Select,限制单客户端,理解原理之后,后续可以改成 SOCKET 数组版本,实现真正多客户端并发。

成员 2(负责客户端代码、通信协议设计、指令收发逻辑)

主要实现客户端代码,完成连接建立、指令发送、接收结果的逻辑。实验前期遇到很多 VS 编译警告、链接报错,反复调整项目配置、处理类型转换问题。在调试收发逻辑的时候,意识到 TCP 是流式协议,没有消息边界,简单字符串收发在短消息场景可以使用,但正式项目需要自定义分包协议。同时负责设计本次实验的交互规则,约定客户端下发求和指令、服务端识别并返回结果。通过本次实验,我熟悉了 Winsock 基础 API 调用流程,明白了客户端和服务端通信时序必须遵守:先监听、后连接。实现的客户端代码,一开始直接写阻塞 recv,如果服务端异常不发数据,客户端就卡死。后来加上 select 和 timeval 超时,实现限时等待。同时使用 inet_pton 替换旧的 inet_addr,消除废弃 API 警告。也理解了 select 不一定是用来管理大量连接,单 socket 场景也可以用它做 IO 超时控制。

成员 3(负责文档整理、测试验证、问题汇总、博客撰写)

工作是整体功能测试、复现 bug、记录排错流程,以及整理实验文档和本次博客内容。测试时验证了单客户端正常通信、新连接被拒绝、客户端超时等多种场景。在整理问题的时候,我发现大部分报错根源都是 VS 项目配置、类型不匹配、API 废弃这类环境层面问题,而不是网络逻辑本身。通过梳理整个实验流程,把 Select 的工作流程梳理清楚,把抽象的多路复用模型转化成清晰的步骤。这次小组协作学会分工配合,测试、记录、复盘也是开发里很重要的一环,同时加深了 TCP 通信的理解。

6. 实验小结

本次实验基于 Win32 Winsock API 实现了一个 Select 机制演示的 TCP 通信程序,完成客户端发送求和指令、服务端计算并回传结果的功能。实验搭建了标准 Winsock 通信框架,使用单个 SOCKET clientSock 变量保存当前在线客户端的套接字,调用 select 函数监听套接字集合,同时检测新连接到来与已有客户端数据可读两类事件;客户端发送固定字符串指令,服务端识别后完成求和运算;在事件就绪分支中使用 send 与 recv 完成双向数据收发,同时做好套接字与 Winsock 资源释放,防止资源泄漏。

实验验证了 Select 的事件等待机制:程序不会永久阻塞在单一的 accept 或者 recv 调用上,可借助超时参数控制等待时长;fd_set 集合会在 select 返回后被内核修改失效,因此每次循环都需要重新构建套接字集合。

开发过程中遇到 Windows 子系统配置错误引发的 LNK2019 链接问题、size_t 与 int 类型转换警告,通过修改项目属性与强制类型转换解决(因指令字符串长度远小于 int 上限,强转安全)。小组分工完成代码编写调试、文档整理注释、功能测试与截图工作。
通过本次实验,掌握了 Winsock 套接字创建、事件循环、select 函数用法、TCP 消息收发;理解了 fd_set 集合管理要点,区分了阻塞单线程 TCP 与 select 事件等待模式的差异。

posted @ 2026-10-20 20:50  IXIXIX0404  阅读(15)  评论(0)    收藏  举报