Socket编程实现回声客户端
所谓“回声”,是指客户端向服务器发送一条数据,服务器再将数据原样返回给客户端。
下面实现 Windows 下的回声程序,Linux 下稍作修改即可。
server.cpp
#include <stdio.h>
#include <winsock2.h>
#pragma comment (lib, "ws2_32.lib") //加载 ws2_32.dll
#define BUF_SIZE 100
int main() {
WSADATA wsaData;
WSAStartup( MAKEWORD(2, 2), &wsaData);
SOCKET servSock = socket(AF_INET, SOCK_STREAM, 0); //创建套接字
sockaddr_in sockAddr;
memset(&sockAddr, 0, sizeof(sockAddr)); //每个字节都用0填充
sockAddr.sin_family = PF_INET; //使用IPv4地址
sockAddr.sin_addr.s_addr = inet_addr("127.0.0.1"); //具体的IP地址
sockAddr.sin_port = htons(1234); //端口
bind(servSock, (SOCKADDR*)&sockAddr, sizeof(SOCKADDR)); //绑定套接字
listen(servSock, 20); //进入监听状态
SOCKADDR clntAddr;
int nSize = sizeof(SOCKADDR);
SOCKET clntSock = accept(servSock, (SOCKADDR*)&clntAddr, &nSize); //接收客户端请求
char buffer[BUF_SIZE];
int strLen = recv(clntSock, buffer, BUF_SIZE, 0); //接收客户端发来的数据
send(clntSock, buffer, strLen, 0); //将数据原样返回
//关闭套接字
closesocket(clntSock);
closesocket(servSock);
WSACleanup(); //终止 DLL 的使用
return 0;
}
client.cpp
#include <stdio.h>
#include <stdlib.h>
#include <WinSock2.h>
#pragma comment(lib, "ws2_32.lib") //加载 ws2_32.dll
#define BUF_SIZE 100
int main() {
//初始化DLL
WSADATA wsaData;
WSAStartup(MAKEWORD(2, 2), &wsaData);
SOCKET sock = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP); //创建套接字
sockaddr_in sockAddr;
memset(&sockAddr, 0, sizeof(sockAddr)); //每个字节都用0填充
sockAddr.sin_family = PF_INET;
sockAddr.sin_addr.s_addr = inet_addr("127.0.0.1");
sockAddr.sin_port = htons(1234);
connect(sock, (SOCKADDR*)&sockAddr, sizeof(SOCKADDR)); //向服务器发起连接请求
char bufSend[BUF_SIZE] = {0};
printf("Input a string: ");
scanf("%s", bufSend); //获取用户输入的字符串并发送给服务器
send(sock, bufSend, strlen(bufSend), 0);
char bufRecv[BUF_SIZE] = {0};
recv(sock, bufRecv, BUF_SIZE, 0); //接收服务器传回的数据
printf("Message form server: %s\n", bufRecv);
closesocket(sock); //关闭套接字
WSACleanup(); //终止使用 DLL
system("pause");
return 0;
}
先运行服务器端,再运行客户端,执行结果为:
Input a string: c-language java cpp
Message form server: c-language
scanf() 读取到空格时认为一个字符串输入结束,所以只能读取到“c-language”;如果不希望把空格作为字符串的结束符,可以使用 gets() 函数。
让服务器端持续监听客户端的请求
前面的程序,不管服务器端还是客户端,都有一个问题,就是处理完一个请求立即退出了,没有太大的实际意义。能不能像Web服务器那样一直接受客户端的请求呢?能,使用 while 循环即可。
修改前面的回声程序,使服务器端可以不断响应客户端的请求。
server.cpp
#include <stdio.h>
#include <winsock2.h>
#pragma comment (lib, "ws2_32.lib") //加载 ws2_32.dll
#define BUF_SIZE 100
int main() {
WSADATA wsaData;
WSAStartup( MAKEWORD(2, 2), &wsaData);
SOCKET servSock = socket(AF_INET, SOCK_STREAM, 0); //创建套接字
sockaddr_in sockAddr;
memset(&sockAddr, 0, sizeof(sockAddr)); //每个字节都用0填充
sockAddr.sin_family = PF_INET; //使用IPv4地址
sockAddr.sin_addr.s_addr = inet_addr("127.0.0.1"); //具体的IP地址
sockAddr.sin_port = htons(1234); //端口
bind(servSock, (SOCKADDR*)&sockAddr, sizeof(SOCKADDR)); //绑定套接字
listen(servSock, 20); //进入监听状态
SOCKADDR clntAddr;
int nSize = sizeof(SOCKADDR);
char buffer[BUF_SIZE] = {0};
while(1) {
SOCKET clntSock = accept(servSock, (SOCKADDR*)&clntAddr, &nSize); //接收客户端请求
int strLen = recv(clntSock, buffer, BUF_SIZE, 0); //接收客户端发来的数据
send(clntSock, buffer, strLen, 0); //将数据原样返回
closesocket(clntSock); //关闭套接字
memset(buffer, 0, BUF_SIZE); //重置缓冲区
}
closesocket(servSock); //关闭套接字
WSACleanup(); //终止 DLL 的使用
return 0;
}
client.cpp
#include <stdio.h>
#include <WinSock2.h>
#include <windows.h>
#pragma comment(lib, "ws2_32.lib") //加载 ws2_32.dll
#define BUF_SIZE 100
int main() {
//初始化DLL
WSADATA wsaData;
WSAStartup(MAKEWORD(2, 2), &wsaData);
sockaddr_in sockAddr;
memset(&sockAddr, 0, sizeof(sockAddr)); //每个字节都用0填充
sockAddr.sin_family = PF_INET;
sockAddr.sin_addr.s_addr = inet_addr("127.0.0.1");
sockAddr.sin_port = htons(1234);
char bufSend[BUF_SIZE] = {0};
char bufRecv[BUF_SIZE] = {0};
while(1) {
SOCKET sock = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP); //创建套接字
connect(sock, (SOCKADDR*)&sockAddr, sizeof(SOCKADDR)); //向服务器发起请求
printf("Input a string: ");
gets(bufSend); //获取用户输入的字符串并发送给服务器
send(sock, bufSend, strlen(bufSend), 0);
recv(sock, bufRecv, BUF_SIZE, 0); //接收服务器传回的数据
printf("Message form server: %s\n", bufRecv);
memset(bufSend, 0, BUF_SIZE); //重置缓冲区
memset(bufRecv, 0, BUF_SIZE); //重置缓冲区
closesocket(sock); //关闭套接字
}
WSACleanup(); //终止使用 DLL
return 0;
}
while(1) 让代码进入死循环,除非用户关闭程序,否则服务端会一直监听客户端的请求。客户端也是一样,会不断向服务器发起连接。
需要注意的是,server.cpp 中调用 closesocket() 不仅会关闭服务器端的 socket,还会通知客户端连接已断开,客户端也会清理 socket 相关资源,所以 client.cpp 中需要将 socket() 放在 while 循环内部,因为每次请求完毕都会清理 socket,下次发起请求时需要重新创建。后续会进行详细讲解。
socket缓冲区以及阻塞模式
socket缓冲区
每个 socket 被创建后,都会分配两个缓冲区,输入缓冲区和输出缓冲区。
write()/send() 并不立即向网络中传输数据,而是先将数据写入缓冲区中,再由TCP协议将数据从缓冲区发送到目标机器。一旦将数据写入到缓冲区,函数就可以成功返回,不管它们有没有到达目标机器,也不管它们何时被发送到网络,这些都是TCP协议负责的事情。
TCP协议独立于 write()/send() 函数,数据有可能刚被写入缓冲区就发送到网络,也可能在缓冲区中不断积压,多次写入的数据被一次性发送到网络,这取决于当时的网络情况、当前线程是否空闲等诸多因素,不由程序员控制。
read()/recv() 函数也是如此,也从输入缓冲区中读取数据,而不是直接从网络中读取。
这些I/O缓冲区特性可整理如下:
- I/O缓冲区在每个TCP套接字中单独存在;
- I/O缓冲区在创建套接字时自动生成;
- 即使关闭套接字也会继续传送输出缓冲区中遗留的数据;
- 关闭套接字将丢失输入缓冲区中的数据。
输入输出缓冲区的默认大小一般都是8K,可以通过 getsockopt() 函数获取:
运行结果:Buffer length: 8192
这里仅给出示例,后面会详细讲解。
阻塞模式
对于TCP套接字(默认情况下),当使用 write()/send() 发送数据时:
- 首先会检查输出缓冲区,如果输出缓冲区的可用空间长度小于要发送的数据,那么 write()/send() 会被阻塞(暂停执行),直到输出缓冲区中的数据被发送到目标机器,腾出足够的空间,才唤醒 write()/send() 函数继续写入数据。
- 如果TCP协议正在向网络发送数据,那么输出缓冲区会被锁定,不允许写入,write()/send() 也会被阻塞,直到数据发送完毕缓冲区解锁,write()/send() 才会被唤醒。
- 如果要写入的数据大于输出缓冲区的最大长度,那么将分批写入。
- 直到所有数据被写入缓冲区 write()/send() 才能返回。
当使用 read()/recv() 读取数据时:
- 首先会检查输入缓冲区,如果输入缓冲区中有数据,那么就读取,否则函数会被阻塞,直到网络上有数据到来。
- 如果要读取的数据长度小于缓冲区中的数据长度,那么就不能一次性将缓冲区中的所有数据读出,剩余数据将不断积压,直到有 read()/recv() 函数再次读取。
- 直到读取到数据后 read()/recv() 函数才会返回,否则就一直被阻塞。
这就是TCP套接字的阻塞模式。所谓阻塞,就是上一步动作没有完成,下一步动作将暂停,直到上一步动作完成后才能继续,以保持同步性。
TCP套接字默认情况下是阻塞模式,也是最常用的。当然也可以更改为非阻塞模式
TCP协议的粘包问题(数据的无边界性)
从socket缓冲区和数据的传递过程,可以看到数据的接收和发送是无关的,read()/recv() 函数不管数据发送了多少次,都会尽可能多的接收数据。也就是说,read()/recv() 和 write()/send() 的执行次数可能不同。
例如,write()/send() 重复执行三次,每次都发送字符串"abc",那么目标机器上的 read()/recv() 可能分三次接收,每次都接收"abc";也可能分两次接收,第一次接收"abcab",第二次接收"cabc";也可能一次就接收到字符串"abcabcabc"。
假设我们希望客户端每次发送一位学生的学号,让服务器端返回该学生的姓名、住址、成绩等信息,这时候可能就会出现问题,服务器端不能区分学生的学号。例如第一次发送 1,第二次发送 3,服务器可能当成 13 来处理,返回的信息显然是错误的。
这就是数据的“粘包”问题,客户端发送的多个数据包被当做一个数据包接收。也称数据的无边界性,read()/recv() 函数不知道数据包的开始或结束标志(实际上也没有任何开始或结束标志),只把它们当做连续的数据流来处理。
下面的代码演示了粘包问题,客户端连续三次向服务器端发送数据,服务器端却一次性接收到所有数据。
server.cpp
#include <stdio.h>
#include <windows.h>
#pragma comment (lib, "ws2_32.lib") //加载 ws2_32.dll
#define BUF_SIZE 100
int main() {
WSADATA wsaData;
WSAStartup( MAKEWORD(2, 2), &wsaData);
SOCKET servSock = socket(AF_INET, SOCK_STREAM, 0); //创建套接字
sockaddr_in sockAddr;
memset(&sockAddr, 0, sizeof(sockAddr)); //每个字节都用0填充
sockAddr.sin_family = PF_INET; //使用IPv4地址
sockAddr.sin_addr.s_addr = inet_addr("127.0.0.1"); //具体的IP地址
sockAddr.sin_port = htons(1234); //端口
bind(servSock, (SOCKADDR*)&sockAddr, sizeof(SOCKADDR)); //绑定套接字
listen(servSock, 20); //进入监听状态
SOCKADDR clntAddr;
int nSize = sizeof(SOCKADDR);
char buffer[BUF_SIZE] = {0};
SOCKET clntSock = accept(servSock, (SOCKADDR*)&clntAddr, &nSize); //接收客户端请求
Sleep(10000); //注意这里,让程序暂停10秒
int recvLen = recv(clntSock, buffer, BUF_SIZE, 0); //接收客户端发来的数据,并原样返回
send(clntSock, buffer, recvLen, 0);
//关闭套接字并终止DLL的使用
closesocket(clntSock);
closesocket(servSock);
WSACleanup();
return 0;
}
client.cpp
#include <stdio.h>
#include <stdlib.h>
#include <WinSock2.h>
#include <windows.h>
#pragma comment(lib, "ws2_32.lib") //加载 ws2_32.dll
#define BUF_SIZE 100
int main() {
//初始化DLL
WSADATA wsaData;
WSAStartup(MAKEWORD(2, 2), &wsaData);
sockaddr_in sockAddr;
memset(&sockAddr, 0, sizeof(sockAddr)); //每个字节都用0填充
sockAddr.sin_family = PF_INET;
sockAddr.sin_addr.s_addr = inet_addr("127.0.0.1");
sockAddr.sin_port = htons(1234);
SOCKET sock = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP); //创建套接字
connect(sock, (SOCKADDR*)&sockAddr, sizeof(SOCKADDR)); //向服务器发起请求
char bufSend[BUF_SIZE] = {0};
printf("Input a string: ");
gets(bufSend); //获取用户输入的字符串并发送给服务器
for(int i=0; i<3; i++){
send(sock, bufSend, strlen(bufSend), 0);
}
char bufRecv[BUF_SIZE] = {0};
recv(sock, bufRecv, BUF_SIZE, 0); //接收服务器传回的数据
printf("Message form server: %s\n", bufRecv);
closesocket(sock); //关闭套接字
WSACleanup(); //终止使用 DLL
system("pause");
return 0;
}
先运行 server,再运行 client,并在10秒内输入字符串"abc",再等数秒,服务器就会返回数据。运行结果如下:
Input a string: abc
Message form server: abcabcabc
本程序的关键是 server.cpp 的 Sleep(10000);,它让程序暂停执行10秒。在这段时间内,client 连续三次发送字符串"abc",由于 server 被阻塞,数据只能堆积在缓冲区中,10秒后,server 开始运行,从缓冲区中一次性读出所有积压的数据,并返回给客户端。
另外还需要说明的是 client 执行到 recv() 函数,由于输入缓冲区中没有数据,所以会被阻塞,直到10秒后 server 传回数据才开始执行。用户看到的直观效果就是,client 暂停一段时间才输出 server 返回的结果。
client 的 send() 发送了三个数据包,而 server 的 recv() 却只接收到一个数据包,这很好的说明了数据的粘包问题。