C++网络编程学习:升级为select网络模型

  • 使用的语言为C/C++
  • 源码支持的平台为:Windows

一、为何要使用select网络模型?

  通过前面的学习,已经实现了简单的网络报文收发。但是可以很明显的看出其中的缺点,那就是整个程序的运行是阻塞模式的。即服务端在与一个客户端进行socket连接时,只要连接不中断,那么就无法接收新的客户端的消息。而客户端在未输入命令时,是阻塞状态,也无法接收服务端发来的消息。
  在之前碰到这个问题时,我的想法是通过多线程来解决程序运行中的阻塞问题,但是在最近的学习中,我了解到可以使用select网络模型来方便快捷的解决小型网络程序运行中的阻塞问题。(I/O多路复用模型相关内容)

二、select系统及其相关

select函数如下:

1
2
3
4
5
6
7
8
WINSOCK_API_LINKAGE int WSAAPI select(
int nfds,//是指待监听集合里的范围 即待监听数量最大值+1
fd_set *readfds,//待监听的可读文件集合
fd_set *writefds,//待监听的可写文件集合
fd_set *exceptfds,//待监听的异常文件集合
const PTIMEVAL timeout);//超时设置 传入NULL为阻塞模式 传入timeval结构体为非阻塞模式

返回值为满足条件的待监听socket数量和,如果出错返回-1,如果超时返回0

通过上面select函数的参数可以发现存在两个特殊的结构体 fd_settimeval,其相关内容如下:

1
2
3
4
5
6
7
8
9
10
11
typedef struct fd_set//可以存放多个socket 
{
u_int fd_count;//记录放了多少个socket
SOCKET fd_array[FD_SETSIZE];//socket数组
} fd_set;

struct timeval//时间结构体
{
long tv_sec;//秒
long tv_usec;//毫秒
};

接下来为select的相关函数

1
2
3
4
void FD_SET(int fd, fd_set *set);//将fd加入set集合
void FD_ZERO(fd_set *set);//使set集合清零 不包含任何socket
void FD_CLR(int fd, fd_set *set);//将fd从set集合中清除
int FD_ISSET(int fd, fd_set *set);//测试fd是否在集合中 0是不在 1是在

★ select相关使用总结与心得

  在一开始的select使用中,我以为向select函数中传入fd_set地址,select会把待处理事件的socket放在set集合中,但是发现并不是这样。
  经过网络上资料的查询以及我个人的测试,可以发现,用户首先需要把一份socket数组传入到此set中,select函数的作用是移除该set中没有待处理事件的socket,则剩下的socket都存在待处理事件(未决I/O操作)。这个过程可以说是一种“选择”的过程,select函数“选择”出需要操作的socket,这或许就是select(选择)的意思吧。
  在接下来的源码中,对于需要存储所有已连接socket的服务端,我使用动态数组vector进行socket的储存。在进行select筛选前,先把vector中的socket导入到set中,随后set中筛选剩下的即为有待处理事件的socket。
  如果服务端自己的socket提示有待处理事件,则说明有新的客户端尝试进行连接,此时进行accept操作即可。
  对于客户端的多线程问题,需要注意使用detach()方法使主线程与新线程分类,否则可能会出现主线程先结束的情况,导致程序出错。
  在线程中,我们可以引入一个bool变量,用来记录客户端是否仍在连接中,当输入exit命令退出客户端时,通过此bool变量使主线程停止,跳出循环。

三、升级为select网络模型的思路

1.服务端升级(select)

在之前,我们的思路是:

1
2
3
4
5
6
7
8
9
10
1.建立socket
2.绑定端口IP
3.监听端口
4.与客户端连接
while(true)
{
5.接收数据
6.发送数据
}
7.关闭socket

  这就导致我们只能与一个客户端进行连接,随后便进入循环,只能接收这一个客户端的消息。且由于send与recv函数都是阻塞函数,所以程序也是阻塞模式的。

接下来,我们需要根据select网络模型,对服务端进行升级。
思路大致如下:

