这篇文章是紧接着上一篇《UDP套接字编程》文章的,里面详细介绍了套接字编程的一些基本准备知识,这里就不过多介绍了,直接介绍如何使用TCP套接字进行编程,使用的函数接口还是一样的,只不过TCP和UDP存在区别,具体的区别这篇文章会介绍,贴上一篇文章的链接:
UDP套接字编程,建议先读这一篇
TCP协议的全称是Transmission Control Protocol,即传输控制协议,它和UDP协议一样也是传输层协议,它的特点是有连接的,可靠传输,以及面向字节流传输,在socket函数的形参type中,SOCK_STREAM代表的就是TCP套接字。
TCP服务器需要通过listen函数将服务器设置成为监听状态,因为TCP协议是需要连接的,服务器设置成为监听状态是在等待客户端连接它。
// listen
int listen(int sockfd, int backlog);
在TCP服务器被设置成监听状态等待其它人来连接的时候,TCP服务器需要使用accept函数来获取连接。
// accept
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
我们利用TCP套接字的编程接口写一个通用版本的TCP服务器,即只提供监听和获取网络连接,不提供其它任何服务,让浏览器暂时充当客户端,访问我们的服务器,测试是否能够连接成功:
UDP协议不是面向连接的,所以UDP服务器只需要创建套接字以后bind网络信息即可。TCP服务器在创建套接字和bind网络信息以后,还需要将TCP服务器设置成listen监听状态,只有设置监听状态才能等待客户端来连接。
当TCP服务器初始化完毕以后,就可以运行服务器了,服务器运行起来需要用accept函数来获取连接,如果此时没有客户端来连接服务器,它会继续循环重新获取连接,直到有人来连接为止。
#include
#include
#include
#include
#include
#include
#include
using namespace std;
class TcpServer
{
public:
TcpServer(int port, const string &ip = "")
: _port(port), _ip(ip), _listenSock(-1)
{
}
~TcpServer()
{}
public:
void init()
{
// 1.创建套接字
_listenSock = socket(AF_INET, SOCK_STREAM, 0);
if(_listenSock < 0)
{
cerr << "socket error" << endl;
exit(1);
}
cout << "socket success" << endl;
// 2.bind
// 2.1填充网络信息
sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str());
// 2.2bind网络信息
if(bind(_listenSock, (const sockaddr*)&local, sizeof(local)) < 0)
{
cerr << "bind error" << endl;
exit(2);
}
cout << "bind success" << endl;
// 3.listen
if(listen(_listenSock, 5) < 0)
{
cerr << "listen error" << endl;
exit(3);
}
cout << "listen success" << endl;
}
void start()
{
while(true)
{
// accept
sockaddr_in peer;
memset(&peer, 0, sizeof(peer));
socklen_t len = sizeof(peer);
int serviceSock = accept(_listenSock, (sockaddr*)&peer, &len);
// 如果没有获取连接成功,则继续重新获取
if(serviceSock < 0)
{
continue;
}
cout << "accept success" << endl;
}
}
private:
uint16_t _port; // 端口号
string _ip; // IP地址
int _listenSock; // 监听套接字
};
int main()
{
TcpServer svr(8080);
svr.init();
svr.start();
return 0;
}
运行程序查看结果,我们先让服务器跑起来,然后打开浏览器,输入我们云服务器的IP地址:端口号,就可以让浏览器连接我们的服务器,由于我们的服务器没有提供任何服务,所以浏览器界面看不到任何东西,只会一直在加载。但我们在命令行后台可以看到,浏览器作为客户端已经成功连接我们的TCP服务器了,并且浏览器一般是多线程执行的,所以我们会看到连接了两次。
我们利用TCP服务器实现一个大小写转换的服务,来演示一下服务端和客户端如何进行TCP套接字的网络通信。客户端负责发送信息给服务端,服务端接收到信息以后,对信息里的小写字母转换成为大写字母,然后再把转换好的信息重新发送给客户端,客户端接收到转换好的信息之后再显示出来。
在上一篇UDP套接字编程中我们也实现了这么一个大小写转换服务,但是客户端和服务端进行数据发送和接收使用的是recvfrom函数和sendto函数。recvfrom函数和sendto函数是UDP套接字专用的接收数据和发送数据函数,它们发送的是用户数据报,是一个固定大小的报文。TCP是面向字节流的,所以不能用recvfrom函数和sendto函数。
TCP套接字要使用的是read函数和write函数进行信息发送和信息读取。
我们上面已经实现了TCP服务器的通用版本,只是还没有添加服务,所以服务端我们只需要在通用版本的基础上新增大小写转换的服务即可。
#include
#include
#include
#include
#include
#include
#include
#include
using namespace std;
class TcpServer
{
public:
TcpServer(int port, const string &ip = "")
: _port(port), _ip(ip), _listenSock(-1)
{
}
~TcpServer()
{}
public:
void init()
{
// 1.创建套接字
_listenSock = socket(AF_INET, SOCK_STREAM, 0);
if(_listenSock < 0)
{
cerr << "socket error" << endl;
exit(1);
}
cout << "socket success" << endl;
// 2.bind
// 2.1填充网络信息
sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str());
// 2.2bind网络信息
if(bind(_listenSock, (const sockaddr*)&local, sizeof(local)) < 0)
{
cerr << "bind error" << endl;
exit(2);
}
cout << "bind success" << endl;
// 3.listen
if(listen(_listenSock, 5) < 0)
{
cerr << "listen error" << endl;
exit(3);
}
cout << "listen success" << endl;
}
void start()
{
while(true)
{
// accept
sockaddr_in peer;
memset(&peer, 0, sizeof(peer));
socklen_t len = sizeof(peer);
int serviceSock = accept(_listenSock, (sockaddr*)&peer, &len);
// 如果没有获取连接成功,则继续重新获取
if(serviceSock < 0)
{
continue;
}
cout << "accept success" << endl;
// 正式提供服务
transformService(serviceSock);
}
}
private:
void transformService(int sock)
{
while(true)
{
char inbuffer[1024];
char outbuffer[1024];
ssize_t readRes = read(sock, inbuffer, sizeof(inbuffer) - 1);
// 读取成功
if(readRes > 0)
{
inbuffer[readRes] = '\0';
// 如果客户端发送过来的是'quit'则退出服务
if(strcasecmp(inbuffer, "quit") == 0)
{
cout << "client quit" << endl;
break;
}
cout << "client #" << inbuffer << endl;
// 小写转换成大写
for(int i = 0; i < strlen(inbuffer); i++)
{
if(isalpha(inbuffer[i]) && islower(inbuffer[i]))
{
outbuffer[i] = toupper(inbuffer[i]);
}
else
{
outbuffer[i] = inbuffer[i];
}
}
// 将转换后的信息发送回给客户端
ssize_t wrietRes = write(sock, outbuffer, strlen(outbuffer));
if(wrietRes < 0)
{
cerr << "write error" << endl;
break;
}
}
// 客户端关闭了
else if(readRes == 0)
{
cout << "client quit" << endl;
break;
}
// 读取失败
else
{
cerr << "read error" << endl;
break;
}
}
// 这里一定要关闭服务套接字
// 否则多进程访问,创建大量套接字不释放的话
// 可能会导致资源无法申请,服务器无法正常提供服务
close(sock);
}
private:
uint16_t _port; // 端口号
string _ip; // IP地址
int _listenSock; // 监听套接字
};
int main()
{
TcpServer svr(8080);
svr.init();
svr.start();
return 0;
}
TCP的客户端与UDP的客户端也不一样,UDP的客户端只需要创建套接字之后就可以通信了,但是TCP是面向连接的,所以TCP客户端在创建套接字之后,还需要用connect函数来连接服务器,只有连接成功了才能实现网络通信。
connect:
// connect
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
客户端代码:
#include
#include
#include
#include
#include
#include
#include
#include
using namespace std;
volatile bool isQuit = false;// 标志客户端是否退出
static void Usage(std::string proc)
{
std::cerr << "Usage:\n\t" << proc << " serverIp serverPort" << std::endl;
std::cerr << "Example:\n\t" << proc << " 127.0.0.1 8081\n"
<< std::endl;
}
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage(argv[0]);
exit(1);
}
std::string serverIp = argv[1];
uint16_t serverPort = atoi(argv[2]);
// 1.创建套接字
int sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock < 0)
{
cerr << "socket error" << endl;
exit(2);
}
// 2.connect
// 2.1填充网络信息
sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(serverPort);
server.sin_addr.s_addr = inet_addr(serverIp.c_str());
// 2.2connect
if(connect(sock, (const sockaddr*)&server, sizeof(server)) != 0)
{
cerr << "connet error" << endl;
exit(2);
}
while(!isQuit)
{
cout << "client #";
string message;
getline(cin, message);
if(strcasecmp(message.c_str(), "quit") == 0)
{
isQuit = true;
}
ssize_t writeRes = write(sock, message.c_str(), message.size());
if(writeRes <= 0)
{
cerr << "write error" << endl;
break;
}
// 写入成功
else
{
char buffer[1024];
ssize_t readRes = read(sock, buffer, sizeof(buffer));
if(readRes > 0)
{
buffer[readRes] = '\0';
cout << "Server #" << buffer << endl;
}
}
}
return 0;
}
上面我们实现的大小写转换服务有局限,它只能让单进程正常访问执行,如果有多个进程同时连接服务器请求服务,就会出现问题。
我们可以演示一下:首先先运行服务端,然后运行客户端1,再运行客户端2,客户端1发送信息是可以正常完成大小写转换服务的,但客户端2发送信息却没有消息发回来,看上去是阻塞住了。
一旦客户端1退出了以后,客户端2历史输入的消息才能接收回来,客户端2也才能够正常地获取服务端的大小写转换服务:
这个问题其实是服务端目前仅仅是单进程版本,当多进程连接访问它时,后面连接上来的进程都会阻塞住。我们可以分析一下服务端的代码,当服务端运行start函数让服务器跑起来之后,服务器首先是accept等待连接,当客户端1连接上来以后,主执行流会继续向下执行,执行到transformService函数之后,主执行流进入了该函数的循环,此时客户端2再连接上来时,由于主执行流正在transformService函数里为客户端1提供服务,所以客户端2只能阻塞着,等客户端1的服务结束之后,主执行流才从transformService函数的循环中跳出来,继续执行accept获取客户端2的连接,此时客户端2才能正常获取服务。
所以我们要把服务器改成多进程版本,让多个进程同时访问服务器时不会出现问题。
我们在服务器启动的start函数中创建多进程,主执行流负责accept获取连接,获取连接成功之后主执行流再fork子进程,让子进程来负责处理大小写转换的任务。
但是有个问题就是,子进程在处理完任务以后就要退出了,退出之后需要父进程来回收子进程,否则子进程会变成僵尸进程,会导致内存泄漏的问题。但是如果父进程用waitpid函数回收子进程的话,有两种选择:阻塞式等待和非阻塞式等待。
如果父进程是阻塞式等待子进程,那么我们多进程的意义就不存在了,父进程会阻塞在那里等待回收子进程,无法继续执行accept获取连接。所以父进程必须非阻塞式等待,非阻塞式等待的话由于我们会创建多个子进程,所以我们必须将多个子进程的id保存起来,然后每次循环检测哪些子进程可以被回收,这样实现会比较麻烦。
所以我们最后采用的方法是用signal函数将SIGCHLD信号的处理方式设置成忽略,这样的话父进程就可以不用回收子进程了,子进程也不会变成僵尸进程。
void start()
{
// 将SIGCHLD信号设置成忽略处理,父进程可以不用回收子进程
signal(SIGCHLD, SIG_IGN);
while (true)
{
// accept
sockaddr_in peer;
memset(&peer, 0, sizeof(peer));
socklen_t len = sizeof(peer);
int serviceSock = accept(_listenSock, (sockaddr *)&peer, &len);
// 如果没有获取连接成功,则继续重新获取
if (serviceSock < 0)
{
continue;
}
cout << "accept success" << endl;
pid_t id = fork();
if(id < 0)
{
cerr << "fork error" << endl;
break;
}
// 子进程
if (id == 0)
{
// 最好把子进程继承过来的监听套接字也关闭了
// 因为子进程用不上
close(_listenSock);
// 正式提供服务
transformService(serviceSock);
exit(0);
}
// 父进程
// 父进程必须关闭服务套接字,因为父进程用不到服务套接字
// 子进程继承了父进程的服务套接字,子进程使用就可以了
// 父进程如果不关闭的话有可能会导致打开套接字资源过多而无法继续申请资源
close(serviceSock);
}
}
除了上述采用signal函数的方法解决子进程的僵尸问题,我们还可以采用另一种比较巧妙的方式:
在主执行流创建子进程之后,在子进程里再创建一个子进程,这样主执行流就是爷爷进程,主执行流创建的子进程就是爸爸进程,爸爸进程创建的子进程就是孙子进程。我们让孙子进程处理大小写转换的任务,爸爸进程一创建好就退出,然后爷爷进程回收爸爸进程,由此孙子进程变成了孤儿进程,由操作系统托管,操作系统负责其回收工作,不需要我们来处理。
void start()
{
// 将SIGCHLD信号设置成忽略处理,父进程可以不用回收子进程
signal(SIGCHLD, SIG_IGN);
while (true)
{
// accept
sockaddr_in peer;
memset(&peer, 0, sizeof(peer));
socklen_t len = sizeof(peer);
int serviceSock = accept(_listenSock, (sockaddr *)&peer, &len);
// 如果没有获取连接成功,则继续重新获取
if (serviceSock < 0)
{
continue;
}
cout << "accept success" << endl;
// 爷爷进程
pid_t id = fork();
if (id < 0)
{
cerr << "fork error" << endl;
break;
}
// 爸爸进程
if (id == 0)
{
// 孙子进程
if (fork() == 0)
{
// 最好把子进程继承过来的监听套接字也关闭了
// 因为子进程用不上
close(_listenSock);
// 正式提供服务
transformService(serviceSock);
exit(0);
}
close(_listenSock);
close(serviceSock);
exit(0);
}
// 父进程
// 父进程必须关闭服务套接字,因为父进程用不到服务套接字
// 子进程继承了父进程的服务套接字,子进程使用就可以了
// 父进程如果不关闭的话有可能会导致打开套接字资源过多而无法继续申请资源
close(serviceSock);
// 爷爷进程回收爸爸进程
// 孙子进程变成了孤儿进程,交给操作系统托管
waitpid(id, nullptr, 0);
}
}
虽然多进程可以解决我们上面的问题,但是操作系统频繁创建进程的开销是非常大的,所以我们可以将进程换成线程,因为线程是轻量级进程,创建线程的成本比较小。我们让主执行流继续执行accept获取连接,让创建的线程来执行大小写转换任务。
struct ThreadData
{
int sock;
TcpServer *tcpServer;
ThreadData(int serviceSock, TcpServer *t)
: sock(serviceSock), tcpServer(t)
{
}
};
static void *callBack(void *args)
{
// 线程分离
pthread_detach(pthread_self());
ThreadData *threadData = (ThreadData *)args;
threadData->tcpServer->transformService(threadData->sock);
}
void start()
{
// 将SIGCHLD信号设置成忽略处理,父进程可以不用回收子进程
signal(SIGCHLD, SIG_IGN);
while (true)
{
// accept
sockaddr_in peer;
memset(&peer, 0, sizeof(peer));
socklen_t len = sizeof(peer);
int serviceSock = accept(_listenSock, (sockaddr *)&peer, &len);
// 如果没有获取连接成功,则继续重新获取
if (serviceSock < 0)
{
continue;
}
cout << "accept success" << endl;
pthread_t thread;
ThreadData *threadData = new ThreadData(serviceSock, this);
pthread_create(&thread, nullptr, callBack, (void *)threadData);
}
}