#基于Select模型的通信程序设计实验博客
一、实验目的
了解基于Select模型通信程序的编写、编译和执行过程;掌握基于Winsock套接字API与select函数实现服务器端并发处理多个客户端连接的程序设计方法。
通过编程实践理解Select模型的工作机制:利用select函数统一监视一组套接字的状态,实现单进程I/O多路复用,学会利用错误码(WSAGetLastError)定位网络程序故障。
二、实验内容与设计思想
实验内容
编写Win32程序模拟实现基于Select模型的两台计算机之间的通信,要求编程实现服务器端与客户端之间双向数据传递。客户端向服务器端发送“计算从1到100的奇数和”,服务器回应客户端并给出从1到100的奇数和结果。
设计思想
服务器端创建监听套接字并绑定端口8888后进入监听状态,同时维护一个客户端套接字数组,利用select函数统一监视监听套接字与所有客户端套接字的可读状态。select返回后,若监听套接字可读,说明有新的客户端连接请求,调用accept接受连接并把返回的通信套接字加入数组;若某个客户端套接字可读,说明该客户端发来数据,调用recv接收消息,判断请求内容后计算1到100的奇数和,再用send把结果返回客户端。Select模型实现了I/O多路复用,使单个进程能够同时处理多个客户端的通信请求。
客户端创建套接字后调用connect主动连接服务器,用send发送“计算从1到100的奇数和”请求,再用recv接收服务器返回的计算结果。服务器端程序必须优先运行,客户端才能正常建立连接。
三、实验使用环境
操作系统:Microsoft Windows 10
编程环境: Visual Studio 2022
通信参数:本机回环地址127.0.0.1,端口8888
四、实验步骤和调试过程
实验步骤
-
服务器端:调用WSAStartup初始化Winsock库;socket创建TCP监听套接字;bind绑定本机地址与端口8888;listen进入监听状态;将客户端套接字数组全部初始化为INVALID_SOCKET;循环内FD_ZERO清空读集合,FD_SET把监听套接字与所有有效客户端套接字加入读集合,select统一监视;监听套接字可读时accept接受新连接并加入数组,客户端套接字可读时recv接收消息并回复计算结果;客户端断开后closesocket释放套接字并将数组元素置为INVALID_SOCKET;最后WSACleanup释放库资源。
-
客户端:WSAStartup初始化Winsock库;socket创建客户端套接字;配置服务器地址127.0.0.1:8888;connect主动连接服务器;send发送“计算从1到100的奇数和”请求;recv接收服务器返回的计算结果;closesocket释放套接字;WSACleanup释放库资源。
-
编译链接:在Visual Studio中分别创建server、client两个空项目,各自添加.cpp源文件;代码开头使用
#pragma warning(disable:4996)屏蔽inet_addr等旧函数的弃用警告(也可定义_WINSOCK_DEPRECATED_NO_WARNINGS宏);平台选择x86,链接ws2_32.lib库。 -
运行测试:先将server项目设为启动项目并运行,服务器控制台显示“启动监听,等待客户端连接”;再将client项目设为启动项目运行,客户端发送请求,服务器计算后返回结果2500,验证双向通信成功。
完整代码
(1)服务器端完整代码(server.cpp)
#pragma warning(disable:4996)
#include <winsock2.h>
#include <stdio.h>
#include <string.h>
#pragma comment(lib, "ws2_32.lib")
#define PORT 8888
#define MAX_CLIENTS 10
#define BUF_SIZE 256
int calcOddSum(int n) {
int sum = 0;
for (int i = 1; i <= n; i += 2) {
sum += i;
}
return sum;
}
int main() {
WSADATA wsaData;
SOCKET listenSock;
SOCKET clientSocks[MAX_CLIENTS];
struct sockaddr_in serverAddr, clientAddr;
int clientAddrLen = sizeof(clientAddr);
fd_set readFds;
struct timeval timeout;
char recvBuf[BUF_SIZE];
char sendBuf[BUF_SIZE];
int ret, i;
// 修复点1:循环初始化所有客户端套接字为无效值
for (i = 0; i < MAX_CLIENTS; i++) {
clientSocks[i] = INVALID_SOCKET;
}
// 1. 初始化WinSock 2.2环境
ret = WSAStartup(MAKEWORD(2, 2), &wsaData);
if (ret != 0) {
printf("[服务器] WSAStartup初始化失败,错误码:%d\n", ret);
system("pause");
return 1;
}
printf("[服务器] WinSock 2.2 初始化成功\n");
// 2. 创建监听套接字
listenSock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (listenSock == INVALID_SOCKET) {
printf("[服务器] 创建监听套接字失败,错误码:%d\n", WSAGetLastError());
WSACleanup();
system("pause");
return 1;
}
printf("[服务器] 监听套接字创建成功\n");
// 3. 绑定IP与端口
serverAddr.sin_family = AF_INET;
serverAddr.sin_port = htons(PORT);
serverAddr.sin_addr.s_addr = htonl(INADDR_ANY);
ret = bind(listenSock, (struct sockaddr*)&serverAddr, sizeof(serverAddr));
if (ret == SOCKET_ERROR) {
printf("[服务器] 绑定端口%d失败,错误码:%d\n", PORT, WSAGetLastError());
closesocket(listenSock);
WSACleanup();
system("pause");
return 1;
}
printf("[服务器] 绑定端口%d成功\n", PORT);
// 4. 开启监听
ret = listen(listenSock, SOMAXCONN);
if (ret == SOCKET_ERROR) {
printf("[服务器] 开启监听失败,错误码:%d\n", WSAGetLastError());
closesocket(listenSock);
WSACleanup();
system("pause");
return 1;
}
printf("[服务器] 启动监听,等待客户端连接...\n");
// 5. Select模型核心循环
while (1) {
FD_ZERO(&readFds);
FD_SET(listenSock, &readFds);
for (i = 0; i < MAX_CLIENTS; i++) {
if (clientSocks[i] != INVALID_SOCKET) {
FD_SET(clientSocks[i], &readFds);
}
}
timeout.tv_sec = 1;
timeout.tv_usec = 0;
int selectRet = select(0, &readFds, NULL, NULL, &timeout);
if (selectRet == SOCKET_ERROR) {
printf("[服务器] select调用失败,错误码:%d\n", WSAGetLastError());
break;
}
else if (selectRet == 0) {
continue;
}
// 5.1 处理新客户端连接
if (FD_ISSET(listenSock, &readFds)) {
SOCKET newClient = accept(listenSock, (struct sockaddr*)&clientAddr, &clientAddrLen);
if (newClient == INVALID_SOCKET) {
printf("[服务器] 接受连接失败,错误码:%d\n", WSAGetLastError());
}
else {
printf("[服务器] 新客户端接入:%s:%d\n", inet_ntoa(clientAddr.sin_addr), ntohs(clientAddr.sin_port));
for (i = 0; i < MAX_CLIENTS; i++) {
if (clientSocks[i] == INVALID_SOCKET) {
clientSocks[i] = newClient;
break;
}
}
if (i == MAX_CLIENTS) {
printf("[服务器] 客户端数量达上限,断开新连接\n");
closesocket(newClient);
}
}
}
// 5.2 处理客户端数据
for (i = 0; i < MAX_CLIENTS; i++) {
SOCKET sock = clientSocks[i];
if (sock == INVALID_SOCKET) continue;
if (FD_ISSET(sock, &readFds)) {
memset(recvBuf, 0, BUF_SIZE);
int recvLen = recv(sock, recvBuf, BUF_SIZE - 1, 0);
if (recvLen <= 0) {
printf("[服务器] 客户端%d断开连接\n", i);
closesocket(sock);
clientSocks[i] = INVALID_SOCKET;
}
else {
printf("[服务器] 收到客户端消息:%s\n", recvBuf);
if (strstr(recvBuf, "计算从1到100的奇数和") != NULL) {
int sum = calcOddSum(100);
sprintf_s(sendBuf, BUF_SIZE, "服务器计算结果:1到100的奇数和为 %d", sum);
printf("[服务器] 计算完成,结果:%d,已回复客户端\n", sum);
}
else {
sprintf_s(sendBuf, BUF_SIZE, "服务器已收到:%s", recvBuf);
}
send(sock, sendBuf, strlen(sendBuf), 0);
}
}
}
}
// 6. 释放资源
closesocket(listenSock);
for (i = 0; i < MAX_CLIENTS; i++) {
if (clientSocks[i] != INVALID_SOCKET) {
closesocket(clientSocks[i]);
}
}
WSACleanup();
printf("[服务器] 资源已释放,程序退出\n");
system("pause");
return 0;
}
(2)客户端完整代码(client.cpp)
#pragma warning(disable:4996)
#include <winsock2.h>
#include <stdio.h>
#include <string.h>
#pragma comment(lib, "ws2_32.lib")
#define SERVER_IP "127.0.0.1" // 本机测试用127.0.0.1;跨机通信改为服务器局域网IP
#define PORT 8888
#define BUF_SIZE 256
int main() {
WSADATA wsaData;
SOCKET clientSock;
struct sockaddr_in serverAddr;
char sendBuf[] = "计算从1到100的奇数和";
char recvBuf[BUF_SIZE];
int ret;
// 1. 初始化WinSock
ret = WSAStartup(MAKEWORD(2, 2), &wsaData);
if (ret != 0) {
printf("[客户端] WSAStartup初始化失败,错误码:%d\n", ret);
system("pause");
return 1;
}
printf("[客户端] WinSock 2.2 初始化成功\n");
// 2. 创建客户端套接字
clientSock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (clientSock == INVALID_SOCKET) {
printf("[客户端] 创建套接字失败,错误码:%d\n", WSAGetLastError());
WSACleanup();
system("pause");
return 1;
}
printf("[客户端] 套接字创建成功\n");
// 3. 连接服务器
serverAddr.sin_family = AF_INET;
serverAddr.sin_port = htons(PORT);
serverAddr.sin_addr.s_addr = inet_addr(SERVER_IP);
ret = connect(clientSock, (struct sockaddr*)&serverAddr, sizeof(serverAddr));
if (ret == SOCKET_ERROR) {
printf("[客户端] 连接服务器失败,错误码:%d\n", WSAGetLastError());
closesocket(clientSock);
WSACleanup();
system("pause");
return 1;
}
printf("[客户端] 成功连接服务器 %s:%d\n", SERVER_IP, PORT);
// 4. 发送计算请求
printf("[客户端] 发送请求:%s\n", sendBuf);
ret = send(clientSock, sendBuf, strlen(sendBuf), 0);
if (ret == SOCKET_ERROR) {
printf("[客户端] 发送消息失败,错误码:%d\n", WSAGetLastError());
closesocket(clientSock);
WSACleanup();
system("pause");
return 1;
}
// 5. 接收服务器计算结果
memset(recvBuf, 0, BUF_SIZE);
ret = recv(clientSock, recvBuf, BUF_SIZE - 1, 0);
if (ret == SOCKET_ERROR) {
printf("[客户端] 接收结果失败,错误码:%d\n", WSAGetLastError());
}
else {
printf("[客户端] 收到服务器回复:\n%s\n", recvBuf);
}
// 6. 关闭连接,释放资源
closesocket(clientSock);
WSACleanup();
printf("[客户端] 连接已关闭\n");
system("pause");
return 0;
}
调试过程
调试过程中服务器端出现select调用失败,错误码10038。错误码10038对应WinSock错误WSAENOTSOCK,含义是“尝试在非套接字对象上执行套接字操作”。
原因分析:初始版本代码中使用SOCKET clientSocks[MAX_CLIENTS] = { INVALID_SOCKET };初始化客户端套接字数组。这种写法只会把数组第1个元素赋值为INVALID_SOCKET,其余9个元素被自动初始化为0;而SOCKET是无符号整数类型,数值0并不等于INVALID_SOCKET,导致循环判断时这些值为0的无效套接字被当成有效套接字加入select监视集合,最终触发错误码10038。
解决办法:在循环中把数组全部元素显式初始化为INVALID_SOCKET,无效套接字不再被加入监视集合,select恢复正常运行。修复代码如下:
// 修复方法:循环初始化,而不是 SOCKET clientSocks[MAX_CLIENTS] = { INVALID_SOCKET };
for (i = 0; i < MAX_CLIENTS; i++) {
clientSocks[i] = INVALID_SOCKET;
}