1
2
3
4
5
6
7
8
9
10
1.建立socket
2.绑定端口IP
3.监听端口
while(true)
{
4.使用select函数获取存在待监听事件的socket
5.如果有新的连接则与新的客户端连接
6.如果有待监听事件,则对其进行处理(接受与发送)
}
7.关闭socket

  按如上思路,即可将程序升级为select网络模型。实现非阻塞模式,可以实现多客户端信息接收。对于select相关的细节与总结,请看上文中的总结。相关代码在下文。

2.客户端升级(select+多线程)

在之前,我们的思路是:

1
2
3
4
5
6
7
8
1.建立socket
2.连接服务器
while(true)
{
3.发送数据
4.接收数据
}
5.关闭socket

  这就导致我们在与一个服务端连接后,无法被动的接收服务器端发来的消息。因为send与recv函数都是阻塞函数,程序也为阻塞模式。如果我们想要客户端能接收服务端发来的消息,那么就可以使用select模型。

接下来,我们需要根据select网络模型,对客户端进行升级。
思路大致如下:

1
2
3
4
5
6
7
8
1.建立socket
2.连接服务器
while(true)
{
3.使用select函数获取服务器端是否有待处理事件
4.如果有,就处理它(接收/发送)
}
5.关闭socket

  按如上思路,即可将程序升级为select网络模型。实现非阻塞模式,可以实现服务器端数据的被动接收。

但是,这样的程序结构也有很明显的缺点,因为scanf等数据接收函数也为阻塞函数,如果我们想要主动输入一些命令发送给服务端,就会阻塞程序运行。对此,我们可以引入多线程解决问题。
思路大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
1.建立socket
2.连接服务器
3.建立新线程 用于发送命令
while(true)
{
4.使用select函数获取服务器端是否有待处理事件
5.如果有,就处理它(接收/发送)
}
5.关闭socket

新线程:
while(1)
{
1.键入数据
2.发送数据
}

  按如上思路,即可将程序变得更加完善。可以被动接受数据且可以主动向服务端发送键入命令。对于select相关的细节与总结以及线程方面的注意事项,请看上文中的总结。相关代码在下文。

四、代码及其详细注释

1.服务端代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
#define WIN32_LEAN_AND_MEAN

#include<winSock2.h>
#include<windows.h>
#include<bits/stdc++.h>

#pragma comment(lib,"ws2_32.lib")//链接此动态链接库 windows特有

using namespace std;

//枚举类型记录命令
enum cmd
{
CMD_LOGIN,//登录
CMD_LOGINRESULT,//登录结果
CMD_LOGOUT,//登出
CMD_LOGOUTRESULT,//登出结果
CMD_NEW_USER_JOIN,//新用户登入
CMD_ERROR//错误
};
//定义数据包头
struct DateHeader
{
short cmd;//命令
short date_length;//数据的长短
};
//包1 登录 传输账号与密码
struct Login : public DateHeader
{
Login()//初始化包头
{
this->cmd = CMD_LOGIN;
this->date_length = sizeof(Login);
}
char UserName[32];//用户名
char PassWord[32];//密码
};
//包2 登录结果 传输结果
struct LoginResult : public DateHeader
{
LoginResult()//初始化包头
{
this->cmd = CMD_LOGINRESULT;
this->date_length = sizeof(LoginResult);
}
int Result;
};
//包3 登出 传输用户名
struct Logout : public DateHeader
{
Logout()//初始化包头
{
this->cmd = CMD_LOGOUT;
this->date_length = sizeof(Logout);
}
char UserName[32];//用户名
};
//包4 登出结果 传输结果
struct LogoutResult : public DateHeader
{
LogoutResult()//初始化包头
{
this->cmd = CMD_LOGOUTRESULT;
this->date_length = sizeof(LogoutResult);
}
int Result;
};
//包5 新用户登入 传输通告
struct NewUserJoin : public DateHeader
{
NewUserJoin()//初始化包头
{
this->cmd = CMD_NEW_USER_JOIN;
this->date_length = sizeof(NewUserJoin);
}
char UserName[32];//用户名
};

vector<SOCKET> _clients;//储存客户端socket

