🔭个人主页: 北 海🛜所属专栏: 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.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
服务器源文件
#include <memory> // 智能指针头文件
#include "server.hpp"
using namespace std;
using namespace nt_server;
int main()
{
unique_ptr<TcpServer> 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.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
客户端源文件
#include <memory>
#include "client.hpp"
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
文件
.PHONY:all
all:server client
server:server.cc
g++ -o $@ $^ -std=c++11
client:client.cc
g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -rf server client
最后为了方便判断程序错误,可以增加上一篇文章中的 err.hpp
头文件,里面包含错误码与简易错误信息
创建 err.hpp
错误码头文件
#pragma once
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
函数
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int 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.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
函数进行连接
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
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
服务器头文件
#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.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
服务器头文件
#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"
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
服务器源文件
#include <memory> // 智能指针头文件
#include <string>
#include "server.hpp"
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
客户端头文件
#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.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
函数进行连接
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
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
错误码头文件
#pragma once
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
类
// 进程创建、等待所需要的头文件
#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);
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
服务器头文件完整代码如下
#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.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
服务器头文件
#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.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
文件
.PHONY:all
all:server client
server:server.cc
g++ -o $@ $^ -std=c++11 -lpthread
client:client.cc
g++ -o $@ $^ -std=c++11 -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 10
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
封装实现的线程库头文件
#pragma once
#include <iostream>
#include <string>
#include <pthread.h>
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
生产者消费者模型头文件
#pragma once
#include <queue>
#include <mutex>
#include <pthread.h>
#include "LockGuard.hpp"
// 命名空间,避免冲突
namespace Yohifo
{
#define DEF_SIZE 10
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
自动化锁头文件
#pragma once
#include <pthread.h>
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
任务类
#pragma once
#include <string>
#include <functional>
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
服务器头文件
#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::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语言】可变参数列表》
比如我们可以通过 可变参数 实现参数遍历
#include <stdio.h>
#include <stdarg.h>
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]*/
# ifdef __USE_BSD
long int tm_gmtoff; /* Seconds east of UTC. */
const char *tm_zone; /* Timezone abbreviation. */
# else
long 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.h>
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
客户端头文件
#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.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
日志头文件
#pragma once
#include <iostream>
#include <string>
#include <vector>
#include <cstdio>
#include <time.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdarg.h>
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()
函数完成 守护进程化
#include <unistd.h>
int 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.hpp"
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
就是谁的,成为一个新的会话后,不会被之前的会话影响
#include <unistd.h>
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
守护进程头文件
#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.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
错误码头文件
#pragma once
enum
{
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的学习提供了有力支持。同时,对套接字编程的重要性也得到了充分体现。希望本文能为读者在网络编程领域的深入学习提供实质性帮助。