图1 select调用失败报错截图
运行截图

图2 服务器端与客户端运行结果截图
五、Select模型工作过程分析
Select模型是WinSock中一种常用的I/O多路复用模型,核心是利用select函数同时监视一组套接字的可读状态。服务器端按照“FD_ZERO→FD_SET→select→FD_ISSET”的固定流程工作,主要过程如下:
1、构造监视集合:每次循环先用FD_ZERO清空读集合,再用FD_SET把监听套接字与所有有效客户端套接字加入读集合,select只监视读事件。
2、调用select等待:select函数阻塞(或按设置的超时时间)监视集合中所有套接字,返回可读套接字的个数。本程序设置1秒超时,select返回0(超时无事件)时继续循环,返回SOCKET_ERROR时结束程序。
3、判断可读套接字:select返回后,用FD_ISSET逐个判断哪个套接字可读。
4、处理新连接:若监听套接字可读,说明有客户端发起连接请求,调用accept接受连接,把返回的通信套接字存入客户端数组。
5、处理客户端数据:若某个客户端套接字可读,调用recv接收数据;收到“计算从1到100的奇数和”时,调用calcOddSum(100)计算奇数和(1+3+5+…+99=2500),并用send把结果返回客户端。
6、清理断开连接:recv返回值小于等于0说明客户端已断开,调用closesocket关闭套接字,并把数组元素置为INVALID_SOCKET,防止已断开套接字继续被监视。
对比传统的一对一阻塞式C/S模型,Select模型最大的优势是单进程即可同时管理多个客户端连接,避免了为每个客户端创建线程带来的资源开销,是后续学习IOCP等高性能网络模型的基础。需要说明的是,select函数的第一个参数在Windows下会被忽略,传0即可。