int _handle(SOCKET _temp_socket)//处理数据
{
//接收客户端发送的数据
DateHeader _head = {};
int _buf_len = recv(_temp_socket,(char*)&_head,sizeof(DateHeader),0);
if(_buf_len<=0)
{
printf("客户端已退出\n");
return -1;
}
printf("接收到包头,命令:%d,数据长度:%d\n",_head.cmd,_head.date_length);
switch(_head.cmd)
{
case CMD_LOGIN://登录 接收登录包体
{
Login _login;
recv(_temp_socket,(char*)&_login+sizeof(DateHeader),sizeof(Login)-sizeof(DateHeader),0);
/*
进行判断操作
*/
printf("%s已登录\n密码:%s\n",_login.UserName,_login.PassWord);
LoginResult _result;
_result.Result = 1;
send(_temp_socket,(char*)&_result,sizeof(LoginResult),0);//发包体
}
break;
case CMD_LOGOUT://登出 接收登出包体
{
Logout _logout;
recv(_temp_socket,(char*)&_logout+sizeof(DateHeader),sizeof(Logout)-sizeof(DateHeader),0);
/*
进行判断操作
*/
printf("%s已登出\n",_logout.UserName);
LogoutResult _result;
_result.Result = 1;
send(_temp_socket,(char*)&_result,sizeof(LogoutResult),0);//发包体
}
break;
default://错误
{
_head.cmd = CMD_ERROR;
_head.date_length = 0;
send(_temp_socket,(char*)&_head,sizeof(DateHeader),0);//发包头
}
break;
}
return 0;
}

int main()
{
//启动windows socket 2,x环境 windows特有
WORD ver = MAKEWORD(2,2);//WinSock库版本号
WSADATA dat;//网络结构体 储存WSAStartup函数调用后返回的Socket数据
if(0 != WSAStartup(ver,&dat))//正确初始化后返回0
{
return 0;
}

//建立一个socket
SOCKET _mysocket = socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);//IPV4 数据流类型 TCP类型
if(INVALID_SOCKET == _mysocket)//建立失败
{
return 0;
}

//绑定网络端口和IP地址
sockaddr_in _myaddr = {};//建立sockaddr结构体 sockaddr_in结构体方便填写 但是下面要进行类型转换
_myaddr.sin_family = AF_INET;//IPV4
_myaddr.sin_port = htons(8888);//端口 host to net unsigned short
_myaddr.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");//网络地址 INADDR_ANY监听所有网卡的端口
if(SOCKET_ERROR == bind(_mysocket,(sockaddr*)&_myaddr,sizeof(sockaddr_in)))//socket (强制转换)sockaddr结构体 结构体大小
{
cout<<"绑定不成功"<<endl;
}
else
{
//cout<<"绑定成功"<<endl;
}

//监听网络端口
if(SOCKET_ERROR == listen(_mysocket,5))//套接字 最大多少人连接
{
cout<<"监听失败"<<endl;
}
else
{
//cout<<"监听成功"<<endl;
}

