目录
前言:
前文我们介绍了UDP的基本使用,本文我们介绍TCP的基本使用,不过TCP的使用我们这里先做一个预热,即只是使用TCP的API简单实现一个回显服务器就可以了。在本文实现回显服务器的时候,分为了三个版本,我们从第一个不靠谱版本逐渐优化~
那么话不多说,我们直接进入回显服务器的实现。
回显服务器
对于回显服务器来说,基本功能就是客户端发送字符串,然后服务器收到这个字符串之后再给客户端发送回去,这是它的一个基本功能,那么我们从TCPserver_v1开始实现。
TCPserver_v0
对于版本一,它的弊端是只能通信一个客户端,多了会阻塞,先埋下伏笔,我们后面慢慢解释。
首先,不管是TCP还是UDP,都是基于网络套接字进行通信的,那么也就是说,TCP也需要创建套接字,bind,到了bind之后UDP就可以通信了,但是TCP因为是面向连接的,所以TCP需要额外的进入listen状态,并且开始accept,客户端要进行通信也需要connect。
具体什么是listen,什么是accept,什么是connect,我们这里展开。
对于listen来说,让服务器进入listen状态,就相当于告诉别人,我准备好了,可以开始准备连接了,使用到的API是listen:
它的参数是sockfd和backlog,对于sockfd是我们创建的套接字,这个套接字我们最好命名为listensockfd,因为这个套接字只是用来进行连接的,后面实际上服务器和客户端进行通信是通过另一个套接字进行的。对于backlog我们后面单独写一篇文章介绍。
它的返回值和socket bind是一样的,如果listen失败,返回的就是-1,成功返回的就是0,我们也可以通过netstat -ntpl,其中的l代表的就是查询处于listen状态的网络。
到了这一步,socket bind listen,我们的服务器的初始化完成了,对应的代码应该是:
void Init()
{
// socket
_listensocket = socket(AF_INET, SOCK_STREAM, 0);
if (_listensocket < 0)
{
perror("socket");
exit(SOCKET_ERR);
}
// bind
sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(_port);
server.sin_addr.s_addr = INADDR_ANY;
socklen_t len = sizeof(server);
if (::bind(_listensocket, (struct sockaddr *)&server, len) < 0)
{
perror("bind");
exit(BIND_ERR);
}
// listen
int n = ::listen(_listensocket, gbacklog);
if (n < 0)
{
perror("listen");
exit(LISTEN_ERR);
}
}
如果我们想像存在一个餐馆,那么listen就代表餐馆开业了,要开始服务了,要开始拉客了。
好了 现在对于tcp通信服务最难的一个点就来了,accept的理解。
对于accept的参数,第一个参数是sockfd,后面的两个参数是客户端的相关信息,当我们看到返回值的时候,我们发现,如果accept成功的时候,返回一个文件描述符,错误的时候返回-1并且错误码被设置。
这里的难点是:如何理解socket返回的sockfd和accept返回的sockfd?
对于socket返回的sockfd,我们把它是作为listen的参数使用的,意在告诉别人我这个服务器已经就绪了,可以开始连接了,那么socket返回的sockfd就像是餐馆本体,进行外部的连接,对于accept返回的sockfd,就像是餐馆和客人进行了连接之后,该通过哪个服务员进行和客人的交互,所以实际上和客人进行通信的是accept返回的套接字。后面为了区分,我们将socket返回的套接字叫做监听套接字,accept返回的套接字就叫做连接套接字。
有了这个套接字,我们才能和客户端进行通信,那么服务器下一步就是通过sockfd处理和客人的请求,那么因为是回显功能,在UDP的时候,使用的是sendto和recvfrom,在TCP这里就比较特殊了,因为TCP是面向连接的,那么双方经过了三次握手,获取到了对应的sockfd,不要忘了sockfd本质上是文件描述符,所以有了文件描述符,双方是可以直接使用read write进行文件读写的。
对于write的本质,是OS将用户提供的数据,拷贝到内核中的发送缓冲区,然后进行分段,加TCP报头,通过网卡发送出去,对于read的本质也是一样的,数据先到接收缓冲区,TCP负责组包一类的工作。
所以我们的service函数就可以这么写了:
void Service(int sockfd)
{
while (true)
{
// read write
char buffer[1024];
ssize_t n = ::read(sockfd, buffer, sizeof(buffer) - 1);
if (n > 0)
{
buffer[n] = 0;
std::string echo = "[Server say]# ";
echo += buffer;
ssize_t wn = ::write(sockfd, echo.c_str(), echo.size());
}
else if (n == 0)
{
std::cout << "client quit" << std::endl;
break;
}
else
{
if (errno == EINTR) continue;
std::cout << "read error" << std::endl;
break;
}
}
::close(sockfd);
}
直接调用,就和我们之前C语言学习的文件操作一样,并且这里有一个非常重要的点是service结束之后我们一定要close(sockfd),不然是会导致文件描述符泄露的,这个操作是非常危险的!!!
那么对于read和write的返回值,具体的我们到后面再谈。
所以这个时候,我们的TCP服务类也差不多了,对于循环服务的代码,第一个版本是这样的:
#include <iostream>
#include <unistd.h>
#include <sys/wait.h>
#include <cstring>
#include <functional>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
enum
{
SOCKET_ERR = 1,
BIND_ERR,
ACCEPT_ERR,
LISTEN_ERR
};
const static int gbacklog = 8;
class TcpServer
{
public:
TcpServer(int port)
: _port(port)
{
}
void Init()
{
// socket
_listensocket = socket(AF_INET, SOCK_STREAM, 0);
if (_listensocket < 0)
{
perror("socket");
exit(SOCKET_ERR);
}
// bind
sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(_port);
server.sin_addr.s_addr = INADDR_ANY;
socklen_t len = sizeof(server);
if (::bind(_listensocket, (struct sockaddr *)&server, len) < 0)
{
perror("bind");
exit(BIND_ERR);
}
// listen
int n = ::listen(_listensocket, gbacklog);
if (n < 0)
{
perror("listen");
exit(LISTEN_ERR);
}
}
void Loop()
{
// signal(SIGCHLD, SIG_IGN);
bool _isrunning = true;
while (_isrunning)
{
// accept
sockaddr_in client;
socklen_t len = sizeof(client);
int sockfd = ::accept(_listensocket, (struct sockaddr *)&client, &len);
if (sockfd < 0)
{
std::cout << "accept error" << std::endl;
continue;
}
// service
// version--1
Service(sockfd);
}
}
void Service(int sockfd)
{
while (true)
{
// read write
char buffer[1024];
ssize_t n = ::read(sockfd, buffer, sizeof(buffer) - 1);
if (n > 0)
{
buffer[n] = 0;
std::string echo = "[Server say]# ";
echo += buffer;
ssize_t wn = ::write(sockfd, echo.c_str(), echo.size());
}
else if (n == 0)
{
std::cout << "client quit" << std::endl;
break;
}
else
{
if (errno == EINTR) continue;
std::cout << "read error" << std::endl;
break;
}
}
::close(sockfd); //
}
~TcpServer()
{
}
private:
uint16_t _port;
int _listensocket;
};
对于服务器的Main方法和之前UDP的时候是一样的,利用命令行参数列表给到对应的IP地址和端口号即可:
#include "TcpServer.hpp"
#include <functional>
#include <memory>
int main(int argc, char *argv[])
{
if(argc != 2)
{
perror("parameter error");
exit(-1);
}
uint16_t port = std::stoi(argv[1]);
std::unique_ptr<TcpServer> tsver = std::make_unique<TcpServer>(port);
tsver->Init();
tsver->Loop();
return 0;
}
这是服务器的,对于客户端倒是有点不同了。
客户端代码编写:
客户端大部分代码和UDP那里很像,同样要定义服务器的sockaddr_in,并且填充对应的信息,这里也有一个亘古不变的话题,客户端是否需要显示的bind自己的sockfd和sockaddr_in?
当然是不需要的,因为这个操作OS已经隐式的帮我们做了,我们不用自己做了。
所以当我们创建好了server的sockaddr_in 并且相关的字段也填充好了,然后就是TCP专有的操作了,客户端需要使用connect进行连接,使用connect发起三次握手,当然具体操作我们后面介绍,我们现在只需要知道客户端需要进行connect和服务器进行连接就行,
它的参数就是客户端的sockfd,后面的两个参数是和谁连接的sockaddr_in。
连接好了之后就可以开始通信了:
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <functional>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int main(int argc, char *argv[])
{
if(argc != 3)
{
perror("parameter");
exit(-1);
}
std::string serverip = argv[1];
uint16_t serverport = std::stoi(argv[2]);
int sockfd = ::socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(serverport);
::inet_pton(AF_INET, serverip.c_str(), &server.sin_addr);
if(connect(sockfd, (struct sockaddr*)&server, sizeof(server)) < 0)
{
perror("connect");
exit(1);
}
while(1)
{
std::cout << "[client say]# ";
std::string message;
std::getline(std::cin, message);
write(sockfd, message.c_str(), message.size());
char buffer[1024];
ssize_t n = read(sockfd, buffer, sizeof(buffer) - 1);
if(n > 0)
std::cout << buffer << std::endl;
else
break;
}
return 0;
}
那么以上就是三个文件的简单编写,不过我们是能发现,如果我们启动两个客户端,服务器就会阻塞,所以第一个版本的最大弊端,是不能够并发执行多个请求,它是串行的,那肯定不可以,我们需要进行改动。
TCPserver_v1--多进程版本
实际上我们要改动的只有loop函数,因为我们明显发现函数loop,当服务器accept成功的时候,就开始执行service的时候,如果该客户端不退出,服务器就要一直在这个循环里面为它服务,所以我们不能让服务器主体来服务客户端,应该让别人服务客户端,具体是哪个客户端呢?
我们可以让子进程来服务对吧?
// version--2 多进程版本
pid_t id = fork();
if (id == 0)
{
// child
::close(_listensocket);
if (fork() > 0) exit(0);
Service(sockfd); // 孙子进程执行
exit(0);
}
// ::close(sockfd);
else
{
// father
::close(sockfd);
int n = waitpid(id, nullptr, 0);
if (n > 0) // 忽略最好
std::cout << "wait success!" << std::endl;
}
那么问题来了,如果我们让子进程来服务,那么父进程是不是需要等待子进程退出?并且接收到SIGCHLD信号然后去回收子进程?这样导致的问题是父进程仍然会因为等待子进程而阻塞,从而不能服务其他客户端,所以,我们需要使用双fork技巧。即让子进程再fork,创建孙子进程,让孙子进程执行服务,子进程创建成功就退出,这样父进程也不会阻塞,直接等待就成功了。
不过这里其实最好的方法是使用signal(SIGCHLD,SIG_IGN),即忽略信号,不等待,当然也可以使用的双fork技巧,都是可以的。反正最后总有一个进程是交给系统处理的。
当然了,在这里我们会涉及到系统层面的知识,即父进程子进程是共享文件描述符表的,这里我们建议,父进程关闭sockfd,子进程关闭listensockfd,因为也用不上,并且如果不小心误操作了,就会导致较为严重的后果,所以建议关闭。
以上就是第二个多进程版本。
TCPserver_v2--多线程版本
都有了多进程了,多线程不过分吧?
使用多线程的时候,我们要注意两个点:
1.线程之间是共享文件描述符表的,所以这里我们是一定不能关闭文件描述符
2.线程执行的函数默认参数是void* args,但是成员函数默认有参数this指针,所以需要使用static
这里就是两个最主要的问题,然后既然没有this指针,我们没有办法调用service,我们就需要单独创建一个类,用来接收TCPserver的字段,其实就是过渡一下没有this指针的问题:
class ThreadData
{
public:
int _sockfd;
TcpServer *_self;
public:
ThreadData(int sockfd, TcpServer *self)
: _sockfd(sockfd), _self(self)
{
}
};
void Loop()
{
// signal(SIGCHLD, SIG_IGN);
bool _isrunning = true;
while (_isrunning)
{
// accept
sockaddr_in client;
socklen_t len = sizeof(client);
int sockfd = ::accept(_listensocket, (struct sockaddr *)&client, &len);
if (sockfd < 0)
{
std::cout << "accept error" << std::endl;
continue;
}
// service
// version--3 多线程版本
pthread_t tid;
ThreadData *td = new ThreadData(sockfd, this);
pthread_create(&tid, nullptr, Excute, td); // 1.成员函数默认有this指针
}
}
static void *Excute(void *args)
{
pthread_detach(pthread_self()); // 为了让主线程不等待
ThreadData *td = static_cast<ThreadData *>(args);
td->_self->Service(td->_sockfd);
delete td;
return nullptr;
}
并且,为了防止内存泄露,我们这里千万不要忘了delete td,然后线程和进程一样,我们不希望主线程等待它,所以使用线程分离,它执行完了之后它自己释放就可以了。
以上就是多线程版本,其实我们能发现不管是什么版本,只是为了改动服务函数的调用而已。
TCP的第一个基本使用就到此结束了~~
感谢阅读!