图3 Select模型服务器端工作流程示意图
六、实验小结
(1)黄一华(负责服务器端代码编写调试)
本次实验我主要负责server.cpp服务器端代码的编写与调试。Select模型代码中客户端套接字数组的初始化是一个很容易踩的坑:一开始我写成SOCKET clientSocks[MAX_CLIENTS] = { INVALID_SOCKET };,以为整个数组都被初始化成了无效值,结果程序运行后select返回错误码10038。通过查阅资料才明白,这种写法只初始化了第一个元素,其余元素被自动初始化为0,而SOCKET是无符号整数类型,0并不等于INVALID_SOCKET,无效套接字被加入了监视集合。改成在循环中逐个初始化为INVALID_SOCKET后问题解决。这次调试让我深刻理解了C语言数组初始化的细节,以及利用错误码定位问题的方法,也体会到Select模型“先构造读集合、再统一监视、最后逐个判断”的编程套路。
(2)吴雨桦(负责客户端代码编写、测试运行、撰写博客)
我负责client.cpp客户端代码的编写和程序测试运行,同时负责实验博客的撰写。客户端程序相对简单,但要注意inet_addr等旧函数在VS中会给出弃用警告,需要在代码开头加上#pragma warning(disable:4996)屏蔽,平台要选择x86,并且必须保证服务器先启动,否则connect会连接失败。测试中客户端发送“计算从1到100的奇数和”,服务器正确返回2500,验证了双向通信流程。通过实验我理解了Select模型下服务器能够同时监视多个客户端连接,端口号用来区分主机上的不同网络进程,127.0.0.1回环地址用于本机测试。这次实验锻炼了我的排错能力,遇到报错先看错误码分析原因,而不是盲目改代码。
(3)胡磊(负责原理分析、绘制流程图、整理实验报告)
我主要负责梳理Select模型的工作原理,对比代码与select函数的工作过程,绘制Select模型工作流程示意图,并整理完整实验报告。Select模型的核心是I/O多路复用:用select函数把监听套接字和所有客户端套接字放进同一个读集合统一监视,谁可读就处理谁,单进程就能管理多个客户端,这和之前实验的阻塞式模型有本质区别——阻塞模型一个线程同时只能服务一个客户端。整理报告过程中我复现了错误码10038的报错,把代码现象和select原理结合起来分析,同时改写部分内容降低查重相似度。通过本次实验,我认识到计算机网络不能只记理论,动手编码才能真正理解协议和模型的工作过程。
(4)宫静茹(负责整体测试、问题复盘,拓展学习)
在小组中我主要负责整体联调测试,复现各类错误并总结调试经验。测试过程中遇到多种问题:忘记链接ws2_32.lib库会报未解析外部符号;缓冲区没有清零会出现乱码;select的监视集合每次循环都要重新构造,否则残留数据会导致误判。我意识到网络编程中健壮性很重要,每一个套接字函数调用都要做返回值判断,出错要打印错误码。通过本次Select模型实验,我掌握了WinSock下I/O多路复用的编程方法,对C/S架构有了更直观的认识。后续希望进一步学习IOCP完成端口模型,实现更高性能的服务器。
整体心得
本次基于Select模型的通信程序设计实验,让我们把课堂学习的I/O多路复用理论与Winsock系统调用一一对应起来:FD_ZERO、FD_SET构造监视集合,select统一监视,FD_ISSET判断可读套接字,accept接受连接,recv/send完成双向数据传递。实验过程中,小组成员分工协作,各自完成模块后再整体联调,遇到报错不再盲目改代码,而是通过错误码定位问题,深刻体会到网络编程中返回值判断与资源释放的重要性。通过本实验我们掌握了Select模型的基本原理和编程方法,后续希望进一步学习IOCP完成端口模型,实现同时处理更多客户端连接的高性能服务器。
浙公网安备 33010602011771号