【Linux笔记】网络部分——socket 编程 TCP实现多台虚拟机使用指令访问云服务器
34.socket 编程 TCP实现多台虚拟机使用指令访问云服务器
之前在使用socket编程UDP实现网络云服务器与本地虚拟机的基本通信的时候我们的一般流程是:
- 获取服务器IP地址和端口号
- 创建套接字
- bind绑定套接字
- 之后就可以使用函数接口
recvfrom
和sendto
来收发消息了。
TCP的处理流程与UDP差不多,但是也有区别,具体理论部分在后面的博客中,这篇博客主要先用socket 编程 TCP代码实现多台虚拟机使用指令访问云服务器。
TCP服务器的特点是必须先建立连接才能通信,而UDP则不需要。服务器在启动时需要创建套接字、绑定和监听,然后进入循环等待客户端连接
API接口
与套接字相关的API基本相同,这里就直接复制粘贴UDP部分的介绍了
-
socket
:创建用于通信的套接字#include <sys/socket.h>int socket(int domain, int type, int protocol);
- domain参数指定通信域:
AF_INET
表示IPv4网络通信 - type指定的类型:
SOCK_DGRAM
表示使用UDP协议,数据包格式。我们这次使用TCP协议,TCP协议面向字节流,所以要使用SOCK_STREAM
- protocol指定套接字使用的特定协议,默认设为0
- 返回值是一个文件描述符,int类型——
returns a file descriptor that refers to that endpoint.
- domain参数指定通信域:
-
bind
绑定#include <sys/socket.h>int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
-
sockfd——文件描述符
-
addr——指向sockaddr结构体的指针,这个结构体包含指定通信域、IP、端口号
-
我们使用网络通信一般定义
sockaddr_in
结构体,之后强转为sockaddr
类型#define CONV(v) (struct sockaddr *)(v)
-
结构体结构
struct sockaddr{__SOCKADDR_COMMON (sa_); /* Common data: address family and length. */char sa_data[14]; /* Address data. */};struct sockaddr_in{__SOCKADDR_COMMON (sin_);in_port_t sin_port; /* Port number. */struct in_addr sin_addr; /* Internet address. *//* Pad to size of `struct sockaddr'. */unsigned char sin_zero[sizeof (struct sockaddr)- __SOCKADDR_COMMON_SIZE- sizeof (in_port_t)- sizeof (struct in_addr)];};
-
使用
struct sockaddr_in local; bzero(&local, sizeof(local));//把结构体中的空间全部初始化为0 local.sin_family = AF_INET; local.sin_port = htons(_port);//这里需要用htons函数将主机的16位无符号短整型数转换为网络字节序,类似的函数还有htonl/ntohs/ntohl local.sin_addr.s_addr = inet_addr(_ip.c_str());//sin_addr是一个结构体,这个结构体中只有s_addr一个成员变量,我们需要对这个赋值
-
-
成功返回0,失败返回-1,同时errno被设置
-
在C语言中,结构体可以在定义时整体初始化,但不能在赋值语句中整体赋值(除非使用memcpy或类似)
-
-
listen
监听套接字#include <sys/socket.h> int listen(int sockfd, int backlog);
- TCP和UDP的区别在于TCP是面向连接的协议,需要先建立连接才能通信,而UDP是无连接的,客户端启动后可以直接发送消息。TCP服务器需要将套接字设置为监听状态,随时等待客户端连接,类似于餐厅老板等待顾客协商点餐的过程。
- 设置监听状态使用listen函数,其作用是在套接字上等待连接。
- listen函数的第一个参数是文件描述符,
- 参数backlog,表示服务器内部操作系统中的链接队列长度,backlog参数不能为零或过长,一般设置为16、32或8,具体大小根据需求决定。backlog代表底层链接队列的长度,类似于餐厅限制排队人数的情况。
- 返回值表示成功或失败。
- listen成功后,服务器可以继续运行,失败则无法进行后续操作。服务器初始化包括创建套接字、绑定和监听三个步骤。
使用举例:
// 监听套接字 int n = listen(_listensockfd, 4); if (n < 0) {LOG(LogLevel::FATAL) << "listen: " << strerror(errno);exit(LISTEN_ERR); } LOG(LogLevel::INFO) << "listen success";
-
accept
获取连接#include <sys/socket.h>int accept(int sockfd, struct sockaddr *_Nullable restrict addr, socklen_t *_Nullable restrict addrlen);
- 使用accept函数从指定的文件描述符中获取新链接。
- accept函数的第一个参数是之前创建的套接字;后两个参数是输出型参数,可以获取客户端的地址信息,类似于
recvfrom
函数的后两个参数。 - 返回值是文件描述符,表示新链接的客户端。
- 如果没有人连接,accept会阻塞,直到有连接或失败。服务器可以通过设置非阻塞模式来改变accept的行为,但默认情况下是阻塞的。
- accept成功后。accept的返回值是新链接的文件描述符,用于后续通信。
- 重点区分
socket
返回的文件描述符(套接字)和accept
返回的文件描述符(套接字):socket
返回的套接字专门用于获取新连接,不参与实际的数据通信。通过accept函数从监听套接字获取的新连接将返回一个新的文件描述符,这个文件描述符用于实际的IO操作和数据通信服务。类比:监听套接字相当于在门口招揽客人的张三,而通信套接字则相当于实际为客人提供服务的李四、王五等服务员。监听套接字只做一件事:获取新连接,不做IO文件处理;而通信套接字负责所有的数据通信服务。这种分工设计使得服务器能够高效处理并发连接,监听套接字持续获取新连接,每个新连接都由独立的通信套接字处理。
-
connect
在套接字上发起连接#include <sys/socket.h>int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- 与UDP类似,TCP也是面向服务端的,服务端的IP和端口是固定的,客户端需要通过指定的IP和端口访问服务器。但是客户端与服务器的通信端口是不需要指定的,由客户端自动分配。因此,客户端不需要显示
bind
套接字,使用connect
连接服务器的时候会由操作系统自动绑定 - 第一个参数是套接字,第二第三个参数是包含目标服务器信息的sockaddr结构体
- 返回值用于判断连接是否成功
- 与UDP类似,TCP也是面向服务端的,服务端的IP和端口是固定的,客户端需要通过指定的IP和端口访问服务器。但是客户端与服务器的通信端口是不需要指定的,由客户端自动分配。因此,客户端不需要显示
-
read
、write
、recv
、send
发送和接收数据用的是上面的四个函数
#include <unistd.h>
ssize_t read(int fd, void buf[.count], size_t count);
ssize_t write(int fd, const void buf[.count], size_t count);#include <sys/socket.h>
ssize_t recv(int sockfd, void buf[.len], size_t len, int flags);
ssize_t send(int sockfd, const void buf[.len], size_t len, int flags);
除了现在我们学的TCP有面向字节流的特性在前面系统部分中我们学的的文件同样也是面向字节流的,因此对文件的读和写函数也同样可以用于TCP中的数据发送和接收。
具体实现
服务端
要实现服务端接受客户端的数据并解析为命令再运行,之后把运行结果返回给客户端,主要由两部分部分构成:服务器TCP数据收发模块,任务处理模块
服务器TCP数据收发模块
主体部分:
// TcpServer.hpp
const in_port_t defport = 8080;
const std::string defip = "127.0.0.1";using handtask_t = std::function<void()>;
using task_t = std::function<std::string(std::string)>;class TcpServer
{
private:class ThreadData{public:int sockfd;TcpServer *self;};void HanderRequest(int sockfd){while (true){char recvbuf[1024] = {0};int n = recv(sockfd, recvbuf, sizeof(recvbuf) - 1, 0);if (n == 0){LOG(LogLevel::INFO) << "client close";break;}else if (n < 0){LOG(LogLevel::WARNING) << "recv: " << strerror(errno);break;}recvbuf[n] = '\0';LOG(LogLevel::INFO) << "recvbuf is : " << recvbuf;// ::send(sockfd, recvbuf, n, 0);std::string res = _task(std::string(recvbuf));::send(sockfd, res.c_str(), res.size(), 0);}::close(sockfd);}static void *ThreadRequest(void *args){pthread_detach(pthread_self());ThreadData *data = (ThreadData *)args;data->self->HanderRequest(data->sockfd);return nullptr;}public:TcpServer(task_t task, in_port_t port = defport, std::string ip = defip): _task(task), _listensockfd(-1), _isrunning(false), _addr(port){}void InitServer(){// 创建套接字_listensockfd = socket(AF_INET, SOCK_STREAM, 0);if (_listensockfd < 0){LOG(LogLevel::FATAL) << "socket err: " << strerror(errno);exit(SOCKET_ERR);}LOG(LogLevel::INFO) << "socket success";// bind套接字int n = bind(_listensockfd, _addr.NetAddr(), _addr.NetAddrLen());if (n < 0){LOG(LogLevel::FATAL) << "bind: " << strerror(errno);exit(BIND_ERR);}LOG(LogLevel::INFO) << "bind success";// 监听套接字n = listen(_listensockfd, 4);if (n < 0){LOG(LogLevel::FATAL) << "listen: " << strerror(errno);exit(LISTEN_ERR);}LOG(LogLevel::INFO) << "listen success";}void Start(){_isrunning = true;while (true){struct sockaddr_in cliaddr;socklen_t len = sizeof(cliaddr);int sockfd = accept(_listensockfd, CONV(&cliaddr), &len);if (sockfd < 0){LOG(LogLevel::WARNING) << "accept: fail";}LOG(LogLevel::INFO) << "accept success, sockfd is : " << sockfd;// 打印客户信息InetAddr cli(cliaddr);LOG(LogLevel::INFO) << "client info is : " << cli.GetStrAddr();// 处理部分,有多种写法.......}}~TcpServer(){}private:int _listensockfd;InetAddr _addr;bool _isrunning;task_t _task;
};
对于任务处理的逻辑有多种实现方法:
- 单进程直接执行
HanderRequest(sockfd);
- 多进程方案
// 多进程版本
pid_t id = fork();
if (id == 0)
{// 子进程::close(_listensockfd); // 关闭不需要的文件描述符if (fork() > 0)exit(0);HanderRequest(sockfd);exit(0);
}
::close(sockfd);
int rid = ::waitpid(id, nullptr, 0);
if(rid < 0)
{LOG(LogLevel::WARNING) << "waitpid err";
}
-
父进程创建子进程后,子进程需要
exit
,不能继续往下走,否则会回到while循环做accept,导致混乱。 -
在系统部分,我们了解到:父进程在创建子进程的时候会发生写时拷贝,它们各有自己的一份文件描述符表副本,但这些副本里的指针指向了同一个内核文件表项。子进程可以继承父进程的文件描述符表,父子进程各有一张文件描述符表,内容完全一样,是一种浅拷贝。子进程需要关闭不需要的文件描述符,如
_listensockfd
,以防止误触发。子进程未来进行IO处理时只关心sockfd
,父进程不再关心socket
。- 还有一件事:父进程和子进程各自关闭不需要的文件描述符,这种方式相当于父进程将文件描述符节点交给子进程统一处理。关闭文件描述符不会影响对方进程,因为文件被打开时会创建包含引用计数的struct file结构体。关闭文件实际上是对引用计数进行减操作。
-
父进程需要等待子进程,否则会有内存泄露问题,但是这样父进程需要阻塞等待子进程回收。通过双重fork技术可以解决父进程阻塞问题。
- 具体实现是:父进程第一次fork创建子进程,子进程再次fork创建孙子进程后立即退出。
- 这样孙子进程成为孤儿进程,被init进程(pid=1)接管。父进程只需要等待直接子进程(第一次fork的子进程)退出,这个等待会立即返回,父进程可以继续accept新连接。孤儿进程完成任务后由系统自动回收资源,不会产生僵尸进程。
- 这种方案的关键在于每个进程只对自己的直接子进程负责,通过让中间进程快速退出,使父进程的wait立即返回,同时将实际工作交给孤儿进程完成。这种方法不需要使用SIGCHLD信号机制,实现了非阻塞的多进程模型。
- 多线程方案
// 多线程版本
ThreadData *td = new ThreadData;
td->self = this;
td->sockfd = sockfd;
pthread_t pid;
pthread_create(&pid, nullptr, ThreadRequest, td);
-
创建进程的成本很高,需要创建PCB、地址空间、页表等,还要进行写时拷贝操作。使用原生线程库。当获取新的socket文件描述符时,创建线程来处理请求。线程需要执行
HanderRequest
任务。 -
多线程环境下,所有线程共享同一文件描述符表,因为主线程和新线程属于同一进程。文件描述符表是进程级别的资源,因此线程不需要像进程那样关闭不需要的文件描述符。但线程需要获取socket文件描述符值来进行处理,不能直接传递地址,因为主线程可能继续accept新连接导致原地址被覆盖。解决方案是在堆上分配空间存储文件描述符值,再传递给线程。也可以传递结构体来包含更多信息。
-
线程处理函数
HanderRequest
的类型需要符合线程库要求,即返回void*
且参数为void*
。在类内使用时需要将方法改为static
,但static
方法无法访问类成员。解决方案是定义包含socket
文件描述符和TcpServer
指针的结构体。创建线程时new一个该结构体实例,传入socket
文件描述符和this
指针。线程入口函数中将void*
参数强制转换为该结构体类型,然后通过结构体中的server
指针调用HanderRequest
方法。这种实现方式允许静态方法间接访问类成员,解决了线程函数类型匹配问题。static void *ThreadRequest(void *args) {pthread_detach(pthread_self());ThreadData *data = (ThreadData *)args;data->self->HanderRequest(data->sockfd);return nullptr; }
-
主线程创建多线程后,需要考虑线程管理问题。如果不使用join操作,线程会像僵尸进程一样存在。解决方案是使用detach方法将线程设置为分离状态,这样主线程就不再关心该线程的运行情况。
- 线程池方案
// 线程池版本
// 方法一:
task_t func = std::bind(&TcpServer::HanderRequest, this, sockfd);
ThreadPool<task_t>::getinstance()->Equeue(func);// 方法二:
ThreadPool<handtask_t>::getinstance()->Equeue([sockfd, this](){ this->HanderRequest(sockfd); });
- 每次新连接到来时创建线程、传参和构建对象的过程较为繁琐。考虑使用线程池来优化这一过程。使用之前封装的单例线程池可以避免对单个线程的复杂操作
- 当新连接到来时,将处理文件描述符的函数包装成任务推入线程池队列。使用单例模式获取线程池实例,通过
push
方法将任务加入队列。线程会自动处理这些任务,执行HanderRequest
函数。
完整代码:
#include <iostream>
#include <string>
#include <cstdio>
#include <cstring>
#include <cerrno>
#include <unistd.h>
#include <pthread.h>
#include <functional>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/wait.h>#include "Common.hpp"
#include "Log.hpp"
#include "InetAddr.hpp"
#include "ThreadPool.hpp"using namespace My_Log;
using namespace My_ThreadPool;const in_port_t defport = 8080;
const std::string defip = "127.0.0.1";using handtask_t = std::function<void()>;
using task_t = std::function<std::string(std::string)>;class TcpServer
{
private:class ThreadData{public:int sockfd;TcpServer *self;};void HanderRequest(int sockfd){while (true){char recvbuf[1024] = {0};int n = recv(sockfd, recvbuf, sizeof(recvbuf) - 1, 0);if (n == 0){LOG(LogLevel::INFO) << "client close";break;}else if (n < 0){LOG(LogLevel::WARNING) << "recv: " << strerror(errno);break;}recvbuf[n] = '\0';LOG(LogLevel::INFO) << "recvbuf is : " << recvbuf;// ::send(sockfd, recvbuf, n, 0);std::string res = _task(std::string(recvbuf));::send(sockfd, res.c_str(), res.size(), 0);}::close(sockfd);}static void *ThreadRequest(void *args){pthread_detach(pthread_self());ThreadData *data = (ThreadData *)args;data->self->HanderRequest(data->sockfd);return nullptr;}public:TcpServer(task_t task, in_port_t port = defport, std::string ip = defip): _task(task), _listensockfd(-1), _isrunning(false), _addr(port){}void InitServer(){// 创建套接字_listensockfd = socket(AF_INET, SOCK_STREAM, 0);if (_listensockfd < 0){LOG(LogLevel::FATAL) << "socket err: " << strerror(errno);exit(SOCKET_ERR);}LOG(LogLevel::INFO) << "socket success";// bind套接字int n = bind(_listensockfd, _addr.NetAddr(), _addr.NetAddrLen());if (n < 0){LOG(LogLevel::FATAL) << "bind: " << strerror(errno);exit(BIND_ERR);}LOG(LogLevel::INFO) << "bind success";// 监听套接字n = listen(_listensockfd, 4);if (n < 0){LOG(LogLevel::FATAL) << "listen: " << strerror(errno);exit(LISTEN_ERR);}LOG(LogLevel::INFO) << "listen success";}void Start(){_isrunning = true;while (true){struct sockaddr_in cliaddr;socklen_t len = sizeof(cliaddr);int sockfd = accept(_listensockfd, CONV(&cliaddr), &len);if (sockfd < 0){LOG(LogLevel::WARNING) << "accept: fail";}LOG(LogLevel::INFO) << "accept success, sockfd is : " << sockfd;// 打印客户信息InetAddr cli(cliaddr);LOG(LogLevel::INFO) << "client info is : " << cli.GetStrAddr();// // 单进程版本// HanderRequest(sockfd);// 多进程版本pid_t id = fork();if (id == 0){// 子进程::close(_listensockfd); // 关闭不需要的文件描述符if (fork() > 0)exit(0);HanderRequest(sockfd);exit(0);}::close(sockfd);int rid = ::waitpid(id, nullptr, 0);if(rid < 0){LOG(LogLevel::WARNING) << "waitpid err";}// 多线程版本ThreadData *td = new ThreadData;td->self = this;td->sockfd = sockfd;pthread_t pid;pthread_create(&pid, nullptr, ThreadRequest, td);// 线程池版本// task_t func = std::bind(&TcpServer::HanderRequest, this, sockfd);// ThreadPool<task_t>::getinstance()->Equeue(func);ThreadPool<handtask_t>::getinstance()->Equeue([sockfd, this](){ this->HanderRequest(sockfd); });}}~TcpServer(){}private:int _listensockfd;InetAddr _addr;bool _isrunning;task_t _task;
};
任务处理模块
#include <iostream>
#include <string>class Command
{
public:std::string Excute(std::string cmdstr){FILE *fp = ::popen(cmdstr.c_str(), "r");if (nullptr == fp){return std::string("Failed");}char buffer[1024];std::string result;while (true){char *ret = ::fgets(buffer, sizeof(buffer), fp);if (!ret)break;result += ret;}pclose(fp);return result.empty() ? std::string("Done") : result;}
private:};
-
当构建服务器时,将命令字符串处理逻辑交给服务器,服务器在内部读写数据时读取命令字符串,传给回调函数获取执行结果,再通过网络发回。
-
基本思路是先创建管道实现进程间通信,再
fork
子进程并使用进程程序替换来执行命令,最后由管道将执行结果返回给父进程。exec程序替换不会影响之前重定向的结果。 -
幸运的是,标准C库提供了
popen
函数来简化这个过程。popen
函数在底层自动完成管道创建、fork
和exec
等操作,将命令执行结果封装成文件流返回。- 调用者可以通过文件操作函数如
fgets
来读取命令输出结果。popen
支持两种模式:读取模式"r"用于获取命令输出,写入模式"w"用于向命令输入数据。 - 使用完毕后需要用
pclose
关闭文件流。 - 如果
popen
调用失败会返回NULL
,此时可以返回错误信息。popen
的一个优势是它自动处理命令字符串解析,支持带多个选项的复杂命令。 - 由于是C标准库提供的函数在,
popen
的实现保证了跨平台兼容性,包括Windows系统也能使用。通过popen可以方便地执行系统命令并获取其输出结果,大大简化了进程间通信的编程工作。
服务器主函数部分:
#include "TcpServer.hpp"
#include "CommandExec.hpp"
#include <memory>int main()
{std::shared_ptr<Command> cmd_ptr = std::make_shared<Command>();std::unique_ptr<TcpServer> ser_ptr = std::make_unique<TcpServer>([&cmd_ptr](std::string cmdstr){ return cmd_ptr->Excute(cmdstr); });ser_ptr->InitServer();ser_ptr->Start();ser_ptr->~TcpServer();return 0;
}
客户端
#include <iostream>
#include <string>
#include <cstdio>
#include <cstring>
#include <cerrno>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>#include "Common.hpp"int main(int argc, char* argv[])
{//获取服务器信息if(argc != 3){std::cout << "Usage: " << argv[0] << " <server_ip> <server_port>" << std::endl;return 1;}std::string ser_ip = argv[1];int ser_port = std::stoi(argv[2]);//创建套接字int sockfd = socket(AF_INET, SOCK_STREAM, 0);if(sockfd < 0){std::cerr << "socket err" << std::endl;return 2;}struct sockaddr_in ser_addr;ser_addr.sin_family = AF_INET;ser_addr.sin_port = htons(ser_port);ser_addr.sin_addr.s_addr = inet_addr(ser_ip.c_str());int n = connect(sockfd, CONV(&ser_addr), sizeof(ser_addr));if(n < 0){std::cerr << "connect err" << std::endl;return 3;}//循环发送接收逻辑std::string message;while(true){char serbuff[1024];std::cout << "send message# ";// std::cin >> message;std::getline(std::cin, message);//发送数据n = ::send(sockfd, message.c_str(), message.size(), 0);if(n < 0){std::cerr << "send err" << std::endl;break;}n = ::recv(sockfd, serbuff, sizeof(serbuff), 0);if(n < 0){std::cerr << "recv err" << std::endl;break;}serbuff[n] = 0;std::cout << "server message# " << serbuff << std::endl;}::close(sockfd);return 0;
}
服务器日志信息:
云服务器:
虚拟机: