初识Linux · TCP基本使用 · 回显服务器
目录
前言:
回显服务器
TCPserver_v0
TCPserver_v1--多进程版本
TCPserver_v2--多线程版本
前言:
前文我们介绍了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);}// bindsockaddr_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);}// listenint 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 writechar 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);}// bindsockaddr_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);}// listenint n = ::listen(_listensocket, gbacklog);if (n < 0){perror("listen");exit(LISTEN_ERR);}}void Loop(){// signal(SIGCHLD, SIG_IGN);bool _isrunning = true;while (_isrunning){// acceptsockaddr_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--1Service(sockfd);}}void Service(int sockfd){while (true){// read writechar 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;elsebreak;}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){// acceptsockaddr_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的第一个基本使用就到此结束了~~
感谢阅读!