while(true)
{
//select相关
/*
WINSOCK_API_LINKAGE int WSAAPI select(
int nfds,//是指待监听集合里的范围 即待监听数量最大值+1
fd_set *readfds,//待监听的可读文件集合
fd_set *writefds,//待监听的可写文件集合
fd_set *exceptfds,//待监听的异常文件集合
const PTIMEVAL timeout);//超时设置 传入NULL为阻塞模式 传入timeval结构体为非阻塞模式

typedef struct fd_set//可以存放多个socket
{
u_int fd_count;//记录放了多少个socket
SOCKET fd_array[FD_SETSIZE];//socket数组
} fd_set;

struct timeval//时间结构体
{
long tv_sec;//秒
long tv_usec;//毫秒
};
*/
fd_set _fdRead;//建立集合
fd_set _fdWrite;
fd_set _fdExcept;
FD_ZERO(&_fdRead);//清空集合
FD_ZERO(&_fdWrite);
FD_ZERO(&_fdExcept);
FD_SET(_mysocket,&_fdRead);//放入集合
FD_SET(_mysocket,&_fdWrite);
FD_SET(_mysocket,&_fdExcept);
timeval _t = {1,0};//select最大响应时间

for(int n=_clients.size()-1; n>=0; --n)//把连接的客户端 放入read集合
{
FD_SET(_clients[n],&_fdRead);
}
//select函数筛选select
int _ret = select(_mysocket+1,&_fdRead,&_fdWrite,&_fdExcept,&_t);
if(_ret<0)
{
printf("select任务结束\n");
break;
}
if(FD_ISSET(_mysocket,&_fdRead))//获取是否有新socket连接
{
FD_CLR(_mysocket,&_fdRead);//清理
//等待接收客户端连接
sockaddr_in _clientAddr = {};//新建sockadd结构体接收客户端数据
int _addr_len = sizeof(sockaddr_in);//获取sockadd结构体长度
SOCKET _temp_socket = INVALID_SOCKET;//声明客户端套接字

_temp_socket = accept(_mysocket,(sockaddr*)&_clientAddr,&_addr_len);//自身套接字 客户端结构体 结构体大小
if(INVALID_SOCKET == _temp_socket)//接收失败
{
cout<<"接收到无效客户端Socket"<<endl;
}
else
{
cout<<"新客户端加入"<<endl;
printf("IP地址为:%s \n", inet_ntoa(_clientAddr.sin_addr));
//群发所有客户端 通知新用户登录
NewUserJoin _user_join;
strcpy(_user_join.UserName,inet_ntoa(_clientAddr.sin_addr));
for(int n=0;n<_clients.size();n++)
{
send(_clients[n],(const char*)&_user_join,sizeof(NewUserJoin),0);
}
//将新的客户端加入动态数组
_clients.push_back(_temp_socket);
}
}
for(int n=0; n<_fdRead.fd_count; ++n)//在read数组里挨个处理
{
if(-1 == _handle(_fdRead.fd_array[n]))//处理请求 客户端退出的话
{
vector<SOCKET>::iterator iter = find(_clients.begin(),_clients.end(),_fdRead.fd_array[n]);
if(iter != _clients.end())//如果找到了的话 就在动态数组里删除掉
{
_clients.erase(iter);
}
}
}
printf("空闲时间处理其他业务\n");
}

//关闭客户端socket
for(int n=0; n<_clients.size(); ++n)
{
closesocket(_clients[n]);
}

//关闭socket
closesocket(_mysocket);

//清除windows socket 环境
WSACleanup();

printf("任务结束,程序已退出");

getchar();

return 0;
}

2.客户端代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
#define WIN32_LEAN_AND_MEAN

#include<winSock2.h>
#include<windows.h>
#include<bits/stdc++.h>
#include<thread>

#pragma comment(lib,"ws2_32.lib")//链接此动态链接库 windows特有

using namespace std;

//枚举类型记录命令
enum cmd
{
CMD_LOGIN,//登录
CMD_LOGINRESULT,//登录结果
CMD_LOGOUT,//登出
CMD_LOGOUTRESULT,//登出结果
CMD_NEW_USER_JOIN,//新用户登入
CMD_ERROR//错误
};
//定义数据包头
struct DateHeader
{
short cmd;//命令
short date_length;//数据的长短
};
//包1 登录 传输账号与密码
struct Login : public DateHeader
{
Login()//初始化包头
{
this->cmd = CMD_LOGIN;
this->date_length = sizeof(Login);
}
char UserName[32];//用户名
char PassWord[32];//密码
};
//包2 登录结果 传输结果
struct LoginResult : public DateHeader
{
LoginResult()//初始化包头
{
this->cmd = CMD_LOGINRESULT;
this->date_length = sizeof(LoginResult);
}
int Result;
};
//包3 登出 传输用户名
struct Logout : public DateHeader
{
Logout()//初始化包头
{
this->cmd = CMD_LOGOUT;
this->date_length = sizeof(Logout);
}
char UserName[32];//用户名
};
//包4 登出结果 传输结果
struct LogoutResult : public DateHeader
{
LogoutResult()//初始化包头
{
this->cmd = CMD_LOGOUTRESULT;
this->date_length = sizeof(LogoutResult);
}
int Result;
};
//包5 新用户登入 传输通告
struct NewUserJoin : public DateHeader
{
NewUserJoin()//初始化包头
{
this->cmd = CMD_NEW_USER_JOIN;
this->date_length = sizeof(NewUserJoin);
}
char UserName[32];//用户名
};

