目录
前言:
一、字符串回响
(一)程序结构
(二)初始化服务器
(三)启动服务器
1. 处理连接请求
2. 业务处理
3. 回调函数
(四)填充server源文件
(五)初始化客户端
(六)启动客户端
1. 尝试进行连接
2. 业务处理
二、多进程服务端
(一)多个客户端请求问题
(二)服务端创建子进程
1. 设置非阻塞
三、多线程服务端
(一)使用原生线程库
(二)使用线程池
四、日志
(一)概念
(二)可变参数
(三)日志器实现
(四)应用到服务端客户端中
(五)持久化存储
五、守护进程
(一)会话、进程组、进程
(二)守护进程化
前言:
TCP(传输控制协议)和UDP(用户数据报协议)是两种主要的传输层协议,它们的主要区别在于连接性、可靠性和效率。TCP是面向连接的协议,提供可靠的数据传输服务,适用于需要确保数据完整性和顺序性的场景,如文件传输和电子邮件。而UDP则是无连接的协议,不保证数据的可靠性,但开销较小,适用于对实时性要求较高、对丢包不敏感的应用,如实时音视频传输和在线游戏。在选择协议时,需要根据应用需求和网络环境进行权衡。在上篇文章我们模拟了基于UDP协议的简易通信编程,这篇就来模拟基于TCP协议的网络通信编程是如何实现的。
一、字符串回响
字符串回响程序类似于 echo
指令,客户端向服务器发送消息,服务器在收到消息后会将消息发送给客户端,该程序实现起来比较简单,同时能很好的体现 socket
套接字编程的流程:
(一)程序结构
这个程序我们已经基于 UDP
协议实现过了,换成 TCP
协议实现时,程序的结构是没有变化的,同样需要 tcp_server.hpp
、tcp_
server.cc
、tcp_
client.hpp
、tcp_
client.cc
这几个文件:
tcp_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 ns_server
{
const uint16_t default_port = 8081; // 默认端口号
class TcpServer
{
public:
TcpServer(const uint16_t port = default_port)
:port_(port)
{}
~TcpServer()
{}
// 初始化服务器
void Init()
{}
// 启动服务器
void Start()
{}
private:
int sock_; // 套接字(稍后需修改)
uint16_t port_; // 端口号
}
}
注意: 这里的 sock_
套接字成员后面需要修改
tcp_server.cc
#include <memory>
#include "tcp_server.hpp"
using namespace std;
using namespace ns_server;
int main()
{
unique_ptr<TcpServer> usvr(new TcpServer());
usvr->Init();
usvr->Start();
return 0;
}
tcp_
client.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 ns_client
{
class TcpClient
{
public:
TcpClient(const std::string& ip, const uint16_t port)
:server_ip_(ip), server_port_(port)
{}
~TcpClient()
{}
// 初始化服务器
void Init()
{}
// 启动服务器
void Start()
{}
private:
int sock_; // 套接字(稍后需修改)
uint16_t server_port_; // 服务器端口号
std::string server_ip_; // 服务器IP地址
}
}
tcp_
client.cc
#include <menory>
#include "tcp_client.hpp"
using namespace std;
using namespace ns_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->Init();
usvr->Start();
return 0;
}
makefile
all:tcp_client tcp_server
tcp_client:tcp_client.cc
g++ -o $@ $^ -std=c++11
tcp_server:tcp_server.cc
g++ -o $@ $ ^ -std=c++11
.PHONY:clean
clean:
rm -f tcp_client tcp_server
最后为了方便判断程序错误,可以增加上一篇文章中的 err.hpp
头文件,里面包含错误码与简易错误信息:
#pragma once
enum
{
USAGE_ERR = 1,
SOCKET_ERR,
BNID_ERR
};
(二)初始化服务器
基于 TCP
协议实现的网络程序也需要 创建套接字、绑定 IP
和端口号
在使用
socket
函数创建套接字时,UDP
协议需要指定参数2为SOCK_DGRAM
,TCP
协议则是指定参数2为SOCK_STREAM
// 初始化服务器
void Init()
{
// 1. 创建套接字
sock_ = socket(AF_INET, SOCK_STREAM, 0);
if(sock_ < 0)
{
std::cerr << "create socket error: " << strerror(errno) << std::endl;
exit(SOCKET_ERR);
}
std::cout << "create socket success: " << sock_ << std::endl;
// 2. 绑定IP地址和端口号
struct sockaddr_in local;
memset(&local, sizeof(local)); // 清零
local.sin_family = AF_INET;
local.sin_addr.s_addr = INADDR_ANY; // 绑定任意可用IP地址
local.sin_port = htons(port_);
if(bind(sock_, (struct sockaddr*)&local, sizeof(local)))
{
std::cerr << "bind socket error: " << strerror(errno) << std::endl;
exit(BIND_ERR);
}
// 3. TODO
}
注意: 在绑定端口号时,一定需要把主机序列转换为网络序列。
为什么在绑定端口号阶段需要手动转换为网络序列,而在发送信息阶段则不需要?
这是因为在发送信息阶段,recvfrom / sendto 等函数会自动将需要发送的信息转换为网络序列,接收信息时同样会将其转换为主机序列,所以不需要手动转换。
如果使用的 UDP
协议,那么初始化服务器到此就结束了,但我们本文中使用的是 TCP
协议,这是一个 面向连接 的传输层协议,意味着在初始化服务器时,需要设置服务器为 监听 状态,即用于接受客户端的连接请求:
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int listen(int sockfd, int backlog);
参数解读:
sockfd
代表已经调用bind函数绑定了地址的套接字,它标识了本地端点,即服务器要监听的端口。backlog
指定了内核为此套接字排队等待处理的最大连接数。当有新的客户端连接请求时,如果已经达到backlog指定的等待连接数,新的连接请求将被拒绝。
返回值:监听成功返回 0
,失败返回 -1
这里的参数2需要设置一个整数,通常为 16、32、64...
,表示 全连接队列 的最大长度,关于 全连接队列 的详细知识放到后续文章中讲解,这里只需要直接使用。
#include <iostream>
#include <cerrno>
#include <ctring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "err.hpp"
namespace ns_server
{
// ...
const int backlog = 32; // 全连接队列的最大长度
class TcpServer
{
public:
// ...
// 初始化服务器
void Init()
{
// ...
// 3. listen
if(listen(sock_, backlog) == -1)
{
std::cerr << "listen error: " << strerror(errno) << std::endl;
exit(LISTEN_ERR);
}
std::cout << "listen success" << std::endl;
}
// ...
private:
int sock_; // 套接字(稍后需修改)
uint16_t port_; // 端口号
}
}
至此基于 TCP
协议实现的初始化服务器函数就填充完成了,编译并运行服务器,显示初始化服务器成功
(三)启动服务器
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
服务器中,这种套接字称为 监听套接字
#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 ns_server
{
const uint16_t default_port = 8081; // 默认端口号
const int backlog = 32; // 全连接队列的最大长度
class TcpServer
{
public:
TcpServer(const uint16_t port = default_port)
:port_(port), quit_(false)
{}
~TcpServer()
{}
// 初始化服务器
void Init()
{
// 1. 创建监听套接字
listensock_ = socket(AF_INET, SOCK_STREAM, 0);
if(listensock_ < 0)
{
std::cerr << "create socket error: " << strerror(errno) << std::endl;
exit(SOCKET_ERR);
}
std::cout << "create socket 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_, (struct sockaddr*)&local, sizeof(local)))
{
std::cerr << "bind socket error: " << strerror(errno) << std::endl;
exit(BIND_ERR);
}
// 3. listen
if(listen(listensock_, backlog) == -1)
{
std::cerr << "listen error: " << strerror(errno) << std::endl;
exit(LISTEN_ERR);
}
std::cout << "listen success" << std::endl;
}
// 启动服务器
void Start()
{
while(!quit_)
{
// 1. 处理连接请求
struct sockaddr_in client;
socklen_t len = sizeof(client);
int sock = accept(listensock_, (struct sockaddr*)&client, &len);
// 2. 如果连接失败就继续尝试连接
if(sock < 0)
{
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.根据套接字进行通信业务处理
// ...
}
}
private:
int listensock_;// 监听套接字
uint16_t port_; // 端口号
bool quit_; // 判断服务器是否结束运行
};
}
2. 业务处理
对于 TCP
服务器来说,它是面向字节流传输的,我们之前使用的文件相关操作也是面向字节流,凑巧的是在 Linux
中网络是以挂接在文件系统的方式实现的,种种迹象表明:可以通过文件相关接口进行通信
read
从文件中读取信息(接收消息)write
向文件中写入信息(发送消息)
这两个系统调用的核心参数是 fd
(文件描述符),即服务器与客户端在连接成功后,获取到的 socket
套接字,所以接下来可以按文件操作的套路,完成业务处理
// 业务处理
void Service(int sock, const std::string& clientip, const uint16_t& clientport)
{
char buff[1024];
std::string who = clientip + "-" + std::to_string(clientip);
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;
}
}
}
3. 回调函数
为了更好的实现功能解耦,这里将真正的业务处理函数交给上层处理,编写完成后传给 TcpServer
对象即可,当然,在 TcpServer
类中需要添加对应的类型
这里设置回调函数的返回值为
string
,参数同样为string
#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 ns_server
{
const uint16_t default_port = 8081; // 默认端口号
const int backlog = 32; // 全连接队列的最大长度
// 参数为string返回值为string的函数
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 Init()
{
// 1. 创建监听套接字
listensock_ = socket(AF_INET, SOCK_STREAM, 0);
if(listensock_ < 0)
{
std::cerr << "create socket error: " << strerror(errno) << std::endl;
exit(SOCKET_ERR);
}
std::cout << "create socket 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_, (struct sockaddr*)&local, sizeof(local)))
{
std::cerr << "bind socket error: " << strerror(errno) << std::endl;
exit(BIND_ERR);
}
// 3. listen
if(listen(listensock_, backlog) == -1)
{
std::cerr << "listen error: " << strerror(errno) << std::endl;
exit(LISTEN_ERR);
}
std::cout << "listen success" << std::endl;
}
// 启动服务器
void Start()
{
while(!quit_)
{
// 1. 处理连接请求
struct sockaddr_in client;
socklen_t len = sizeof(client);
int sock = accept(listensock_, (struct sockaddr*)&client, &len);
// 2. 如果连接失败就继续尝试连接
if(sock < 0)
{
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.根据套接字进行通信业务处理
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
服务器源文件
(四)填充server源文件
对于当前的 TCP
网络程序(字符串回响)来说,业务处理函数逻辑非常简单,无非就是直接将客户端发送过来的消息,重新转发给客户端
#include <memory>
#include "tcp_server.hpp"
using namespace std;
using namespace ns_server;
// 业务处理回调函数(字符串回响)
string echo(string request)
{
return request;
}
int main()
{
unique_ptr<TcpServer> usvr(new TcpServer(echo));
usvr->Init();
usvr->Start();
return 0;
}
尝试编译并运行服务器,可以看到当前 bash
已经被我们的服务器程序占用了,重新打开一个终端,并通过 netstat
命令查看网络使用情况(基于 TCP
协议)
netstat -nltp
当前服务确实使用的是 8081
端口,并且采用的是 TCP
协议
(五)初始化客户端
对于客户端来说,服务器的 IP
地址与端口号是两个不可或缺的元素,因此在客户端类中,server_ip
和 server_port
这两个成员是少不了的,当然也得有 socket
套接字
初始化客户端只需要干一件事:创建套接字,客户端是主动发起连接请求的一方,也就意味着它不需要使用 listen
函数设置为监听状态
注意: 客户端也是需要 bind
绑定的,但不需要自己手动绑定,由操作系统帮我们自动完成。
#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 ns_client
{
class TcpClient
{
public:
TcpClient(const std::string& ip, const uint16_t port)
:server_ip_(ip), server_port_(port)
{}
~TcpClient()
{}
// 初始化服务器
void Init()
{
// 创建套接字
sock_ = socket(AF_INET, SOCK_STREAM, 0);
if(sock_ < 0)
{
std::cerr << "create socket fail" << strerror(errno) << std::endl;
exit(SOCKET_ERR);
}
std::cout << "create socket success " << sock_ << std::endl;
}
// 启动服务器
void Start()
{}
private:
int sock_; // 套接字
uint16_t server_port_; // 服务器端口号
std::string server_ip_; // 服务器IP地址
};
}
编译并运行客户端,显示 socket
套接字创建成功
(六)启动客户端
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()
等函数使程序睡眠一会,给网络恢复留出时间
// 启动服务器
void Start()
{
// 填充服务器的 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_, (struct sockaddr*)&server, len);
// 连接成功
if(ret == 0) break;
// 尝试重连
std::cerr << "正在进行重新连接...剩余次数: " << --n << std::endl;
sleep(1);
}
if(n == 0)
{
std::cerr << "连接失败!" << strerror(errno) << std::endl;
close(sock_);
exit(CONNECT_ERR);
}
// 连接成功
std::cout << "连接成功!" << std::endl;
// 进行业务处理
// Service();
}
现在先不启动服务器,编译并启动客户端,模拟连接失败的情况:
如果在数秒之后启动再服务器,可以看到重连成功:
我们打游戏想必都会遇到重新连接的情况,例如csgo:
2. 业务处理
客户端在进行业务处理时,同样可以使用 read
和 write
进行网络通信
// 业务处理
void Service()
{
char buff[1024];
std::string who = server_ip_ + "-" + std::to_string(server_port_);
while(true)
{
// 由用户输入信息
std::string msg;
std::cout << "Please Enter Message >> ";
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
状态),详细原因将会在后续文章中讲解:
二、多进程服务端
(一)多个客户端请求问题
对于之前编写的 字符串回响程序 来说,如果只有一个客户端进行连接并通信,是没有问题的,但如果有多个客户端发起连接请求,并尝试进行通信,服务器是无法应对的,只能等其他客户端退出才能连接服务端,这显然是不符合需求的。
原因就在于 服务器是一个单进程版本,处理连接请求 和 业务处理 是串行化执行的,如果想处理下一个连接请求,需要把当前的业务处理完成:
具体表现为下面这种情况:
蓝色主机的消息没有被服务器收到,证明蓝色主机与服务器还没有建立连接关系,因为当前红色主机正在与服务器通信(此时连接和通信是串行化的)
为什么蓝色客户端会显示当前已经连接成功?
这是因为是客户端是主动发起连接请求的一方,在请求发出后,如果出现连接错误,客户端就认为已经连接成功了,但实际上服务器还没有处理这个连接请求。
当红色客户端断开连接之后服务器才开始处理蓝色客户端的连接请求此时才轮到蓝色客户端与服务器进行通信 :
这显然是服务器的问题,处理连接请求 与 业务处理 应该交给两个不同的执行流完成,可以使用多进程或者多线程解决,这里先采用多进程的方案
所以当前需要实现的网络程序核心功能为:当服务器成功处理连接请求后,fork
新建一个子进程,用于进行业务处理,原来的进程专注于处理连接请求
(二)服务端创建子进程
注:当前的版本的修改只涉及 Start()
函数
创建子进程使用 fork()
函数,它的返回值含义如下
ret == 0
表示创建子进程成功,接下来执行子进程的代码ret > 0
表示创建子进程成功,接下来执行父进程的代码ret < 0
表示创建子进程失败
子进程创建成功后,会继承父进程的文件描述符表,能轻而易举的获取客户端的 socket
套接字,从而进行网络通信。
当然不止文件描述符表,得益于 写时拷贝 机制,子进程还会共享父进程的变量,当发生修改行为时,才会自己创建。
注意: 当子进程取走客户端的 socket
套接字进行通信后,父进程需要将其关闭(因为它不需要了),避免文件描述符泄漏
// 启动服务器
void Start()
{
while(!quit_)
{
// 1. 处理连接请求
struct sockaddr_in client;
socklen_t len = sizeof(client);
int sock = accept(listensock_, (struct sockaddr*)&client, &len);
// 2. 如果连接失败就继续尝试连接
if(sock < 0)
{
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!";
}
}
虽然此时成功创建了子进程,但父进程(处理连接请求)仍然需要等待子进程退出后,才能继续运行,说白了就是 父进程现在处于阻塞等待状态,需要设置为 非阻塞等待:
只有两个进程:
1. 设置非阻塞
设置父进程为非阻塞的方式有很多,这里来一一列举
方式一:通过参数设置为非阻塞等待(不推荐)
可以直接给 waitpid()
函数的参数3传递 WNOHANG
,表示当前为 非阻塞等待
pid_t ret = waitpid(id, nullptr, WNOHANG); // 设置为非阻塞式等待
现在有三个进程:
这种方法可行,但不推荐,原因如下:虽然设置成了非阻塞式等待,但父进程终究是需要通过
waitpid()
函数来尝试等待子进程,倘若父进程一直卡在accept()
函数处,会导致子进程退出后暂时无人收尸,进而导致资源泄漏。
方式二:忽略 SIGCHLD
信号(推荐使用)
这是一个子进程在结束后发出的信号,默认动作是什么都不做;父进程需要检测并回收子进程,我们可以直接忽略该信号,这里的忽略是个特例,只是父进程不对其进行处理,转而由 操作系统 对其负责,自动清理资源并进行回收,并不会产生 僵尸进程。
直接在 StartServer()
服务器启动函数刚开始时,使用 signal()
函数设置 SIGCHLD
信号的执行动作为 忽略
// 启动服务器
void Start()
{
while(!quit_)
{
// 忽略 SIGCHLD 信号
signal(SIGCHLD, SIG_IGN);
// ...
// 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
信号的处理动作为子进程回收(不是很推荐)
当子进程退出并发送该信号时,执行父进程回收子进程的操作
设置 SIGCHLD
信号的处理动作为 回收子进程后,父进程同样不必再考虑回收子进程的问题
注意: 因为现在处于 TcpServer
类中,handler()
函数需要设置为静态(避免隐含的 this
指针),避免不符合 signal()
函数中信号处理函数的参数要求
static void handler(int signo)
{
printf("进程 %d 捕捉到了 %d 信号\n", getpid(), signo);
// 这里的 -1 表示父进程等待时,只要是已经退出了的1子进程,都可以进行回收
while(1)
{
pid_t ret = waitpid(-1, NULL, WNOHANG);
if(ret > 0) printf("父进程:%d 已经成功回收了 %d 号进程\n", getpid(), ret);
else break;
}
printf("子进程回收成功\n");
}
// 启动服务器
void Start()
{
// 设置 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 Start()
{
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); // 默认为阻塞式等待
// pid_t ret = waitpid(id, nullptr, WNOHANG); // 非阻塞式等待(不推荐)
if(ret == id) std::cout << "wait " << id << " success!";
}
}
}
这种方法代码也很简单,但依旧不推荐,因为倘若连接请求变多,会导致孤儿进程变多,孤儿进程由操作系统接管,数量变多会给操作系统带来负担
以上就是设置 非阻塞 的四种方式,推荐使用方式二:忽略 SIGCHLD
信号
至此我们的 字符串回响程序 可以支持多客户端了
细节补充:当子进程取走 sock
套接字进行网络通信后,父进程就不需要使用 sock
套接字了,可以将其进行关闭,下次连接时继续使用,避免文件描述符不断增长,可以减少资源消耗,建议加上
void Start()
{
// 忽略 SIGCHLD 信号
signal(SIGCHLD, SIG_IGN);
while(!quit_)
{
// ...
close(sock); // 父进程不再需要资源(建议关闭)
}
}
没有close:
close后:
整体代码:
#pragma once
#include <iostream>
#include <string>
#include <functional>
#include <cerrno>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "err.hpp"
namespace ns_server
{
const uint16_t default_port = 8081; // 默认端口号
const int backlog = 32; // 全连接队列的最大长度
// 参数为string返回值为string的函数
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 Init()
{
// 1. 创建监听套接字
listensock_ = socket(AF_INET, SOCK_STREAM, 0);
if(listensock_ < 0)
{
std::cerr << "create socket error: " << strerror(errno) << std::endl;
exit(SOCKET_ERR);
}
std::cout << "create socket 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_, (struct sockaddr*)&local, sizeof(local)))
{
std::cerr << "bind socket error: " << strerror(errno) << std::endl;
exit(BIND_ERR);
}
// 3. listen
if(listen(listensock_, backlog) == -1)
{
std::cerr << "listen error: " << strerror(errno) << std::endl;
exit(LISTEN_ERR);
}
std::cout << "listen success" << std::endl;
}
static void handler(int signo)
{
printf("进程 %d 捕捉到了 %d 信号\n", getpid(), signo);
// 这里的 -1 表示父进程等待时,只要是已经退出了的1子进程,都可以进行回收
while(1)
{
pid_t ret = waitpid(-1, NULL, WNOHANG);
if(ret > 0) printf("父进程:%d 已经成功回收了 %d 号进程\n", getpid(), ret);
else break;
}
printf("子进程回收成功\n");
}
// 启动服务器
void Start()
{
// 忽略 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 < 0)
{
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_; // 回调函数
};
}
三、多线程服务端
通过多线程,实现支持多客户端同时通信的服务器
核心功能:服务器端与客户端成功连接后,创建一个线程,服务于客户端的业务处理
这里先通过 原生线程库 模拟实现
(一)使用原生线程库
线程的回调函数中需要 Service() 业务处理函数中的所有参数,同时也需要具备访问 Service() 业务处理函数的能力,单凭一个 void* 的参数是无法解决的,为此可以创建一个类,里面可以包含我们所需要的参数
// 包含我们所需参数的类型
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
设置为静态函数
#pragma once
#include <iostream>
#include <string>
#include <functional>
#include <cerrno>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <pthread.h>
#include "err.hpp"
namespace ns_server
{
const uint16_t default_port = 8081; // 默认端口号
const int backlog = 32; // 全连接队列的最大长度
// 参数为string返回值为string的函数
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 Init()
{
// 1. 创建监听套接字
listensock_ = socket(AF_INET, SOCK_STREAM, 0);
if(listensock_ < 0)
{
std::cerr << "create socket error: " << strerror(errno) << std::endl;
exit(SOCKET_ERR);
}
std::cout << "create socket 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_, (struct sockaddr*)&local, sizeof(local)))
{
std::cerr << "bind socket error: " << strerror(errno) << std::endl;
exit(BIND_ERR);
}
// 3. listen
if(listen(listensock_, backlog) == -1)
{
std::cerr << "listen error: " << strerror(errno) << std::endl;
exit(LISTEN_ERR);
}
std::cout << "listen success" << std::endl;
}
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 Start()
{
while(!quit_)
{
// 1. 处理连接请求
struct sockaddr_in client;
socklen_t len = sizeof(client);
int sock = accept(listensock_, (struct sockaddr*)&client, &len);
// 2. 如果连接失败就继续尝试连接
if(sock < 0)
{
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);
}
}
// 业务处理
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_; // 回调函数
};
}
因为当前使用了 原生线程库,所以在编译时,makefile文件需要加上 -lpthread
接下来就是编译并运行程序,可以看到 当前只有一个进程,同时有五个线程在运行:
使用 原生线程库 过于单薄了,并且这种方式存在问题:连接都准备好了,才创建线程,如果创建线程所需要的资源较多,会拖慢服务器整体连接效率。
为此可以改用之前实现的 线程池 提前创建好线程。
(二)使用线程池
我们选择使用 单例模式版线程池:<Linux> 线程池的v4版本
#pragma once
#include "Task.hpp"
#include "Thread.hpp"
#include "LockGuard.hpp"
#include <iostream>
#include <vector>
#include <string>
#include <queue>
#include <memory>
#include <unistd.h>
#include <pthread.h>
#include <stdio.h>
#include <functional>
#define THREAD_NUM 5
template<class T>
class ThreadPool
{
using func_t = std::function<void(T&)>; // 包装器
private:
ThreadPool(int num = THREAD_NUM)
: _num(num)
{
// 初始化互斥锁和条件变量
pthread_mutex_init(&_mtx, nullptr);
pthread_cond_init(&_cond, nullptr);
}
~ThreadPool()
{
// 等待线程退出
for(auto &t : _threads)
t.join();
// 销毁互斥锁和条件变量
pthread_mutex_destroy(&_mtx);
pthread_cond_destroy(&_cond);
}
// 删除拷贝构造
ThreadPool(const ThreadPool<T> &) = delete;
public:
pthread_mutex_t* getlock()
{
return &_mtx;
}
void threadWait()
{
pthread_cond_wait(&_cond, &_mtx);
}
void threadWakeup()
{
pthread_cond_signal(&_cond);
}
bool isEmpty()
{
return _tasks.empty();
}
T popTask()
{
T task = _tasks.front();
_tasks.pop();
return task;
}
// 装载任务
void pushTask(const T &task)
{
// 本质上就是在生产商品,需要加锁保护,自动加锁解锁
LockGuard lockgrard(&_mtx);
_tasks.push(task);
// 唤醒消费者消费
threadWakeup();
}
func_t callBack(T &task)
{
_func(task);
}
public:
static ThreadPool<T>* getInstance()
{
// 双检查
if(_inst == nullptr)
{
// 加锁
LockGuard lock(&_instance_mtx);
if(_inst == nullptr)
{
// 创建对象
_inst = new ThreadPool<T>();
// 初始化及启动服务
_inst->init();
_inst->start();
}
}
return _inst;
}
void init()
{
for(int i = 0; i < _num; i++)
_threads.push_back(Thread(i, threadRoutine, this));
}
void start()
{
// 启动线程
for(int i = 0; i < _num; i++)
_threads[i].run();
}
// 给线程的回调函数
static void threadRoutine(void *args)
{
// 任务处理
// 避免等待线程,直接分离
// pthread_detach(pthread_self());
ThreadPool<T> *ptr = static_cast<ThreadPool<T> *>(args);
while(true)
{
// 自动解锁加锁
LockGuard lockguard(ptr->getlock());
// 等待条件满足
while(ptr->isEmpty())
ptr->threadWait();
// 获取任务
T task = ptr->popTask();
// 进行消费,可以不用加锁,因为一个商品只会被一个线程消费
task();
ptr->callBack(task);
}
}
private:
func_t _func;
std::vector<Thread> _threads;
int _num; // 线程数量
std::queue<T> _tasks; // 利用STL自动扩容的特性,无须担心容量
pthread_mutex_t _mtx;
pthread_cond_t _cond;
// 创建静态单例对象指针及互斥锁
static ThreadPool<T> *_inst;
static pthread_mutex_t _instance_mtx;
};
// 初始化指针
template<class T>
ThreadPool<T>* ThreadPool<T>::_inst = nullptr;
// 初始化互斥锁
template<class T>
pthread_mutex_t ThreadPool<T>::_instance_mtx = PTHREAD_MUTEX_INITIALIZER;
Thread.hpp
#pragma once
#include <iostream>
#include <string>
#include <cstdlib>
#include <pthread.h>
using namespace std;
class Thread
{
public:
// 状态表
typedef enum
{
NEW = 0,
RUNNING,
EXITED
}ThreadStatus;
typedef void (*func_t)(void *);
public:
Thread(int num, func_t func, void *args)
:_tid(0), _func(func), _status(NEW), _args(args)
{
// 根据ID写入名字
char name[128];
snprintf(name, sizeof(name), "thread-%d", num);
_name = name;
}
~Thread()
{}
// 获取线程ID
pthread_t getID()
{
if (_status == RUNNING) return _tid;
else return 0;
}
// 获取线程名
string getName() { return _name; }
// 获取线程状态
int getStatus() { return _status; }
// 启动线程
void run()
{
int n = pthread_create(&_tid, nullptr, runHelper, this/*需考虑*/);
if(n != 0)
{
cerr << "create thread fail" << endl;
exit(1);
}
_status = RUNNING;// 线程跑起来状态为运行中
}
// 回调函数
static void *runHelper(void *args)
{
Thread *ts = static_cast<Thread*>(args);
ts->_func(ts->_args);
// return nullptr;
}
void operator ()() //仿函数
{
if(_func != nullptr) _func(_args);
}
// 线程等待
void join()
{
int n = pthread_join(_tid, nullptr);
if(n != 0)
{
cerr << "join thread fail" << endl;
exit(1);
}
_status = EXITED;// 线程等待成功后状态为退出
}
private:
pthread_t _tid; // 线程ID
func_t _func; // 线程回调函数
ThreadStatus _status; // 线程状态
void *_args; // 回调函数的参数,可以设置成模板
string _name; // 线程名
};
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 对象
#pragma once
#include <string>
#include <functional>
// Service() 业务处理函数的类型
using cb_t = std::function<void(int, std::string, uint16_t)>;
// template<class 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
对象即可
其实也就是增加了这两句代码
// 3.构建任务对象 注意:使用 bind 绑定 this 指针
Task t(sock, clientip, clientport, std::bind(&TcpServer::Service, this,
std::placeholders::_1, std::placeholders::_2, std::placeholders::_3));
// 4. 通过线程池操作将对象push到线程池中处理
ThreadPool<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/wait.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <pthread.h>
#include "err.hpp"
#include "ThreadPool.hpp"
namespace ns_server
{
const uint16_t default_port = 8081; // 默认端口号
const int backlog = 32; // 全连接队列的最大长度
// 参数为string返回值为string的函数
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 Init()
{
// 1. 创建监听套接字
listensock_ = socket(AF_INET, SOCK_STREAM, 0);
if(listensock_ < 0)
{
std::cerr << "create socket error: " << strerror(errno) << std::endl;
exit(SOCKET_ERR);
}
std::cout << "create socket 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_, (struct sockaddr*)&local, sizeof(local)))
{
std::cerr << "bind socket error: " << strerror(errno) << std::endl;
exit(BIND_ERR);
}
// 3. listen
if(listen(listensock_, backlog) == -1)
{
std::cerr << "listen error: " << strerror(errno) << std::endl;
exit(LISTEN_ERR);
}
std::cout << "listen success" << std::endl;
}
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 Start()
{
while(!quit_)
{
// 1. 处理连接请求
struct sockaddr_in client;
socklen_t len = sizeof(client);
int sock = accept(listensock_, (struct sockaddr*)&client, &len);
// 2. 如果连接失败就继续尝试连接
if(sock < 0)
{
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 指针
Task t(sock, clientip, clientport, std::bind(&TcpServer::Service, this,
std::placeholders::_1, std::placeholders::_2, std::placeholders::_3));
// 4. 通过线程池操作将对象push到线程池中处理
ThreadPool<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_; // 回调函数
};
}
接下来编译并运行程序,当服务器启动后(此时无客户端连接),只有一个线程,这是因为我们当前的 线程池 是基于 懒汉模式 实现的,只有当第一次使用时,才会创建线程:
接下来启动客户端,可以看到确实创建了一批次线程(5个)
当然可以支持多客户端同时通信
看似程序已经很完善了,其实隐含着一个大问题:当前线程池中的线程,本质上是在回调一个 while(true) 死循环函数,当连接的客户端大于线程池中的最大线程数时,会导致所有线程始终处于满负载状态,直接影响就是连接成功后,无法再创建通信会话(倘若客户端不断开连接,线程池中的线程就无力处理其他客户端的会话)。
说白了就是 线程池 比较适合用于处理短任务,对于当前的场景来说,线程池 不适合建立持久通信会话,应该将其用于处理 read
读取、write
写入 任务。
如果想解决这个问题,有两个方向:Service()
函数中支持一次 [收 / 发],或者多线程+线程池,多线程用于构建通信会话,线程池则用于处理 [收 / 发] 任务
前者实现起来比较简单,无非就是把 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);
break;
}
else
{
// 读取出问题(暂时)
std::cerr << "read fail" << strerror(errno) << std::endl;
close(sock);
break;
}
}
至于后者就比较麻烦了,需要结合 高级IO 相关知识,这里不再阐述。
四、日志
(一)概念
日志(Log)是记录系统或应用程序所发生事件的详细信息的重要工具。日志系统帮助管理员、开发人员和用户了解系统或应用程序的运行状态、性能以及可能出现的问题。
在之前的编程经历中,如果我们的程序运行出现了问题,都是通过 标准输出 或 标准错误 将 错误信息 直接输出到屏幕上,debug 阶段这样使用没啥问题,但如果出错的是一个不断在运行中的服务,那问题就大了,因为服务器是不间断运行中,直接将 错误信息 输出到屏幕上,会导致错误排查变得极为困难。
将各种 错误信息 组织管理,就形成了日志,日志有属于自己的格式(包括时间、文件名及行号、错误等级等),利于排查问题。
所以接下来我们将会实现一个简易版日志器,用于定向输出我们的日志信息
(二)可变参数
日志需要我们指定格式并输出,依赖于可变参数
在编写简易版日志器之前,需要先认识一下 C语言 中有关可变参数的使用,主要包括这几个 宏:
#include <stdarg.h>
va_list // 指向可变参数列表的指针
va_start() // 将指针指向起始地址
va_arg() // 根据类型,提取可变参数列表中的参数
va_end() // 将指针置为空
比如我们可以通过 可变参数 实现参数遍历
#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()
函数进行参数解析。
(三)日志器实现
日志是有等级的,一般分为五级:
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>", "<Unkown>"};
// 避免非法情况
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
指针,该函数就可以自动解析出参数,并存入缓冲区中,并将 日志等级 时间 PID
与 参数 进行拼接,形成日志
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.c_str());
}
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>", "<Unkown>"};
// 避免非法情况
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.c_str());
}
为什么日志消息最后还是向屏幕输出?这样组织日志消息的好处是什么?
因为现在还在测试阶段,等测试完成后,可以将日志消息存入文件中,做到持久化存储;至于统一组织的好处不言而喻,能够确保每条日志消息都包含必要信息,便于排查错误。
(四)应用到服务端客户端中
#pragma once
#include <iostream>
#include <string>
#include <functional>
#include <cerrno>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <pthread.h>
#include "err.hpp"
#include "ThreadPool.hpp"
#include "log.hpp"
namespace ns_server
{
const uint16_t default_port = 8081; // 默认端口号
const int backlog = 32; // 全连接队列的最大长度
// 参数为string返回值为string的函数
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 Init()
{
// 1. 创建监听套接字
listensock_ = socket(AF_INET, SOCK_STREAM, 0);
if(listensock_ < 0)
{
logMessage(Fatal, "create socket error: %s", strerror(errno));
exit(SOCKET_ERR);
}
logMessage(Debug, "create socket success: %d", listensock_);
// 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_, (struct sockaddr*)&local, sizeof(local)))
{
logMessage(Fatal, "bind socket error: %s", strerror(errno));
exit(BIND_ERR);
}
// 3. listen
if(listen(listensock_, backlog) == -1)
{
logMessage(Fatal, "listen error: %s", strerror(errno));
exit(LISTEN_ERR);
}
logMessage(Debug, "listen success");
}
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 Start()
{
while(!quit_)
{
// 1. 处理连接请求
struct sockaddr_in client;
socklen_t len = sizeof(client);
int sock = accept(listensock_, (struct sockaddr*)&client, &len);
// 2. 如果连接失败就继续尝试连接
if(sock < 0)
{
logMessage(Fatal, "accept fail %s", strerror(errno));
continue;
}
// 连接成功,获取客户端信息
std::string clientip = inet_ntoa(client.sin_addr);
uint16_t clientport = ntohs(client.sin_port);
logMessage(Debug, "server accept %s-%d %d from %d success",
clientip.c_str(), clientport, sock, listensock_);
// 3.创建线程以及所需要的线程信息类
ThreadData *td = new ThreadData(sock, clientip, clientport, this);
pthread_t p;
pthread_create(&p, nullptr, Routine, 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)
{
logMessage(Error, "client %s quit! %s", who.c_str(), strerror(errno));
close(sock);
break;
}
else
{
// 读取出问题(暂时)
logMessage(Error, "Read Fail! %s", strerror(errno));
close(sock);
break;
}
}
}
private:
int listensock_;// 监听套接字
uint16_t port_; // 端口号
bool quit_; // 判断服务器是否结束运行
func_t func_; // 回调函数
};
}
#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 ns_client
{
class TcpClient
{
public:
TcpClient(const std::string& ip, const uint16_t port)
:server_ip_(ip), server_port_(port)
{}
~TcpClient()
{}
// 初始化服务器
void Init()
{
// 创建套接字
sock_ = socket(AF_INET, SOCK_STREAM, 0);
if(sock_ < 0)
{
logMessage(Fatal, "create socket fail %s", strerror(errno));
exit(SOCKET_ERR);
}
logMessage(Debug, "create socket success %d", sock_);
}
// 启动服务器
void Start()
{
// 填充服务器的 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_, (struct sockaddr*)&server, len);
// 连接成功
if(ret == 0) break;
// 尝试重连
logMessage(Warning, "正在进行重新连接...剩余次数: %d", --n);
sleep(1);
}
if(n == 0)
{
logMessage(Warning, "连接失败! %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 Message >> ";
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_; // 套接字
uint16_t server_port_; // 服务器端口号
std::string server_ip_; // 服务器IP地址
};
}
替换打印后的程序结果:
(五)持久化存储
所谓持久化存储就是将日志消息输出至文件中,修改 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/TCPLogMessage.log"; // 指定存放日志文件路径
string getLevel(int level)
{
vector<string> vs = {"<Debug>", "<Info>", "<Warning>",
"<Error>", "<Fatal>", "<Unkown>"};
// 避免非法情况
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;
}
五、守护进程
守护进程(Daemon Process)是在Unix、Linux以及类Unix操作系统中的一个在后台运行的进程,它独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。守护进程脱离于终端是为了避免进程在执行过程中的信息在任何终端上显示,并且进程也不会被任何终端所产生的终端信息所打断。守护进程在后台运行,并且它独立于控制终端,周期性地执行某种任务或等待处理某些发生的事件。
(一)会话、进程组、进程
守护进程 的意思就是让进程不间断的在后台运行,即便是 bash 关闭了,也能照旧运行,不受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为会话ID、PGID为进程组ID、PID为进程ID ,显然,sleep 1000、2000、3000 处于同一个管道中(有血缘关系),属于同一个 进程组,所以他们的 PGID 都是一样的,都是 7786;至于 sleep 100、200、300 属于另一个 7834进程组,;再仔细观察可以发现 每一组的进程组 PGID 都与当前组中第一个被创建的进程 PID 一致,这个进程被称为 组长进程。
会话 >= 进程组 >= 进程
无论是 后台进程 还是 前台进程,都是从同一个 bash
中启动的,所以它们处于同一个 会话 中,SID
都是 3926,并且关联的 终端文件 TTY
都是 pts/0。
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
(二)守护进程化
一般网络服务器为了不受到用户登录重启的影响,会以 守护进程 的形式运行,有了上面那一批前置知识后,就可以很好的理解 守护进程 的本质了。
守护进程:进程单独成一个会话,并且以后台进程的形式运行。说白了就是让服务器不间断运行,可以直接使用 daemon()
函数完成 守护进程化
#include <unistd.h>
int daemon(int nochdir, int noclose);
参数解读:
nochdir
改变进程的工作路径noclose
重定向标准输入、标准输出、标准错误
返回值:成功返回 0
,失败返回 -1
一般情况下,daemon()
函数的两个参数都只需要传递 0
,默认工作在 /
路径下,默认重定向至 /dev/null
/dev/null
就像是一个 黑洞,可以把所有数据都丢入其中,相当于丢弃数据
使用 damon()
函数使之前的 tcp_server.cc 守护进程化
#include <memory>
#include <string>
#include <unistd.h>
#include "tcp_server.hpp"
using namespace std;
using namespace ns_server;
// 业务处理回调函数(字符串回响)
string echo(string request)
{
return request;
}
int main()
{
// 守护进程化
daemon(0, 0);
unique_ptr<TcpServer> usvr(new TcpServer(echo));
usvr->Init();
usvr->Start();
return 0;
}
现在服务器启动后,会自动变成 后台进程,并且自成一个 新会话,归操作系统管(守护进程 本质上是一种比较坚强的 孤儿进程)。
注意: 现在标准输出、标准错误都被重定向至 /dev/null
中了,之前向屏幕输出的数据,现在都会直接被丢弃,如果想保存数据,可以选择使用日志
如果想终止 守护进程,需要通过
kill pid
杀死目标进程
使用系统提供的接口一键 守护进程化 固然方便,不过大多数程序员都会选择手动 守护进程化(可以根据自己的需求定制操作)
原理是 使用 setsid()
函数新设一个会话,谁调用,会话 SID
就是谁的,成为一个新的会话后,不会被之前的会话影响:
#include <unistd.h>
pid_t setsid(void);
返回值:成功返回该进程的 pid
,失败返回 -1
注意: 调用该函数的进程,不能是组长进程,需要创建子进程后调用。
手动实现守护进程时需要注意以下几点:
- 忽略异常信号。
0、1、2
要做特殊处理(关闭或者重定向文件描述符)。
特殊处理文件描述符能够增强守护进程的安全性。守护进程通常不需要与任何控制终端相关联,关闭或者重定向继承自父进程的文件描述符,特别是与终端相关的标准输入、输出和错误文件描述符,可以防止守护进程意外地将输出发送到终端或受到终端信号的影响,从而避免了信息泄露和不可预测的行为。- 进程的工作路径可能要改变(从用户目录中脱离至根目录)。
具体实现步骤如下:
- 忽略常见的异常信号:SIGPIPE、SIGCHLD。
- 如何保证自己不是组长? 创建子进程 ,成功后父进程退出,子进程变成守护进程。
- 新建会话,自己成为会话的 话首进程。
- (可选)更改守护进程的工作路径:chdir。
- 处理后续对于 0、1、2 的问题。
对于 标准输入、标准输出、标准错误 的处理方式有两种:
- 暴力处理:直接关闭
fd
- 优雅处理:将
fd
重定向至/dev/null
,也就是daemon()
函数的做法
这里我们选择后者,守护进程 的函数实现如下
#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 = "/";
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);
}
错误码文件
#pragma once
enum
{
USAGE_ERR = 1,
SOCKET_ERR,
BIND_ERR,
LISTEN_ERR,
CONNECT_ERR,
FORK_ERR,
SETSID_ERR,
CHDIR_ERR,
OPEN_ERR
};
接下来就是在服务启动成功后,将其 守护进程化
#include "Daemon.hpp"
// 启动服务器
void StartServer()
{
// 守护进程化
Daemon();
// ...
}
#include <memory>
#include <string>
#include <unistd.h>
#include "tcp_server.hpp"
using namespace std;
using namespace ns_server;
// 业务处理回调函数(字符串回响)
string echo(string request)
{
return request;
}
int main()
{
unique_ptr<TcpServer> usvr(new TcpServer(echo));
usvr->Init();
usvr->Start();
return 0;
}
现在服务器在启动后,会自动新建会话,以 守护进程 的形式运行
关于 inet_ntoa 函数的返回值(该函数的作用是将四字节的 IP 地址转化为点分十进制的 IP 地址)
inet_ntoa 返回值为 char*,转化后的 IP 地址存储在静态区,二次调用会覆盖上一次的结果,多线程场景中不是线程安全的
- 不过在 CentOS 7 及更高版本中,接口进行了更新,新增了互斥锁,多线程场景中测试没问题。