沈阳网站开发技术公司,怎么建设网站让国外看,wordpress flat theme,如何做电商 个人#x1f52d;个人主页#xff1a; 北 海 #x1f6dc;所属专栏#xff1a; Linux学习之旅、神奇的网络世界 #x1f4bb;操作环境#xff1a; CentOS 7.6 阿里云远程服务器 文章目录 #x1f324;️前言#x1f326;️正文TCP网络程序1.字符串回响1.1.核心功能1.2.程序… 个人主页 北 海 所属专栏 Linux学习之旅、神奇的网络世界 操作环境 CentOS 7.6 阿里云远程服务器 文章目录 ️前言️正文TCP网络程序1.字符串回响1.1.核心功能1.2.程序结构服务器1.3.初始化服务器1.4.启动服务器1.4.1.处理连接请求1.4.2.业务处理1.4.3.回调函数 1.5.服务器源文件客户端1.6.初始化客户端1.7.启动客户端1.7.1.尝试进行连接1.7.2.业务处理 2.多进程版服务器2.1.核心功能2.2.创建子进程2.3.设置非阻塞 3.多线程版服务器3.1.核心功能3.2.使用原生线程库3.3.使用线程池 4.日志输出4.1.日志的重要性4.2.可变参数4.3.日志器实现4.4.应用于程序中4.5.持久化存储 5.守护进程5.1.会话、进程组、进程5.2.守护进程化 6.完整代码 ️总结 ️前言
随着数字时代的来临TCP网络程序已成为程序员不可或缺的技术领域。本博客将带领读者深入研究从最基础的字符串回响开始逐步探索至多进程、多线程服务器的高级实践。我们将详细探讨每个环节的核心功能和实现细节致力于帮助读者深刻理解网络编程的本质。通过系统学习本博客内容读者将获得构建稳健网络应用的重要技能更加自信地应对日益复杂的软件开发挑战。这里将为你的编程旅程提供扎实的基础和深远的启示。 ️正文
TCP网络程序
接下来实现一批基于 TCP 协议的网络程序 1.字符串回响
1.1.核心功能
字符串回响程序类似于 echo 指令客户端向服务器发送消息服务器在收到消息后会将消息发送给客户端该程序实现起来比较简单同时能很好的体现 socket 套接字编程的流程 1.2.程序结构
这个程序我们已经基于 UDP 协议实现过了换成 TCP 协议实现时程序的结构是没有变化的同样需要 server.hpp、server.cc、client.hpp、client.cc 这几个文件 创建 server.hpp 服务器头文件 #pragma once#include iostream
#include sys/types.h
#include sys/socket.h
#include netinet/in.h
#include arpa/inet.h
#include err.hppnamespace nt_server
{const uint16_t default_port 8888; // 默认端口号class TcpServer{public:TcpServer(const uint16_t port default_port):port_(port){}~TcpServer(){}// 初始化服务器void InitServer(){}// 启动服务器void StartServer(){}private:int sock_; // 套接字存疑uint16_t port_; // 端口号};
}注意 这里的 sock_ 套接字成员后面需要修改 创建 server.cc 服务器源文件 #include memory // 智能指针头文件
#include server.hppusing namespace std;
using namespace nt_server;int main()
{unique_ptrTcpServer usvr (new TcpServer());usvr-InitServer();usvr-StartServer();return 0;
}创建 client.hpp 客户端头文件 #pragma once#include iostream
#include string
#include sys/types.h
#include sys/socket.h
#include netinet/in.h
#include arpa/inet.h
#include err.hppnamespace nt_client
{class TcpClient{public:TcpClient(const std::string ip, const uint16_t port):server_ip_(ip), server_port_(port){}~TcpClient(){}// 初始化客户端void InitClient(){}// 启动客户端void StartClient(){}private:int sock_; // 套接字std::string server_ip_; // 服务器IPuint16_t server_port_; // 服务器端口号};
}创建 client.cc 客户端源文件 #include memory
#include client.hppusing namespace std;
using namespace nt_client;void Usage(const char *program)
{cout Usage: endl;cout \t program ServerIP ServerPort endl;
}int main(int argc, char *argv[])
{if (argc ! 3){// 错误的启动方式提示错误信息Usage(argv[0]);return USAGE_ERR;}// 服务器IP与端口号string ip(argv[1]);uint16_t port stoi(argv[2]);unique_ptrTcpClient usvr(new TcpClient(ip, port));usvr-InitClient();usvr-StartClient();return 0;
}同时需要一个 Makefile 文件用于快速编译和清理可执行程序 创建 Makefile 文件 .PHONY:all
all:server clientserver:server.ccg -o $ $^ -stdc11client:client.ccg -o $ $^ -stdc11.PHONY:clean
clean:rm -rf server client最后为了方便判断程序错误可以增加上一篇文章中的 err.hpp 头文件里面包含错误码与简易错误信息 创建 err.hpp 错误码头文件 #pragma onceenum
{USAGE_ERR 1,SOCKET_ERR,BIND_ERR
};接下来开始填充代码内容 服务器 1.3.初始化服务器
基于 TCP 协议实现的网络程序也需要 创建套接字、绑定 IP 和端口号 在使用 socket 函数创建套接字时UDP 协议需要指定参数2为 SOCK_DGRAMTCP 协议则是指定参数2为 SOCK_STREAM 注关于 socket、bind、sockaddr 的细节可以看看这篇文章《网络编程『socket套接字 ‖ 简易UDP网络程序』》 InitServer() 初始化服务器函数 — 位于 server.hpp 服务器头文件中的 TcpServer 类 const uint16_t default_port 8888; // 默认端口号// 初始化服务器
void InitServer()
{// 1.创建套接字sock_ socket(AF_INET, SOCK_STREAM, 0);if(sock_ -1){std::cerr Create Socket Fail! strerror(errno) std::endl;exit(SOCKET_ERR);}std::cout Create Socket Success! sock_ std::endl;// 2.绑定IP地址与端口号struct sockaddr_in local;memset(local, 0, sizeof(local)); // 清零local.sin_family AF_INET;local.sin_addr.s_addr INADDR_ANY; // 绑定任意可用IP地址local.sin_port htons(port_);if(bind(listensock_, (const sockaddr*)local, sizeof(local))){std::cout Bind IPPort Fail: strerror(errno) std::endl;exit(BIND_ERR);}// 3.TODO
}注意 在绑定端口号时一定需要把主机序列转换为网络序列 为什么在绑定端口号阶段需要手动转换为网络序列而在发送信息阶段则不需要 这是因为在发送信息阶段recvfrom / sendto 等函数会自动将需要发送的信息转换为网络序列接收信息时同样会将其转换为主机序列所以不需要手动转换 如果使用的 UDP 协议那么初始化服务器到此就结束了但我们本文中使用的是 TCP 协议这是一个 面向连接 的传输层协议意味着在初始化服务器时需要设置服务器为 监听 状态
使用到的函数是 listen 函数
#include sys/types.h /* See NOTES */
#include sys/socket.hint listen(int sockfd, int backlog);参数解读
sockfd 通过该套接字进行监听backlog 全连接队列最大长度
返回值监听成功返回 0失败返回 -1 这里的参数2需要设置一个整数通常为 16、32、64...表示 全连接队列 的最大长度关于 全连接队列 的详细知识放到后续博客中讲解这里只需要直接使用 server.hpp 服务器头文件 #pragma once#include iostream
#include cerrno
#include cstring
#include sys/types.h
#include sys/socket.h
#include netinet/in.h
#include arpa/inet.h
#include err.hppnamespace nt_server
{const uint16_t default_port 8888; // 默认端口号const int backlog 32; // 全连接队列的最大长度class TcpServer{public:TcpServer(const uint16_t port default_port):port_(port){}~TcpServer(){}// 初始化服务器void InitServer(){// 1.创建套接字sock_ socket(AF_INET, SOCK_STREAM, 0);if(sock_ -1){std::cerr Create Socket Fail! strerror(errno) std::endl;exit(SOCKET_ERR);}std::cout Create Socket Success! sock_ std::endl;// 2.绑定IP地址与端口号struct sockaddr_in local;memset(local, 0, sizeof(local)); // 清零local.sin_family AF_INET;local.sin_addr.s_addr INADDR_ANY; // 绑定任意可用IP地址local.sin_port htons(port_);if(bind(listensock_, (const sockaddr*)local, sizeof(local))){std::cout Bind IPPort Fail: strerror(errno) std::endl;exit(BIND_ERR);}// 3.监听if(listen(sock_, backlog) -1){std::cerr Listen Fail! strerror(errno) std::endl;exit(LISTEN_ERR);}std::cout Listen Success! std::endl;}// 启动服务器void StartServer(){}private:int sock_; // 套接字存疑uint16_t port_; // 端口号};
}至此基于 TCP 协议实现的初始化服务器函数就填充完成了编译并运行服务器显示初始化服务器成功 1.4.启动服务器
1.4.1.处理连接请求
TCP 是面向连接当有客户端发起连接请求时TCP 服务器需要正确识别并尝试进行连接当连接成功时与其进行通信可使用 accept 函数进行连接
#include sys/types.h /* See NOTES */
#include sys/socket.hint accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);参数解读
sockfd 服务器用于处理连接请求的 socket 套接字addr 客户端的 sockaddr 结构体信息addrlen 客户端的 sockaddr 结构体大写
其中 addr 与 addrlen 是一个 输入输出型 参数类似于 recvfrom 中的参数
返回值连接成功返回一个用于通信的 socket 套接字文件描述符失败返回 -1 这也就意味着之前我们在 TcpServer 中创建的类内成员 sock_ 并非是用于通信而是专注于处理连接请求在 TCP 服务器中这种套接字称为 监听套接字
使用 accept 函数处理连接请求 server.hpp 服务器头文件 #pragma once#include iostream
#include cerrno
#include cstring
#include sys/types.h
#include sys/socket.h
#include netinet/in.h
#include arpa/inet.h
#include err.hppnamespace nt_server
{const uint16_t default_port 8888; // 默认端口号const int backlog 32; // 全连接队列的最大长度class TcpServer{public:TcpServer(const uint16_t port default_port):port_(port), quit_(false){}~TcpServer(){}// 初始化服务器void InitServer(){// 1.创建监听套接字listensock_ socket(AF_INET, SOCK_STREAM, 0);if(listensock_ -1){std::cerr Create ListenSocket Fail! strerror(errno) std::endl;exit(SOCKET_ERR);}std::cout Create ListenSocket Success! listensock_ std::endl;// 2.绑定IP地址与端口号struct sockaddr_in local;memset(local, 0, sizeof(local)); // 清零local.sin_family AF_INET;local.sin_addr.s_addr INADDR_ANY; // 绑定任意可用IP地址local.sin_port htons(port_);if(bind(listensock_, (const sockaddr*)local, sizeof(local))){std::cout Bind IPPort Fail: strerror(errno) std::endl;exit(BIND_ERR);}// 3.监听if(listen(listensock_, backlog) -1){std::cerr Listen Fail! strerror(errno) std::endl;exit(LISTEN_ERR);}std::cout Listen Success! std::endl;}// 启动服务器void StartServer(){while(!quit_){// 1.处理连接请求struct sockaddr_in client;socklen_t len sizeof(client);int sock accept(listensock_, (struct sockaddr*)client, len);// 2.如果连接失败继续尝试连接if(sock -1){std::cerr Accept Fail! strerror(errno) std::endl;continue;}// 连接成功获取客户端信息std::string clientip inet_ntoa(client.sin_addr);uint16_t clientport ntohs(client.sin_port);std::cout Server accept clientip - clientport sock from listensock_ success! std::endl;// 3.根据 sock 套接字进行通信Service(sock, clientip, clientport);}}private:int listensock_; // 监听套接字uint16_t port_; // 端口号bool quit_; // 判断服务器是否结束运行};
}1.4.2.业务处理
对于 TCP 服务器来说它是面向字节流传输的我们之前使用的文件相关操作也是面向字节流凑巧的是在 Linux 中网络是以挂接在文件系统的方式实现的种种迹象表明可以通过文件相关接口进行通信
read 从文件中读取信息接收消息write 向文件中写入信息发送消息
这两个系统调用的核心参数是 fd文件描述符即服务器与客户端在连接成功后获取到的 socket 套接字所以接下来可以按文件操作的套路完成业务处理 Service() 业务处理函数 — 位于 server.hpp 服务器头文件中的 TcpServer 类 // 业务处理
void Service(int sock, const std::string clientip, const uint16_t clientport)
{char buff[1024];std::string who clientip - std::to_string(clientport);while (true){ssize_t n read(sock, buff, sizeof(buff) - 1); // 预留 \0 的位置if (n 0){// 读取成功buff[n] \0;std::cout Server get: buff from who std::endl;std::string respond func_(buff); // 实际业务处理由上层指定// 发送给服务器write(sock, buff, strlen(buff));}else if (n 0){// 表示当前读取到文件末尾了结束读取std::cout Client who sock quit! std::endl;close(sock); // 关闭文件描述符break;}else{// 读取出问题暂时std::cerr Read Fail! strerror(errno) std::endl;close(sock); // 关闭文件描述符break;}}
}1.4.3.回调函数
为了更好的实现功能解耦这里将真正的业务处理函数交给上层处理编写完成后传给 TcpServer 对象即可当然在 TcpServer 类中需要添加对应的类型 这里设置回调函数的返回值为 string参数同样为 string server.hpp 服务器头文件 #pragma once#include iostream
#include string
#include functional
#include cerrno
#include cstring
#include unistd.h
#include sys/types.h
#include sys/socket.h
#include netinet/in.h
#include arpa/inet.h
#include err.hppnamespace nt_server
{const uint16_t default_port 8888; // 默认端口号const int backlog 32; // 全连接队列的最大长度using func_t std::functionstd::string(std::string); // 回调函数类型class TcpServer{public:TcpServer(const func_t func, const uint16_t port default_port): func_(func), port_(port), quit_(false){}~TcpServer(){}// 初始化服务器void InitServer(){// 1.创建监听套接字listensock_ socket(AF_INET, SOCK_STREAM, 0);if (listensock_ -1){std::cerr Create ListenSocket Fail! strerror(errno) std::endl;exit(SOCKET_ERR);}std::cout Create ListenSocket Success! listensock_ std::endl;// 2.绑定IP地址与端口号struct sockaddr_in local;memset(local, 0, sizeof(local)); // 清零local.sin_family AF_INET;local.sin_addr.s_addr INADDR_ANY; // 绑定任意可用IP地址local.sin_port htons(port_);if (bind(listensock_, (const sockaddr *)local, sizeof(local))){std::cout Bind IPPort Fail: strerror(errno) std::endl;exit(BIND_ERR);}// 3.监听if (listen(listensock_, backlog) -1){std::cerr Listen Fail! strerror(errno) std::endl;exit(LISTEN_ERR);}std::cout Listen Success! std::endl;}// 启动服务器void StartServer(){while (!quit_){// 1.处理连接请求struct sockaddr_in client;socklen_t len sizeof(client);int sock accept(listensock_, (struct sockaddr *)client, len);// 2.如果连接失败继续尝试连接if (sock -1){std::cerr Accept Fail! strerror(errno) std::endl;continue;}// 连接成功获取客户端信息std::string clientip inet_ntoa(client.sin_addr);uint16_t clientport ntohs(client.sin_port);std::cout Server accept clientip - clientport sock from listensock_ success! std::endl;// 3.根据 sock 套接字进行通信Service(sock, clientip, clientport);}}// 业务处理void Service(int sock, const std::string clientip, const uint16_t clientport){char buff[1024];std::string who clientip - std::to_string(clientport);while (true){ssize_t n read(sock, buff, sizeof(buff) - 1); // 预留 \0 的位置if (n 0){// 读取成功buff[n] \0;std::cout Server get: buff from who std::endl;std::string respond func_(buff); // 实际业务处理由上层指定// 发送给服务器write(sock, buff, strlen(buff));}else if (n 0){// 表示当前读取到文件末尾了结束读取std::cout Client who sock quit! std::endl;close(sock); // 关闭文件描述符break;}else{// 读取出问题暂时std::cerr Read Fail! strerror(errno) std::endl;close(sock); // 关闭文件描述符break;}}}private:int listensock_; // 监听套接字uint16_t port_; // 端口号bool quit_; // 判断服务器是否结束运行func_t func_; // 回调函数};
}服务器头文件准备完成接下来就是填充 server.cc 服务器源文件
1.5.服务器源文件
对于当前的 TCP 网络程序字符串回响来说业务处理函数逻辑非常简单无非就是直接将客户端发送过来的消息重新转发给客户端 server.cc 服务器源文件 #include memory // 智能指针头文件
#include string
#include server.hppusing namespace std;
using namespace nt_server;// 业务处理回调函数字符串回响
string echo(string request)
{return request;
}int main()
{unique_ptrTcpServer usvr (new TcpServer(echo)); // 将回调函数进行传递usvr-InitServer();usvr-StartServer();return 0;
}尝试编译并运行服务器可以看到当前 bash 已经被我们的服务器程序占用了重新打开一个终端并通过 netstat 命令查看网络使用情况基于 TCP 协议
netstat -nltp当前服务确实使用的是 8888 端口并且采用的是 TCP 协议 客户端 1.6.初始化客户端
对于客户端来说服务器的 IP 地址与端口号是两个不可或缺的元素因此在客户端类中server_ip 和 server_port 这两个成员是少不了的当然得有 socket 套接字
初始化客户端只需要干一件事创建套接字客户端是主动发起连接请求的一方也就意味着它不需要使用 listen 函数设置为监听状态
注意 客户端也是需要 bind 绑定的但不需要自己手动绑定由操作系统帮我们自动完成 client.hpp 客户端头文件 #pragma once#include iostream
#include string
#include cstring
#include cerrno
#include sys/types.h
#include sys/socket.h
#include netinet/in.h
#include arpa/inet.h
#include err.hppnamespace nt_client
{class TcpClient{public:TcpClient(const std::string ip, const uint16_t port):server_ip_(ip), server_port_(port){}~TcpClient(){}// 初始化客户端void InitClient(){// 创建套接字sock_ socket(AF_INET, SOCK_STREAM, 0);if (sock_ -1){std::cerr Create Socket Fail! strerror(errno) std::endl;exit(SOCKET_ERR);}std::cout Create Sock Succeess! sock_ std::endl;}// 启动客户端void StartClient(){}private:int sock_; // 套接字std::string server_ip_; // 服务器IPuint16_t server_port_; // 服务器端口号};
}编译并运行客户端显示 socket 套接字创建成功 1.7.启动客户端
1.7.1.尝试进行连接
因为 TCP 协议是面向连接的服务器已经处于处理连接请求的状态了客户端现在需要做的就是尝试进行连接使用 connect 函数进行连接
#include sys/types.h /* See NOTES */
#include sys/socket.hint connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);参数解读
sockfd 需要进行连接的套接字addr 服务器的 sockaddr 结构体信息addrlen 服务器的 sockaddr 结构体大小
返回值连接成功返回 0连接失败返回 -1 在连接过程中可能遇到很多问题比如 网络传输失败、服务器未启动 等这些问题的最终结果都是客户端连接失败如果按照之前的逻辑失败就退出那么客户端的体验感会非常不好因此在面对连接失败这种常见问题时客户端应该尝试重连如果重连数次后仍然失败才考虑终止进程
注意 在进行重连时可以使用 sleep() 等函数使程序睡眠一会给网络恢复留出时间 StartClient() 启动客户端函数 — 位于 client.hpp 中的 TcpClient 类 // 启动客户端
void StartClient()
{// 填充服务器的 sockaddr_in 结构体信息struct sockaddr_in server;socklen_t len sizeof(server);memset(server, 0, len);server.sin_family AF_INET;inet_aton(server_ip_.c_str(), server.sin_addr); // 将点分十进制转化为二进制IP地址的另一种方法server.sin_port htons(server_port_);// 尝试重连 5 次int n 5;while(n){int ret connect(sock_, (const struct sockaddr*)server, len);if(ret 0){// 连接成功可以跳出循环break;}// 尝试进行重连std::cerr 网络异常正在进行重连... 剩余连接次数: --n std::endl;sleep(1);}// 如果剩余重连次数为 0证明连接失败if(n 0){std::cerr 连接失败! strerror(errno) std::endl;close(sock_);exit(CONNECT_ERR);}// 连接成功std::cout 连接成功! std::endl;// 进行业务处理// Service();
} 当然相应的错误码也得添加 err.hpp 错误码头文件 #pragma onceenum
{USAGE_ERR 1,SOCKET_ERR,BIND_ERR,LISTEN_ERR,CONNECT_ERR
};现在先不启动服务器编译并启动客户端模拟连接失败的情况 如果在数秒之后启动再服务器可以看到重连成功 这种重连机制在实际中非常常见出现这种 1.7.2.业务处理
客户端在进行业务处理时同样可以使用 read 和 write 进行网络通信 Service() 业务处理函数 — 位于 client.hpp 客户端头文件中的 TcpClient 类 // 业务处理
void Service()
{char buff[1024];std::string who server_ip_ - std::to_string(server_port_);while(true){// 由用户输入信息std::string msg;std::cout Please Enter ;std::getline(std::cin, msg);// 发送信息给服务器write(sock_, msg.c_str(), msg.size());// 接收来自服务器的信息ssize_t n read(sock_, buff, sizeof(buff) - 1);if(n 0){// 正常通信buff[n] \0;std::cout Client get: buff from who std::endl;}else if(n 0){// 读取到文件末尾服务器关闭了std::cout Server who quit! std::endl;close(sock_); // 关闭文件描述符break;}else{// 读取异常std::cerr Read Fail! strerror(errno) std::endl;close(sock_); // 关闭文件描述符break;}}
}至此整个 基于 TCP 协议的字符串回响程序 就完成了下面来看看效果 可以看到当客户端向服务器发起连接请求时服务器可以识别并接受连接双方建立连接关系后可以正常进行通信当客户端主动退出断开连接服务器也能感知到并判断出是谁断开了连接
如果在通信过程中服务器主动断开了连接客户端也能感知到 如果我们此时立马重启服务器会发现短期内无法再次启动服务显示端口正在被占用这是由于 TCP 协议断开连接时的特性导致的正在处于 TIME_WAIT 状态详细原因将会在后续博客中讲解 2.多进程版服务器
2.1.核心功能
对于之前编写的 字符串回响程序 来说如果只有一个客户端进行连接并通信是没有问题的但如果有多个客户端发起连接请求并尝试进行通信服务器是无法应对的
原因在于 服务器是一个单进程版本处理连接请求 和 业务处理 是串行化执行的如果想处理下一个连接请求需要把当前的业务处理完成 具体表现为下面这种情况 为什么客户端B会显示当前已经连接成功 这是因为是客户端是主动发起连接请求的一方在请求发出后如果出现连接错误客户端就认为已经连接成功了但实际上服务器还没有处理这个连接请求 这显然是服务器的问题处理连接请求 与 业务处理 应该交给两个不同的执行流完成可以使用多进程或者多线程解决这里先采用多进程的方案
所以当前需要实现的网络程序核心功能为当服务器成功处理连接请求后fork 新建一个子进程用于进行业务处理原来的进程专注于处理连接请求 2.2.创建子进程
注当前的版本的修改只涉及 StartServer() 函数
创建子进程使用 fork() 函数它的返回值含义如下
ret 0 表示创建子进程成功接下来执行子进程的代码ret 0 表示创建子进程成功接下来执行父进程的代码ret 0 表示创建子进程失败
子进程创建成功后会继承父进程的文件描述符表能轻而易举的获取客户端的 socket 套接字从而进行网络通信 当然不止文件描述符表得益于 写时拷贝 机制子进程还会共享父进程的变量当发生修改行为时才会自己创建 注意 当子进程取走客户端的 socket 套接字进行通信后父进程需要将其关闭因为它不需要了避免文件描述符泄漏 StartServer() 服务器启动函数 — 位于 server.hpp 的 TcpServer 类 // 进程创建、等待所需要的头文件
#include unistd.h
#include sys/wait.h
#include sys/types.h// 启动服务器
void StartServer()
{while (!quit_){// 1.处理连接请求struct sockaddr_in client;socklen_t len sizeof(client);int sock accept(listensock_, (struct sockaddr *)client, len);// 2.如果连接失败继续尝试连接if (sock -1){std::cerr Accept Fail! strerror(errno) std::endl;continue;}// 连接成功获取客户端信息std::string clientip inet_ntoa(client.sin_addr);uint16_t clientport ntohs(client.sin_port);std::cout Server accept clientip - clientport sock from listensock_ success! std::endl;// 3.创建子进程pid_t id fork();if(id 0){// 创建子进程失败暂时不与当前客户端建立通信会话close(sock);std::cerr Fork Fail! std::endl;}else if(id 0){// 子进程内close(listensock_); // 子进程不需要监听建议关闭// 执行业务处理函数Service(sock, clientip, clientport);exit(0); // 子进程退出}else{// 父进程需要等待子进程pid_t ret waitpid(id, nullptr, 0); // 默认为阻塞式等待if(ret id)std::cout Wait id success!;}}
}虽然此时成功创建了子进程但父进程处理连接请求仍然需要等待子进程退出后才能继续运行说白了就是 父进程现在处于阻塞等待状态需要设置为 非阻塞等待 2.3.设置非阻塞
设置父进程为非阻塞的方式有很多这里来一一列举
方式一通过参数设置为非阻塞等待不推荐
可以直接给 waitpid() 函数的参数3传递 WNOHANG表示当前为 非阻塞等待
详见 《Linux进程控制【创建、终止、等待】》
pid_t ret waitpid(id, nullptr, WNOHANG); // 设置为非阻塞式等待这种方法可行但不推荐原因如下虽然设置成了非阻塞式等待但父进程终究是需要通过 waitpid() 函数来尝试等待子进程倘若父进程一直卡在 accept() 函数处会导致子进程退出后暂时无人收尸进而导致资源泄漏 方式二忽略 SIGCHLD 信号推荐使用
这是一个子进程在结束后发出的信号默认动作是什么都不做父进程需要检测并回收子进程我们可以直接忽略该信号这里的忽略是个特例只是父进程不对其进行处理转而由 操作系统 对其负责自动清理资源并进行回收不会产生 僵尸进程
详见 《Linux进程信号【信号处理】》
直接在 StartServer() 服务器启动函数刚开始时使用 signal() 函数设置 SIGCHLD 信号的执行动作为 忽略
忽略了该信号后就不需要父进程等待子进程退出了由操作系统承担
#include signal.h // 信号处理相关头文件// 启动服务器
void StartServer()
{// 忽略 SIGCHLD 信号signal(SIGCHLD, SIG_IGN);while (!quit_){// ...// 3.创建子进程pid_t id fork();if(id 0){// 创建子进程失败暂时不与当前客户端建立通信会话close(sock);std::cerr Fork Fail! std::endl;}else if(id 0){// 子进程内close(listensock_); // 子进程不需要监听建议关闭// 执行业务处理函数Service(sock, clientip, clientport);exit(0); // 子进程退出}}
}强烈推荐使用该方案因为操作简单并且没有后患之忧 方式三设置 SIGCHLD 信号的处理动作为子进程回收不是很推荐
当子进程退出并发送该信号时执行父进程回收子进程的操作
详见 《Linux进程信号【信号处理】》
设置 SIGCHLD 信号的处理动作为 回收子进程后父进程同样不必再考虑回收子进程的问题
注意 因为现在处于 TcpServer 类中handler() 函数需要设置为静态避免隐含的 this 指针避免不符合 signal() 函数中信号处理函数的参数要求
#include signal.h // 信号处理相关头文件// 需要设置为静态
static void handler(int signo)
{printf(进程 %d 捕捉到了 %d 号信号\n, getpid(), signo);// 这里的 -1 表示父进程等待时只要是已经退出了的子进程都可以进行回收while (1){pid_t ret waitpid(-1, NULL, WNOHANG);if (ret 0)printf(父进程: %d 已经成功回收了 %d 号进程\n, getpid(), ret);elsebreak;}printf(子进程回收成功\n);
}// 启动服务器
void StartServer()
{// 设置 SIGCHLD 信号的处理动作signal(SIGCHLD, handler);while (!quit_){// ...// 3.创建子进程pid_t id fork();if(id 0){// 创建子进程失败暂时不与当前客户端建立通信会话close(sock);std::cerr Fork Fail! std::endl;}else if(id 0){// 子进程内close(listensock_); // 子进程不需要监听建议关闭// 执行业务处理函数Service(sock, clientip, clientport);exit(0); // 子进程退出}}
}为什么不是很推荐这种方法因为这种方法实现起来比较麻烦不如直接忽略 SIGCHLD 信号 方式四设置孙子进程不是很推荐
众所周知父进程只需要对子进程负责至于孙子进程交给子进程负责如果某个子进程的父进程终止运行了那么它就会变成 孤儿进程父进程会变成 1 号进程也就是由操作系统领养回收进程的重担也交给了操作系统
可以利用该特性在子进程内部再创建一个子进程孙子进程然后子进程退出父进程可以直接回收不必阻塞子进程孙子进程的父进程变成 1 号进程
这种实现方法比较巧妙而且与我们后面即将学到的 守护进程 有关
注意 使用这种方式时父进程是需要等待子进程退出的
// 启动服务器
void StartServer()
{while (!quit_){// ...// 3.创建子进程pid_t id fork();if(id 0){// 创建子进程失败暂时不与当前客户端建立通信会话close(sock);std::cerr Fork Fail! std::endl;}else if(id 0){// 子进程内close(listensock_); // 子进程不需要监听建议关闭// 再创建孙子进程if(fork() 0)exit(0); // 子进程退出// 执行业务处理函数Service(sock, clientip, clientport);exit(0); // 子进程退出}else{// 父进程需要等待子进程pid_t ret waitpid(id, nullptr, 0);if(ret id)std::cout Wait id success!;}}
}这种方法代码也很简单但依旧不推荐因为倘若连接请求变多会导致孤儿进程变多孤儿进程由操作系统接管数量变多会给操作系统带来负担
以上就是设置 非阻塞 的四种方式推荐使用方式二忽略 SIGCHLD 信号
至此我们的 字符串回响程序 可以支持多客户端了 细节补充当子进程取走 sock 套接字进行网络通信后父进程就不需要使用 sock 套接字了可以将其进行关闭下次连接时继续使用避免文件描述符不断增长 StartServer() 服务器启动函数 — 位于 server.hpp 服务器头文件中的 TcpServer 类 // 启动服务器
void StartServer()
{// 忽略 SIGCHLD 信号signal(SIGCHLD, SIG_IGN);while (!quit_){// 1.处理连接请求// ...// 2.如果连接失败继续尝试连接// ...// 连接成功获取客户端信息// ...// 3.创建子进程// ...close(sock); // 父进程不再需要资源建议关闭}
}这个补丁可以减少资源消耗建议加上前面是忘记加了并且不太好修改server.hpp 服务器头文件完整代码如下
#pragma once#include iostream
#include string
#include functional
#include cerrno
#include cstring
#include unistd.h
#include signal.h // 信号处理相关头文件
#include sys/wait.h // 进程等待时需要包含该头文件
#include sys/types.h
#include sys/socket.h
#include netinet/in.h
#include arpa/inet.h
#include err.hppnamespace nt_server
{const uint16_t default_port 8888; // 默认端口号const int backlog 32; // 全连接队列的最大长度using func_t std::functionstd::string(std::string); // 回调函数类型class TcpServer{public:TcpServer(const func_t func, const uint16_t port default_port): func_(func), port_(port), quit_(false){}~TcpServer(){}// 初始化服务器void InitServer(){// 1.创建监听套接字listensock_ socket(AF_INET, SOCK_STREAM, 0);if (listensock_ -1){std::cerr Create ListenSocket Fail! strerror(errno) std::endl;exit(SOCKET_ERR);}std::cout Create ListenSocket Success! listensock_ std::endl;// 2.绑定IP地址与端口号struct sockaddr_in local;memset(local, 0, sizeof(local)); // 清零local.sin_family AF_INET;local.sin_addr.s_addr INADDR_ANY; // 绑定任意可用IP地址local.sin_port htons(port_);if (bind(listensock_, (const sockaddr *)local, sizeof(local))){std::cout Bind IPPort Fail: strerror(errno) std::endl;exit(BIND_ERR);}// 3.监听if (listen(listensock_, backlog) -1){std::cerr Listen Fail! strerror(errno) std::endl;exit(LISTEN_ERR);}std::cout Listen Success! std::endl;}// 启动服务器void StartServer(){// 忽略 SIGCHLD 信号signal(SIGCHLD, SIG_IGN);while (!quit_){// 1.处理连接请求struct sockaddr_in client;socklen_t len sizeof(client);int sock accept(listensock_, (struct sockaddr *)client, len);// 2.如果连接失败继续尝试连接if (sock -1){std::cerr Accept Fail! strerror(errno) std::endl;continue;}// 连接成功获取客户端信息std::string clientip inet_ntoa(client.sin_addr);uint16_t clientport ntohs(client.sin_port);std::cout Server accept clientip - clientport sock from listensock_ success! std::endl;// 3.创建子进程pid_t id fork();if(id 0){// 创建子进程失败暂时不与当前客户端建立通信会话close(sock);std::cerr Fork Fail! std::endl;}else if(id 0){// 子进程内close(listensock_); // 子进程不需要监听建议关闭// 执行业务处理函数Service(sock, clientip, clientport);exit(0); // 子进程退出}close(sock); // 父进程不再需要资源必须关闭}}// 业务处理void Service(int sock, const std::string clientip, const uint16_t clientport){char buff[1024];std::string who clientip - std::to_string(clientport);while (true){ssize_t n read(sock, buff, sizeof(buff) - 1); // 预留 \0 的位置if (n 0){// 读取成功buff[n] \0;std::cout Server get: buff from who std::endl;std::string respond func_(buff); // 实际业务处理由上层指定// 发送给服务器write(sock, buff, strlen(buff));}else if (n 0){// 表示当前读取到文件末尾了结束读取std::cout Client who sock quit! std::endl;close(sock); // 关闭文件描述符break;}else{// 读取出问题暂时std::cerr Read Fail! strerror(errno) std::endl;close(sock); // 关闭文件描述符break;}}}private:int listensock_; // 监听套接字uint16_t port_; // 端口号bool quit_; // 判断服务器是否结束运行func_t func_; // 回调函数};
}3.多线程版服务器
3.1.核心功能
通过多线程实现支持多客户端同时通信的服务器
核心功能服务器与客户端成功连接后创建一个线程服务于客户端的业务处理 这里先通过 原生线程库 模拟实现
3.2.使用原生线程库
关于 原生线程库 中对于线程的操作可以看看这篇文章《Linux多线程【线程控制】》
线程的回调函数中需要 Service() 业务处理函数中的所有参数同时也需要具备访问 Service() 业务处理函数的能力单凭一个 void* 的参数是无法解决的为此可以创建一个类里面可以包含我们所需要的参数 ThreadData 类 — 位于 server.hpp 服务器头文件中 // 包含我们所需参数的类型
class ThreadData
{
public:ThreadData(int sock, const std::string ip, const uint16_t port, TcpServer* ptr):sock_(sock), clientip_(ip), clientport_(port), current_(ptr){}// 设置为公有是为了方便访问
public:int sock_;std::string clientip_;uint16_t clientport_;TcpServer* current_; // 指向 TcpServer 对象的指针
};接下来就可以考虑如何借助多线程了
线程创建后需要关闭不必要的 socket 套接字吗
不需要线程之间是可以共享这些资源的无需关闭
如何设置主线程不必等待次线程退出
可以把次线程进行分离
所以接下来我们需要在连接成功后创建次线程利用已有信息构建 ThreadData 对象为次线程编写回调函数最终目的是为了执行 Service() 业务处理函数
注意 因为当前在类中线程的回调函数需要使用 static 设置为静态函数 server.hpp 服务器头文件 #pragma once#include iostream
#include string
#include functional
#include cerrno
#include cstring
#include pthread.h // 原生线程库
#include unistd.h
#include sys/types.h
#include sys/socket.h
#include netinet/in.h
#include arpa/inet.h
#include err.hppnamespace nt_server
{const uint16_t default_port 8888; // 默认端口号const int backlog 32; // 全连接队列的最大长度using func_t std::functionstd::string(std::string); // 回调函数类型class TcpServer; // 前置声明// 包含我们所需参数的类型class ThreadData{public:ThreadData(int sock, const std::string ip, const uint16_t port, TcpServer* ptr):sock_(sock), clientip_(ip), clientport_(port), current_(ptr){}// 设置为公有是为了方便访问public:int sock_;std::string clientip_;uint16_t clientport_;TcpServer* current_; // 指向 TcpServer 对象的指针};class TcpServer{public:TcpServer(const func_t func, const uint16_t port default_port): func_(func), port_(port), quit_(false){}~TcpServer(){}// 初始化服务器void InitServer(){// 1.创建监听套接字listensock_ socket(AF_INET, SOCK_STREAM, 0);if (listensock_ -1){std::cerr Create ListenSocket Fail! strerror(errno) std::endl;exit(SOCKET_ERR);}std::cout Create ListenSocket Success! listensock_ std::endl;// 2.绑定IP地址与端口号struct sockaddr_in local;memset(local, 0, sizeof(local)); // 清零local.sin_family AF_INET;local.sin_addr.s_addr INADDR_ANY; // 绑定任意可用IP地址local.sin_port htons(port_);if (bind(listensock_, (const sockaddr *)local, sizeof(local))){std::cout Bind IPPort Fail: strerror(errno) std::endl;exit(BIND_ERR);}// 3.监听if (listen(listensock_, backlog) -1){std::cerr Listen Fail! strerror(errno) std::endl;exit(LISTEN_ERR);}std::cout Listen Success! std::endl;}// 启动服务器void StartServer(){while (!quit_){// 1.处理连接请求struct sockaddr_in client;socklen_t len sizeof(client);int sock accept(listensock_, (struct sockaddr *)client, len);// 2.如果连接失败继续尝试连接if (sock -1){std::cerr Accept Fail! strerror(errno) std::endl;continue;}// 连接成功获取客户端信息std::string clientip inet_ntoa(client.sin_addr);uint16_t clientport ntohs(client.sin_port);std::cout Server accept clientip - clientport sock from listensock_ success! std::endl;// 3.创建线程及所需要的线程信息类ThreadData* td new ThreadData(sock, clientip, clientport, this);pthread_t p;pthread_create(p, nullptr, Routine, td);}}// 线程回调函数static void* Routine(void* args){// 线程分离pthread_detach(pthread_self());ThreadData* td static_castThreadData*(args);// 调用业务处理函数td-current_-Service(td-sock_, td-clientip_, td-clientport_);// 销毁对象delete td;}// 业务处理void Service(int sock, const std::string clientip, const uint16_t clientport){char buff[1024];std::string who clientip - std::to_string(clientport);while (true){ssize_t n read(sock, buff, sizeof(buff) - 1); // 预留 \0 的位置if (n 0){// 读取成功buff[n] \0;std::cout Server get: buff from who std::endl;std::string respond func_(buff); // 实际业务处理由上层指定// 发送给服务器write(sock, buff, strlen(buff));}else if (n 0){// 表示当前读取到文件末尾了结束读取std::cout Client who sock quit! std::endl;close(sock); // 关闭文件描述符break;}else{// 读取出问题暂时std::cerr Read Fail! strerror(errno) std::endl;close(sock); // 关闭文件描述符break;}}}private:int listensock_; // 监听套接字uint16_t port_; // 端口号bool quit_; // 判断服务器是否结束运行func_t func_; // 回调函数};
}因为当前使用了 原生线程库所以在编译时需要加上 -lpthread Makefile 文件 .PHONY:all
all:server clientserver:server.ccg -o $ $^ -stdc11 -lpthreadclient:client.ccg -o $ $^ -stdc11 -lpthread.PHONY:clean
clean:rm -rf server client接下来就是编译并运行程序可以看到 当前只有一个进程同时有五个线程在运行 使用 原生线程库 过于单薄了并且这种方式存在问题连接都准备好了才创建线程如果创建线程所需要的资源较多会拖慢服务器整体连接效率
为此可以改用之前实现的 线程池
3.3.使用线程池
之前在 《Linux多线程【线程池】》一文中实现了多个版本的线程池这里我们直接使用最终版也就是 单例模式版线程池 部分组件不需要修改代码如下 ThreadPool.hpp 线程池头文件 #pragma once#include vector
#include string
#include memory
#include functional
#include unistd.h
#include pthread.h
#include Task.hpp
#include Thread.hpp
#include BlockingQueue.hpp // CP模型namespace Yohifo
{
#define THREAD_NUM 10templateclass Tclass ThreadPool{private:ThreadPool(int num THREAD_NUM):_num(num){}~ThreadPool(){// 等待线程退出for(auto t : _threads)t.join();}// 删除拷贝构造ThreadPool(const ThreadPoolT ) delete;public:static ThreadPoolT* getInstance(){// 双检查if(_inst nullptr){// 加锁LockGuard lock(_mtx);if(_inst nullptr){// 创建对象_inst new ThreadPoolT();// 初始化及启动服务_inst-init();_inst-start();}}return _inst;}public:void init(){// 创建一批线程for(int i 0; i _num; i)_threads.push_back(Thread(i, threadRoutine, this));}void start(){// 启动线程for(auto t : _threads)t.run();}// 提供给线程的回调函数已修改返回类型为 voidstatic void threadRoutine(void *args){// 避免等待线程直接剥离pthread_detach(pthread_self());auto ptr static_castThreadPoolT*(args);while (true){// 从CP模型中获取任务T task ptr-popTask();task(); // 回调函数}}// 装载任务void pushTask(const T task){_blockqueue.Push(task);}protected:T popTask(){T task;_blockqueue.Pop(task);return task;}private:std::vectorThread _threads;int _num; // 线程数量BlockQueueT _blockqueue; // 阻塞队列// 创建静态单例对象指针及互斥锁static ThreadPoolT *_inst;static pthread_mutex_t _mtx;};// 初始化指针templateclass TThreadPoolT* ThreadPoolT::_inst nullptr;// 初始化互斥锁templateclass Tpthread_mutex_t ThreadPoolT::_mtx PTHREAD_MUTEX_INITIALIZER;
}Thread.hpp 封装实现的线程库头文件 #pragma once#include iostream
#include string
#include pthread.henum class Status
{NEW 0, // 新建RUNNING, // 运行中EXIT // 已退出
};// 参数、返回值为 void 的函数类型
typedef void (*func_t)(void*);class Thread
{
public:Thread(int num 0, func_t func nullptr, void* args nullptr):_tid(0), _status(Status::NEW), _func(func), _args(args){// 根据编号写入名字char name[128];snprintf(name, sizeof name, thread-%d, num);_name name;}~Thread(){}// 获取 IDpthread_t getTID() const{return _tid;}// 获取线程名std::string getName() const{return _name;}// 获取状态Status getStatus() const{return _status;}// 回调方法static void* runHelper(void* args){Thread* myThis static_castThread*(args);// 很简单回调用户传进来的 func 函数即可myThis-_func(myThis-_args);}// 启动线程void run(){int ret pthread_create(_tid, nullptr, runHelper, this);if(ret ! 0){std::cerr create thread fail! std::endl;exit(1); // 创建线程失败直接退出}_status Status::RUNNING; // 更改状态为 运行中}// 线程等待void join(){int ret pthread_join(_tid, nullptr);if(ret ! 0){std::cerr thread join fail! std::endl;exit(1); // 等待失败直接退出}_status Status::EXIT; // 更改状态为 退出}private:pthread_t _tid; // 线程 IDstd::string _name; // 线程名Status _status; // 线程状态func_t _func; // 线程回调函数void* _args; // 传递给回调函数的参数
};BlockingQueue.hpp 生产者消费者模型头文件 #pragma once#include queue
#include mutex
#include pthread.h
#include LockGuard.hpp// 命名空间避免冲突
namespace Yohifo
{
#define DEF_SIZE 10templateclass Tclass BlockQueue{public:BlockQueue(size_t cap DEF_SIZE):_cap(cap){// 初始化锁与条件变量pthread_mutex_init(_mtx, nullptr);pthread_cond_init(_pro_cond, nullptr);pthread_cond_init(_con_cond, nullptr);}~BlockQueue(){// 销毁锁与条件变量pthread_mutex_destroy(_mtx);pthread_cond_destroy(_pro_cond);pthread_cond_destroy(_con_cond);}// 生产数据入队void Push(const T inData){// 加锁RAII风格LockGuard lock(_mtx);// 循环判断条件是否满足while(IsFull()){pthread_cond_wait(_pro_cond, _mtx);}_queue.push(inData);// 可以加策略唤醒比如生产一半才唤醒消费者pthread_cond_signal(_con_cond);// 自动解锁}// 消费数据出队void Pop(T* outData){// 加锁RAII 风格LockGuard lock(_mtx);// 循环判读条件是否满足while(IsEmpty()){pthread_cond_wait(_con_cond, _mtx);}*outData _queue.front();_queue.pop();// 可以加策略唤醒比如消费完后才唤醒生产者pthread_cond_signal(_pro_cond);// 自动解锁}private:// 判断是否为满bool IsFull(){return _queue.size() _cap;}// 判断是否为空bool IsEmpty(){return _queue.empty();}private:std::queueT _queue;size_t _cap; // 阻塞队列的容量pthread_mutex_t _mtx; // 互斥锁pthread_cond_t _pro_cond; // 生产者条件变量pthread_cond_t _con_cond; // 消费者条件变量};
}LockGuard.hpp 自动化锁头文件 #pragma once#include pthread.hclass LockGuard
{
public:LockGuard(pthread_mutex_t*pmtx):_pmtx(pmtx){// 加锁pthread_mutex_lock(_pmtx);}~LockGuard(){// 解锁pthread_mutex_unlock(_pmtx);}private:pthread_mutex_t* _pmtx;
};现在需要修改 Task.hpp 任务头文件中的 Task 任务类将其修改为一个服务于 网络通信中业务处理 的任务类也就是 Service() 业务处理函数
在 Service() 业务处理函数中需要包含 socket 套接字、客户端 IP、客户端端口号 等必备信息除此之外我们还可以将 可调用对象Service() 业务处理函数 作为参数传递给 Task 对象 Task.hpp 任务类 #pragma once#include string
#include functionalnamespace Yohifo
{// Service() 业务处理函数的类型using cb_t std::functionvoid(int, std::string, uint16_t);class Task{public:// 可以再提供一个默认构造防止部分场景中构建对象失败Task(){}Task(int sock, const std::string ip, const uint16_t port, const cb_t cb):sock_(sock), ip_(ip), port_(port), cb_(cb){}// 重载运算操作用于回调 [业务处理函数]void operator()(){// 直接回调 cb [业务处理函数] 即可cb_(sock_, ip_, port_);}private:int sock_;std::string ip_;uint16_t port_;cb_t cb_; // 回调函数};
}准备工作完成后接下来就是往 server.hpp 服务器头文件中添加组件了
注意
在构建 Task 对象时需要使用 bind 绑定类内函数避免参数不匹配当前的线程池是单例模式在 Task 任务对象构建后通过线程池操作句柄 push 对象即可
其实也就是在 StartServer.hpp 中增加了这两句代码
// 3.构建任务对象 注意使用 bind 绑定 this 指针
Yohifo::Task t(sock, clientip, clientport, std::bind(TcpServer::Service, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3));// 4.通过线程池操作句柄将任务对象 push 进线程池中处理
Yohifo::ThreadPoolYohifo::Task::getInstance()-pushTask(t);完整的服务器代码如下 server.hpp 服务器头文件 #pragma once#include iostream
#include string
#include functional
#include cerrno
#include cstring
#include unistd.h
#include sys/types.h
#include sys/socket.h
#include netinet/in.h
#include arpa/inet.h
#include err.hpp
#include ThreadPool.hpp // 线程池
#include Task.hpp // 任务类namespace nt_server
{const uint16_t default_port 8888; // 默认端口号const int backlog 32; // 全连接队列的最大长度using func_t std::functionstd::string(std::string); // 回调函数类型class TcpServer; // 前置声明// 包含我们所需参数的类型class ThreadData{public:ThreadData(int sock, const std::string ip, const uint16_t port, TcpServer* ptr):sock_(sock), clientip_(ip), clientport_(port), current_(ptr){}// 设置为公有是为了方便访问public:int sock_;std::string clientip_;uint16_t clientport_;TcpServer* current_; // 指向 TcpServer 对象的指针};class TcpServer{public:TcpServer(const func_t func, const uint16_t port default_port): func_(func), port_(port), quit_(false){}~TcpServer(){}// 初始化服务器void InitServer(){// 1.创建监听套接字listensock_ socket(AF_INET, SOCK_STREAM, 0);if (listensock_ -1){std::cerr Create ListenSocket Fail! strerror(errno) std::endl;exit(SOCKET_ERR);}std::cout Create ListenSocket Success! listensock_ std::endl;// 2.绑定IP地址与端口号struct sockaddr_in local;memset(local, 0, sizeof(local)); // 清零local.sin_family AF_INET;local.sin_addr.s_addr INADDR_ANY; // 绑定任意可用IP地址local.sin_port htons(port_);if (bind(listensock_, (const sockaddr *)local, sizeof(local))){std::cout Bind IPPort Fail: strerror(errno) std::endl;exit(BIND_ERR);}// 3.监听if (listen(listensock_, backlog) -1){std::cerr Listen Fail! strerror(errno) std::endl;exit(LISTEN_ERR);}std::cout Listen Success! std::endl;}// 启动服务器void StartServer(){while (!quit_){// 1.处理连接请求struct sockaddr_in client;socklen_t len sizeof(client);int sock accept(listensock_, (struct sockaddr *)client, len);// 2.如果连接失败继续尝试连接if (sock -1){std::cerr Accept Fail! strerror(errno) std::endl;continue;}// 连接成功获取客户端信息std::string clientip inet_ntoa(client.sin_addr);uint16_t clientport ntohs(client.sin_port);std::cout Server accept clientip - clientport sock from listensock_ success! std::endl;// 3.构建任务对象 注意使用 bind 绑定 this 指针Yohifo::Task t(sock, clientip, clientport, std::bind(TcpServer::Service, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3));// 4.通过线程池操作句柄将任务对象 push 进线程池中处理Yohifo::ThreadPoolYohifo::Task::getInstance()-pushTask(t);}}// 业务处理void Service(int sock, const std::string clientip, const uint16_t clientport){char buff[1024];std::string who clientip - std::to_string(clientport);while (true){ssize_t n read(sock, buff, sizeof(buff) - 1); // 预留 \0 的位置if (n 0){// 读取成功buff[n] \0;std::cout Server get: buff from who std::endl;std::string respond func_(buff); // 实际业务处理由上层指定// 发送给服务器write(sock, buff, strlen(buff));}else if (n 0){// 表示当前读取到文件末尾了结束读取std::cout Client who sock quit! std::endl;close(sock); // 关闭文件描述符break;}else{// 读取出问题暂时std::cerr Read Fail! strerror(errno) std::endl;close(sock); // 关闭文件描述符break;}}}private:int listensock_; // 监听套接字uint16_t port_; // 端口号bool quit_; // 判断服务器是否结束运行func_t func_; // 回调函数};
}接下来编译并运行程序当服务器启动后此时无客户端连接只有一个线程这是因为我们当前的 线程池 是基于 懒汉模式 实现的只有当第一次使用时才会创建线程 接下来启动客户端可以看到确实创建了一批次线程十个 当然可以支持多客户端同时通信 看似程序已经很完善了其实隐含着一个大问题当前线程池中的线程本质上是在回调一个 while(true) 死循环函数当连接的客户端大于线程池中的最大线程数时会导致所有线程始终处于满负载状态直接影响就是连接成功后无法再创建通信会话倘若客户端不断开连接线程池中的线程就无力处理其他客户端的会话
说白了就是 线程池 比较适合用于处理短任务对于当前的场景来说线程池 不适合建立持久通信会话应该将其用于处理 read 读取、write 写入 任务 如果想解决这个问题有两个方向Service() 函数中支持一次 [收 / 发]或者多线程线程池多线程用于构建通信会话线程池则用于处理 [收 / 发] 任务
前者实现起来比较简单无非就是把 Service() 业务处理函数中的 while(true) 循环去掉 Service() 业务处理函数 // 业务处理
void Service(int sock, const std::string clientip, const uint16_t clientport)
{char buff[1024];std::string who clientip - std::to_string(clientport);ssize_t n read(sock, buff, sizeof(buff) - 1); // 预留 \0 的位置if (n 0){// 读取成功buff[n] \0;std::cout Server get: buff from who std::endl;std::string respond func_(buff); // 实际业务处理由上层指定// 发送给服务器write(sock, buff, strlen(buff));}else if (n 0){// 表示当前读取到文件末尾了结束读取std::cout Client who sock quit! std::endl;close(sock); // 关闭文件描述符}else{// 读取出问题暂时std::cerr Read Fail! strerror(errno) std::endl;close(sock); // 关闭文件描述符}
}至于后者就比较麻烦了需要结合 高级IO 相关知识这里不再阐述 4.日志输出
4.1.日志的重要性
在之前的编程经历中如果我们的程序运行出现了问题都是通过 标准输出 或 标准错误 将 错误信息 直接输出到屏幕上debug 阶段这样使用没啥问题但如果出错的是一个不断在运行中的服务那问题就大了因为服务器是不间断运行中直接将 错误信息 输出到屏幕上会导致错误排查变得极为困难 将各种 错误信息 组织管理就形成了日志日志有属于自己的格式包括时间、文件名及行号、错误等级等利于排查问题
所以接下来我们将会实现一个简易版日志器用于定向输出我们的日志信息 4.2.可变参数 日志需要我们指定格式并输出依赖于可变参数 在编写简易版日志器之前需要先认识一下 C语言 中有关可变参数的使用主要包括这几个 宏
#include stdarg.hva_list // 指向可变参数列表的指针va_start() // 将指针指向起始地址va_arg() // 根据类型提取可变参数列表中的参数va_end() // 将指针置为空 关于 可变参数 更多知识详见 《【C语言】可变参数列表》
比如我们可以通过 可变参数 实现参数遍历
#include stdio.h
#include stdarg.hvoid foreach(int format, ...)
{va_list p;va_start(p, format);// 接下来就是获取其中的每一个参数for(int i 0; i format; i)printf(%d , va_arg(p, int));printf(\n);// 置空va_end(p);
}int main()
{foreach(5, 1,2,3,4,5);return 0;
}这种依靠自己动手的方法比较麻烦我们也可以借助标准库提供的 vsnprintf() 函数进行参数解析
4.3.日志器实现
日志是有等级的一般分为五级
Debug 用于调试Info 提示信息Warning 警告Errorr 错误Fatal 致命错误
错误等级越高代表影响越大
当然难免有不明确的错误可以再添加一级UnKnow 未知错误
// 日志等级
enum
{Debug 0,Info,Warning,Error,Fatal
};string getLevel(int level)
{vectorstring vs {Debug, Info, Warning, Error, Fatal, Unknown};//避免非法情况if(level 0 || level vs.size() - 1)return vs[vs.size() - 1];return vs[level];
}接下来是获取时间信息可以通过 time() 函数获取当前时间戳然后再利用 localtime() 函数构建 struct tm 结构体对象这个对象会将时间戳解析成 年月日 时分秒 等详细信息直接获取即可
strcut tm 结构体的信息如下细节年份已经 -1900 了使用时需要加上 1900月份从 0 开始使用时需要 1
/* Used by other time functions. */
struct tm
{int tm_sec; /* Seconds. [0-60] (1 leap second) */int tm_min; /* Minutes. [0-59] */int tm_hour; /* Hours. [0-23] */int tm_mday; /* Day. [1-31] */int tm_mon; /* Month. [0-11] */int tm_year; /* Year - 1900. */int tm_wday; /* Day of week. [0-6] */int tm_yday; /* Days in year.[0-365] */int tm_isdst; /* DST. [-1/0/1]*/# ifdef __USE_BSDlong int tm_gmtoff; /* Seconds east of UTC. */const char *tm_zone; /* Timezone abbreviation. */
# elselong int __tm_gmtoff; /* Seconds east of UTC. */const char *__tm_zone; /* Timezone abbreviation. */
# endif
};可以这样获取当前时间
// 获取当前时间
string getTime()
{time_t t time(nullptr); //获取时间戳struct tm *st localtime(t); //获取时间相关的结构体char buff[128];snprintf(buff, sizeof(buff), %d-%d-%d %d:%d:%d, st-tm_year 1900, st-tm_mon 1, st-tm_mday, st-tm_hour, st-tm_min, st-tm_sec);return buff;
}
接下来就是获取进程 PID这个简单直接使用 getpid() 函数获取即可最后是解析参数需要用到 vsnprintf() 函数只要传入缓冲区和 va_list 指针该函数就可以自动解析出参数并存入缓冲区中
void logMessage(int level, const char* format, ...)
{//截获主体消息char msgbuff[1024];va_list p;va_start(p, format); //将 p 定位至 format 的起始位置vsnprintf(msgbuff, sizeof(msgbuff), format, p); //自动根据格式进行读取va_end(p);
}接下来就是将 日志等级 时间 PID 与 参数 进行拼接形成日志 log.hpp 日志头文件 #pragma once#include iostream
#include string
#include vector
#include cstdio
#include time.h
#include sys/types.h
#include unistd.h
#include stdarg.husing namespace std;enum
{Debug 0,Info,Warning,Error,Fatal
};string getLevel(int level)
{vectorstring vs {Debug, Info, Warning, Error, Fatal, Unknown};//避免非法情况if(level 0 || level vs.size() - 1)return vs[vs.size() - 1];return vs[level];
}string getTime()
{time_t t time(nullptr); //获取时间戳struct tm *st localtime(t); //获取时间相关的结构体char buff[128];snprintf(buff, sizeof(buff), %d-%d-%d %d:%d:%d, st-tm_year 1900, st-tm_mon 1, st-tm_mday, st-tm_hour, st-tm_min, st-tm_sec);return buff;
}//处理信息
void logMessage(int level, const char* format, ...)
{//日志格式日志等级 [时间] [PID] {消息体}string logmsg getLevel(level); //获取日志等级logmsg getTime(); //获取时间logmsg [ to_string(getpid()) ]; //获取进程PID//截获主体消息char msgbuff[1024];va_list p;va_start(p, format); //将 p 定位至 format 的起始位置vsnprintf(msgbuff, sizeof(msgbuff), format, p); //自动根据格式进行读取va_end(p);logmsg { string(msgbuff) }; //获取主体消息printf(%s\n, logmsg);} 为什么日志消息最后还是向屏幕输出这样组织日志消息的好处是什么 因为现在还在测试阶段等测试完成后可以将日志消息存入文件中做到持久化存储至于统一组织的好处不言而喻能够确保每条日志消息都包含必要信息便于排查错误 简单测试的效果如下 4.4.应用于程序中
接下来可以包含 log.hpp 这个日志器头文件并进行日志输出了比如先将 client.hpp 客户端头文件中的错误信息日志化代码少一些比较好改 client.hpp 客户端头文件 #pragma once#include iostream
#include string
#include cstring
#include cerrno
#include unistd.h
#include sys/types.h
#include sys/socket.h
#include netinet/in.h
#include arpa/inet.h
#include err.hpp
#include log.hppnamespace nt_client
{class TcpClient{public:TcpClient(const std::string ip, const uint16_t port):server_ip_(ip), server_port_(port){}~TcpClient(){}// 初始化客户端void InitClient(){// 创建套接字sock_ socket(AF_INET, SOCK_STREAM, 0);if (sock_ -1){logMessage(Fatal, Create Socket Fail! %s, strerror(errno));exit(SOCKET_ERR);}logMessage(Debug, Create Sock Succeess! %d, sock_);}// 启动客户端void StartClient(){// 填充服务器的 sockaddr_in 结构体信息struct sockaddr_in server;socklen_t len sizeof(server);memset(server, 0, len);server.sin_family AF_INET;inet_aton(server_ip_.c_str(), server.sin_addr); // 将点分十进制转化为二进制IP地址的另一种方法server.sin_port htons(server_port_);// 尝试重连 5 次int n 5;while(n){int ret connect(sock_, (const struct sockaddr*)server, len);if(ret 0){// 连接成功可以跳出循环break;}// 尝试进行重连logMessage(Warning, 网络异常正在进行重连... 剩余连接次数: %d, --n);sleep(1);}// 如果剩余重连次数为 0证明连接失败if(n 0){logMessage(Fatal, 连接失败! %s, strerror(errno));close(sock_);exit(CONNECT_ERR);}// 连接成功logMessage(Info, 连接成功!);// 进行业务处理Service();}// 业务处理void Service(){char buff[1024];std::string who server_ip_ - std::to_string(server_port_);while(true){// 由用户输入信息std::string msg;std::cout Please Enter ;std::getline(std::cin, msg);// 发送信息给服务器write(sock_, msg.c_str(), msg.size());// 接收来自服务器的信息ssize_t n read(sock_, buff, sizeof(buff) - 1);if(n 0){// 正常通信buff[n] \0;std::cout Client get: buff from who std::endl;}else if(n 0){// 读取到文件末尾服务器关闭了logMessage(Error, Server %s quit! %s, who.c_str(), strerror(errno));close(sock_); // 关闭文件描述符break;}else{// 读取异常logMessage(Error, Read Fail! %s, strerror(errno));close(sock_); // 关闭文件描述符break;}}}private:int sock_; // 套接字std::string server_ip_; // 服务器IPuint16_t server_port_; // 服务器端口号};
}效果就是这个样子至于代码中其他输出错误的地方都可以采用 简易版日志器 进行统一输出 改造完成的程序长这个样子 4.5.持久化存储
所谓持久化存储就是将日志消息输出至文件中修改 log.hpp 中的代码即可
指定日志文件存放路径打开文件将日志消息追加至文件中
注意 当前的改动中并未涉及目录创建所以需要手动创建相关目录 log.hpp 日志头文件 #pragma once#include iostream
#include string
#include vector
#include cstdio
#include time.h
#include sys/types.h
#include unistd.h
#include stdarg.husing namespace std;enum
{Debug 0,Info,Warning,Error,Fatal
};static const string file_name log/TcpServer.log;string getLevel(int level)
{vectorstring vs {Debug, Info, Warning, Error, Fatal, Unknown};//避免非法情况if(level 0 || level vs.size() - 1)return vs[vs.size() - 1];return vs[level];
}string getTime()
{time_t t time(nullptr); //获取时间戳struct tm *st localtime(t); //获取时间相关的结构体char buff[128];snprintf(buff, sizeof(buff), %d-%d-%d %d:%d:%d, st-tm_year 1900, st-tm_mon 1, st-tm_mday, st-tm_hour, st-tm_min, st-tm_sec);return buff;
}//处理信息
void logMessage(int level, const char* format, ...)
{//日志格式日志等级 [时间] [PID] {消息体}string logmsg getLevel(level); //获取日志等级logmsg getTime(); //获取时间logmsg [ to_string(getpid()) ]; //获取进程PID//截获主体消息char msgbuff[1024];va_list p;va_start(p, format); //将 p 定位至 format 的起始位置vsnprintf(msgbuff, sizeof(msgbuff), format, p); //自动根据格式进行读取va_end(p);logmsg { string(msgbuff) }; //获取主体消息//持久化。写入文件中FILE* fp fopen(file_name.c_str(), a); //以追加的方式写入if(fp nullptr) return; //不太可能出错fprintf(fp, %s\n, logmsg.c_str());fflush(fp); //手动刷新一下fclose(fp);fp nullptr;
} 5.守护进程
5.1.会话、进程组、进程
接下来进入本文中的最后一个小节 守护进程
守护进程 的意思就是让进程不间断的在后台运行即便是 bash 关闭了也能照旧运行。守护进程 就是现实生活中的服务器因为服务器是需要 24H 不间断运行的
当前我们的程序在启动后属于 前台进程前台进程 是由 bash 进程替换而来的因此会导致 bash 暂时无法使用 如果在启动程序时带上 符号程序就会变成 后台进程后台进程 并不会与 bash 进程冲突bash 仍然可以使用 后台进程 也可以实现服务器不间断运行但问题在于 如果当前 bash 关闭了那么运行中的后台进程也会被关闭最好的解决方案是使用 守护进程
在正式学习 守护进程 之前需要先了解一组概念会话、进程组、进程
分别运行一批 前台、后台进程并通过指令查看进程运行情况
sleep 1000 | sleep 2000 | sleep 3000 sleep 100 | sleep 200 | sleep 300ps -ajx | head -1 ps -ajx | grep sleep | grep -v grep其中 会话 - SID、进程组 - PGID、进程 - PID显然sleep 1000、2000、3000 处于同一个管道中有血缘关系属于同一个 进程组所以他们的 PGID 都是一样的都是 4261至于 sleep 100、200、300 属于另一个 进程组PGID 为 4308再仔细观察可以发现 每一组的进程组 PGID 都与当前组中第一个被创建的进程 PID 一致这个进程被称为 组长进程
会话 进程组 进程
无论是 后台进程 还是 前台进程都是从同一个 bash 中启动的所以它们处于同一个 会话 中SID 都是 1939并且关联的 终端文件 TTY 都是 pts/1 Linux 中一切皆文件终端文件也是如此这里的终端其实就是当前 bash 输出结果时使用的文件也就是屏幕终端文件位于 dev/pts 目录下如果向指定终端文件中写入数据那么对方也可以直接收到 (关联终端文件说白了就是打开了文件一方写一方读不就是管道吗) 根据当前的 会话 SID 查找目标进程发现这玩意就是 bash 进程bash 进程本质上就是一个不断运行中的 前台进程并且自成 进程组 在同一个 bash 中启动前台、后台进程它们的 SID 都是一样的属于同一个 会话关联了同一个 终端 SID 其实就是 bash 的 PID 我们使用 XShell 等工具登录 Linux 服务器时会在服务器中创建一个 会话bash可以在该会话内创建 进程当 进程 间有关系时构成一个 进程组组长 进程的 PID 就是该 进程组 的 PGID Linux 中的登录操作实际上就是创建了一个会话Windows 中也是如此当你的 Windows 变卡时可以使用 [注销] 按钮结束整个会话重新登录电脑就会流畅如初 在同一个会话中只允许一个前台进程在运行默认是 bash如果其他进程运行了bash 就会变成后台进程暂时无法使用让出前台进程这个位置后台进程与前台进程之前是可以进程切换
如何将一个 后台进程 变成 前台进程
首先通过指令查看当前 会话 中正在运行的 后台进程获取 任务号
jobs接下来通过 任务号 将 后台进程 变成 前台进程此时 bash 就无法使用了
fg 1那如何将 前台进程 变成 后台进程
首先是通过 ctrl z 发送 19 号 SIGSTOP 信号暂停正在运行中的 前台进程
键盘输入 ctrl z然后通过 任务号可以把暂停中的进程变成 后台进程
bg 15.2.守护进程化
一般网络服务器为了不受到用户登录重启的影响会以 守护进程 的形式运行有了上面那一批前置知识后就可以很好的理解 守护进程 的本质了
守护进程进程单独成一个会话并且以后台进程的形式运行
说白了就是让服务器不间断运行可以直接使用 daemon() 函数完成 守护进程化
#include unistd.hint daemon(int nochdir, int noclose);参数解读
nochdir 改变进程的工作路径noclose 重定向标准输入、标准输出、标准错误
返回值成功返回 0失败返回 -1
一般情况下daemon() 函数的两个参数都只需要传递 0默认工作在 / 路径下默认重定向至 /dev/null /dev/null 就像是一个 黑洞可以把所有数据都丢入其中相当于丢弃数据 使用 damon() 函数使之前的server.cc 守护进程化 server.cc 服务器源文件 #include memory // 智能指针头文件
#include string
#include unistd.h
#include server.hppusing namespace std;
using namespace nt_server;// 业务处理回调函数字符串回响
string echo(string request)
{return request;
}int main()
{// 直接守护进程化daemon(0, 0);unique_ptrTcpServer usvr (new TcpServer(echo)); // 将回调函数进行传递usvr-InitServer();usvr-StartServer();return 0;
}现在服务器启动后会自动变成 后台进程并且自成一个 新会话归操作系统管守护进程 本质上是一种比较坚强的 孤儿进程
注意 现在标准输出、标准错误都被重定向至 /dev/null 中了之前向屏幕输出的数据现在都会直接被丢弃如果想保存数据可以选择使用日志 如果想终止 守护进程需要通过 kill pid 杀死目标进程 使用系统提供的接口一键 守护进程化 固然方便不过大多数程序员都会选择手动 守护进程化可以根据自己的需求定制操作
原理是 使用 setsid() 函数新设一个会话谁调用会话 SID 就是谁的成为一个新的会话后不会被之前的会话影响
#include unistd.hpid_t setsid(void);返回值成功返回该进程的 pid失败返回 -1
注意 调用该函数的进程不能是组长进程需要创建子进程后调用
手动实现守护进程时需要注意以下几点
忽略异常信号0、1、2 要做特殊处理文件描述符进程的工作路径可能要改变从用户目录中脱离至根目录
具体实现步骤如下
1、忽略常见的异常信号SIGPIPE、SIGCHLD
2、如何保证自己不是组长 创建子进程 成功后父进程退出子进程变成守护进程
3、新建会话自己成为会话的 话首进程
4、可选更改守护进程的工作路径chdir
5、处理后续对于 0、1、2 的问题
对于 标准输入、标准输出、标准错误 的处理方式有两种
暴力处理直接关闭 fd
优雅处理将 fd 重定向至 /dev/null也就是 daemon() 函数的做法
这里我们选择后者守护进程 的函数实现如下 Daemon.hpp 守护进程头文件 #pragma once#include iostream
#include cstring
#include cerrno
#include signal.h
#include unistd.h
#include sys/types.h
#include sys/stat.h
#include fcntl.h
#include err.hpp
#include log.hppstatic const char *path /home/Yohifo;void Daemon()
{// 1、忽略常见信号signal(SIGPIPE, SIG_IGN);signal(SIGCHLD, SIG_IGN);// 2、创建子进程自己退休pid_t id fork();if (id 0)exit(0);else if (id 0){// 子进程创建失败logMessage(Error, Fork Fail: %s, strerror(errno));exit(FORK_ERR);}// 3、新建会话使自己成为一个单独的组pid_t ret setsid();if (ret -1){// 守护化失败logMessage(Error, Setsid Fail: %s, strerror(errno));exit(SETSID_ERR);}// 4、更改工作路径int n chdir(path);if (n -1){// 更改路径失败logMessage(Error, Chdir Fail: %s, strerror(errno));exit(CHDIR_ERR);}// 5、重定向标准输入输出错误int fd open(/dev/null, O_RDWR);if (fd -1){// 文件打开失败logMessage(Error, Open Fail: %s, strerror(errno));exit(OPEN_ERR);}// 重定向标准输入、标准输出、标准错误dup2(fd, 0);dup2(fd, 1);dup2(fd, 2);close(fd);
}当然相应的错误码也需要更新 err.hpp 错误码头文件 #pragma onceenum
{USAGE_ERR 1,SOCKET_ERR,BIND_ERR,LISTEN_ERR,CONNECT_ERR,FORK_ERR,SETSID_ERR,CHDIR_ERR,OPEN_ERR
};接下来就是在服务启动成功后将其 守护进程化 StartServer() 服务器启动函数 — 位于 server.hpp 服务器头文件中的 TcpServer 类 #include myDaemon.hpp// 启动服务器
void StartServer()
{// 守护进程化Daemon();// ...
}现在服务器在启动后会自动新建会话以 守护进程 的形式运行 关于 inet_ntoa 函数的返回值该函数的作用是将四字节的 IP 地址转化为点分十进制的 IP 地址 inet_ntoa 返回值为 char*转化后的 IP 地址存储在静态区二次调用会覆盖上一次的结果多线程场景中不是线程安全的 不过在 CentOS 7 及更高版本中接口进行了更新新增了互斥锁多线程场景中测试没问题 6.完整代码
下面是不同版本服务器的完整代码
「朴素版支持单客户端连接」
「多进程版支持多客户端连接」
「多线程版原生线程库支持多客户端连接」
「多线程版线程池支持多客户端连接」
「日志版支持简易日志输出」
「守护进程版支持服务部署」 ️总结
以上是关于『简易TCP网络程序』的全部内容作为上一篇博客的延伸本文重新实现了字符串回响网络程序基于TCP协议逐步改造并引入多进程、多线程、线程池、日志输出、守护进程等技术。这使得网络程序更为成熟为后续网络和高级IO的学习提供了有力支持。同时对套接字编程的重要性也得到了充分体现。希望本文能为读者在网络编程领域的深入学习提供实质性帮助。 相关文章推荐 网络编程『socket套接字 ‖ 简易UDP网络程序』 网络基础『发展 ‖ 协议 ‖ 传输 ‖ 地址』