int _handle(SOCKET _temp_socket)//处理数据
{
//接收客户端发送的数据
DateHeader _head = {};
int _buf_len = recv(_temp_socket,(char*)&_head,sizeof(DateHeader),0);
if(_buf_len<=0)
{
printf("与服务器断开连接,任务结束\n");
return -1;
}
printf("接收到包头,命令:%d,数据长度:%d\n",_head.cmd,_head.date_length);
switch(_head.cmd)
{
case CMD_LOGINRESULT://登录结果 接收登录包体
{
LoginResult _result;
recv(_temp_socket,(char*)&_result+sizeof(DateHeader),sizeof(LoginResult)-sizeof(DateHeader),0);
printf("登录结果:%d\n",_result.Result);
}
break;
case CMD_LOGOUTRESULT://登出结果 接收登出包体
{
LogoutResult _result;
recv(_temp_socket,(char*)&_result+sizeof(DateHeader),sizeof(LogoutResult)-sizeof(DateHeader),0);
printf("登录结果:%d\n",_result.Result);
}
break;
case CMD_NEW_USER_JOIN://新用户登录通知
{
NewUserJoin _result;
recv(_temp_socket,(char*)&_result+sizeof(DateHeader),sizeof(NewUserJoin)-sizeof(DateHeader),0);
printf("用户:%s已登录\n",_result.UserName);
}
}
return 0;
}

bool _run = true;//当前程序是否还在运行中
void _cmdThread(SOCKET _mysocket)//命令线程
{
while(true)
{
//输入请求
char _msg[256] = {};
scanf("%s",_msg);
//处理请求
if(0 == strcmp(_msg,"exit"))
{
_run = false;
printf("程序退出\n");
break;
}
else if(0 == strcmp(_msg,"login"))
{
//发送
Login _login;
strcpy(_login.UserName,"河边小咸鱼");
strcpy(_login.PassWord,"123456");
send(_mysocket,(const char*)&_login,sizeof(_login),0);
//这里就不用接收了 由select用来检测接收
}
else if(0 == strcmp(_msg,"logout"))
{
//发送
Logout _logout;
strcpy(_logout.UserName,"河边小咸鱼");
send(_mysocket,(const char*)&_logout,sizeof(_logout),0);
//这里就不用接收了 由select用来检测接收
}
else
{
printf("不存在的命令\n");
}
}
}

int main()
{
//启动windows socket 2,x环境 windows特有
WORD ver = MAKEWORD(2,2);//WinSock库版本号
WSADATA dat;//网络结构体 储存WSAStartup函数调用后返回的Socket数据
if(0 != WSAStartup(ver,&dat))//正确初始化后返回0
{
return 0;
}

//建立一个socket
SOCKET _mysocket = socket(AF_INET,SOCK_STREAM,0);//IPV4 数据流类型 类型可以不用写
if(INVALID_SOCKET == _mysocket)//建立失败
{
return 0;
}

//连接服务器
sockaddr_in _sin = {};//sockaddr结构体
_sin.sin_family = AF_INET;//IPV4
_sin.sin_port = htons(8888);//想要连接的端口号
_sin.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");//想要连接的IP
if(SOCKET_ERROR == connect(_mysocket,(sockaddr*)&_sin,sizeof(sockaddr_in)))
{
cout<<"连接失败"<<endl;
closesocket(_mysocket);
}
else
{
cout<<"连接成功"<<endl;
}

//创建新线程
thread t1(_cmdThread,_mysocket);
t1.detach();//线程分离

while(_run)
{
fd_set _fdRead;//建立集合
FD_ZERO(&_fdRead);//清空集合
FD_SET(_mysocket,&_fdRead);//放入集合
timeval _t = {1,0};//select最大响应时间
//新建seclect
int _ret = select(_mysocket+1,&_fdRead,NULL,NULL,&_t);
if(_ret<0)
{
printf("seclect任务结束\n");
break;
}
if(FD_ISSET(_mysocket,&_fdRead))//获取是否有可读socket
{
FD_CLR(_mysocket,&_fdRead);//清理计数器
if(-1 == _handle(_mysocket))
{
printf("seclect任务结束\n");
break;
}
}
}

//关闭socket
closesocket(_mysocket);

//清除windows socket 环境
WSACleanup();

return 0;
}