🔭个人主页: 北 海🛜所属专栏: 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
服务器头文件
namespace 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
服务器源文件
using namespace std; | |
using namespace nt_server; | |
int main() | |
{ | |
unique_ptr<TcpServer> usvr (new TcpServer()); | |
usvr->InitServer(); | |
usvr->StartServer(); | |
return 0; | |
} |
创建 client.hpp
客户端头文件
namespace 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_; // 服务器IP | |
uint16_t server_port_; // 服务器端口号 | |
}; | |
} |
创建 client.cc
客户端源文件
using 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_ptr<TcpClient> usvr(new TcpClient(ip, port)); | |
usvr->InitClient(); | |
usvr->StartClient(); | |
return 0; | |
} |
同时需要一个 Makefile
文件,用于快速编译和清理可执行程序
创建 Makefile
文件
all:server client | |
server:server.cc | |
g++ -o $@ $^ -std=c++11 | |
client:client.cc | |
g++ -o $@ $^ -std=c++11 | |
clean: | |
rm -rf server client |
最后为了方便判断程序错误,可以增加上一篇文章中的 err.hpp
头文件,里面包含错误码与简易错误信息
创建 err.hpp
错误码头文件
enum | |
{ | |
USAGE_ERR = 1, | |
SOCKET_ERR, | |
BIND_ERR | |
}; |
接下来开始填充代码内容
服务器
1.3.初始化服务器
基于 TCP
协议实现的网络程序也需要 创建套接字、绑定 IP
和端口号
在使用socket
函数创建套接字时,UDP
协议需要指定参数2为SOCK_DGRAM
,TCP
协议则是指定参数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 IP&&Port Fail: " << strerror(errno) << std::endl; | |
exit(BIND_ERR); | |
} | |
// 3.TODO | |
} |
注意: 在绑定端口号时,一定需要把主机序列转换为网络序列
为什么在绑定端口号阶段需要手动转换为网络序列,而在发送信息阶段则不需要? 这是因为在发送信息阶段,recvfrom / sendto
等函数会自动将需要发送的信息转换为网络序列,接收信息时同样会将其转换为主机序列,所以不需要手动转换
如果使用的 UDP
协议,那么初始化服务器到此就结束了,但我们本文中使用的是 TCP
协议,这是一个 面向连接 的传输层协议,意味着在初始化服务器时,需要设置服务器为 监听 状态
使用到的函数是 listen
函数
int listen(int sockfd, int backlog); |
参数解读:
sockfd
通过该套接字进行监听backlog
全连接队列最大长度
返回值:监听成功返回 0
,失败返回 -1
这里的参数2需要设置一个整数,通常为 16、32、64...
,表示 全连接队列 的最大长度,关于 全连接队列 的详细知识放到后续博客中讲解,这里只需要直接使用
server.hpp
服务器头文件
namespace 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 IP&&Port 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
函数进行连接
int 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
服务器头文件
namespace 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 IP&&Port 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
服务器头文件
namespace nt_server | |
{ | |
const uint16_t default_port = 8888; // 默认端口号 | |
const int backlog = 32; // 全连接队列的最大长度 | |
using func_t = std::function<std::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 IP&&Port 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
服务器源文件
using namespace std; | |
using namespace nt_server; | |
// 业务处理回调函数(字符串回响) | |
string echo(string request) | |
{ | |
return request; | |
} | |
int main() | |
{ | |
unique_ptr<TcpServer> 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
客户端头文件
namespace 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_; // 服务器IP | |
uint16_t server_port_; // 服务器端口号 | |
}; | |
} |
编译并运行客户端,显示 socket
套接字创建成功
1.7.启动客户端
1.7.1.尝试进行连接
因为 TCP
协议是面向连接的,服务器已经处于处理连接请求的状态了,客户端现在需要做的就是尝试进行连接,使用 connect
函数进行连接
int 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
错误码头文件
enum | |
{ | |
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
类
// 进程创建、等待所需要的头文件 | |
// 启动服务器 | |
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
信号的执行动作为 忽略
忽略了该信号后,就不需要父进程等待子进程退出了(由操作系统承担)
// 启动服务器 | |
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()
函数中信号处理函数的参数要求
// 需要设置为静态 | |
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); | |
else | |
break; | |
} | |
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
服务器头文件完整代码如下
namespace nt_server | |
{ | |
const uint16_t default_port = 8888; // 默认端口号 | |
const int backlog = 32; // 全连接队列的最大长度 | |
using func_t = std::function<std::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 IP&&Port 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
服务器头文件
namespace nt_server | |
{ | |
const uint16_t default_port = 8888; // 默认端口号 | |
const int backlog = 32; // 全连接队列的最大长度 | |
using func_t = std::function<std::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 IP&&Port 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_cast<ThreadData*>(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
文件
all:server client | |
server:server.cc | |
g++ -o $@ $^ -std=c++11 -lpthread | |
client:client.cc | |
g++ -o $@ $^ -std=c++11 -lpthread | |
clean: | |
rm -rf server client |
接下来就是编译并运行程序,可以看到 当前只有一个进程,同时有五个线程在运行
使用 原生线程库 过于单薄了,并且这种方式存在问题:连接都准备好了,才创建线程,如果创建线程所需要的资源较多,会拖慢服务器整体连接效率
为此可以改用之前实现的 线程池
3.3.使用线程池
之前在 《Linux多线程【线程池】》一文中实现了多个版本的线程池,这里我们直接使用最终版,也就是 单例模式版线程池
部分组件不需要修改,代码如下:
ThreadPool.hpp
线程池头文件
namespace Yohifo | |
{ | |
template<class T> | |
class ThreadPool | |
{ | |
private: | |
ThreadPool(int num = THREAD_NUM) | |
:_num(num) | |
{ | |
} | |
~ThreadPool() | |
{ | |
// 等待线程退出 | |
for(auto &t : _threads) | |
t.join(); | |
} | |
// 删除拷贝构造 | |
ThreadPool(const ThreadPool<T> &) = delete; | |
public: | |
static ThreadPool<T>* getInstance() | |
{ | |
// 双检查 | |
if(_inst == nullptr) | |
{ | |
// 加锁 | |
LockGuard lock(&_mtx); | |
if(_inst == nullptr) | |
{ | |
// 创建对象 | |
_inst = new ThreadPool<T>(); | |
// 初始化及启动服务 | |
_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(); | |
} | |
// 提供给线程的回调函数(已修改返回类型为 void) | |
static void threadRoutine(void *args) | |
{ | |
// 避免等待线程,直接剥离 | |
pthread_detach(pthread_self()); | |
auto ptr = static_cast<ThreadPool<T>*>(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::vector<Thread> _threads; | |
int _num; // 线程数量 | |
BlockQueue<T> _blockqueue; // 阻塞队列 | |
// 创建静态单例对象指针及互斥锁 | |
static ThreadPool<T> *_inst; | |
static pthread_mutex_t _mtx; | |
}; | |
// 初始化指针 | |
template<class T> | |
ThreadPool<T>* ThreadPool<T>::_inst = nullptr; | |
// 初始化互斥锁 | |
template<class T> | |
pthread_mutex_t ThreadPool<T>::_mtx = PTHREAD_MUTEX_INITIALIZER; | |
} |
Thread.hpp
封装实现的线程库头文件
enum 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() | |
{} | |
// 获取 ID | |
pthread_t getTID() const | |
{ | |
return _tid; | |
} | |
// 获取线程名 | |
std::string getName() const | |
{ | |
return _name; | |
} | |
// 获取状态 | |
Status getStatus() const | |
{ | |
return _status; | |
} | |
// 回调方法 | |
static void* runHelper(void* args) | |
{ | |
Thread* myThis = static_cast<Thread*>(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; // 线程 ID | |
std::string _name; // 线程名 | |
Status _status; // 线程状态 | |
func_t _func; // 线程回调函数 | |
void* _args; // 传递给回调函数的参数 | |
}; |
BlockingQueue.hpp
生产者消费者模型头文件
// 命名空间,避免冲突 | |
namespace Yohifo | |
{ | |
template<class T> | |
class 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::queue<T> _queue; | |
size_t _cap; // 阻塞队列的容量 | |
pthread_mutex_t _mtx; // 互斥锁 | |
pthread_cond_t _pro_cond; // 生产者条件变量 | |
pthread_cond_t _con_cond; // 消费者条件变量 | |
}; | |
} |
LockGuard.hpp
自动化锁头文件
class 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
任务类
namespace Yohifo | |
{ | |
// Service() 业务处理函数的类型 | |
using cb_t = std::function<void(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::ThreadPool<Yohifo::Task>::getInstance()->pushTask(t); |
完整的服务器代码如下
server.hpp
服务器头文件
namespace nt_server | |
{ | |
const uint16_t default_port = 8888; // 默认端口号 | |
const int backlog = 32; // 全连接队列的最大长度 | |
using func_t = std::function<std::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 IP&&Port 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::ThreadPool<Yohifo::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.h> | |
va_list // 指向可变参数列表的指针 | |
va_start() // 将指针指向起始地址 | |
va_arg() // 根据类型,提取可变参数列表中的参数 | |
va_end() // 将指针置为空 |
关于 可变参数 更多知识详见 《【C语言】可变参数列表》
比如我们可以通过 可变参数 实现参数遍历
void 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) | |
{ | |
vector<string> 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]*/ | |
long int tm_gmtoff; /* Seconds east of UTC. */ | |
const char *tm_zone; /* Timezone abbreviation. */ | |
long int __tm_gmtoff; /* Seconds east of UTC. */ | |
const char *__tm_zone; /* Timezone abbreviation. */ | |
}; |
可以这样获取当前时间
// 获取当前时间 | |
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
日志头文件
using namespace std; | |
enum | |
{ | |
Debug = 0, | |
Info, | |
Warning, | |
Error, | |
Fatal | |
}; | |
string getLevel(int level) | |
{ | |
vector<string> 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
客户端头文件
namespace 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_; // 服务器IP | |
uint16_t server_port_; // 服务器端口号 | |
}; | |
} |
效果就是这个样子,至于代码中其他输出错误的地方,都可以采用 简易版日志器 进行统一输出
改造完成的程序长这个样子
4.5.持久化存储
所谓持久化存储就是将日志消息输出至文件中,修改 log.hpp
中的代码即可
- 指定日志文件存放路径
- 打开文件,将日志消息追加至文件中
注意: 当前的改动中并未涉及目录创建,所以需要手动创建相关目录
log.hpp
日志头文件
using namespace std; | |
enum | |
{ | |
Debug = 0, | |
Info, | |
Warning, | |
Error, | |
Fatal | |
}; | |
static const string file_name = "log/TcpServer.log"; | |
string getLevel(int level) | |
{ | |
vector<string> 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 300 | |
ps -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 1
5.2.守护进程化
一般网络服务器为了不受到用户登录重启的影响,会以 守护进程 的形式运行,有了上面那一批前置知识后,就可以很好的理解 守护进程 的本质了
守护进程:进程单独成一个会话,并且以后台进程的形式运行
说白了就是让服务器不间断运行,可以直接使用 daemon()
函数完成 守护进程化
int daemon(int nochdir, int noclose); |
参数解读:
nochdir
改变进程的工作路径noclose
重定向标准输入、标准输出、标准错误
返回值:成功返回 0
,失败返回 -1
一般情况下,daemon()
函数的两个参数都只需要传递 0
,默认工作在 /
路径下,默认重定向至 /dev/null
/dev/null
就像是一个 黑洞,可以把所有数据都丢入其中,相当于丢弃数据
使用 damon()
函数使之前的server.cc
守护进程化
server.cc
服务器源文件
using namespace std; | |
using namespace nt_server; | |
// 业务处理回调函数(字符串回响) | |
string echo(string request) | |
{ | |
return request; | |
} | |
int main() | |
{ | |
// 直接守护进程化 | |
daemon(0, 0); | |
unique_ptr<TcpServer> usvr (new TcpServer(echo)); // 将回调函数进行传递 | |
usvr->InitServer(); | |
usvr->StartServer(); | |
return 0; | |
} |
现在服务器启动后,会自动变成 后台进程,并且自成一个 新会话,归操作系统管(守护进程 本质上是一种比较坚强的 孤儿进程)
注意: 现在标准输出、标准错误都被重定向至 /dev/null
中了,之前向屏幕输出的数据,现在都会直接被丢弃,如果想保存数据,可以选择使用日志
如果想终止 守护进程,需要通过 kill pid
杀死目标进程
使用系统提供的接口一键 守护进程化 固然方便,不过大多数程序员都会选择手动 守护进程化(可以根据自己的需求定制操作)
原理是 使用 setsid()
函数新设一个会话,谁调用,会话 SID
就是谁的,成为一个新的会话后,不会被之前的会话影响
pid_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
守护进程头文件
static 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
错误码头文件
enum | |
{ | |
USAGE_ERR = 1, | |
SOCKET_ERR, | |
BIND_ERR, | |
LISTEN_ERR, | |
CONNECT_ERR, | |
FORK_ERR, | |
SETSID_ERR, | |
CHDIR_ERR, | |
OPEN_ERR | |
}; |
接下来就是在服务启动成功后,将其 守护进程化
StartServer()
服务器启动函数 — 位于server.hpp
服务器头文件中的TcpServer
类
// 启动服务器 | |
void StartServer() | |
{ | |
// 守护进程化 | |
Daemon(); | |
// ... | |
} |
现在服务器在启动后,会自动新建会话,以 守护进程 的形式运行
关于inet_ntoa
函数的返回值(该函数的作用是将四字节的IP
地址转化为点分十进制的IP
地址)inet_ntoa
返回值为char*
,转化后的IP
地址存储在静态区,二次调用会覆盖上一次的结果,多线程场景中不是线程安全的
- 不过在
CentOS 7
及更高版本中,接口进行了更新,新增了互斥锁,多线程场景中测试没问题
6.完整代码
下面是不同版本服务器的完整代码
🌨️总结
以上是关于『简易TCP网络程序』的全部内容,作为上一篇博客的延伸,本文重新实现了字符串回响网络程序,基于TCP协议逐步改造并引入多进程、多线程、线程池、日志输出、守护进程等技术。这使得网络程序更为成熟,为后续网络和高级IO的学习提供了有力支持。同时,对套接字编程的重要性也得到了充分体现。希望本文能为读者在网络编程领域的深入学习提供实质性帮助。